1use regex::Regex;
8use std::sync::LazyLock;
9
10#[derive(Debug, Clone)]
12pub struct LeakMatch {
13 pub rule: &'static str,
14 pub line: usize,
15 pub redacted: String,
16}
17
18struct LeakPatternSpec {
21 rule: &'static str,
22 pattern: &'static str,
23 high_confidence: bool,
24}
25
26const LEAK_PATTERN_SPECS: &[LeakPatternSpec] = &[
27 LeakPatternSpec {
29 rule: "aws_access_key",
30 pattern: r"AKIA[0-9A-Z]{16}",
31 high_confidence: true,
32 },
33 LeakPatternSpec {
34 rule: "aws_secret_key",
35 pattern: r"(?i)aws[_\-]?secret[_\-]?access[_\-]?key\s*[=:]\s*\S+",
36 high_confidence: true,
37 },
38 LeakPatternSpec {
39 rule: "github_token",
40 pattern: r"gh[pousr]_[A-Za-z0-9_]{36,}",
41 high_confidence: true,
42 },
43 LeakPatternSpec {
44 rule: "github_pat",
45 pattern: r"github_pat_[A-Za-z0-9_]{22,}",
46 high_confidence: true,
47 },
48 LeakPatternSpec {
49 rule: "anthropic_api_key",
50 pattern: r"sk-ant-[A-Za-z0-9\-]{20,}",
51 high_confidence: true,
52 },
53 LeakPatternSpec {
54 rule: "openai_api_key",
55 pattern: r"sk-[A-Za-z0-9\-]{20,}",
56 high_confidence: true,
57 },
58 LeakPatternSpec {
59 rule: "slack_token",
60 pattern: r"xox[baprs]-[0-9A-Za-z\-]{10,}",
61 high_confidence: true,
62 },
63 LeakPatternSpec {
64 rule: "stripe_key",
65 pattern: r"[sr]k_(live|test)_[A-Za-z0-9]{20,}",
66 high_confidence: true,
67 },
68 LeakPatternSpec {
69 rule: "telegram_bot_token",
70 pattern: r"\d{8,10}:[A-Za-z0-9_-]{35}",
71 high_confidence: false,
72 },
73 LeakPatternSpec {
75 rule: "private_key",
76 pattern: r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
77 high_confidence: true,
78 },
79 LeakPatternSpec {
81 rule: "generic_secret",
82 pattern: r#"(?i)(password|secret|token|api[_\-]?key)\s*[=:]\s*["']?[A-Za-z0-9+/=_\-]{16,}["']?"#,
83 high_confidence: false,
84 },
85 LeakPatternSpec {
86 rule: "connection_string",
87 pattern: r"(?i)(postgres|mysql|mongodb|redis)://[^\s]{10,}",
88 high_confidence: true,
89 },
90 LeakPatternSpec {
91 rule: "bearer_token",
92 pattern: r"(?i)bearer\s+[A-Za-z0-9\-._~+/]+=*",
93 high_confidence: false,
94 },
95];
96
97fn build_leak_patterns(specs: &[LeakPatternSpec]) -> Vec<(&'static str, Regex, bool)> {
98 let mut patterns = Vec::new();
99
100 for spec in specs {
101 match Regex::new(spec.pattern) {
102 Ok(regex) => patterns.push((spec.rule, regex, spec.high_confidence)),
103 Err(err) => {
104 tracing::error!(
105 rule = spec.rule,
106 pattern = spec.pattern,
107 error = %err,
108 "failed to compile leak detection regex"
109 );
110 }
111 }
112 }
113
114 patterns
115}
116
117static LEAK_PATTERNS: LazyLock<Vec<(&str, Regex, bool)>> =
118 LazyLock::new(|| build_leak_patterns(LEAK_PATTERN_SPECS));
119
120pub fn scan(text: &str) -> Vec<LeakMatch> {
131 let mut matches = Vec::new();
132
133 for (line_num, line) in text.lines().enumerate() {
134 let mut matched_ranges: Vec<(usize, usize)> = Vec::new();
137
138 for (rule, pattern, _high_conf) in LEAK_PATTERNS.iter() {
139 for m in pattern.find_iter(line) {
140 if matched_ranges
142 .iter()
143 .any(|&(s, e)| m.start() < e && m.end() > s)
144 {
145 continue;
146 }
147 matched_ranges.push((m.start(), m.end()));
148 let matched = m.as_str();
149 let redacted = if matched.len() > 8 {
151 let head_end = (0..=4)
152 .rev()
153 .find(|&i| matched.is_char_boundary(i))
154 .unwrap_or(0);
155 let tail_start = (matched.len() - 2..=matched.len())
156 .find(|&i| matched.is_char_boundary(i))
157 .unwrap_or(matched.len());
158 format!("{}...{}", &matched[..head_end], &matched[tail_start..])
159 } else {
160 "****".to_string()
161 };
162
163 matches.push(LeakMatch {
164 rule,
165 line: line_num + 1,
166 redacted,
167 });
168 }
169 }
170 }
171
172 matches
173}
174
175pub fn has_leaks(text: &str) -> bool {
179 LEAK_PATTERNS
180 .iter()
181 .any(|(_, pattern, _)| pattern.is_match(text))
182}
183
184pub fn redact(text: &str) -> String {
186 let mut result = text.to_string();
187
188 for (rule, pattern, _) in LEAK_PATTERNS.iter() {
189 result = pattern
190 .replace_all(&result, format!("[REDACTED:{rule}]"))
191 .to_string();
192 }
193
194 result
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn detects_github_token() {
203 let text = "export GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
204 let matches = scan(text);
205 assert!(!matches.is_empty());
206 assert_eq!(matches[0].rule, "github_token");
207 }
208
209 #[test]
210 fn detects_aws_key() {
211 let text = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE";
212 let matches = scan(text);
213 assert!(!matches.is_empty());
214 assert_eq!(matches[0].rule, "aws_access_key");
215 }
216
217 #[test]
218 fn detects_openai_key() {
219 let text = "OPENAI_API_KEY=sk-proj-1234567890abcdefghijklmn";
220 let matches = scan(text);
221 assert!(matches.iter().any(|m| m.rule == "openai_api_key"));
222 }
223
224 #[test]
225 fn anthropic_key_matches_only_anthropic_rule() {
226 let text = "ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxx";
228 let matches = scan(text);
229 assert_eq!(
230 matches.len(),
231 1,
232 "expected exactly one match, got: {:?}",
233 matches.iter().map(|m| m.rule).collect::<Vec<_>>()
234 );
235 assert_eq!(matches[0].rule, "anthropic_api_key");
236 }
237
238 #[test]
239 fn detects_private_key() {
240 let text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQ...";
241 assert!(has_leaks(text));
242 }
243
244 #[test]
245 fn detects_connection_string() {
246 let text = "DATABASE_URL=postgres://user:pass@host:5432/mydb";
247 assert!(has_leaks(text));
248 }
249
250 #[test]
251 fn ignores_comments() {
252 let text = "// Example: OPENAI_API_KEY=sk-proj-1234567890abcdefghijklmn";
253 let matches = scan(text);
257 assert!(!matches.is_empty());
258 }
259
260 #[test]
261 fn redacts_secrets() {
262 let text = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
263 let redacted = redact(text);
264 assert!(redacted.contains("[REDACTED:github_token]"));
265 assert!(!redacted.contains("ghp_"));
266 }
267
268 #[test]
269 fn clean_text_has_no_leaks() {
270 let text = "This is normal agent output.\nFixed bug in parser.rs\nAll tests pass.";
271 assert!(!has_leaks(text));
272 }
273
274 #[test]
275 fn scans_markdown_bullets() {
276 let text = "* token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
278 let matches = scan(text);
279 assert!(!matches.is_empty());
280 }
281
282 #[test]
283 fn build_leak_patterns_skips_invalid_regex() {
284 let specs = [
285 LeakPatternSpec {
286 rule: "good",
287 pattern: r"good",
288 high_confidence: true,
289 },
290 LeakPatternSpec {
291 rule: "bad",
292 pattern: r"(",
293 high_confidence: false,
294 },
295 ];
296
297 let patterns = build_leak_patterns(&specs);
298
299 assert!(patterns.iter().any(|(rule, _, _)| *rule == "good"));
300 assert!(!patterns.iter().any(|(rule, _, _)| *rule == "bad"));
301 }
302
303 #[test]
304 fn has_leaks_triggers_on_low_confidence_patterns() {
305 let text = "password = \"some_long_config_value_that_is_16_chars\"";
306 assert!(
307 has_leaks(text),
308 "generic_secret (low confidence) should trigger has_leaks"
309 );
310
311 let text = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0";
312 assert!(
313 has_leaks(text),
314 "bearer_token (low confidence) should trigger has_leaks"
315 );
316 }
317
318 #[test]
319 fn has_leaks_triggers_on_high_confidence_patterns() {
320 let text = "password = \"sk-proj-1234567890abcdefghijklmnopqrstuv\"";
321 assert!(
322 has_leaks(text),
323 "openai_api_key (high confidence) should trigger has_leaks"
324 );
325 }
326
327 #[test]
328 fn detects_multiple_secrets_per_line() {
329 let text = "key1=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa and token2=ghp_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
330 let matches = scan(text);
331 assert_eq!(
332 matches.len(),
333 2,
334 "expected two github_token matches, got: {:?}",
335 matches
336 );
337 }
338
339 #[test]
340 fn detects_multiple_different_secrets_per_line() {
341 let text = "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890ABC token2=sk-proj-1234567890abcdefghijklmn";
342 let matches = scan(text);
343 assert!(
344 matches.iter().any(|m| m.rule == "github_token"),
345 "should detect github_token"
346 );
347 assert!(
348 matches.iter().any(|m| m.rule == "openai_api_key"),
349 "should detect openai_api_key"
350 );
351 }
352}