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 {
74 rule: "jwt",
75 pattern: r"eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}",
76 high_confidence: true,
77 },
78 LeakPatternSpec {
80 rule: "private_key",
81 pattern: r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
82 high_confidence: true,
83 },
84 LeakPatternSpec {
86 rule: "generic_secret",
87 pattern: r#"(?i)(password|secret|token|api[_\-]?key)\s*[=:]\s*["']?[A-Za-z0-9+/=_\-]{16,}["']?"#,
88 high_confidence: false,
89 },
90 LeakPatternSpec {
91 rule: "connection_string",
92 pattern: r"(?i)(postgres|mysql|mongodb|redis)://[^\s]{10,}",
93 high_confidence: true,
94 },
95 LeakPatternSpec {
96 rule: "bearer_token",
97 pattern: r"(?i)bearer\s+[A-Za-z0-9\-._~+/]+=*",
98 high_confidence: false,
99 },
100];
101
102fn build_leak_patterns(specs: &[LeakPatternSpec]) -> Vec<(&'static str, Regex, bool)> {
103 let mut patterns = Vec::new();
104
105 for spec in specs {
106 match Regex::new(spec.pattern) {
107 Ok(regex) => patterns.push((spec.rule, regex, spec.high_confidence)),
108 Err(err) => {
109 tracing::error!(
110 rule = spec.rule,
111 pattern = spec.pattern,
112 error = %err,
113 "failed to compile leak detection regex"
114 );
115 }
116 }
117 }
118
119 patterns
120}
121
122static LEAK_PATTERNS: LazyLock<Vec<(&str, Regex, bool)>> =
123 LazyLock::new(|| build_leak_patterns(LEAK_PATTERN_SPECS));
124
125pub fn scan(text: &str) -> Vec<LeakMatch> {
136 let mut matches = Vec::new();
137
138 for (line_num, line) in text.lines().enumerate() {
139 let mut matched_ranges: Vec<(usize, usize)> = Vec::new();
142
143 for (rule, pattern, _high_conf) in LEAK_PATTERNS.iter() {
144 for m in pattern.find_iter(line) {
145 if matched_ranges
147 .iter()
148 .any(|&(s, e)| m.start() < e && m.end() > s)
149 {
150 continue;
151 }
152 matched_ranges.push((m.start(), m.end()));
153 let matched = m.as_str();
154 let redacted = if matched.len() > 8 {
156 let head_end = (0..=4)
157 .rev()
158 .find(|&i| matched.is_char_boundary(i))
159 .unwrap_or(0);
160 let tail_start = (matched.len() - 2..=matched.len())
161 .find(|&i| matched.is_char_boundary(i))
162 .unwrap_or(matched.len());
163 format!("{}...{}", &matched[..head_end], &matched[tail_start..])
164 } else {
165 "****".to_string()
166 };
167
168 matches.push(LeakMatch {
169 rule,
170 line: line_num + 1,
171 redacted,
172 });
173 }
174 }
175 }
176
177 matches
178}
179
180pub fn has_leaks(text: &str) -> bool {
184 LEAK_PATTERNS
185 .iter()
186 .any(|(_, pattern, _)| pattern.is_match(text))
187}
188
189pub fn redact(text: &str) -> String {
191 let mut result = text.to_string();
192
193 for (rule, pattern, _) in LEAK_PATTERNS.iter() {
194 result = pattern
195 .replace_all(&result, format!("[REDACTED:{rule}]"))
196 .to_string();
197 }
198
199 result
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn detects_github_token() {
208 let text = "export GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
209 let matches = scan(text);
210 assert!(!matches.is_empty());
211 assert_eq!(matches[0].rule, "github_token");
212 }
213
214 #[test]
215 fn detects_aws_key() {
216 let text = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE";
217 let matches = scan(text);
218 assert!(!matches.is_empty());
219 assert_eq!(matches[0].rule, "aws_access_key");
220 }
221
222 #[test]
223 fn detects_openai_key() {
224 let text = "OPENAI_API_KEY=sk-proj-1234567890abcdefghijklmn";
225 let matches = scan(text);
226 assert!(matches.iter().any(|m| m.rule == "openai_api_key"));
227 }
228
229 #[test]
230 fn anthropic_key_matches_only_anthropic_rule() {
231 let text = "ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxx";
233 let matches = scan(text);
234 assert_eq!(
235 matches.len(),
236 1,
237 "expected exactly one match, got: {:?}",
238 matches.iter().map(|m| m.rule).collect::<Vec<_>>()
239 );
240 assert_eq!(matches[0].rule, "anthropic_api_key");
241 }
242
243 #[test]
244 fn detects_private_key() {
245 let text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQ...";
246 assert!(has_leaks(text));
247 }
248
249 #[test]
250 fn detects_connection_string() {
251 let text = "DATABASE_URL=postgres://user:pass@host:5432/mydb";
252 assert!(has_leaks(text));
253 }
254
255 #[test]
256 fn ignores_comments() {
257 let text = "// Example: OPENAI_API_KEY=sk-proj-1234567890abcdefghijklmn";
258 let matches = scan(text);
262 assert!(!matches.is_empty());
263 }
264
265 #[test]
266 fn redacts_secrets() {
267 let text = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
268 let redacted = redact(text);
269 assert!(redacted.contains("[REDACTED:github_token]"));
270 assert!(!redacted.contains("ghp_"));
271 }
272
273 #[test]
274 fn clean_text_has_no_leaks() {
275 let text = "This is normal agent output.\nFixed bug in parser.rs\nAll tests pass.";
276 assert!(!has_leaks(text));
277 }
278
279 #[test]
280 fn scans_markdown_bullets() {
281 let text = "* token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
283 let matches = scan(text);
284 assert!(!matches.is_empty());
285 }
286
287 #[test]
288 fn build_leak_patterns_skips_invalid_regex() {
289 let specs = [
290 LeakPatternSpec {
291 rule: "good",
292 pattern: r"good",
293 high_confidence: true,
294 },
295 LeakPatternSpec {
296 rule: "bad",
297 pattern: r"(",
298 high_confidence: false,
299 },
300 ];
301
302 let patterns = build_leak_patterns(&specs);
303
304 assert!(patterns.iter().any(|(rule, _, _)| *rule == "good"));
305 assert!(!patterns.iter().any(|(rule, _, _)| *rule == "bad"));
306 }
307
308 #[test]
309 fn has_leaks_triggers_on_low_confidence_patterns() {
310 let text = "password = \"some_long_config_value_that_is_16_chars\"";
311 assert!(
312 has_leaks(text),
313 "generic_secret (low confidence) should trigger has_leaks"
314 );
315
316 let text = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0";
317 assert!(
318 has_leaks(text),
319 "bearer_token (low confidence) should trigger has_leaks"
320 );
321 }
322
323 #[test]
324 fn has_leaks_triggers_on_high_confidence_patterns() {
325 let text = "password = \"sk-proj-1234567890abcdefghijklmnopqrstuv\"";
326 assert!(
327 has_leaks(text),
328 "openai_api_key (high confidence) should trigger has_leaks"
329 );
330 }
331
332 #[test]
333 fn detects_multiple_secrets_per_line() {
334 let text = "key1=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa and token2=ghp_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
335 let matches = scan(text);
336 assert_eq!(
337 matches.len(),
338 2,
339 "expected two github_token matches, got: {:?}",
340 matches
341 );
342 }
343
344 #[test]
345 fn detects_multiple_different_secrets_per_line() {
346 let text = "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890ABC token2=sk-proj-1234567890abcdefghijklmn";
347 let matches = scan(text);
348 assert!(
349 matches.iter().any(|m| m.rule == "github_token"),
350 "should detect github_token"
351 );
352 assert!(
353 matches.iter().any(|m| m.rule == "openai_api_key"),
354 "should detect openai_api_key"
355 );
356 }
357}