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 scrub_single_value(&mut scrubbed, value);
98 }
99 scrubbed
100}
101
102pub 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
120fn scrub_single_value(scrubbed: &mut String, value: &str) {
122 if scrubbed.contains(value) {
124 warn!("credential value detected in response body, scrubbing");
125 *scrubbed = scrubbed.replace(value, REDACTED);
126 }
127 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 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 if let Ok(json_str) = serde_json::to_string(value) {
141 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 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 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 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 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 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 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
189fn 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
205fn 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
223fn unicode_json_escape(input: &str) -> String {
227 input.bytes().map(|b| format!("\\u{:04x}", b)).collect()
228}
229
230pub 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
238async 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 let refs = find_refs(input);
252 if refs.is_empty() {
253 return Ok(input.to_string());
254 }
255
256 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 secret_values.push(SecretString::from(secret.expose_secret().to_owned()));
275 }
276 }
277
278 Ok(output)
279}
280
281fn 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
292fn 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 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 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 #[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 #[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 #[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 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 #[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 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 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 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 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}