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) || excluded(excludes, whole) {
250                continue;
251            }
252            let start = caps.get(0).map_or(0, |m| m.start());
253            matches.push(SecretMatch {
254                pattern_name: name,
255                line_number: offset_to_line(start),
256                redacted_preview: make_redacted_preview(whole),
257            });
258        }
259    }
260
261    for pattern_str in custom_patterns {
262        if let Ok(re) = Regex::new(pattern_str) {
263            for m in re.find_iter(&normalized) {
264                if excluded(excludes, m.as_str()) {
265                    continue;
266                }
267                matches.push(SecretMatch {
268                    pattern_name: "custom_pattern",
269                    line_number: offset_to_line(m.start()),
270                    redacted_preview: make_redacted_preview(m.as_str()),
271                });
272            }
273        }
274    }
275
276    matches
277}
278
279/// How many matches the `custom_patterns` of `config` alone have in
280/// `content` — no built-in pattern runs. The custom-pattern step of
281/// `collect_matches` on its own: the same normalized text, the same
282/// `exclude_patterns`, a pattern that does not compile is skipped. For a
283/// caller that has counted the built-in patterns elsewhere (the gateway's
284/// whole-text pass for custom patterns that can match across lines).
285pub(crate) fn custom_pattern_hits(content: &str, config: &SecretDetectionConfig) -> usize {
286    let excludes = compile_excludes(&config.exclude_patterns);
287    let normalized = crate::core::redaction::normalize_secret_text(content);
288    config
289        .custom_patterns
290        .iter()
291        .filter_map(|pattern| Regex::new(pattern).ok())
292        .map(|re| {
293            re.find_iter(&normalized)
294                .filter(|found| !excluded(&excludes, found.as_str()))
295                .count()
296        })
297        .sum()
298}
299
300pub fn detect_secrets(content: &str) -> Vec<SecretMatch> {
301    collect_matches(content, &[], &[])
302}
303
304pub fn detect_secrets_with_custom(content: &str, custom_patterns: &[String]) -> Vec<SecretMatch> {
305    collect_matches(content, custom_patterns, &[])
306}
307
308pub fn scan_and_redact(
309    content: &str,
310    config: &SecretDetectionConfig,
311) -> (String, Vec<SecretMatch>) {
312    if !config.enabled {
313        return (content.to_string(), Vec::new());
314    }
315
316    let excludes = compile_excludes(&config.exclude_patterns);
317    let matches = collect_matches(content, &config.custom_patterns, &excludes);
318
319    if matches.is_empty() || !config.redact {
320        return (content.to_string(), matches);
321    }
322
323    // The same masker and the same labels as every other redaction pass
324    // (`redaction::redact_counting`): one spelling per secret kind.
325    let mut redacted = crate::core::redaction::redact_text_with_excludes(content, &excludes);
326
327    for pattern_str in &config.custom_patterns {
328        if let Ok(re) = Regex::new(pattern_str) {
329            redacted = re
330                .replace_all(&redacted, |caps: &regex::Captures| {
331                    let whole = caps.get(0).map_or("", |m| m.as_str());
332                    if excluded(&excludes, whole) {
333                        return whole.to_string();
334                    }
335                    "[REDACTED:custom_pattern]".to_string()
336                })
337                .to_string();
338        }
339    }
340
341    (redacted, matches)
342}
343
344pub fn scan_and_redact_from_config(content: &str) -> (String, Vec<SecretMatch>) {
345    let cfg = Config::load();
346    scan_and_redact(content, &cfg.secret_detection)
347}
348
349#[cfg(test)]
350#[path = "security_corpus_tests.rs"]
351mod security_corpus_tests;
352
353#[cfg(test)]
354pub mod tests {
355    use super::*;
356
357    #[test]
358    fn detects_aws_key() {
359        let input = concat!("aws_key = AK", "IAIOSFODNN7EXAMPLE");
360        let matches = detect_secrets(input);
361        assert!(matches.iter().any(|m| m.pattern_name == "aws_key"));
362    }
363
364    #[test]
365    fn detects_private_key_header() {
366        let input = concat!("-----BEGIN RSA PRIVATE", " KEY-----\nMIIEpAIB...");
367        let matches = detect_secrets(input);
368        assert!(matches.iter().any(|m| m.pattern_name == "private_key"));
369    }
370
371    #[test]
372    fn detects_github_token() {
373        let input = concat!(
374            "export GITHUB_TOKEN=gh",
375            "p_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijk"
376        );
377        let matches = detect_secrets(input);
378        assert!(matches.iter().any(|m| m.pattern_name == "github_token"));
379    }
380
381    #[test]
382    fn detects_anthropic_key() {
383        let input = concat!("ANTHROPIC_API_KEY=sk-ant-api03-", "abcdef1234567890ABCD");
384        let matches = detect_secrets(input);
385        assert!(matches.iter().any(|m| m.pattern_name == "anthropic_key"));
386    }
387
388    #[test]
389    fn detects_openai_key() {
390        let input = concat!("OPENAI_API_KEY=sk-", "abcdefghijklmnopqrstuvwx");
391        let matches = detect_secrets(input);
392        assert!(matches.iter().any(|m| m.pattern_name == "openai_key"));
393    }
394
395    #[test]
396    fn detects_gitlab_pat() {
397        let input = concat!("token = glpat-", "xxxxxxxxxxxxxxxxxxxx");
398        let matches = detect_secrets(input);
399        assert!(matches.iter().any(|m| m.pattern_name == "gitlab_pat"));
400    }
401
402    #[test]
403    fn detects_generic_api_key() {
404        let input = concat!("api_key = abcdefghijklmnop", "qrstuvwxyz1234567890");
405        let matches = detect_secrets(input);
406        assert!(matches.iter().any(
407            |m| m.pattern_name == "generic_api_key" || m.pattern_name == "high_entropy_secret"
408        ));
409    }
410
411    #[test]
412    fn clean_content_returns_empty() {
413        let input = "fn main() { println!(\"hello world\"); }";
414        let matches = detect_secrets(input);
415        assert!(matches.is_empty());
416    }
417
418    #[test]
419    fn detects_jwt() {
420        let input = concat!("token = eyJhbGciOiJIU", "zI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkw");
421        let matches = detect_secrets(input);
422        assert!(matches.iter().any(|m| m.pattern_name == "jwt"));
423    }
424
425    #[test]
426    fn detects_slack_token() {
427        let input = concat!("SLACK_TOKEN=xoxb-12345", "67890-abcdefghij");
428        let matches = detect_secrets(input);
429        assert!(matches.iter().any(|m| m.pattern_name == "slack_token"));
430    }
431
432    #[test]
433    fn detects_stripe_key() {
434        let input = concat!("stripe_key = sk_", "live_abcdefghij1234567890");
435        let matches = detect_secrets(input);
436        assert!(matches.iter().any(|m| m.pattern_name == "stripe_key"));
437    }
438
439    #[test]
440    fn detects_db_url() {
441        let input = concat!(
442            "DATABASE_URL=postgres://user:pass",
443            "word@localhost:5432/db"
444        );
445        let matches = detect_secrets(input);
446        assert!(matches.iter().any(|m| m.pattern_name == "db_url"));
447    }
448
449    /// Ops telemetry (3.11.4): `ctx_read` withheld whole files with
450    /// `(secret.redacted, redaction.incomplete)` — the redacted database URL
451    /// `scheme://[REDACTED:db_url]@host` matched the detector's
452    /// `user:password@` again. Redacted output must never re-match.
453    #[test]
454    fn redacted_database_urls_do_not_match_again() {
455        let url = |scheme: &str, user: &str, pw: &str| {
456            format!("{scheme}{}{user}:{pw}{}db.internal:5432/app", "://", "@")
457        };
458        let cases = [
459            url("postgres", "app", "s3cretPassw0rd"),
460            url("postgresql", "app", "Zq7Lm2Vx"),
461            url("mysql", "root", "hunter2hunter2"),
462            url("mongodb+srv", "admin", "Xy9pQ2rT"),
463            url("redis", "", "Zk3mP9qL"),
464            url("postgres", "app", "p@ss"),
465        ];
466        let report_only = SecretDetectionConfig {
467            redact: false,
468            ..SecretDetectionConfig::default()
469        };
470        for case in cases {
471            let input = format!("DATABASE_URL={case}\nkeep after\n");
472            assert!(
473                detect_secrets(&input)
474                    .iter()
475                    .any(|m| m.pattern_name == "db_url"),
476                "credential must be detected: {input}"
477            );
478            let (redacted, _) = scan_and_redact(&input, &SecretDetectionConfig::default());
479            assert!(
480                !redacted.contains("Passw0rd") && !redacted.contains("ss@db"),
481                "{redacted}"
482            );
483            assert!(redacted.ends_with("keep after\n"), "{redacted}");
484            let (_, residual) = scan_and_redact(&redacted, &report_only);
485            assert!(
486                residual.is_empty(),
487                "re-scan of {redacted} found {residual:?}"
488            );
489        }
490        // A documentation placeholder is neither reported nor rewritten.
491        let doc = format!("DATABASE_URL={}\n", url("postgres", "user", "changeme"));
492        assert!(detect_secrets(&doc).is_empty(), "{doc}");
493    }
494
495    #[test]
496    fn detects_npm_token() {
497        let input = concat!("NPM_TOKEN=npm_abcd", "efghij1234567890");
498        let matches = detect_secrets(input);
499        assert!(matches.iter().any(|m| m.pattern_name == "npm_token"));
500    }
501
502    #[test]
503    fn detects_github_fine_grained() {
504        let input = concat!("token = github_pat_ABC", "DEFGHIJKLMNOPQRSTuv");
505        let matches = detect_secrets(input);
506        assert!(
507            matches
508                .iter()
509                .any(|m| m.pattern_name == "github_fine_grained")
510        );
511    }
512
513    #[test]
514    fn redacted_preview_format() {
515        let preview = make_redacted_preview(concat!("AK", "IAIOSFODNN7EXAMPLE"));
516        assert!(preview.starts_with("AKIA"));
517        assert!(preview.ends_with("LE"));
518        assert!(preview.contains("***"));
519    }
520
521    #[test]
522    fn redacted_preview_short_string() {
523        let preview = make_redacted_preview("short");
524        assert_eq!(preview, "***");
525    }
526
527    #[test]
528    fn scan_and_redact_replaces_secrets() {
529        let cfg = SecretDetectionConfig {
530            enabled: true,
531            redact: true,
532            ..Default::default()
533        };
534        let input = concat!("key = AK", "IAIOSFODNN7EXAMPLE");
535        let (redacted, matches) = scan_and_redact(input, &cfg);
536        assert!(!matches.is_empty());
537        assert!(redacted.contains("[REDACTED:AWS key]"));
538        assert!(!redacted.contains(concat!("AK", "IAIOSFODNN7EXAMPLE")));
539    }
540
541    #[test]
542    fn scan_without_redact_preserves_content() {
543        let cfg = SecretDetectionConfig {
544            enabled: true,
545            redact: false,
546            ..Default::default()
547        };
548        let input = concat!("key = AK", "IAIOSFODNN7EXAMPLE");
549        let (output, matches) = scan_and_redact(input, &cfg);
550        assert!(!matches.is_empty());
551        assert_eq!(output, input);
552    }
553
554    #[test]
555    fn disabled_detection_returns_unchanged() {
556        let cfg = SecretDetectionConfig {
557            enabled: false,
558            redact: true,
559            ..Default::default()
560        };
561        let input = concat!("key = AK", "IAIOSFODNN7EXAMPLE");
562        let (output, matches) = scan_and_redact(input, &cfg);
563        assert!(matches.is_empty());
564        assert_eq!(output, input);
565    }
566
567    #[test]
568    fn custom_pattern_detected() {
569        let cfg = SecretDetectionConfig {
570            enabled: true,
571            redact: true,
572            custom_patterns: vec![r"MYCORP_[A-Z]{10,}".to_string()],
573            ..Default::default()
574        };
575        let input = "value is MYCORP_ABCDEFGHIJKLMNO here";
576        let (redacted, matches) = scan_and_redact(input, &cfg);
577        assert!(matches.iter().any(|m| m.pattern_name == "custom_pattern"));
578        assert!(redacted.contains("[REDACTED:custom_pattern]"));
579    }
580
581    #[test]
582    fn line_numbers_are_correct() {
583        let input = concat!("line1\nline2\nAK", "IAIOSFODNN7EXAMPLE\nline4");
584        let matches = detect_secrets(input);
585        assert!(!matches.is_empty());
586        assert_eq!(matches[0].line_number, 3);
587    }
588
589    #[test]
590    fn key_value_secret_patterns_do_not_cross_lines() {
591        let input = [
592            "token:",
593            "\n",
594            "ABCDEFGHIJKLMNOP",
595            "QRSTUVWXYZabcdef",
596            "012345",
597        ]
598        .concat();
599        let matches = detect_secrets(&input);
600        assert!(
601            !matches
602                .iter()
603                .any(|m| matches!(m.pattern_name, "generic_api_key" | "high_entropy_secret")),
604            "a valueless key must not consume the next line: {matches:?}"
605        );
606
607        let same_line_input =
608            ["token: ", "ABCDEFGHIJKLMNOP", "QRSTUVWXYZabcdef", "012345"].concat();
609        let matches = detect_secrets(&same_line_input);
610        assert!(
611            matches.iter().any(|m| m.pattern_name == "generic_api_key"),
612            "same-line key/value secrets must still be detected: {matches:?}"
613        );
614    }
615
616    #[test]
617    fn multiple_secrets_on_different_lines() {
618        let input = concat!(
619            "AK",
620            "IAIOSFODNN7EXAMPLE\nclean\nsk-abcdef",
621            "ghijklmnopqrstuvwx"
622        );
623        let matches = detect_secrets(input);
624        assert!(matches.len() >= 2);
625        let aws = matches
626            .iter()
627            .find(|m| m.pattern_name == "aws_key")
628            .unwrap();
629        assert_eq!(aws.line_number, 1);
630        let oai = matches
631            .iter()
632            .find(|m| m.pattern_name == "openai_key")
633            .unwrap();
634        assert_eq!(oai.line_number, 3);
635    }
636
637    #[test]
638    fn ec_private_key_detected() {
639        let input = concat!("-----BEGIN EC PRIVATE", " KEY-----");
640        let matches = detect_secrets(input);
641        assert!(matches.iter().any(|m| m.pattern_name == "private_key"));
642    }
643
644    #[test]
645    fn openssh_private_key_detected() {
646        let input = concat!("-----BEGIN OPENSSH PRIVATE", " KEY-----");
647        let matches = detect_secrets(input);
648        assert!(matches.iter().any(|m| m.pattern_name == "private_key"));
649    }
650
651    /// The context-gateway admission path relies on this contract: report-only mode
652    /// returns the input untouched, custom/exclude patterns are honoured, and redacted
653    /// output has no residual match (admission re-scans it and withholds on a hit).
654    /// Normalization (zero-width, full-width) only affects matching, never the bytes
655    /// outside a redacted span.
656    #[test]
657    fn scan_and_redact_keeps_the_gateway_contract() {
658        let aws = format!("AK\u{200b}IA{}", "Q7".repeat(8));
659        let key_block = [
660            "-----BEGIN PRIVATE KEY",
661            "-----\nbody-line-one\nbody-line-two\n-----END PRIVATE KEY",
662            "-----",
663        ]
664        .concat();
665        let excluded_key = concat!("AK", "IAIOSFODNN7EXAMPLE");
666        let input = format!(
667            "keep before\nAWS_ACCESS_KEY_ID={aws}\n{key_block}\nticket EMP-4711\nsample {excluded_key}\nkeep after\n"
668        );
669        let cfg = SecretDetectionConfig {
670            custom_patterns: vec![r"EMP-\d{4}".into()],
671            exclude_patterns: vec![excluded_key.into()],
672            ..SecretDetectionConfig::default()
673        };
674
675        let report_only = SecretDetectionConfig {
676            redact: false,
677            ..cfg.clone()
678        };
679        let (unchanged, found) = scan_and_redact(&input, &report_only);
680        assert_eq!(unchanged, input, "report-only mode must not touch the text");
681        assert!(!found.is_empty());
682
683        let (redacted, _) = scan_and_redact(&input, &cfg);
684        assert!(redacted.starts_with("keep before\n"), "{redacted}");
685        assert!(redacted.ends_with("keep after\n"), "{redacted}");
686        assert!(
687            redacted.contains(excluded_key),
688            "excluded value kept verbatim"
689        );
690        assert!(!redacted.contains("EMP-4711"), "custom pattern redacted");
691        assert!(!redacted.contains("body-line-one"), "{redacted}");
692        let (_, residual) = scan_and_redact(&redacted, &report_only);
693        assert!(
694            residual.is_empty(),
695            "redacted output must not match again: {residual:?}\n{redacted}"
696        );
697    }
698}