Skip to main content

arbiter_credential/
inject.rs

1//! Credential injection middleware.
2//!
3//! Scans outgoing HTTP request bodies and headers for credential reference
4//! patterns (`${CRED:ref_name}`) and substitutes them with resolved secret
5//! values. Also scans response bodies to ensure credentials never leak back
6//! to the agent.
7//!
8//! This module provides standalone async functions. Wiring into the full proxy
9//! pipeline is done in the main binary crate.
10
11use regex::Regex;
12use secrecy::{ExposeSecret, SecretString};
13use std::sync::LazyLock;
14use tracing::{debug, warn};
15
16use crate::error::CredentialError;
17use crate::provider::CredentialProvider;
18
19/// Pattern matching `${CRED:reference_name}`. The reference name is captured
20/// in group 1 and may contain alphanumerics, underscores, hyphens, and dots.
21static CRED_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(r"\$\{CRED:([A-Za-z0-9_.\-]+)\}").expect("credential pattern is valid regex")
23});
24
25/// The sentinel string used to mask leaked credentials in response bodies.
26const REDACTED: &str = "[CREDENTIAL]";
27
28/// Result of injecting credentials into a request.
29#[derive(Debug)]
30pub struct InjectedRequest {
31    /// The rewritten request body with credential references substituted.
32    pub body: String,
33    /// Rewritten headers (name, value) with credential references substituted.
34    pub headers: Vec<(String, String)>,
35    /// Which credential references were resolved during injection.
36    pub resolved_refs: Vec<String>,
37    /// The actual resolved secret values for response scrubbing.
38    /// Stored as [`SecretString`] so they are zeroized on drop and never
39    /// accidentally exposed through `Debug` or `Display`.
40    pub resolved_values: Vec<SecretString>,
41}
42
43/// Inject credentials into a request body and a set of headers.
44///
45/// Every occurrence of `${CRED:ref}` in `body` and in the header values is
46/// replaced with the resolved secret. Returns an error if any referenced
47/// credential cannot be resolved.
48pub async fn inject_credentials(
49    body: &str,
50    headers: &[(String, String)],
51    provider: &dyn CredentialProvider,
52) -> Result<InjectedRequest, CredentialError> {
53    let mut resolved_refs: Vec<String> = Vec::new();
54    let mut resolved_values: Vec<SecretString> = Vec::new();
55
56    // --- body ---
57    let new_body = replace_refs(body, provider, &mut resolved_refs, &mut resolved_values).await?;
58
59    // --- headers ---
60    let mut new_headers = Vec::with_capacity(headers.len());
61    for (name, value) in headers {
62        let new_value =
63            replace_refs(value, provider, &mut resolved_refs, &mut resolved_values).await?;
64        new_headers.push((name.clone(), new_value));
65    }
66
67    debug!(count = resolved_refs.len(), "credential injection complete");
68
69    Ok(InjectedRequest {
70        body: new_body,
71        headers: new_headers,
72        resolved_refs,
73        resolved_values,
74    })
75}
76
77/// Encoding-aware response scrubbing using [`SecretString`] values.
78///
79/// The plaintext is only exposed for the duration of the scrub operation and
80/// is not stored in any intermediate `String` that outlives this call.
81///
82/// `known_values` should contain the secret values that were injected into the
83/// outgoing request so we can detect them if the upstream echoes them back.
84pub fn scrub_response(body: &str, known_values: &[SecretString]) -> String {
85    // Sort credentials by length (longest first) to prevent
86    // partial-match interference. If credential A = "abc" and B = "abcdef",
87    // scrubbing A first would mangle B's value, preventing its detection.
88    let mut sorted_values: Vec<&SecretString> = known_values.iter().collect();
89    sorted_values.sort_by_key(|v| std::cmp::Reverse(v.expose_secret().len()));
90
91    let mut scrubbed = body.to_string();
92    for secret in sorted_values {
93        let value = secret.expose_secret();
94        if value.is_empty() {
95            continue;
96        }
97        scrub_single_value(&mut scrubbed, value);
98    }
99    scrubbed
100}
101
102/// Encoding-aware response scrubbing for plain `&[String]` values.
103///
104/// Backward-compatible entry point for callers that do not yet use
105/// [`SecretString`]. Prefer [`scrub_response`] with `SecretString` values.
106pub fn scrub_response_plain(body: &str, known_values: &[String]) -> String {
107    let mut sorted_values: Vec<&String> = known_values.iter().collect();
108    sorted_values.sort_by_key(|v| std::cmp::Reverse(v.len()));
109
110    let mut scrubbed = body.to_string();
111    for value in sorted_values {
112        if value.is_empty() {
113            continue;
114        }
115        scrub_single_value(&mut scrubbed, value);
116    }
117    scrubbed
118}
119
120/// Scrub all encoded variants of a single credential value from a response body.
121fn scrub_single_value(scrubbed: &mut String, value: &str) {
122    // Direct match
123    if scrubbed.contains(value) {
124        warn!("credential value detected in response body, scrubbing");
125        *scrubbed = scrubbed.replace(value, REDACTED);
126    }
127    // URL-encoded variant
128    let url_encoded = urlencoding_encode(value);
129    if url_encoded != value && scrubbed.contains(&url_encoded) {
130        warn!("URL-encoded credential value detected in response body, scrubbing");
131        *scrubbed = scrubbed.replace(&url_encoded, REDACTED);
132    }
133    // Lowercase percent-encoded variant (RFC 3986 allows both %2F and %2f).
134    let url_lower = urlencoding_encode_lower(value);
135    if url_lower != url_encoded && url_lower != value && scrubbed.contains(&url_lower) {
136        warn!("lowercase-percent-encoded credential value detected in response body, scrubbing");
137        *scrubbed = scrubbed.replace(&url_lower, REDACTED);
138    }
139    // JSON-escaped variant (handles quotes, backslashes, etc.)
140    if let Ok(json_str) = serde_json::to_string(value) {
141        // Remove surrounding quotes from JSON string
142        let json_escaped = &json_str[1..json_str.len() - 1];
143        if json_escaped != value && scrubbed.contains(json_escaped) {
144            warn!("JSON-escaped credential value detected in response body, scrubbing");
145            *scrubbed = scrubbed.replace(json_escaped, REDACTED);
146        }
147    }
148    // Hex-encoded variant (lowercase)
149    let hex_encoded: String = value.bytes().map(|b| format!("{:02x}", b)).collect();
150    if scrubbed.contains(&hex_encoded) {
151        warn!("hex-encoded credential value detected in response body, scrubbing");
152        *scrubbed = scrubbed.replace(&hex_encoded, REDACTED);
153    }
154    // Uppercase hex variant.
155    let hex_upper: String = value.bytes().map(|b| format!("{:02X}", b)).collect();
156    if hex_upper != hex_encoded && scrubbed.contains(&hex_upper) {
157        warn!("uppercase-hex-encoded credential value detected in response body, scrubbing");
158        *scrubbed = scrubbed.replace(&hex_upper, REDACTED);
159    }
160    // Base64-encoded credential scrubbing.
161    let b64_encoded = base64_encode_value(value);
162    if b64_encoded != value && scrubbed.contains(&b64_encoded) {
163        warn!("base64-encoded credential value detected in response body, scrubbing");
164        *scrubbed = scrubbed.replace(&b64_encoded, REDACTED);
165    }
166    // Base64url variant (RFC 4648 S5: '-' '_', no padding).
167    let b64url_encoded = base64url_encode_value(value);
168    if b64url_encoded != value
169        && b64url_encoded != b64_encoded
170        && scrubbed.contains(&b64url_encoded)
171    {
172        warn!("base64url-encoded credential value detected in response body, scrubbing");
173        *scrubbed = scrubbed.replace(&b64url_encoded, REDACTED);
174    }
175    // Unicode JSON escape sequence variant (\u00XX per character).
176    let unicode_escaped = unicode_json_escape(value);
177    if unicode_escaped != value && scrubbed.contains(&unicode_escaped) {
178        warn!("unicode-escaped credential value detected in response body, scrubbing");
179        *scrubbed = scrubbed.replace(&unicode_escaped, REDACTED);
180    }
181    // Double URL-encoding variant.
182    let double_url_encoded = urlencoding_encode(&url_encoded);
183    if double_url_encoded != url_encoded && scrubbed.contains(&double_url_encoded) {
184        warn!("double-URL-encoded credential value detected in response body, scrubbing");
185        *scrubbed = scrubbed.replace(&double_url_encoded, REDACTED);
186    }
187}
188
189/// Simple percent-encoding for credential scrubbing.
190fn urlencoding_encode(input: &str) -> String {
191    let mut encoded = String::new();
192    for byte in input.bytes() {
193        match byte {
194            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
195                encoded.push(byte as char);
196            }
197            _ => {
198                encoded.push_str(&format!("%{:02X}", byte));
199            }
200        }
201    }
202    encoded
203}
204
205/// Lowercase percent-encoding variant for credential scrubbing.
206/// RFC 3986 allows both `%2F` and `%2f`; the standard encoder uses uppercase,
207/// but a malicious upstream may use lowercase to evade scrubbing.
208fn urlencoding_encode_lower(input: &str) -> String {
209    let mut encoded = String::new();
210    for byte in input.bytes() {
211        match byte {
212            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
213                encoded.push(byte as char);
214            }
215            _ => {
216                encoded.push_str(&format!("%{:02x}", byte));
217            }
218        }
219    }
220    encoded
221}
222
223/// Unicode JSON escape sequence encoding (\u00XX per byte).
224/// A credential "abc" becomes "\u0061\u0062\u0063" which is valid JSON
225/// and renders identically when parsed.
226fn unicode_json_escape(input: &str) -> String {
227    input.bytes().map(|b| format!("\\u{:04x}", b)).collect()
228}
229
230/// Collect all `${CRED:...}` references found in `input`.
231pub fn find_refs(input: &str) -> Vec<String> {
232    CRED_PATTERN
233        .captures_iter(input)
234        .map(|cap| cap[1].to_string())
235        .collect()
236}
237
238// ---------------------------------------------------------------------------
239// Internal helpers
240// ---------------------------------------------------------------------------
241
242/// Replace all `${CRED:ref}` patterns in `input` by resolving each reference
243/// through the provider. Appends resolved reference names and values.
244async fn replace_refs(
245    input: &str,
246    provider: &dyn CredentialProvider,
247    resolved: &mut Vec<String>,
248    secret_values: &mut Vec<SecretString>,
249) -> Result<String, CredentialError> {
250    // Collect all matches first so we can resolve them.
251    let refs = find_refs(input);
252    if refs.is_empty() {
253        return Ok(input.to_string());
254    }
255
256    // Validate all credential references exist before injecting any.
257    // This prevents partial injection where some refs are resolved but others fail,
258    // which could leak information about which credential names are valid.
259    let mut resolved_pairs: Vec<(String, SecretString)> = Vec::new();
260    for reference in &refs {
261        let value = provider.resolve(reference).await?;
262        resolved_pairs.push((reference.clone(), value));
263    }
264
265    let mut output = input.to_string();
266    for (reference, secret) in &resolved_pairs {
267        let placeholder = format!("${{CRED:{reference}}}");
268        output = output.replace(&placeholder, secret.expose_secret());
269        if !resolved.contains(reference) {
270            resolved.push(reference.clone());
271            // Capture resolved values at injection time.
272            // Previously the handler re-resolved from the provider for scrubbing,
273            // which could return different values after credential rotation.
274            secret_values.push(SecretString::from(secret.expose_secret().to_owned()));
275        }
276    }
277
278    Ok(output)
279}
280
281/// Base64url encoding (RFC 4648 S5) for credential scrubbing.
282/// Uses '-' and '_' instead of '+' and '/', no padding.
283fn base64url_encode_value(input: &str) -> String {
284    let standard = base64_encode_value(input);
285    standard
286        .replace('+', "-")
287        .replace('/', "_")
288        .trim_end_matches('=')
289        .to_string()
290}
291
292/// Simple base64 encoding for credential scrubbing (standard alphabet, with padding).
293fn base64_encode_value(input: &str) -> String {
294    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
295    let bytes = input.as_bytes();
296    let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
297    for chunk in bytes.chunks(3) {
298        let b0 = chunk[0] as u32;
299        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
300        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
301        let triple = (b0 << 16) | (b1 << 8) | b2;
302        result.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char);
303        result.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char);
304        if chunk.len() > 1 {
305            result.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char);
306        } else {
307            result.push('=');
308        }
309        if chunk.len() > 2 {
310            result.push(ALPHABET[(triple & 0x3F) as usize] as char);
311        } else {
312            result.push('=');
313        }
314    }
315    result
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::provider::{CredentialProvider, CredentialRef};
322    use async_trait::async_trait;
323    use std::collections::HashMap;
324
325    /// A trivial in-memory provider for tests.
326    struct MockProvider {
327        store: HashMap<String, String>,
328    }
329
330    impl MockProvider {
331        fn new(entries: &[(&str, &str)]) -> Self {
332            Self {
333                store: entries
334                    .iter()
335                    .map(|(k, v)| (k.to_string(), v.to_string()))
336                    .collect(),
337            }
338        }
339    }
340
341    #[async_trait]
342    impl CredentialProvider for MockProvider {
343        async fn resolve(&self, reference: &str) -> Result<SecretString, CredentialError> {
344            self.store
345                .get(reference)
346                .map(|v| SecretString::from(v.clone()))
347                .ok_or_else(|| CredentialError::NotFound(reference.to_string()))
348        }
349
350        async fn list_refs(&self) -> Result<Vec<CredentialRef>, CredentialError> {
351            Ok(self
352                .store
353                .keys()
354                .map(|k| CredentialRef {
355                    name: k.clone(),
356                    provider: "mock".into(),
357                    last_rotated: None,
358                })
359                .collect())
360        }
361    }
362
363    /// Helper: create a Vec<SecretString> from plain string slices.
364    fn secret_vec(values: &[&str]) -> Vec<SecretString> {
365        values
366            .iter()
367            .map(|v| SecretString::from(v.to_string()))
368            .collect()
369    }
370
371    #[tokio::test]
372    async fn injects_body_credentials() {
373        let provider = MockProvider::new(&[("api_key", "sk-secret-123")]);
374        let body = r#"{"key": "${CRED:api_key}"}"#;
375
376        let result = inject_credentials(body, &[], &provider).await.unwrap();
377        assert_eq!(result.body, r#"{"key": "sk-secret-123"}"#);
378        assert_eq!(result.resolved_refs, vec!["api_key"]);
379    }
380
381    #[tokio::test]
382    async fn injects_header_credentials() {
383        let provider = MockProvider::new(&[("token", "ghp_abc")]);
384        let headers = vec![
385            (
386                "Authorization".to_string(),
387                "Bearer ${CRED:token}".to_string(),
388            ),
389            ("X-Custom".to_string(), "plain-value".to_string()),
390        ];
391
392        let result = inject_credentials("", &headers, &provider).await.unwrap();
393        assert_eq!(result.headers[0].1, "Bearer ghp_abc");
394        assert_eq!(result.headers[1].1, "plain-value");
395    }
396
397    #[tokio::test]
398    async fn multiple_refs_in_body() {
399        let provider = MockProvider::new(&[("a", "AAA"), ("b", "BBB")]);
400        let body = "first=${CRED:a}&second=${CRED:b}";
401
402        let result = inject_credentials(body, &[], &provider).await.unwrap();
403        assert_eq!(result.body, "first=AAA&second=BBB");
404        assert!(result.resolved_refs.contains(&"a".to_string()));
405        assert!(result.resolved_refs.contains(&"b".to_string()));
406    }
407
408    #[tokio::test]
409    async fn unknown_ref_returns_error() {
410        let provider = MockProvider::new(&[]);
411        let body = "key=${CRED:missing}";
412
413        let err = inject_credentials(body, &[], &provider).await.unwrap_err();
414        assert!(matches!(err, CredentialError::NotFound(_)));
415    }
416
417    #[tokio::test]
418    async fn no_refs_is_passthrough() {
419        let provider = MockProvider::new(&[]);
420        let body = "no credential references here";
421
422        let result = inject_credentials(body, &[], &provider).await.unwrap();
423        assert_eq!(result.body, body);
424        assert!(result.resolved_refs.is_empty());
425    }
426
427    #[test]
428    fn scrub_response_masks_leaked_values() {
429        let body = r#"{"echo": "sk-secret-123", "other": "safe"}"#;
430        let known = secret_vec(&["sk-secret-123"]);
431
432        let scrubbed = scrub_response(body, &known);
433        assert_eq!(scrubbed, r#"{"echo": "[CREDENTIAL]", "other": "safe"}"#);
434    }
435
436    #[test]
437    fn scrub_response_no_match_is_passthrough() {
438        let body = "nothing to see here";
439        let known = secret_vec(&["secret"]);
440        let scrubbed = scrub_response(body, &known);
441        assert_eq!(scrubbed, body);
442    }
443
444    #[test]
445    fn scrub_response_empty_value_ignored() {
446        let body = "some body";
447        let known = secret_vec(&[""]);
448        let scrubbed = scrub_response(body, &known);
449        assert_eq!(scrubbed, body);
450    }
451
452    #[test]
453    fn scrub_response_multiple_values() {
454        let body = "has AAA and BBB in it";
455        let known = secret_vec(&["AAA", "BBB"]);
456        let scrubbed = scrub_response(body, &known);
457        assert_eq!(scrubbed, "has [CREDENTIAL] and [CREDENTIAL] in it");
458    }
459
460    #[test]
461    fn find_refs_extracts_all_references() {
462        let input = "${CRED:one} and ${CRED:two.three} and ${CRED:four-five}";
463        let refs = find_refs(input);
464        assert_eq!(refs, vec!["one", "two.three", "four-five"]);
465    }
466
467    #[test]
468    fn find_refs_empty_on_no_match() {
469        assert!(find_refs("no credentials here").is_empty());
470    }
471
472    // -----------------------------------------------------------------------
473    // Encoding-aware scrubbing tests
474    // -----------------------------------------------------------------------
475
476    #[test]
477    fn scrub_response_url_encoded() {
478        let known = secret_vec(&["p@ss w0rd!"]);
479        let body = "the response contains p%40ss%20w0rd%21 in a query string";
480
481        let scrubbed = scrub_response(body, &known);
482        assert_eq!(
483            scrubbed,
484            "the response contains [CREDENTIAL] in a query string"
485        );
486        assert!(!scrubbed.contains("%40"));
487        assert!(!scrubbed.contains("%20"));
488        assert!(!scrubbed.contains("%21"));
489    }
490
491    #[test]
492    fn scrub_response_json_escaped() {
493        let known = secret_vec(&[r#"pass"word\"#]);
494        let body = r#"{"field": "pass\"word\\"}"#;
495
496        let scrubbed = scrub_response(body, &known);
497        assert_eq!(scrubbed, r#"{"field": "[CREDENTIAL]"}"#);
498        assert!(!scrubbed.contains("pass"));
499        assert!(!scrubbed.contains("word"));
500    }
501
502    #[test]
503    fn scrub_response_hex_encoded() {
504        let hex_of_secret = "736563726574";
505        let body = format!("debug dump: hex={hex_of_secret} end");
506        let known = secret_vec(&["secret"]);
507
508        let scrubbed = scrub_response(&body, &known);
509        assert_eq!(scrubbed, "debug dump: hex=[CREDENTIAL] end");
510        assert!(!scrubbed.contains(hex_of_secret));
511    }
512
513    #[test]
514    fn scrub_response_base64_encoded() {
515        let b64 = base64_encode_value("my-secret-key");
516        assert_eq!(b64, "bXktc2VjcmV0LWtleQ==");
517        let body = format!("Authorization: Basic {b64} is here");
518        let known = secret_vec(&["my-secret-key"]);
519
520        let scrubbed = scrub_response(&body, &known);
521        assert_eq!(scrubbed, "Authorization: Basic [CREDENTIAL] is here");
522        assert!(!scrubbed.contains(&b64));
523    }
524
525    #[test]
526    fn scrub_response_longest_first_ordering() {
527        let known = secret_vec(&["abc", "abcdef"]);
528        let body = "values: abcdef and abc end";
529
530        let scrubbed = scrub_response(body, &known);
531        assert_eq!(scrubbed, "values: [CREDENTIAL] and [CREDENTIAL] end");
532        assert!(!scrubbed.contains("abc"));
533        assert!(!scrubbed.contains("abcdef"));
534    }
535
536    #[test]
537    fn scrub_response_all_encodings_simultaneously() {
538        let secret = "s3cr&t!";
539        let url_enc = urlencoding_encode(secret);
540        let hex_enc: String = secret.bytes().map(|b| format!("{:02x}", b)).collect();
541        let b64_enc = base64_encode_value(secret);
542        let json_enc = {
543            let full = serde_json::to_string(secret).unwrap();
544            full[1..full.len() - 1].to_string()
545        };
546
547        let body =
548            format!("plain={secret} url={url_enc} json={json_enc} hex={hex_enc} b64={b64_enc}");
549        let known = secret_vec(&[secret]);
550
551        let scrubbed = scrub_response(&body, &known);
552
553        assert_eq!(
554            scrubbed,
555            "plain=[CREDENTIAL] url=[CREDENTIAL] json=[CREDENTIAL] hex=[CREDENTIAL] b64=[CREDENTIAL]"
556        );
557        assert!(!scrubbed.contains(secret));
558        assert!(!scrubbed.contains(&url_enc));
559        assert!(!scrubbed.contains(&hex_enc));
560        assert!(!scrubbed.contains(&b64_enc));
561    }
562
563    #[tokio::test]
564    async fn scrub_response_partial_injection_prevented() {
565        let provider = MockProvider::new(&[("valid_key", "resolved-value")]);
566        let body = "first=${CRED:valid_key}&second=${CRED:missing_key}";
567
568        let result = inject_credentials(body, &[], &provider).await;
569        assert!(
570            result.is_err(),
571            "injection must fail when any ref is unresolvable"
572        );
573        assert!(
574            matches!(result.unwrap_err(), CredentialError::NotFound(ref name) if name == "missing_key")
575        );
576    }
577
578    #[tokio::test]
579    async fn resolved_values_captured_at_injection_time() {
580        let provider = MockProvider::new(&[("key_a", "alpha-secret"), ("key_b", "beta-secret")]);
581        let body = "a=${CRED:key_a} b=${CRED:key_b}";
582
583        let result = inject_credentials(body, &[], &provider).await.unwrap();
584
585        assert_eq!(result.body, "a=alpha-secret b=beta-secret");
586
587        let exposed: Vec<&str> = result
588            .resolved_values
589            .iter()
590            .map(|s| s.expose_secret())
591            .collect();
592        assert!(exposed.contains(&"alpha-secret"));
593        assert!(exposed.contains(&"beta-secret"));
594        assert_eq!(result.resolved_values.len(), 2);
595
596        let leaked_response = "upstream echoed alpha-secret back";
597        let scrubbed = scrub_response(leaked_response, &result.resolved_values);
598        assert_eq!(scrubbed, "upstream echoed [CREDENTIAL] back");
599    }
600
601    // -----------------------------------------------------------------------
602    // Recursive credential injection must not expand
603    // -----------------------------------------------------------------------
604
605    #[test]
606    fn recursive_ref_not_expanded() {
607        let input = "${CRED:${CRED:inner}}";
608        let refs = find_refs(input);
609        assert_eq!(
610            refs,
611            vec!["inner"],
612            "only the inner ref 'inner' should be matched. Got: {:?}",
613            refs
614        );
615
616        let input3 = "${CRED:${CRED:${CRED:deep}}}";
617        let refs3 = find_refs(input3);
618        assert_eq!(
619            refs3,
620            vec!["deep"],
621            "only the innermost ref 'deep' should be matched. Got: {:?}",
622            refs3
623        );
624    }
625
626    #[tokio::test]
627    async fn recursive_injection_does_not_re_expand() {
628        let provider = MockProvider::new(&[("outer", "${CRED:inner}"), ("inner", "real-secret")]);
629        let body = "key=${CRED:outer}";
630
631        let result = inject_credentials(body, &[], &provider).await.unwrap();
632
633        assert_eq!(
634            result.body, "key=${CRED:inner}",
635            "credential values containing ${{CRED:...}} must NOT be re-expanded"
636        );
637        assert_eq!(result.resolved_refs, vec!["outer"]);
638    }
639
640    // -----------------------------------------------------------------------
641    // Credential value with regex metacharacters in scrubbing
642    // -----------------------------------------------------------------------
643
644    #[test]
645    fn credential_with_special_chars_in_value() {
646        let special_value = r"p@$$w0rd!.*+";
647        let body = format!("the password is {} here", special_value);
648        let known = secret_vec(&[special_value]);
649
650        let scrubbed = scrub_response(&body, &known);
651        assert_eq!(
652            scrubbed, "the password is [CREDENTIAL] here",
653            "credential with regex metacharacters must be scrubbed literally"
654        );
655        assert!(
656            !scrubbed.contains(special_value),
657            "original credential value must not survive in scrubbed output"
658        );
659    }
660
661    #[test]
662    fn credential_ref_name_injection_rejected() {
663        assert!(find_refs("${CRED:../../etc/passwd}").is_empty());
664        assert!(find_refs("${CRED:..\\..\\windows\\system32}").is_empty());
665        assert!(find_refs("${CRED:ref;rm -rf /}").is_empty());
666        assert!(find_refs("${CRED:ref$(whoami)}").is_empty());
667        assert!(find_refs("${CRED:ref`id`}").is_empty());
668        assert!(find_refs("${CRED:ref name}").is_empty());
669        assert!(find_refs("${CRED:ref&other}").is_empty());
670        assert!(find_refs("${CRED:ref|pipe}").is_empty());
671        assert!(find_refs("${CRED:}").is_empty());
672        assert_eq!(
673            find_refs("${CRED:valid.ref-name_123}"),
674            vec!["valid.ref-name_123"]
675        );
676        assert_eq!(find_refs("${CRED:API_KEY}"), vec!["API_KEY"]);
677        assert_eq!(find_refs("${CRED:my-secret.v2}"), vec!["my-secret.v2"]);
678    }
679
680    // -----------------------------------------------------------------------
681    // Encoding variant bypass tests
682    // -----------------------------------------------------------------------
683
684    fn test_base64url_encode(input: &str) -> String {
685        let standard = base64_encode_value(input);
686        standard
687            .replace('+', "-")
688            .replace('/', "_")
689            .trim_end_matches('=')
690            .to_string()
691    }
692
693    #[test]
694    fn scrub_response_base64url_bypass() {
695        let secret = "secret+key/value";
696        let b64url_encoded = test_base64url_encode(secret);
697        let b64_standard = base64_encode_value(secret);
698        assert_ne!(
699            b64url_encoded, b64_standard,
700            "base64url and standard base64 should differ for this input"
701        );
702
703        let body = format!("token={b64url_encoded} end");
704        let known = secret_vec(&[secret]);
705        let scrubbed = scrub_response(&body, &known);
706
707        assert!(
708            !scrubbed.contains(&b64url_encoded),
709            "SCRUBBING BYPASS: base64url-encoded credential survives in response: {scrubbed}"
710        );
711    }
712
713    #[test]
714    fn scrub_response_uppercase_hex_bypass() {
715        let secret = "sk-key";
716        let upper_hex: String = secret.bytes().map(|b| format!("{:02X}", b)).collect();
717        let lower_hex: String = secret.bytes().map(|b| format!("{:02x}", b)).collect();
718        assert_ne!(
719            upper_hex, lower_hex,
720            "test requires hex representations to differ: upper={upper_hex} lower={lower_hex}"
721        );
722
723        let body = format!("hex={upper_hex} end");
724        let known = secret_vec(&[secret]);
725        let scrubbed = scrub_response(&body, &known);
726
727        assert!(
728            !scrubbed.contains(&upper_hex),
729            "SCRUBBING BYPASS: uppercase-hex credential survives in response: {scrubbed}"
730        );
731    }
732
733    #[test]
734    fn scrub_response_double_url_encoding_bypass() {
735        let secret = "p@ss!";
736        let single_encoded = urlencoding_encode(secret);
737        let double_encoded = urlencoding_encode(&single_encoded);
738
739        assert_ne!(single_encoded, double_encoded);
740
741        let body = format!("reflected={double_encoded} end");
742        let known = secret_vec(&[secret]);
743        let scrubbed = scrub_response(&body, &known);
744
745        assert!(
746            !scrubbed.contains(&double_encoded),
747            "SCRUBBING BYPASS: double-URL-encoded credential survives in response: {scrubbed}"
748        );
749    }
750
751    // -----------------------------------------------------------------------
752    // Memory safety: SecretString Debug/Display never leaks plaintext
753    // -----------------------------------------------------------------------
754
755    #[tokio::test]
756    async fn secret_string_debug_does_not_leak() {
757        let provider = MockProvider::new(&[("api_key", "super-secret-value-12345")]);
758        let body = r#"{"key": "${CRED:api_key}"}"#;
759
760        let result = inject_credentials(body, &[], &provider).await.unwrap();
761
762        // Debug formatting of resolved_values must NOT contain the plaintext.
763        // Note: the body field legitimately contains the injected credential
764        // (it's the rewritten request), so we check resolved_values specifically.
765        let values_debug = format!("{:?}", result.resolved_values);
766        assert!(
767            !values_debug.contains("super-secret-value-12345"),
768            "Debug output of resolved_values must not contain plaintext credential. Got: {values_debug}"
769        );
770        // The SecretString Debug impl should show the redacted wrapper
771        assert!(
772            values_debug.contains("REDACTED") || values_debug.contains("SecretBox"),
773            "Debug output should show redacted wrapper, got: {values_debug}"
774        );
775    }
776
777    #[test]
778    fn scrub_response_with_secret_string() {
779        // Verify that scrub_response works correctly with SecretString values,
780        // scrubbing all encoding variants.
781        let secrets = secret_vec(&["my-api-key-999"]);
782        let b64 = base64_encode_value("my-api-key-999");
783        let hex: String = "my-api-key-999"
784            .bytes()
785            .map(|b| format!("{:02x}", b))
786            .collect();
787
788        let body = format!("plain=my-api-key-999 b64={b64} hex={hex}");
789        let scrubbed = scrub_response(&body, &secrets);
790
791        assert!(
792            !scrubbed.contains("my-api-key-999"),
793            "plaintext must be scrubbed"
794        );
795        assert!(!scrubbed.contains(&b64), "base64 must be scrubbed");
796        assert!(!scrubbed.contains(&hex), "hex must be scrubbed");
797        assert_eq!(
798            scrubbed,
799            "plain=[CREDENTIAL] b64=[CREDENTIAL] hex=[CREDENTIAL]"
800        );
801    }
802
803    #[tokio::test]
804    async fn scrub_response_lowercase_percent_encoding() {
805        let secret = SecretString::from("my-secret/value".to_string());
806        // Lowercase percent-encoded: '/' becomes %2f instead of %2F
807        let response = "result: my-secret%2fvalue";
808        let scrubbed = scrub_response(response, &[secret]);
809        assert!(
810            !scrubbed.contains("my-secret"),
811            "lowercase percent-encoded credential should be scrubbed"
812        );
813        assert!(scrubbed.contains(REDACTED));
814    }
815
816    #[tokio::test]
817    async fn scrub_response_unicode_json_escape() {
818        let secret = SecretString::from("abc".to_string());
819        let response = r#"{"data": "\u0061\u0062\u0063"}"#;
820        let scrubbed = scrub_response(response, &[secret]);
821        assert!(
822            !scrubbed.contains(r"\u0061\u0062\u0063"),
823            "unicode-escaped credential should be scrubbed"
824        );
825        assert!(scrubbed.contains(REDACTED));
826    }
827}