Skip to main content

lean_ctx/core/
secret_detection.rs

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
41// #718 word boundaries for the generic key/value patterns: the keyword must
42// not be a camelCase subword (`superuserPassword`), while SNAKE_CASE env
43// names (`MY_TOKEN=…`) keep matching — `_` stays a permitted predecessor.
44// Group 1 = predecessor + key prefix (kept on redaction), group 2 = value
45// (guarded: identifier references and placeholders are benign).
46// #1830: blanks only around the separator — a valueless `token:` line must not
47// take the next line for its value.
48fn 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
70/// A bare `Bearer <token>` outside an `Authorization:` header (curl flags, logs).
71/// Group 1 is filtered by `redaction::bearer_value_is_credential`, the same check
72/// the core redactor applies, so prose ("Bearer authentication") stays clean and
73/// the two layers cannot disagree.
74fn 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
117/// Key/value patterns with a kept prefix (group 1) and a guarded value
118/// (group 2): identifier references, placeholders and language literals are
119/// benign (#718) and neither reported nor redacted.
120const 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
126/// True when a guarded-pattern match should be skipped: the value is benign
127/// (#718) — shares the benign-value heuristics with `core::redaction` so the
128/// two layers cannot drift.
129fn guarded_match_is_benign(caps: &regex::Captures) -> bool {
130    // A quoted value is a literal, never an identifier reference — same rule as
131    // the core redactor, so the scanner cannot report "clean" for what it redacts.
132    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
144/// Compile the subtractive `exclude_patterns` from config (#718). Invalid
145/// regexes are skipped — a broken exclude must never disable detection.
146fn 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    // Built on the first match only: most content has none. Offsets index
173    // the normalized text the patterns run on.
174    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    // Same rule as the redactor: a placeholder password or an already
220    // redacted URL is no credential, so redacted output never re-matches.
221    // Database URLs and every other URL with userinfo share that test
222    // (GHSA-xmx8-69qv-7f86: `https://user:****@host` stays deliverable).
223    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                            // The redactor's "Generic long secret" rule keeps
261                            // a verified package signer key (GHSA-xmx8).
262                            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
299/// How many matches the `custom_patterns` of `config` alone have in
300/// `content` — no built-in pattern runs. The custom-pattern step of
301/// `collect_matches` on its own: the same normalized text, the same
302/// `exclude_patterns`, a pattern that does not compile is skipped. For a
303/// caller that has counted the built-in patterns elsewhere (the gateway's
304/// whole-text pass for custom patterns that can match across lines).
305pub(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    // The same masker and the same labels as every other redaction pass
344    // (`redaction::redact_counting`): one spelling per secret kind.
345    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: &regex::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    /// Ops telemetry (3.11.4): `ctx_read` withheld whole files with
470    /// `(secret.redacted, redaction.incomplete)` — the redacted database URL
471    /// `scheme://[REDACTED:db_url]@host` matched the detector's
472    /// `user:password@` again. Redacted output must never re-match.
473    #[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        // A documentation placeholder is neither reported nor rewritten.
511        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    /// The context-gateway admission path relies on this contract: report-only mode
672    /// returns the input untouched, custom/exclude patterns are honoured, and redacted
673    /// output has no residual match (admission re-scans it and withholds on a hit).
674    /// Normalization (zero-width, full-width) only affects matching, never the bytes
675    /// outside a redacted span.
676    #[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}