1use std::sync::OnceLock;
2
3use regex::Regex;
4
5use crate::core::config::{Config, SecretDetectionConfig};
6
7macro_rules! static_regex {
8 ($pattern:expr_2021) => {{
9 static RE: OnceLock<Regex> = OnceLock::new();
10 RE.get_or_init(|| Regex::new($pattern).expect(concat!("invalid regex: ", $pattern)))
11 }};
12}
13
14#[derive(Debug, Clone)]
15pub struct SecretMatch {
16 pub pattern_name: &'static str,
17 pub line_number: usize,
18 pub redacted_preview: String,
19}
20
21fn aws_key_re() -> &'static Regex {
22 static_regex!(r"(?:AKIA|ASIA)[0-9A-Z]{16}")
23}
24
25fn private_key_re() -> &'static Regex {
26 static_regex!(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----")
27}
28
29fn github_token_re() -> &'static Regex {
30 static_regex!(r"gh[ps]_[A-Za-z0-9_]{36,}")
31}
32
33pub(crate) fn anthropic_key_re() -> &'static Regex {
34 static_regex!(r"sk-ant-[A-Za-z0-9_\-]{20,}")
35}
36
37pub(crate) fn openai_key_re() -> &'static Regex {
38 static_regex!(r"\bsk-(?:(?:proj|svcacct)-[A-Za-z0-9_\-]{20,}|[A-Za-z0-9]{20,})")
39}
40
41fn generic_api_key_re() -> &'static Regex {
49 static_regex!(
50 r#"(?im)((?:^|[^a-z0-9])(?:aws[_-]?secret[_-]?access[_-]?key|secret[_-]?access[_-]?key|api[_-]?key|secret[_-]?key|token|password|passwd|access[_-]?token|client[_-]?secret)(?:\\?["'])?[ \t]*[=:][ \t]*(?:\\?["'])?)([A-Za-z0-9_+/=\-]{20,})"#
51 )
52}
53
54fn high_entropy_b64_re() -> &'static Regex {
55 static_regex!(
56 r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)(?:\\?["'])?[ \t]*[=:][ \t]*(?:\\?["'])?)([A-Za-z0-9+/=\-_]{40,})"#
57 )
58}
59
60fn url_encoded_secret_re() -> &'static Regex {
61 static_regex!(
62 r#"(?im)((?:^|[^a-z0-9])(?:aws[_-]?secret[_-]?access[_-]?key|secret[_-]?access[_-]?key|api[_-]?key|secret[_-]?key|token|password|passwd|access[_-]?token|client[_-]?secret)(?:\\?["'])?[ \t]*[=:][ \t]*(?:\\?["'])?)((?:%[0-9a-f]{2}|[A-Za-z0-9_\-]){20,})"#
63 )
64}
65
66fn authorization_header_re() -> &'static Regex {
67 static_regex!(r"(?i)authorization[ \t]*:[ \t]*(?:bearer|basic)[ \t]+[A-Za-z0-9_+/=\-.]{4,}")
68}
69
70fn bearer_token_re() -> &'static Regex {
75 static_regex!(r"(?i)bearer[ \t]+([A-Za-z0-9_\-.]{8,})")
76}
77
78fn gitlab_pat_re() -> &'static Regex {
79 static_regex!(r"glpat-[A-Za-z0-9_\-]{20,}")
80}
81
82fn jwt_re() -> &'static Regex {
83 static_regex!(r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}")
84}
85
86fn slack_token_re() -> &'static Regex {
87 static_regex!(r"xox[bpas]-[0-9a-zA-Z\-]{10,}")
88}
89
90fn stripe_key_re() -> &'static Regex {
91 static_regex!(r"[sr]k_live_[0-9a-zA-Z]{10,}")
92}
93
94fn npm_token_re() -> &'static Regex {
95 static_regex!(r"npm_[A-Za-z0-9]{10,}")
96}
97
98fn github_fine_grained_re() -> &'static Regex {
99 static_regex!(r"github_pat_[A-Za-z0-9_]{20,}")
100}
101
102const BUILTIN_PATTERNS: &[(&str, fn() -> &'static Regex)] = &[
103 ("aws_key", aws_key_re),
104 ("authorization_header", authorization_header_re),
105 ("private_key", private_key_re),
106 ("github_token", github_token_re),
107 ("github_fine_grained", github_fine_grained_re),
108 ("anthropic_key", anthropic_key_re),
109 ("openai_key", openai_key_re),
110 ("gitlab_pat", gitlab_pat_re),
111 ("jwt", jwt_re),
112 ("slack_token", slack_token_re),
113 ("stripe_key", stripe_key_re),
114 ("npm_token", npm_token_re),
115];
116
117const GUARDED_PATTERNS: &[(&str, fn() -> &'static Regex)] = &[
121 ("generic_api_key", generic_api_key_re),
122 ("high_entropy_secret", high_entropy_b64_re),
123 ("url_encoded_secret", url_encoded_secret_re),
124];
125
126fn guarded_match_is_benign(caps: ®ex::Captures) -> bool {
130 let quoted = caps
133 .get(1)
134 .is_some_and(|prefix| prefix.as_str().ends_with(['"', '\'']));
135 caps.get(2).is_some_and(|value| {
136 if quoted {
137 crate::core::redaction::is_benign_quoted_value(value.as_str())
138 } else {
139 crate::core::redaction::is_benign_secret_value(value.as_str())
140 }
141 })
142}
143
144fn compile_excludes(patterns: &[String]) -> Vec<Regex> {
147 patterns.iter().filter_map(|p| Regex::new(p).ok()).collect()
148}
149
150fn excluded(excludes: &[Regex], matched: &str) -> bool {
151 excludes.iter().any(|ex| ex.is_match(matched))
152}
153
154fn make_redacted_preview(matched: &str) -> String {
155 let chars: Vec<char> = matched.chars().collect();
156 if chars.len() <= 6 {
157 return "***".to_string();
158 }
159 let prefix: String = chars[..4].iter().collect();
160 let suffix: String = chars[chars.len() - 2..].iter().collect();
161 format!("{prefix}***{suffix}")
162}
163
164fn collect_matches(
165 content: &str,
166 custom_patterns: &[String],
167 excludes: &[Regex],
168) -> Vec<SecretMatch> {
169 let mut matches = Vec::new();
170 let normalized = crate::core::redaction::normalize_secret_text(content);
171
172 let line_offsets: std::cell::OnceCell<Vec<usize>> = std::cell::OnceCell::new();
175
176 let offset_to_line = |byte_offset: usize| -> usize {
177 let line_offsets = line_offsets.get_or_init(|| {
178 std::iter::once(0)
179 .chain(normalized.match_indices('\n').map(|(i, _)| i + 1))
180 .collect()
181 });
182 match line_offsets.binary_search(&byte_offset) {
183 Ok(i) => i + 1,
184 Err(i) => i,
185 }
186 };
187
188 for &(name, regex_fn) in BUILTIN_PATTERNS {
189 let re = regex_fn();
190 for m in re.find_iter(&normalized) {
191 if excluded(excludes, m.as_str()) {
192 continue;
193 }
194 matches.push(SecretMatch {
195 pattern_name: name,
196 line_number: offset_to_line(m.start()),
197 redacted_preview: make_redacted_preview(m.as_str()),
198 });
199 }
200 }
201
202 for caps in bearer_token_re().captures_iter(&normalized) {
203 let Some(whole) = caps.get(0) else {
204 continue;
205 };
206 let credential = caps.get(1).is_some_and(|value| {
207 crate::core::redaction::bearer_value_is_credential(value.as_str())
208 });
209 if !credential || excluded(excludes, whole.as_str()) {
210 continue;
211 }
212 matches.push(SecretMatch {
213 pattern_name: "bearer_token",
214 line_number: offset_to_line(whole.start()),
215 redacted_preview: make_redacted_preview(whole.as_str()),
216 });
217 }
218
219 for (name, re) in [
224 ("db_url", crate::core::redaction::database_url_re()),
225 (
226 "url_credentials",
227 crate::core::redaction::url_credentials_re(),
228 ),
229 ] {
230 for caps in re.captures_iter(&normalized) {
231 let Some(whole) = caps.get(0) else {
232 continue;
233 };
234 if !crate::core::redaction::database_url_has_credential(&caps)
235 || excluded(excludes, whole.as_str())
236 {
237 continue;
238 }
239 matches.push(SecretMatch {
240 pattern_name: name,
241 line_number: offset_to_line(whole.start()),
242 redacted_preview: make_redacted_preview(whole.as_str()),
243 });
244 }
245 }
246
247 for &(name, regex_fn) in GUARDED_PATTERNS {
248 let re = regex_fn();
249 for caps in re.captures_iter(&normalized) {
250 let whole = caps.get(0).map_or("", |m| m.as_str());
251 if guarded_match_is_benign(&caps)
252 || (name == "high_entropy_secret"
253 && caps.get(2).is_some_and(|value| {
254 caps.get(1).is_some_and(|prefix| {
255 crate::core::redaction::is_fact_key_slug(
256 prefix.as_str(),
257 value.as_str(),
258 )
259 }) || caps.get(0).is_some_and(|found| {
260 crate::core::redaction::is_verified_signer_key(
263 &normalized[..found.start()],
264 value.as_str(),
265 )
266 })
267 }))
268 || excluded(excludes, whole)
269 {
270 continue;
271 }
272 let start = caps.get(0).map_or(0, |m| m.start());
273 matches.push(SecretMatch {
274 pattern_name: name,
275 line_number: offset_to_line(start),
276 redacted_preview: make_redacted_preview(whole),
277 });
278 }
279 }
280
281 for pattern_str in custom_patterns {
282 if let Ok(re) = Regex::new(pattern_str) {
283 for m in re.find_iter(&normalized) {
284 if excluded(excludes, m.as_str()) {
285 continue;
286 }
287 matches.push(SecretMatch {
288 pattern_name: "custom_pattern",
289 line_number: offset_to_line(m.start()),
290 redacted_preview: make_redacted_preview(m.as_str()),
291 });
292 }
293 }
294 }
295
296 matches
297}
298
299pub(crate) fn custom_pattern_hits(content: &str, config: &SecretDetectionConfig) -> usize {
306 let excludes = compile_excludes(&config.exclude_patterns);
307 let normalized = crate::core::redaction::normalize_secret_text(content);
308 config
309 .custom_patterns
310 .iter()
311 .filter_map(|pattern| Regex::new(pattern).ok())
312 .map(|re| {
313 re.find_iter(&normalized)
314 .filter(|found| !excluded(&excludes, found.as_str()))
315 .count()
316 })
317 .sum()
318}
319
320pub fn detect_secrets(content: &str) -> Vec<SecretMatch> {
321 collect_matches(content, &[], &[])
322}
323
324pub fn detect_secrets_with_custom(content: &str, custom_patterns: &[String]) -> Vec<SecretMatch> {
325 collect_matches(content, custom_patterns, &[])
326}
327
328pub fn scan_and_redact(
329 content: &str,
330 config: &SecretDetectionConfig,
331) -> (String, Vec<SecretMatch>) {
332 if !config.enabled {
333 return (content.to_string(), Vec::new());
334 }
335
336 let excludes = compile_excludes(&config.exclude_patterns);
337 let matches = collect_matches(content, &config.custom_patterns, &excludes);
338
339 if matches.is_empty() || !config.redact {
340 return (content.to_string(), matches);
341 }
342
343 let mut redacted = crate::core::redaction::redact_text_with_excludes(content, &excludes);
346
347 for pattern_str in &config.custom_patterns {
348 if let Ok(re) = Regex::new(pattern_str) {
349 redacted = re
350 .replace_all(&redacted, |caps: ®ex::Captures| {
351 let whole = caps.get(0).map_or("", |m| m.as_str());
352 if excluded(&excludes, whole) {
353 return whole.to_string();
354 }
355 "[REDACTED:custom_pattern]".to_string()
356 })
357 .to_string();
358 }
359 }
360
361 (redacted, matches)
362}
363
364pub fn scan_and_redact_from_config(content: &str) -> (String, Vec<SecretMatch>) {
365 let cfg = Config::load();
366 scan_and_redact(content, &cfg.secret_detection)
367}
368
369#[cfg(test)]
370#[path = "security_corpus_tests.rs"]
371mod security_corpus_tests;
372
373#[cfg(test)]
374pub mod tests {
375 use super::*;
376
377 #[test]
378 fn detects_aws_key() {
379 let input = concat!("aws_key = AK", "IAIOSFODNN7EXAMPLE");
380 let matches = detect_secrets(input);
381 assert!(matches.iter().any(|m| m.pattern_name == "aws_key"));
382 }
383
384 #[test]
385 fn detects_private_key_header() {
386 let input = concat!("-----BEGIN RSA PRIVATE", " KEY-----\nMIIEpAIB...");
387 let matches = detect_secrets(input);
388 assert!(matches.iter().any(|m| m.pattern_name == "private_key"));
389 }
390
391 #[test]
392 fn detects_github_token() {
393 let input = concat!(
394 "export GITHUB_TOKEN=gh",
395 "p_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijk"
396 );
397 let matches = detect_secrets(input);
398 assert!(matches.iter().any(|m| m.pattern_name == "github_token"));
399 }
400
401 #[test]
402 fn detects_anthropic_key() {
403 let input = concat!("ANTHROPIC_API_KEY=sk-ant-api03-", "abcdef1234567890ABCD");
404 let matches = detect_secrets(input);
405 assert!(matches.iter().any(|m| m.pattern_name == "anthropic_key"));
406 }
407
408 #[test]
409 fn detects_openai_key() {
410 let input = concat!("OPENAI_API_KEY=sk-", "abcdefghijklmnopqrstuvwx");
411 let matches = detect_secrets(input);
412 assert!(matches.iter().any(|m| m.pattern_name == "openai_key"));
413 }
414
415 #[test]
416 fn detects_gitlab_pat() {
417 let input = concat!("token = glpat-", "xxxxxxxxxxxxxxxxxxxx");
418 let matches = detect_secrets(input);
419 assert!(matches.iter().any(|m| m.pattern_name == "gitlab_pat"));
420 }
421
422 #[test]
423 fn detects_generic_api_key() {
424 let input = concat!("api_key = abcdefghijklmnop", "qrstuvwxyz1234567890");
425 let matches = detect_secrets(input);
426 assert!(matches.iter().any(
427 |m| m.pattern_name == "generic_api_key" || m.pattern_name == "high_entropy_secret"
428 ));
429 }
430
431 #[test]
432 fn clean_content_returns_empty() {
433 let input = "fn main() { println!(\"hello world\"); }";
434 let matches = detect_secrets(input);
435 assert!(matches.is_empty());
436 }
437
438 #[test]
439 fn detects_jwt() {
440 let input = concat!("token = eyJhbGciOiJIU", "zI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkw");
441 let matches = detect_secrets(input);
442 assert!(matches.iter().any(|m| m.pattern_name == "jwt"));
443 }
444
445 #[test]
446 fn detects_slack_token() {
447 let input = concat!("SLACK_TOKEN=xoxb-12345", "67890-abcdefghij");
448 let matches = detect_secrets(input);
449 assert!(matches.iter().any(|m| m.pattern_name == "slack_token"));
450 }
451
452 #[test]
453 fn detects_stripe_key() {
454 let input = concat!("stripe_key = sk_", "live_abcdefghij1234567890");
455 let matches = detect_secrets(input);
456 assert!(matches.iter().any(|m| m.pattern_name == "stripe_key"));
457 }
458
459 #[test]
460 fn detects_db_url() {
461 let input = concat!(
462 "DATABASE_URL=postgres://user:pass",
463 "word@localhost:5432/db"
464 );
465 let matches = detect_secrets(input);
466 assert!(matches.iter().any(|m| m.pattern_name == "db_url"));
467 }
468
469 #[test]
474 fn redacted_database_urls_do_not_match_again() {
475 let url = |scheme: &str, user: &str, pw: &str| {
476 format!("{scheme}{}{user}:{pw}{}db.internal:5432/app", "://", "@")
477 };
478 let cases = [
479 url("postgres", "app", "s3cretPassw0rd"),
480 url("postgresql", "app", "Zq7Lm2Vx"),
481 url("mysql", "root", "hunter2hunter2"),
482 url("mongodb+srv", "admin", "Xy9pQ2rT"),
483 url("redis", "", "Zk3mP9qL"),
484 url("postgres", "app", "p@ss"),
485 ];
486 let report_only = SecretDetectionConfig {
487 redact: false,
488 ..SecretDetectionConfig::default()
489 };
490 for case in cases {
491 let input = format!("DATABASE_URL={case}\nkeep after\n");
492 assert!(
493 detect_secrets(&input)
494 .iter()
495 .any(|m| m.pattern_name == "db_url"),
496 "credential must be detected: {input}"
497 );
498 let (redacted, _) = scan_and_redact(&input, &SecretDetectionConfig::default());
499 assert!(
500 !redacted.contains("Passw0rd") && !redacted.contains("ss@db"),
501 "{redacted}"
502 );
503 assert!(redacted.ends_with("keep after\n"), "{redacted}");
504 let (_, residual) = scan_and_redact(&redacted, &report_only);
505 assert!(
506 residual.is_empty(),
507 "re-scan of {redacted} found {residual:?}"
508 );
509 }
510 let doc = format!("DATABASE_URL={}\n", url("postgres", "user", "changeme"));
512 assert!(detect_secrets(&doc).is_empty(), "{doc}");
513 }
514
515 #[test]
516 fn detects_npm_token() {
517 let input = concat!("NPM_TOKEN=npm_abcd", "efghij1234567890");
518 let matches = detect_secrets(input);
519 assert!(matches.iter().any(|m| m.pattern_name == "npm_token"));
520 }
521
522 #[test]
523 fn detects_github_fine_grained() {
524 let input = concat!("token = github_pat_ABC", "DEFGHIJKLMNOPQRSTuv");
525 let matches = detect_secrets(input);
526 assert!(
527 matches
528 .iter()
529 .any(|m| m.pattern_name == "github_fine_grained")
530 );
531 }
532
533 #[test]
534 fn redacted_preview_format() {
535 let preview = make_redacted_preview(concat!("AK", "IAIOSFODNN7EXAMPLE"));
536 assert!(preview.starts_with("AKIA"));
537 assert!(preview.ends_with("LE"));
538 assert!(preview.contains("***"));
539 }
540
541 #[test]
542 fn redacted_preview_short_string() {
543 let preview = make_redacted_preview("short");
544 assert_eq!(preview, "***");
545 }
546
547 #[test]
548 fn scan_and_redact_replaces_secrets() {
549 let cfg = SecretDetectionConfig {
550 enabled: true,
551 redact: true,
552 ..Default::default()
553 };
554 let input = concat!("key = AK", "IAIOSFODNN7EXAMPLE");
555 let (redacted, matches) = scan_and_redact(input, &cfg);
556 assert!(!matches.is_empty());
557 assert!(redacted.contains("[REDACTED:AWS key]"));
558 assert!(!redacted.contains(concat!("AK", "IAIOSFODNN7EXAMPLE")));
559 }
560
561 #[test]
562 fn scan_without_redact_preserves_content() {
563 let cfg = SecretDetectionConfig {
564 enabled: true,
565 redact: false,
566 ..Default::default()
567 };
568 let input = concat!("key = AK", "IAIOSFODNN7EXAMPLE");
569 let (output, matches) = scan_and_redact(input, &cfg);
570 assert!(!matches.is_empty());
571 assert_eq!(output, input);
572 }
573
574 #[test]
575 fn disabled_detection_returns_unchanged() {
576 let cfg = SecretDetectionConfig {
577 enabled: false,
578 redact: true,
579 ..Default::default()
580 };
581 let input = concat!("key = AK", "IAIOSFODNN7EXAMPLE");
582 let (output, matches) = scan_and_redact(input, &cfg);
583 assert!(matches.is_empty());
584 assert_eq!(output, input);
585 }
586
587 #[test]
588 fn custom_pattern_detected() {
589 let cfg = SecretDetectionConfig {
590 enabled: true,
591 redact: true,
592 custom_patterns: vec![r"MYCORP_[A-Z]{10,}".to_string()],
593 ..Default::default()
594 };
595 let input = "value is MYCORP_ABCDEFGHIJKLMNO here";
596 let (redacted, matches) = scan_and_redact(input, &cfg);
597 assert!(matches.iter().any(|m| m.pattern_name == "custom_pattern"));
598 assert!(redacted.contains("[REDACTED:custom_pattern]"));
599 }
600
601 #[test]
602 fn line_numbers_are_correct() {
603 let input = concat!("line1\nline2\nAK", "IAIOSFODNN7EXAMPLE\nline4");
604 let matches = detect_secrets(input);
605 assert!(!matches.is_empty());
606 assert_eq!(matches[0].line_number, 3);
607 }
608
609 #[test]
610 fn key_value_secret_patterns_do_not_cross_lines() {
611 let input = [
612 "token:",
613 "\n",
614 "ABCDEFGHIJKLMNOP",
615 "QRSTUVWXYZabcdef",
616 "012345",
617 ]
618 .concat();
619 let matches = detect_secrets(&input);
620 assert!(
621 !matches
622 .iter()
623 .any(|m| matches!(m.pattern_name, "generic_api_key" | "high_entropy_secret")),
624 "a valueless key must not consume the next line: {matches:?}"
625 );
626
627 let same_line_input =
628 ["token: ", "ABCDEFGHIJKLMNOP", "QRSTUVWXYZabcdef", "012345"].concat();
629 let matches = detect_secrets(&same_line_input);
630 assert!(
631 matches.iter().any(|m| m.pattern_name == "generic_api_key"),
632 "same-line key/value secrets must still be detected: {matches:?}"
633 );
634 }
635
636 #[test]
637 fn multiple_secrets_on_different_lines() {
638 let input = concat!(
639 "AK",
640 "IAIOSFODNN7EXAMPLE\nclean\nsk-abcdef",
641 "ghijklmnopqrstuvwx"
642 );
643 let matches = detect_secrets(input);
644 assert!(matches.len() >= 2);
645 let aws = matches
646 .iter()
647 .find(|m| m.pattern_name == "aws_key")
648 .unwrap();
649 assert_eq!(aws.line_number, 1);
650 let oai = matches
651 .iter()
652 .find(|m| m.pattern_name == "openai_key")
653 .unwrap();
654 assert_eq!(oai.line_number, 3);
655 }
656
657 #[test]
658 fn ec_private_key_detected() {
659 let input = concat!("-----BEGIN EC PRIVATE", " KEY-----");
660 let matches = detect_secrets(input);
661 assert!(matches.iter().any(|m| m.pattern_name == "private_key"));
662 }
663
664 #[test]
665 fn openssh_private_key_detected() {
666 let input = concat!("-----BEGIN OPENSSH PRIVATE", " KEY-----");
667 let matches = detect_secrets(input);
668 assert!(matches.iter().any(|m| m.pattern_name == "private_key"));
669 }
670
671 #[test]
677 fn scan_and_redact_keeps_the_gateway_contract() {
678 let aws = format!("AK\u{200b}IA{}", "Q7".repeat(8));
679 let key_block = [
680 "-----BEGIN PRIVATE KEY",
681 "-----\nbody-line-one\nbody-line-two\n-----END PRIVATE KEY",
682 "-----",
683 ]
684 .concat();
685 let excluded_key = concat!("AK", "IAIOSFODNN7EXAMPLE");
686 let input = format!(
687 "keep before\nAWS_ACCESS_KEY_ID={aws}\n{key_block}\nticket EMP-4711\nsample {excluded_key}\nkeep after\n"
688 );
689 let cfg = SecretDetectionConfig {
690 custom_patterns: vec![r"EMP-\d{4}".into()],
691 exclude_patterns: vec![excluded_key.into()],
692 ..SecretDetectionConfig::default()
693 };
694
695 let report_only = SecretDetectionConfig {
696 redact: false,
697 ..cfg.clone()
698 };
699 let (unchanged, found) = scan_and_redact(&input, &report_only);
700 assert_eq!(unchanged, input, "report-only mode must not touch the text");
701 assert!(!found.is_empty());
702
703 let (redacted, _) = scan_and_redact(&input, &cfg);
704 assert!(redacted.starts_with("keep before\n"), "{redacted}");
705 assert!(redacted.ends_with("keep after\n"), "{redacted}");
706 assert!(
707 redacted.contains(excluded_key),
708 "excluded value kept verbatim"
709 );
710 assert!(!redacted.contains("EMP-4711"), "custom pattern redacted");
711 assert!(!redacted.contains("body-line-one"), "{redacted}");
712 let (_, residual) = scan_and_redact(&redacted, &report_only);
713 assert!(
714 residual.is_empty(),
715 "redacted output must not match again: {residual:?}\n{redacted}"
716 );
717 }
718}