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
94/// A password in the userinfo of any other URL (`https://user:pw@host`).
95fn 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
125/// Key/value patterns with a kept prefix (group 1) and a guarded value
126/// (group 2): identifier references, placeholders and language literals are
127/// benign (#718) and neither reported nor redacted.
128const 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
134/// True when a guarded-pattern match should be skipped: the value is benign
135/// (#718) — shares the benign-value heuristics with `core::redaction` so the
136/// two layers cannot drift.
137fn guarded_match_is_benign(caps: &regex::Captures) -> bool {
138    // A quoted value is a literal, never an identifier reference — same rule as
139    // the core redactor, so the scanner cannot report "clean" for what it redacts.
140    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
152/// Compile the subtractive `exclude_patterns` from config (#718). Invalid
153/// regexes are skipped — a broken exclude must never disable detection.
154fn 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    // Built on the first match only: most content has none. Offsets index
181    // the normalized text the patterns run on.
182    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    // Same rule as the redactor: a placeholder password or an already
228    // redacted URL is no credential, so redacted output never re-matches.
229    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
290/// How many matches the `custom_patterns` of `config` alone have in
291/// `content` — no built-in pattern runs. The custom-pattern step of
292/// `collect_matches` on its own: the same normalized text, the same
293/// `exclude_patterns`, a pattern that does not compile is skipped. For a
294/// caller that has counted the built-in patterns elsewhere (the gateway's
295/// whole-text pass for custom patterns that can match across lines).
296pub(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    // The same masker and the same labels as every other redaction pass
335    // (`redaction::redact_counting`): one spelling per secret kind.
336    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: &regex::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    /// Ops telemetry (3.11.4): `ctx_read` withheld whole files with
461    /// `(secret.redacted, redaction.incomplete)` — the redacted database URL
462    /// `scheme://[REDACTED:db_url]@host` matched the detector's
463    /// `user:password@` again. Redacted output must never re-match.
464    #[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        // A documentation placeholder is neither reported nor rewritten.
502        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    /// The context-gateway admission path relies on this contract: report-only mode
663    /// returns the input untouched, custom/exclude patterns are honoured, and redacted
664    /// output has no residual match (admission re-scans it and withholds on a hit).
665    /// Normalization (zero-width, full-width) only affects matching, never the bytes
666    /// outside a redacted span.
667    #[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}