1use regex::Regex;
12use secrecy::{ExposeSecret, SecretString};
13use std::sync::LazyLock;
14use tracing::{debug, warn};
15
16use crate::error::CredentialError;
17use crate::provider::CredentialProvider;
18
19static CRED_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
22 Regex::new(r"\$\{CRED:([A-Za-z0-9_.\-]+)\}").expect("credential pattern is valid regex")
23});
24
25const REDACTED: &str = "[CREDENTIAL]";
27
28#[derive(Debug)]
30pub struct InjectedRequest {
31 pub body: String,
33 pub headers: Vec<(String, String)>,
35 pub resolved_refs: Vec<String>,
37 pub resolved_values: Vec<SecretString>,
41}
42
43pub 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 let new_body = replace_refs(body, provider, &mut resolved_refs, &mut resolved_values).await?;
58
59 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
77pub fn scrub_response(body: &str, known_values: &[SecretString]) -> String {
85 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 if value.len() < 4 {
98 tracing::debug!(
102 len = value.len(),
103 "scrubbing short credential value; consider using longer secrets"
104 );
105 }
106 scrub_single_value(&mut scrubbed, value);
107 }
108 scrubbed
109}
110
111pub fn scrub_response_plain(body: &str, known_values: &[String]) -> String {
116 let mut sorted_values: Vec<&String> = known_values.iter().collect();
117 sorted_values.sort_by_key(|v| std::cmp::Reverse(v.len()));
118
119 let mut scrubbed = body.to_string();
120 for value in sorted_values {
121 if value.is_empty() {
122 continue;
123 }
124 scrub_single_value(&mut scrubbed, value);
125 }
126 scrubbed
127}
128
129fn scrub_single_value(scrubbed: &mut String, value: &str) {
131 if scrubbed.contains(value) {
133 warn!("credential value detected in response body, scrubbing");
134 *scrubbed = scrubbed.replace(value, REDACTED);
135 }
136 let url_encoded = urlencoding_encode(value);
138 if url_encoded != value && scrubbed.contains(&url_encoded) {
139 warn!("URL-encoded credential value detected in response body, scrubbing");
140 *scrubbed = scrubbed.replace(&url_encoded, REDACTED);
141 }
142 let url_lower = urlencoding_encode_lower(value);
144 if url_lower != url_encoded && url_lower != value && scrubbed.contains(&url_lower) {
145 warn!("lowercase-percent-encoded credential value detected in response body, scrubbing");
146 *scrubbed = scrubbed.replace(&url_lower, REDACTED);
147 }
148 if let Ok(json_str) = serde_json::to_string(value) {
150 let json_escaped = &json_str[1..json_str.len() - 1];
152 if json_escaped != value && scrubbed.contains(json_escaped) {
153 warn!("JSON-escaped credential value detected in response body, scrubbing");
154 *scrubbed = scrubbed.replace(json_escaped, REDACTED);
155 }
156 }
157 let hex_encoded: String = value.bytes().map(|b| format!("{:02x}", b)).collect();
159 if scrubbed.contains(&hex_encoded) {
160 warn!("hex-encoded credential value detected in response body, scrubbing");
161 *scrubbed = scrubbed.replace(&hex_encoded, REDACTED);
162 }
163 let hex_upper: String = value.bytes().map(|b| format!("{:02X}", b)).collect();
165 if hex_upper != hex_encoded && scrubbed.contains(&hex_upper) {
166 warn!("uppercase-hex-encoded credential value detected in response body, scrubbing");
167 *scrubbed = scrubbed.replace(&hex_upper, REDACTED);
168 }
169 let b64_encoded = base64_encode_value(value);
171 if b64_encoded != value && scrubbed.contains(&b64_encoded) {
172 warn!("base64-encoded credential value detected in response body, scrubbing");
173 *scrubbed = scrubbed.replace(&b64_encoded, REDACTED);
174 }
175 let b64url_encoded = base64url_encode_value(value);
177 if b64url_encoded != value
178 && b64url_encoded != b64_encoded
179 && scrubbed.contains(&b64url_encoded)
180 {
181 warn!("base64url-encoded credential value detected in response body, scrubbing");
182 *scrubbed = scrubbed.replace(&b64url_encoded, REDACTED);
183 }
184 let unicode_escaped = unicode_json_escape(value);
186 if unicode_escaped != value && scrubbed.contains(&unicode_escaped) {
187 warn!("unicode-escaped credential value detected in response body, scrubbing");
188 *scrubbed = scrubbed.replace(&unicode_escaped, REDACTED);
189 }
190 let double_url_encoded = urlencoding_encode(&url_encoded);
192 if double_url_encoded != url_encoded && scrubbed.contains(&double_url_encoded) {
193 warn!("double-URL-encoded credential value detected in response body, scrubbing");
194 *scrubbed = scrubbed.replace(&double_url_encoded, REDACTED);
195 }
196 let html_decimal = html_entity_encode_decimal(value);
200 if scrubbed.contains(&html_decimal) {
201 warn!("HTML-entity-encoded (decimal) credential value detected in response body, scrubbing");
202 *scrubbed = scrubbed.replace(&html_decimal, REDACTED);
203 }
204 let html_hex = html_entity_encode_hex(value);
205 if html_hex != html_decimal && scrubbed.contains(&html_hex) {
206 warn!("HTML-entity-encoded (hex) credential value detected in response body, scrubbing");
207 *scrubbed = scrubbed.replace(&html_hex, REDACTED);
208 }
209}
210
211fn html_entity_encode_decimal(input: &str) -> String {
213 input.bytes().map(|b| format!("&#{};", b)).collect()
214}
215
216fn html_entity_encode_hex(input: &str) -> String {
218 input.bytes().map(|b| format!("&#x{:02X};", b)).collect()
219}
220
221fn urlencoding_encode(input: &str) -> String {
223 let mut encoded = String::new();
224 for byte in input.bytes() {
225 match byte {
226 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
227 encoded.push(byte as char);
228 }
229 _ => {
230 encoded.push_str(&format!("%{:02X}", byte));
231 }
232 }
233 }
234 encoded
235}
236
237fn urlencoding_encode_lower(input: &str) -> String {
241 let mut encoded = String::new();
242 for byte in input.bytes() {
243 match byte {
244 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
245 encoded.push(byte as char);
246 }
247 _ => {
248 encoded.push_str(&format!("%{:02x}", byte));
249 }
250 }
251 }
252 encoded
253}
254
255fn unicode_json_escape(input: &str) -> String {
259 input.bytes().map(|b| format!("\\u{:04x}", b)).collect()
260}
261
262pub fn find_refs(input: &str) -> Vec<String> {
264 CRED_PATTERN
265 .captures_iter(input)
266 .map(|cap| cap[1].to_string())
267 .collect()
268}
269
270async fn replace_refs(
277 input: &str,
278 provider: &dyn CredentialProvider,
279 resolved: &mut Vec<String>,
280 secret_values: &mut Vec<SecretString>,
281) -> Result<String, CredentialError> {
282 let refs = find_refs(input);
284 if refs.is_empty() {
285 return Ok(input.to_string());
286 }
287
288 let mut resolved_pairs: Vec<(String, SecretString)> = Vec::new();
292 for reference in &refs {
293 let value = provider.resolve(reference).await?;
294 resolved_pairs.push((reference.clone(), value));
295 }
296
297 let mut output = input.to_string();
298 for (reference, secret) in &resolved_pairs {
299 let placeholder = format!("${{CRED:{reference}}}");
300 output = output.replace(&placeholder, secret.expose_secret());
301 if !resolved.contains(reference) {
302 resolved.push(reference.clone());
303 secret_values.push(SecretString::from(secret.expose_secret().to_owned()));
307 }
308 }
309
310 Ok(output)
311}
312
313fn base64url_encode_value(input: &str) -> String {
316 let standard = base64_encode_value(input);
317 standard
318 .replace('+', "-")
319 .replace('/', "_")
320 .trim_end_matches('=')
321 .to_string()
322}
323
324fn base64_encode_value(input: &str) -> String {
326 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
327 let bytes = input.as_bytes();
328 let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
329 for chunk in bytes.chunks(3) {
330 let b0 = chunk[0] as u32;
331 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
332 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
333 let triple = (b0 << 16) | (b1 << 8) | b2;
334 result.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char);
335 result.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char);
336 if chunk.len() > 1 {
337 result.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char);
338 } else {
339 result.push('=');
340 }
341 if chunk.len() > 2 {
342 result.push(ALPHABET[(triple & 0x3F) as usize] as char);
343 } else {
344 result.push('=');
345 }
346 }
347 result
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::provider::{CredentialProvider, CredentialRef};
354 use async_trait::async_trait;
355 use std::collections::HashMap;
356
357 struct MockProvider {
359 store: HashMap<String, String>,
360 }
361
362 impl MockProvider {
363 fn new(entries: &[(&str, &str)]) -> Self {
364 Self {
365 store: entries
366 .iter()
367 .map(|(k, v)| (k.to_string(), v.to_string()))
368 .collect(),
369 }
370 }
371 }
372
373 #[async_trait]
374 impl CredentialProvider for MockProvider {
375 async fn resolve(&self, reference: &str) -> Result<SecretString, CredentialError> {
376 self.store
377 .get(reference)
378 .map(|v| SecretString::from(v.clone()))
379 .ok_or_else(|| CredentialError::NotFound(reference.to_string()))
380 }
381
382 async fn list_refs(&self) -> Result<Vec<CredentialRef>, CredentialError> {
383 Ok(self
384 .store
385 .keys()
386 .map(|k| CredentialRef {
387 name: k.clone(),
388 provider: "mock".into(),
389 last_rotated: None,
390 })
391 .collect())
392 }
393 }
394
395 fn secret_vec(values: &[&str]) -> Vec<SecretString> {
397 values
398 .iter()
399 .map(|v| SecretString::from(v.to_string()))
400 .collect()
401 }
402
403 #[tokio::test]
404 async fn injects_body_credentials() {
405 let provider = MockProvider::new(&[("api_key", "sk-secret-123")]);
406 let body = r#"{"key": "${CRED:api_key}"}"#;
407
408 let result = inject_credentials(body, &[], &provider).await.unwrap();
409 assert_eq!(result.body, r#"{"key": "sk-secret-123"}"#);
410 assert_eq!(result.resolved_refs, vec!["api_key"]);
411 }
412
413 #[tokio::test]
414 async fn injects_header_credentials() {
415 let provider = MockProvider::new(&[("token", "ghp_abc")]);
416 let headers = vec![
417 (
418 "Authorization".to_string(),
419 "Bearer ${CRED:token}".to_string(),
420 ),
421 ("X-Custom".to_string(), "plain-value".to_string()),
422 ];
423
424 let result = inject_credentials("", &headers, &provider).await.unwrap();
425 assert_eq!(result.headers[0].1, "Bearer ghp_abc");
426 assert_eq!(result.headers[1].1, "plain-value");
427 }
428
429 #[tokio::test]
430 async fn multiple_refs_in_body() {
431 let provider = MockProvider::new(&[("a", "AAA"), ("b", "BBB")]);
432 let body = "first=${CRED:a}&second=${CRED:b}";
433
434 let result = inject_credentials(body, &[], &provider).await.unwrap();
435 assert_eq!(result.body, "first=AAA&second=BBB");
436 assert!(result.resolved_refs.contains(&"a".to_string()));
437 assert!(result.resolved_refs.contains(&"b".to_string()));
438 }
439
440 #[tokio::test]
441 async fn unknown_ref_returns_error() {
442 let provider = MockProvider::new(&[]);
443 let body = "key=${CRED:missing}";
444
445 let err = inject_credentials(body, &[], &provider).await.unwrap_err();
446 assert!(matches!(err, CredentialError::NotFound(_)));
447 }
448
449 #[tokio::test]
450 async fn no_refs_is_passthrough() {
451 let provider = MockProvider::new(&[]);
452 let body = "no credential references here";
453
454 let result = inject_credentials(body, &[], &provider).await.unwrap();
455 assert_eq!(result.body, body);
456 assert!(result.resolved_refs.is_empty());
457 }
458
459 #[test]
460 fn scrub_response_masks_leaked_values() {
461 let body = r#"{"echo": "sk-secret-123", "other": "safe"}"#;
462 let known = secret_vec(&["sk-secret-123"]);
463
464 let scrubbed = scrub_response(body, &known);
465 assert_eq!(scrubbed, r#"{"echo": "[CREDENTIAL]", "other": "safe"}"#);
466 }
467
468 #[test]
469 fn scrub_response_no_match_is_passthrough() {
470 let body = "nothing to see here";
471 let known = secret_vec(&["secret"]);
472 let scrubbed = scrub_response(body, &known);
473 assert_eq!(scrubbed, body);
474 }
475
476 #[test]
477 fn scrub_response_empty_value_ignored() {
478 let body = "some body";
479 let known = secret_vec(&[""]);
480 let scrubbed = scrub_response(body, &known);
481 assert_eq!(scrubbed, body);
482 }
483
484 #[test]
485 fn scrub_response_multiple_values() {
486 let body = "has AAA and BBB in it";
487 let known = secret_vec(&["AAA", "BBB"]);
488 let scrubbed = scrub_response(body, &known);
489 assert_eq!(scrubbed, "has [CREDENTIAL] and [CREDENTIAL] in it");
490 }
491
492 #[test]
493 fn find_refs_extracts_all_references() {
494 let input = "${CRED:one} and ${CRED:two.three} and ${CRED:four-five}";
495 let refs = find_refs(input);
496 assert_eq!(refs, vec!["one", "two.three", "four-five"]);
497 }
498
499 #[test]
500 fn find_refs_empty_on_no_match() {
501 assert!(find_refs("no credentials here").is_empty());
502 }
503
504 #[test]
509 fn scrub_response_url_encoded() {
510 let known = secret_vec(&["p@ss w0rd!"]);
511 let body = "the response contains p%40ss%20w0rd%21 in a query string";
512
513 let scrubbed = scrub_response(body, &known);
514 assert_eq!(
515 scrubbed,
516 "the response contains [CREDENTIAL] in a query string"
517 );
518 assert!(!scrubbed.contains("%40"));
519 assert!(!scrubbed.contains("%20"));
520 assert!(!scrubbed.contains("%21"));
521 }
522
523 #[test]
524 fn scrub_response_json_escaped() {
525 let known = secret_vec(&[r#"pass"word\"#]);
526 let body = r#"{"field": "pass\"word\\"}"#;
527
528 let scrubbed = scrub_response(body, &known);
529 assert_eq!(scrubbed, r#"{"field": "[CREDENTIAL]"}"#);
530 assert!(!scrubbed.contains("pass"));
531 assert!(!scrubbed.contains("word"));
532 }
533
534 #[test]
535 fn scrub_response_hex_encoded() {
536 let hex_of_secret = "736563726574";
537 let body = format!("debug dump: hex={hex_of_secret} end");
538 let known = secret_vec(&["secret"]);
539
540 let scrubbed = scrub_response(&body, &known);
541 assert_eq!(scrubbed, "debug dump: hex=[CREDENTIAL] end");
542 assert!(!scrubbed.contains(hex_of_secret));
543 }
544
545 #[test]
546 fn scrub_response_base64_encoded() {
547 let b64 = base64_encode_value("my-secret-key");
548 assert_eq!(b64, "bXktc2VjcmV0LWtleQ==");
549 let body = format!("Authorization: Basic {b64} is here");
550 let known = secret_vec(&["my-secret-key"]);
551
552 let scrubbed = scrub_response(&body, &known);
553 assert_eq!(scrubbed, "Authorization: Basic [CREDENTIAL] is here");
554 assert!(!scrubbed.contains(&b64));
555 }
556
557 #[test]
558 fn scrub_response_longest_first_ordering() {
559 let known = secret_vec(&["abc", "abcdef"]);
560 let body = "values: abcdef and abc end";
561
562 let scrubbed = scrub_response(body, &known);
563 assert_eq!(scrubbed, "values: [CREDENTIAL] and [CREDENTIAL] end");
564 assert!(!scrubbed.contains("abc"));
565 assert!(!scrubbed.contains("abcdef"));
566 }
567
568 #[test]
569 fn scrub_response_all_encodings_simultaneously() {
570 let secret = "s3cr&t!";
571 let url_enc = urlencoding_encode(secret);
572 let hex_enc: String = secret.bytes().map(|b| format!("{:02x}", b)).collect();
573 let b64_enc = base64_encode_value(secret);
574 let json_enc = {
575 let full = serde_json::to_string(secret).unwrap();
576 full[1..full.len() - 1].to_string()
577 };
578
579 let body =
580 format!("plain={secret} url={url_enc} json={json_enc} hex={hex_enc} b64={b64_enc}");
581 let known = secret_vec(&[secret]);
582
583 let scrubbed = scrub_response(&body, &known);
584
585 assert_eq!(
586 scrubbed,
587 "plain=[CREDENTIAL] url=[CREDENTIAL] json=[CREDENTIAL] hex=[CREDENTIAL] b64=[CREDENTIAL]"
588 );
589 assert!(!scrubbed.contains(secret));
590 assert!(!scrubbed.contains(&url_enc));
591 assert!(!scrubbed.contains(&hex_enc));
592 assert!(!scrubbed.contains(&b64_enc));
593 }
594
595 #[tokio::test]
596 async fn scrub_response_partial_injection_prevented() {
597 let provider = MockProvider::new(&[("valid_key", "resolved-value")]);
598 let body = "first=${CRED:valid_key}&second=${CRED:missing_key}";
599
600 let result = inject_credentials(body, &[], &provider).await;
601 assert!(
602 result.is_err(),
603 "injection must fail when any ref is unresolvable"
604 );
605 assert!(
606 matches!(result.unwrap_err(), CredentialError::NotFound(ref name) if name == "missing_key")
607 );
608 }
609
610 #[tokio::test]
611 async fn resolved_values_captured_at_injection_time() {
612 let provider = MockProvider::new(&[("key_a", "alpha-secret"), ("key_b", "beta-secret")]);
613 let body = "a=${CRED:key_a} b=${CRED:key_b}";
614
615 let result = inject_credentials(body, &[], &provider).await.unwrap();
616
617 assert_eq!(result.body, "a=alpha-secret b=beta-secret");
618
619 let exposed: Vec<&str> = result
620 .resolved_values
621 .iter()
622 .map(|s| s.expose_secret())
623 .collect();
624 assert!(exposed.contains(&"alpha-secret"));
625 assert!(exposed.contains(&"beta-secret"));
626 assert_eq!(result.resolved_values.len(), 2);
627
628 let leaked_response = "upstream echoed alpha-secret back";
629 let scrubbed = scrub_response(leaked_response, &result.resolved_values);
630 assert_eq!(scrubbed, "upstream echoed [CREDENTIAL] back");
631 }
632
633 #[test]
638 fn recursive_ref_not_expanded() {
639 let input = "${CRED:${CRED:inner}}";
640 let refs = find_refs(input);
641 assert_eq!(
642 refs,
643 vec!["inner"],
644 "only the inner ref 'inner' should be matched. Got: {:?}",
645 refs
646 );
647
648 let input3 = "${CRED:${CRED:${CRED:deep}}}";
649 let refs3 = find_refs(input3);
650 assert_eq!(
651 refs3,
652 vec!["deep"],
653 "only the innermost ref 'deep' should be matched. Got: {:?}",
654 refs3
655 );
656 }
657
658 #[tokio::test]
659 async fn recursive_injection_does_not_re_expand() {
660 let provider = MockProvider::new(&[("outer", "${CRED:inner}"), ("inner", "real-secret")]);
661 let body = "key=${CRED:outer}";
662
663 let result = inject_credentials(body, &[], &provider).await.unwrap();
664
665 assert_eq!(
666 result.body, "key=${CRED:inner}",
667 "credential values containing ${{CRED:...}} must NOT be re-expanded"
668 );
669 assert_eq!(result.resolved_refs, vec!["outer"]);
670 }
671
672 #[test]
677 fn credential_with_special_chars_in_value() {
678 let special_value = r"p@$$w0rd!.*+";
679 let body = format!("the password is {} here", special_value);
680 let known = secret_vec(&[special_value]);
681
682 let scrubbed = scrub_response(&body, &known);
683 assert_eq!(
684 scrubbed, "the password is [CREDENTIAL] here",
685 "credential with regex metacharacters must be scrubbed literally"
686 );
687 assert!(
688 !scrubbed.contains(special_value),
689 "original credential value must not survive in scrubbed output"
690 );
691 }
692
693 #[test]
694 fn credential_ref_name_injection_rejected() {
695 assert!(find_refs("${CRED:../../etc/passwd}").is_empty());
696 assert!(find_refs("${CRED:..\\..\\windows\\system32}").is_empty());
697 assert!(find_refs("${CRED:ref;rm -rf /}").is_empty());
698 assert!(find_refs("${CRED:ref$(whoami)}").is_empty());
699 assert!(find_refs("${CRED:ref`id`}").is_empty());
700 assert!(find_refs("${CRED:ref name}").is_empty());
701 assert!(find_refs("${CRED:ref&other}").is_empty());
702 assert!(find_refs("${CRED:ref|pipe}").is_empty());
703 assert!(find_refs("${CRED:}").is_empty());
704 assert_eq!(
705 find_refs("${CRED:valid.ref-name_123}"),
706 vec!["valid.ref-name_123"]
707 );
708 assert_eq!(find_refs("${CRED:API_KEY}"), vec!["API_KEY"]);
709 assert_eq!(find_refs("${CRED:my-secret.v2}"), vec!["my-secret.v2"]);
710 }
711
712 fn test_base64url_encode(input: &str) -> String {
717 let standard = base64_encode_value(input);
718 standard
719 .replace('+', "-")
720 .replace('/', "_")
721 .trim_end_matches('=')
722 .to_string()
723 }
724
725 #[test]
726 fn scrub_response_base64url_bypass() {
727 let secret = "secret+key/value";
728 let b64url_encoded = test_base64url_encode(secret);
729 let b64_standard = base64_encode_value(secret);
730 assert_ne!(
731 b64url_encoded, b64_standard,
732 "base64url and standard base64 should differ for this input"
733 );
734
735 let body = format!("token={b64url_encoded} end");
736 let known = secret_vec(&[secret]);
737 let scrubbed = scrub_response(&body, &known);
738
739 assert!(
740 !scrubbed.contains(&b64url_encoded),
741 "SCRUBBING BYPASS: base64url-encoded credential survives in response: {scrubbed}"
742 );
743 }
744
745 #[test]
746 fn scrub_response_uppercase_hex_bypass() {
747 let secret = "sk-key";
748 let upper_hex: String = secret.bytes().map(|b| format!("{:02X}", b)).collect();
749 let lower_hex: String = secret.bytes().map(|b| format!("{:02x}", b)).collect();
750 assert_ne!(
751 upper_hex, lower_hex,
752 "test requires hex representations to differ: upper={upper_hex} lower={lower_hex}"
753 );
754
755 let body = format!("hex={upper_hex} end");
756 let known = secret_vec(&[secret]);
757 let scrubbed = scrub_response(&body, &known);
758
759 assert!(
760 !scrubbed.contains(&upper_hex),
761 "SCRUBBING BYPASS: uppercase-hex credential survives in response: {scrubbed}"
762 );
763 }
764
765 #[test]
766 fn scrub_response_double_url_encoding_bypass() {
767 let secret = "p@ss!";
768 let single_encoded = urlencoding_encode(secret);
769 let double_encoded = urlencoding_encode(&single_encoded);
770
771 assert_ne!(single_encoded, double_encoded);
772
773 let body = format!("reflected={double_encoded} end");
774 let known = secret_vec(&[secret]);
775 let scrubbed = scrub_response(&body, &known);
776
777 assert!(
778 !scrubbed.contains(&double_encoded),
779 "SCRUBBING BYPASS: double-URL-encoded credential survives in response: {scrubbed}"
780 );
781 }
782
783 #[tokio::test]
788 async fn secret_string_debug_does_not_leak() {
789 let provider = MockProvider::new(&[("api_key", "super-secret-value-12345")]);
790 let body = r#"{"key": "${CRED:api_key}"}"#;
791
792 let result = inject_credentials(body, &[], &provider).await.unwrap();
793
794 let values_debug = format!("{:?}", result.resolved_values);
798 assert!(
799 !values_debug.contains("super-secret-value-12345"),
800 "Debug output of resolved_values must not contain plaintext credential. Got: {values_debug}"
801 );
802 assert!(
804 values_debug.contains("REDACTED") || values_debug.contains("SecretBox"),
805 "Debug output should show redacted wrapper, got: {values_debug}"
806 );
807 }
808
809 #[test]
810 fn scrub_response_with_secret_string() {
811 let secrets = secret_vec(&["my-api-key-999"]);
814 let b64 = base64_encode_value("my-api-key-999");
815 let hex: String = "my-api-key-999"
816 .bytes()
817 .map(|b| format!("{:02x}", b))
818 .collect();
819
820 let body = format!("plain=my-api-key-999 b64={b64} hex={hex}");
821 let scrubbed = scrub_response(&body, &secrets);
822
823 assert!(
824 !scrubbed.contains("my-api-key-999"),
825 "plaintext must be scrubbed"
826 );
827 assert!(!scrubbed.contains(&b64), "base64 must be scrubbed");
828 assert!(!scrubbed.contains(&hex), "hex must be scrubbed");
829 assert_eq!(
830 scrubbed,
831 "plain=[CREDENTIAL] b64=[CREDENTIAL] hex=[CREDENTIAL]"
832 );
833 }
834
835 #[tokio::test]
836 async fn scrub_response_lowercase_percent_encoding() {
837 let secret = SecretString::from("my-secret/value".to_string());
838 let response = "result: my-secret%2fvalue";
840 let scrubbed = scrub_response(response, &[secret]);
841 assert!(
842 !scrubbed.contains("my-secret"),
843 "lowercase percent-encoded credential should be scrubbed"
844 );
845 assert!(scrubbed.contains(REDACTED));
846 }
847
848 #[tokio::test]
849 async fn scrub_response_unicode_json_escape() {
850 let secret = SecretString::from("abc".to_string());
851 let response = r#"{"data": "\u0061\u0062\u0063"}"#;
852 let scrubbed = scrub_response(response, &[secret]);
853 assert!(
854 !scrubbed.contains(r"\u0061\u0062\u0063"),
855 "unicode-escaped credential should be scrubbed"
856 );
857 assert!(scrubbed.contains(REDACTED));
858 }
859}