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