1use std::sync::OnceLock;
2
3use regex::Regex;
4
5use crate::core::config::{Config, SecretDetectionConfig};
6
7macro_rules! static_regex {
8 ($pattern:expr_2021) => {{
9 static RE: OnceLock<Regex> = OnceLock::new();
10 RE.get_or_init(|| Regex::new($pattern).expect(concat!("invalid regex: ", $pattern)))
11 }};
12}
13
14#[derive(Debug, Clone)]
15pub struct SecretMatch {
16 pub pattern_name: &'static str,
17 pub line_number: usize,
18 pub redacted_preview: String,
19}
20
21fn aws_key_re() -> &'static Regex {
22 static_regex!(r"(?:AKIA|ASIA)[0-9A-Z]{16}")
23}
24
25fn private_key_re() -> &'static Regex {
26 static_regex!(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----")
27}
28
29fn github_token_re() -> &'static Regex {
30 static_regex!(r"gh[ps]_[A-Za-z0-9_]{36,}")
31}
32
33pub(crate) fn anthropic_key_re() -> &'static Regex {
34 static_regex!(r"sk-ant-[A-Za-z0-9_\-]{20,}")
35}
36
37pub(crate) fn openai_key_re() -> &'static Regex {
38 static_regex!(r"\bsk-(?:(?:proj|svcacct)-[A-Za-z0-9_\-]{20,}|[A-Za-z0-9]{20,})")
39}
40
41fn generic_api_key_re() -> &'static Regex {
49 static_regex!(
50 r#"(?im)((?:^|[^a-z0-9])(?:aws[_-]?secret[_-]?access[_-]?key|secret[_-]?access[_-]?key|api[_-]?key|secret[_-]?key|token|password|passwd|access[_-]?token|client[_-]?secret)(?:\\?["'])?[ \t]*[=:][ \t]*(?:\\?["'])?)([A-Za-z0-9_+/=\-]{20,})"#
51 )
52}
53
54fn high_entropy_b64_re() -> &'static Regex {
55 static_regex!(
56 r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)(?:\\?["'])?[ \t]*[=:][ \t]*(?:\\?["'])?)([A-Za-z0-9+/=\-_]{40,})"#
57 )
58}
59
60fn url_encoded_secret_re() -> &'static Regex {
61 static_regex!(
62 r#"(?im)((?:^|[^a-z0-9])(?:aws[_-]?secret[_-]?access[_-]?key|secret[_-]?access[_-]?key|api[_-]?key|secret[_-]?key|token|password|passwd|access[_-]?token|client[_-]?secret)(?:\\?["'])?[ \t]*[=:][ \t]*(?:\\?["'])?)((?:%[0-9a-f]{2}|[A-Za-z0-9_\-]){20,})"#
63 )
64}
65
66fn authorization_header_re() -> &'static Regex {
67 static_regex!(r"(?i)authorization[ \t]*:[ \t]*(?:bearer|basic)[ \t]+[A-Za-z0-9_+/=\-.]{4,}")
68}
69
70fn bearer_token_re() -> &'static Regex {
75 static_regex!(r"(?i)bearer[ \t]+([A-Za-z0-9_\-.]{8,})")
76}
77
78fn gitlab_pat_re() -> &'static Regex {
79 static_regex!(r"glpat-[A-Za-z0-9_\-]{20,}")
80}
81
82fn jwt_re() -> &'static Regex {
83 static_regex!(r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}")
84}
85
86fn slack_token_re() -> &'static Regex {
87 static_regex!(r"xox[bpas]-[0-9a-zA-Z\-]{10,}")
88}
89
90fn stripe_key_re() -> &'static Regex {
91 static_regex!(r"[sr]k_live_[0-9a-zA-Z]{10,}")
92}
93
94fn url_credentials_re() -> &'static Regex {
96 static_regex!(
97 r"(?i)(?:https?|ftps?|sftp|ssh|amqps?|smtps?|ldaps?|wss?|git)://[^\s:/@]+:[^\s@/]+@"
98 )
99}
100
101fn npm_token_re() -> &'static Regex {
102 static_regex!(r"npm_[A-Za-z0-9]{10,}")
103}
104
105fn github_fine_grained_re() -> &'static Regex {
106 static_regex!(r"github_pat_[A-Za-z0-9_]{20,}")
107}
108
109const BUILTIN_PATTERNS: &[(&str, fn() -> &'static Regex)] = &[
110 ("aws_key", aws_key_re),
111 ("authorization_header", authorization_header_re),
112 ("private_key", private_key_re),
113 ("github_token", github_token_re),
114 ("github_fine_grained", github_fine_grained_re),
115 ("anthropic_key", anthropic_key_re),
116 ("openai_key", openai_key_re),
117 ("gitlab_pat", gitlab_pat_re),
118 ("jwt", jwt_re),
119 ("slack_token", slack_token_re),
120 ("stripe_key", stripe_key_re),
121 ("url_credentials", url_credentials_re),
122 ("npm_token", npm_token_re),
123];
124
125const GUARDED_PATTERNS: &[(&str, fn() -> &'static Regex)] = &[
129 ("generic_api_key", generic_api_key_re),
130 ("high_entropy_secret", high_entropy_b64_re),
131 ("url_encoded_secret", url_encoded_secret_re),
132];
133
134fn guarded_match_is_benign(caps: ®ex::Captures) -> bool {
138 let quoted = caps
141 .get(1)
142 .is_some_and(|prefix| prefix.as_str().ends_with(['"', '\'']));
143 caps.get(2).is_some_and(|value| {
144 if quoted {
145 crate::core::redaction::is_benign_quoted_value(value.as_str())
146 } else {
147 crate::core::redaction::is_benign_secret_value(value.as_str())
148 }
149 })
150}
151
152fn compile_excludes(patterns: &[String]) -> Vec<Regex> {
155 patterns.iter().filter_map(|p| Regex::new(p).ok()).collect()
156}
157
158fn excluded(excludes: &[Regex], matched: &str) -> bool {
159 excludes.iter().any(|ex| ex.is_match(matched))
160}
161
162fn make_redacted_preview(matched: &str) -> String {
163 let chars: Vec<char> = matched.chars().collect();
164 if chars.len() <= 6 {
165 return "***".to_string();
166 }
167 let prefix: String = chars[..4].iter().collect();
168 let suffix: String = chars[chars.len() - 2..].iter().collect();
169 format!("{prefix}***{suffix}")
170}
171
172fn collect_matches(
173 content: &str,
174 custom_patterns: &[String],
175 excludes: &[Regex],
176) -> Vec<SecretMatch> {
177 let mut matches = Vec::new();
178 let normalized = crate::core::redaction::normalize_secret_text(content);
179
180 let line_offsets: std::cell::OnceCell<Vec<usize>> = std::cell::OnceCell::new();
183
184 let offset_to_line = |byte_offset: usize| -> usize {
185 let line_offsets = line_offsets.get_or_init(|| {
186 std::iter::once(0)
187 .chain(normalized.match_indices('\n').map(|(i, _)| i + 1))
188 .collect()
189 });
190 match line_offsets.binary_search(&byte_offset) {
191 Ok(i) => i + 1,
192 Err(i) => i,
193 }
194 };
195
196 for &(name, regex_fn) in BUILTIN_PATTERNS {
197 let re = regex_fn();
198 for m in re.find_iter(&normalized) {
199 if excluded(excludes, m.as_str()) {
200 continue;
201 }
202 matches.push(SecretMatch {
203 pattern_name: name,
204 line_number: offset_to_line(m.start()),
205 redacted_preview: make_redacted_preview(m.as_str()),
206 });
207 }
208 }
209
210 for caps in bearer_token_re().captures_iter(&normalized) {
211 let Some(whole) = caps.get(0) else {
212 continue;
213 };
214 let credential = caps.get(1).is_some_and(|value| {
215 crate::core::redaction::bearer_value_is_credential(value.as_str())
216 });
217 if !credential || excluded(excludes, whole.as_str()) {
218 continue;
219 }
220 matches.push(SecretMatch {
221 pattern_name: "bearer_token",
222 line_number: offset_to_line(whole.start()),
223 redacted_preview: make_redacted_preview(whole.as_str()),
224 });
225 }
226
227 for caps in crate::core::redaction::database_url_re().captures_iter(&normalized) {
230 let Some(whole) = caps.get(0) else {
231 continue;
232 };
233 if !crate::core::redaction::database_url_has_credential(&caps)
234 || excluded(excludes, whole.as_str())
235 {
236 continue;
237 }
238 matches.push(SecretMatch {
239 pattern_name: "db_url",
240 line_number: offset_to_line(whole.start()),
241 redacted_preview: make_redacted_preview(whole.as_str()),
242 });
243 }
244
245 for &(name, regex_fn) in GUARDED_PATTERNS {
246 let re = regex_fn();
247 for caps in re.captures_iter(&normalized) {
248 let whole = caps.get(0).map_or("", |m| m.as_str());
249 if guarded_match_is_benign(&caps) || 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
279pub(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 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: ®ex::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 #[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 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 #[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}