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