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[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
33fn anthropic_key_re() -> &'static Regex {
34    static_regex!(r"sk-ant-[A-Za-z0-9_\-]{20,}")
35}
36
37fn openai_key_re() -> &'static Regex {
38    static_regex!(r"sk-[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).
46fn generic_api_key_re() -> &'static Regex {
47    static_regex!(
48        r#"(?im)((?:^|[^a-z0-9])(?:api[_-]?key|secret[_-]?key|token|password|passwd|access[_-]?token|client[_-]?secret)\s*[=:]\s*)(['"]?[a-zA-Z0-9_\-]{20,})"#
49    )
50}
51
52fn high_entropy_b64_re() -> &'static Regex {
53    static_regex!(
54        r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)\s*[=:]\s*)(['"]?[A-Za-z0-9+/=\-_]{40,})"#
55    )
56}
57
58fn gitlab_pat_re() -> &'static Regex {
59    static_regex!(r"glpat-[A-Za-z0-9_\-]{20,}")
60}
61
62fn jwt_re() -> &'static Regex {
63    static_regex!(r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}")
64}
65
66fn slack_token_re() -> &'static Regex {
67    static_regex!(r"xox[bpas]-[0-9a-zA-Z\-]{10,}")
68}
69
70fn stripe_key_re() -> &'static Regex {
71    static_regex!(r"[sr]k_live_[0-9a-zA-Z]{10,}")
72}
73
74fn db_url_re() -> &'static Regex {
75    static_regex!(r"(?:postgres|mysql|mongodb|redis)://[^\s]+:[^\s]+@")
76}
77
78fn npm_token_re() -> &'static Regex {
79    static_regex!(r"npm_[A-Za-z0-9]{10,}")
80}
81
82fn github_fine_grained_re() -> &'static Regex {
83    static_regex!(r"github_pat_[A-Za-z0-9_]{20,}")
84}
85
86const BUILTIN_PATTERNS: &[(&str, fn() -> &'static Regex)] = &[
87    ("aws_key", aws_key_re),
88    ("private_key", private_key_re),
89    ("github_token", github_token_re),
90    ("github_fine_grained", github_fine_grained_re),
91    ("anthropic_key", anthropic_key_re),
92    ("openai_key", openai_key_re),
93    ("gitlab_pat", gitlab_pat_re),
94    ("jwt", jwt_re),
95    ("slack_token", slack_token_re),
96    ("stripe_key", stripe_key_re),
97    ("db_url", db_url_re),
98    ("npm_token", npm_token_re),
99];
100
101/// Key/value patterns with a kept prefix (group 1) and a guarded value
102/// (group 2): identifier references, placeholders and language literals are
103/// benign (#718) and neither reported nor redacted.
104const GUARDED_PATTERNS: &[(&str, fn() -> &'static Regex)] = &[
105    ("generic_api_key", generic_api_key_re),
106    ("high_entropy_secret", high_entropy_b64_re),
107];
108
109/// True when a guarded-pattern match should be skipped: the value is benign
110/// (#718) — shares the benign-value heuristics with `core::redaction` so the
111/// two layers cannot drift.
112fn guarded_match_is_benign(caps: &regex::Captures) -> bool {
113    caps.get(2)
114        .is_some_and(|value| crate::core::redaction::is_benign_secret_value(value.as_str()))
115}
116
117/// Compile the subtractive `exclude_patterns` from config (#718). Invalid
118/// regexes are skipped — a broken exclude must never disable detection.
119fn compile_excludes(patterns: &[String]) -> Vec<Regex> {
120    patterns.iter().filter_map(|p| Regex::new(p).ok()).collect()
121}
122
123fn excluded(excludes: &[Regex], matched: &str) -> bool {
124    excludes.iter().any(|ex| ex.is_match(matched))
125}
126
127fn make_redacted_preview(matched: &str) -> String {
128    let chars: Vec<char> = matched.chars().collect();
129    if chars.len() <= 6 {
130        return "***".to_string();
131    }
132    let prefix: String = chars[..4].iter().collect();
133    let suffix: String = chars[chars.len() - 2..].iter().collect();
134    format!("{prefix}***{suffix}")
135}
136
137fn collect_matches(
138    content: &str,
139    custom_patterns: &[String],
140    excludes: &[Regex],
141) -> Vec<SecretMatch> {
142    let mut matches = Vec::new();
143
144    let line_offsets: Vec<usize> = std::iter::once(0)
145        .chain(content.match_indices('\n').map(|(i, _)| i + 1))
146        .collect();
147
148    let offset_to_line = |byte_offset: usize| -> usize {
149        match line_offsets.binary_search(&byte_offset) {
150            Ok(i) => i + 1,
151            Err(i) => i,
152        }
153    };
154
155    for &(name, regex_fn) in BUILTIN_PATTERNS {
156        let re = regex_fn();
157        for m in re.find_iter(content) {
158            if excluded(excludes, m.as_str()) {
159                continue;
160            }
161            matches.push(SecretMatch {
162                pattern_name: name,
163                line_number: offset_to_line(m.start()),
164                redacted_preview: make_redacted_preview(m.as_str()),
165            });
166        }
167    }
168
169    for &(name, regex_fn) in GUARDED_PATTERNS {
170        let re = regex_fn();
171        for caps in re.captures_iter(content) {
172            let whole = caps.get(0).map_or("", |m| m.as_str());
173            if guarded_match_is_benign(&caps) || excluded(excludes, whole) {
174                continue;
175            }
176            let start = caps.get(0).map_or(0, |m| m.start());
177            matches.push(SecretMatch {
178                pattern_name: name,
179                line_number: offset_to_line(start),
180                redacted_preview: make_redacted_preview(whole),
181            });
182        }
183    }
184
185    for pattern_str in custom_patterns {
186        if let Ok(re) = Regex::new(pattern_str) {
187            for m in re.find_iter(content) {
188                if excluded(excludes, m.as_str()) {
189                    continue;
190                }
191                matches.push(SecretMatch {
192                    pattern_name: "custom_pattern",
193                    line_number: offset_to_line(m.start()),
194                    redacted_preview: make_redacted_preview(m.as_str()),
195                });
196            }
197        }
198    }
199
200    matches
201}
202
203pub fn detect_secrets(content: &str) -> Vec<SecretMatch> {
204    collect_matches(content, &[], &[])
205}
206
207pub fn detect_secrets_with_custom(content: &str, custom_patterns: &[String]) -> Vec<SecretMatch> {
208    collect_matches(content, custom_patterns, &[])
209}
210
211pub fn scan_and_redact(
212    content: &str,
213    config: &SecretDetectionConfig,
214) -> (String, Vec<SecretMatch>) {
215    if !config.enabled {
216        return (content.to_string(), Vec::new());
217    }
218
219    let excludes = compile_excludes(&config.exclude_patterns);
220    let matches = collect_matches(content, &config.custom_patterns, &excludes);
221
222    if matches.is_empty() || !config.redact {
223        return (content.to_string(), matches);
224    }
225
226    let mut redacted = content.to_string();
227    for &(name, regex_fn) in BUILTIN_PATTERNS {
228        let re = regex_fn();
229        redacted = re
230            .replace_all(&redacted, |caps: &regex::Captures| {
231                let whole = caps.get(0).map_or("", |m| m.as_str());
232                if excluded(&excludes, whole) {
233                    return whole.to_string();
234                }
235                format!("[REDACTED:{name}]")
236            })
237            .to_string();
238    }
239
240    for &(name, regex_fn) in GUARDED_PATTERNS {
241        let re = regex_fn();
242        redacted = re
243            .replace_all(&redacted, |caps: &regex::Captures| {
244                let whole = caps.get(0).map_or("", |m| m.as_str());
245                if guarded_match_is_benign(caps) || excluded(&excludes, whole) {
246                    return whole.to_string();
247                }
248                let prefix = caps.get(1).map_or("", |m| m.as_str());
249                format!("{prefix}[REDACTED:{name}]")
250            })
251            .to_string();
252    }
253
254    for pattern_str in &config.custom_patterns {
255        if let Ok(re) = Regex::new(pattern_str) {
256            redacted = re
257                .replace_all(&redacted, |caps: &regex::Captures| {
258                    let whole = caps.get(0).map_or("", |m| m.as_str());
259                    if excluded(&excludes, whole) {
260                        return whole.to_string();
261                    }
262                    "[REDACTED:custom_pattern]".to_string()
263                })
264                .to_string();
265        }
266    }
267
268    (redacted, matches)
269}
270
271pub fn scan_and_redact_from_config(content: &str) -> (String, Vec<SecretMatch>) {
272    let cfg = Config::load();
273    scan_and_redact(content, &cfg.secret_detection)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn detects_aws_key() {
282        let input = "aws_key = AKIAIOSFODNN7EXAMPLE";
283        let matches = detect_secrets(input);
284        assert!(matches.iter().any(|m| m.pattern_name == "aws_key"));
285    }
286
287    #[test]
288    fn detects_private_key_header() {
289        let input = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIB...";
290        let matches = detect_secrets(input);
291        assert!(matches.iter().any(|m| m.pattern_name == "private_key"));
292    }
293
294    #[test]
295    fn detects_github_token() {
296        let input = "export GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl";
297        let matches = detect_secrets(input);
298        assert!(matches.iter().any(|m| m.pattern_name == "github_token"));
299    }
300
301    #[test]
302    fn detects_anthropic_key() {
303        let input = "ANTHROPIC_API_KEY=sk-ant-api03-abcdef1234567890ABCD";
304        let matches = detect_secrets(input);
305        assert!(matches.iter().any(|m| m.pattern_name == "anthropic_key"));
306    }
307
308    #[test]
309    fn detects_openai_key() {
310        let input = "OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwx";
311        let matches = detect_secrets(input);
312        assert!(matches.iter().any(|m| m.pattern_name == "openai_key"));
313    }
314
315    #[test]
316    fn detects_gitlab_pat() {
317        let input = "token = glpat-xxxxxxxxxxxxxxxxxxxx";
318        let matches = detect_secrets(input);
319        assert!(matches.iter().any(|m| m.pattern_name == "gitlab_pat"));
320    }
321
322    #[test]
323    fn detects_generic_api_key() {
324        let input = "api_key = abcdefghijklmnopqrstuvwxyz1234567890";
325        let matches = detect_secrets(input);
326        assert!(matches.iter().any(
327            |m| m.pattern_name == "generic_api_key" || m.pattern_name == "high_entropy_secret"
328        ));
329    }
330
331    #[test]
332    fn clean_content_returns_empty() {
333        let input = "fn main() { println!(\"hello world\"); }";
334        let matches = detect_secrets(input);
335        assert!(matches.is_empty());
336    }
337
338    #[test]
339    fn detects_jwt() {
340        let input = "token = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkw";
341        let matches = detect_secrets(input);
342        assert!(matches.iter().any(|m| m.pattern_name == "jwt"));
343    }
344
345    #[test]
346    fn detects_slack_token() {
347        let input = "SLACK_TOKEN=xoxb-1234567890-abcdefghij";
348        let matches = detect_secrets(input);
349        assert!(matches.iter().any(|m| m.pattern_name == "slack_token"));
350    }
351
352    #[test]
353    fn detects_stripe_key() {
354        let input = "stripe_key = sk_live_abcdefghij1234567890";
355        let matches = detect_secrets(input);
356        assert!(matches.iter().any(|m| m.pattern_name == "stripe_key"));
357    }
358
359    #[test]
360    fn detects_db_url() {
361        let input = "DATABASE_URL=postgres://user:password@localhost:5432/db";
362        let matches = detect_secrets(input);
363        assert!(matches.iter().any(|m| m.pattern_name == "db_url"));
364    }
365
366    #[test]
367    fn detects_npm_token() {
368        let input = "NPM_TOKEN=npm_abcdefghij1234567890";
369        let matches = detect_secrets(input);
370        assert!(matches.iter().any(|m| m.pattern_name == "npm_token"));
371    }
372
373    #[test]
374    fn detects_github_fine_grained() {
375        let input = "token = github_pat_ABCDEFGHIJKLMNOPQRSTuvwx";
376        let matches = detect_secrets(input);
377        assert!(
378            matches
379                .iter()
380                .any(|m| m.pattern_name == "github_fine_grained")
381        );
382    }
383
384    #[test]
385    fn redacted_preview_format() {
386        let preview = make_redacted_preview("AKIAIOSFODNN7EXAMPLE");
387        assert!(preview.starts_with("AKIA"));
388        assert!(preview.ends_with("LE"));
389        assert!(preview.contains("***"));
390    }
391
392    #[test]
393    fn redacted_preview_short_string() {
394        let preview = make_redacted_preview("short");
395        assert_eq!(preview, "***");
396    }
397
398    #[test]
399    fn scan_and_redact_replaces_secrets() {
400        let cfg = SecretDetectionConfig {
401            enabled: true,
402            redact: true,
403            ..Default::default()
404        };
405        let input = "key = AKIAIOSFODNN7EXAMPLE";
406        let (redacted, matches) = scan_and_redact(input, &cfg);
407        assert!(!matches.is_empty());
408        assert!(redacted.contains("[REDACTED:aws_key]"));
409        assert!(!redacted.contains("AKIAIOSFODNN7EXAMPLE"));
410    }
411
412    #[test]
413    fn scan_without_redact_preserves_content() {
414        let cfg = SecretDetectionConfig {
415            enabled: true,
416            redact: false,
417            ..Default::default()
418        };
419        let input = "key = AKIAIOSFODNN7EXAMPLE";
420        let (output, matches) = scan_and_redact(input, &cfg);
421        assert!(!matches.is_empty());
422        assert_eq!(output, input);
423    }
424
425    #[test]
426    fn disabled_detection_returns_unchanged() {
427        let cfg = SecretDetectionConfig {
428            enabled: false,
429            redact: true,
430            ..Default::default()
431        };
432        let input = "key = AKIAIOSFODNN7EXAMPLE";
433        let (output, matches) = scan_and_redact(input, &cfg);
434        assert!(matches.is_empty());
435        assert_eq!(output, input);
436    }
437
438    #[test]
439    fn custom_pattern_detected() {
440        let cfg = SecretDetectionConfig {
441            enabled: true,
442            redact: true,
443            custom_patterns: vec![r"MYCORP_[A-Z]{10,}".to_string()],
444            ..Default::default()
445        };
446        let input = "value is MYCORP_ABCDEFGHIJKLMNO here";
447        let (redacted, matches) = scan_and_redact(input, &cfg);
448        assert!(matches.iter().any(|m| m.pattern_name == "custom_pattern"));
449        assert!(redacted.contains("[REDACTED:custom_pattern]"));
450    }
451
452    #[test]
453    fn line_numbers_are_correct() {
454        let input = "line1\nline2\nAKIAIOSFODNN7EXAMPLE\nline4";
455        let matches = detect_secrets(input);
456        assert!(!matches.is_empty());
457        assert_eq!(matches[0].line_number, 3);
458    }
459
460    #[test]
461    fn multiple_secrets_on_different_lines() {
462        let input = "AKIAIOSFODNN7EXAMPLE\nclean\nsk-abcdefghijklmnopqrstuvwx";
463        let matches = detect_secrets(input);
464        assert!(matches.len() >= 2);
465        let aws = matches
466            .iter()
467            .find(|m| m.pattern_name == "aws_key")
468            .unwrap();
469        assert_eq!(aws.line_number, 1);
470        let oai = matches
471            .iter()
472            .find(|m| m.pattern_name == "openai_key")
473            .unwrap();
474        assert_eq!(oai.line_number, 3);
475    }
476
477    #[test]
478    fn ec_private_key_detected() {
479        let input = "-----BEGIN EC PRIVATE KEY-----";
480        let matches = detect_secrets(input);
481        assert!(matches.iter().any(|m| m.pattern_name == "private_key"));
482    }
483
484    #[test]
485    fn openssh_private_key_detected() {
486        let input = "-----BEGIN OPENSSH PRIVATE KEY-----";
487        let matches = detect_secrets(input);
488        assert!(matches.iter().any(|m| m.pattern_name == "private_key"));
489    }
490}