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
41fn 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
101const GUARDED_PATTERNS: &[(&str, fn() -> &'static Regex)] = &[
105 ("generic_api_key", generic_api_key_re),
106 ("high_entropy_secret", high_entropy_b64_re),
107];
108
109fn guarded_match_is_benign(caps: ®ex::Captures) -> bool {
113 caps.get(2)
114 .is_some_and(|value| crate::core::redaction::is_benign_secret_value(value.as_str()))
115}
116
117fn 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: ®ex::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: ®ex::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: ®ex::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}