1use regex::{Regex, RegexBuilder};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
7pub enum Severity {
8 Medium,
9 High,
10 Critical,
11}
12
13impl Severity {
14 pub fn label(&self) -> &'static str {
15 match self {
16 Severity::Critical => "critical",
17 Severity::High => "high",
18 Severity::Medium => "medium",
19 }
20 }
21}
22
23#[derive(Debug, Clone)]
24pub struct Finding {
25 pub file: String,
26 pub line: usize,
27 pub rule: String,
28 pub severity: Severity,
29 pub snippet: String,
30}
31
32struct Rule {
33 name: &'static str,
34 severity: Severity,
35 re: Regex,
36 placeholder_guarded: bool,
39}
40
41pub struct Scanner {
42 rules: Vec<Rule>,
43 secret_values: Vec<String>,
44}
45
46impl Scanner {
47 pub fn new(secret_values: Vec<String>) -> Self {
50 let secret_values = secret_values
51 .into_iter()
52 .filter(|v| v.len() >= 4 && !is_placeholder(v))
53 .collect();
54 Scanner {
55 rules: build_rules(),
56 secret_values,
57 }
58 }
59
60 pub fn scan_text(&self, file: &str, content: &str) -> Vec<Finding> {
61 let mut findings = Vec::new();
62 for (idx, line) in content.lines().enumerate() {
63 let line_no = idx + 1;
64 if line.len() > 4000 {
66 continue;
67 }
68 for rule in &self.rules {
69 if rule.placeholder_guarded {
70 if let Some(caps) = rule.re.captures(line) {
71 let candidate = caps.get(1).map(|m| m.as_str()).unwrap_or_default();
72 if is_placeholder(candidate) {
73 continue;
74 }
75 let whole = caps.get(0).unwrap().as_str();
76 findings.push(self.finding(file, line_no, rule, line, whole));
77 }
78 } else if let Some(m) = rule.re.find(line) {
79 findings.push(self.finding(file, line_no, rule, line, m.as_str()));
80 }
81 }
82 for value in &self.secret_values {
83 if line.contains(value.as_str()) {
84 findings.push(Finding {
85 file: file.to_string(),
86 line: line_no,
87 rule: "Known secret value".to_string(),
88 severity: Severity::Critical,
89 snippet: redact(line, value),
90 });
91 }
92 }
93 }
94 findings
95 }
96
97 fn finding(
98 &self,
99 file: &str,
100 line_no: usize,
101 rule: &Rule,
102 line: &str,
103 matched: &str,
104 ) -> Finding {
105 Finding {
106 file: file.to_string(),
107 line: line_no,
108 rule: rule.name.to_string(),
109 severity: rule.severity,
110 snippet: redact(line, matched),
111 }
112 }
113}
114
115pub fn sensitive_file_finding(path: &str) -> Option<Finding> {
122 let name = path.rsplit('/').next().unwrap_or(path);
123 let lower = name.to_ascii_lowercase();
124
125 let allowed = lower.ends_with(".enc")
126 || lower == ".envo"
127 || lower.ends_with(".envo")
128 || lower.contains("example")
129 || lower.contains("sample")
130 || lower.contains("template")
131 || lower.ends_with(".pub");
132 if allowed {
133 return None;
134 }
135
136 let sensitive = lower == ".env"
137 || lower == ".env.local"
138 || (lower.starts_with(".env.") && lower.ends_with(".local"))
139 || lower.ends_with(".secrets")
140 || lower.ends_with(".pem")
141 || lower.ends_with(".key")
142 || lower == "id_rsa"
143 || lower == "id_dsa"
144 || lower == "id_ecdsa"
145 || lower == "id_ed25519";
146
147 if sensitive {
148 Some(Finding {
149 file: path.to_string(),
150 line: 0,
151 rule: "Sensitive file committed".to_string(),
152 severity: Severity::Critical,
153 snippet: format!("`{path}` looks like a secrets file - it should be gitignored"),
154 })
155 } else {
156 None
157 }
158}
159
160fn build_rules() -> Vec<Rule> {
161 let mut rules = Vec::new();
162 let mut ci = |name, severity, pattern: &str| {
163 rules.push(Rule {
164 name,
165 severity,
166 re: RegexBuilder::new(pattern)
167 .case_insensitive(true)
168 .build()
169 .expect("valid regex"),
170 placeholder_guarded: false,
171 });
172 };
173 ci(
174 "AWS secret access key",
175 Severity::Critical,
176 r#"aws_secret_access_key\s*[:=]\s*['"]?[A-Za-z0-9/+]{40}"#,
177 );
178
179 let mut cs = |name, severity, pattern: &str, guarded: bool| {
180 rules.push(Rule {
181 name,
182 severity,
183 re: Regex::new(pattern).expect("valid regex"),
184 placeholder_guarded: guarded,
185 });
186 };
187 cs(
188 "AWS access key ID",
189 Severity::Critical,
190 r"AKIA[0-9A-Z]{16}",
191 false,
192 );
193 cs(
194 "GitHub token",
195 Severity::Critical,
196 r"gh[pousr]_[A-Za-z0-9]{36,}",
197 false,
198 );
199 cs(
200 "GitHub fine-grained PAT",
201 Severity::Critical,
202 r"github_pat_[A-Za-z0-9_]{22,}",
203 false,
204 );
205 cs(
206 "Slack token",
207 Severity::High,
208 r"xox[baprs]-[A-Za-z0-9-]{10,}",
209 false,
210 );
211 cs(
212 "Slack webhook",
213 Severity::High,
214 r"https://hooks\.slack\.com/services/[A-Za-z0-9/]+",
215 false,
216 );
217 cs(
218 "Google API key",
219 Severity::High,
220 r"AIza[0-9A-Za-z_\-]{35}",
221 false,
222 );
223 cs(
224 "Google OAuth client secret",
225 Severity::High,
226 r"GOCSPX-[A-Za-z0-9_\-]{20,}",
227 false,
228 );
229 cs(
230 "Stripe secret key",
231 Severity::Critical,
232 r"sk_live_[0-9A-Za-z]{16,}",
233 false,
234 );
235 cs(
236 "Stripe restricted key",
237 Severity::High,
238 r"rk_live_[0-9A-Za-z]{16,}",
239 false,
240 );
241 cs(
242 "Private key block",
243 Severity::Critical,
244 r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----",
245 false,
246 );
247 cs(
248 "JSON Web Token",
249 Severity::Medium,
250 r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}",
251 false,
252 );
253
254 rules.push(Rule {
258 name: "Generic assigned secret",
259 severity: Severity::Medium,
260 re: RegexBuilder::new(
261 r##"(?:api[_-]?key|secret|token|password|passwd|access[_-]?key|auth[_-]?token)\s*[:=]\s*['"]?([^\s'"#]{8,})"##,
262 )
263 .case_insensitive(true)
264 .build()
265 .expect("valid regex"),
266 placeholder_guarded: true,
267 });
268
269 rules
270}
271
272fn is_placeholder(value: &str) -> bool {
273 let v = value.trim().trim_matches(|c| c == '"' || c == '\'');
274 if v.is_empty() {
275 return true;
276 }
277 if v.starts_with("${") || v.starts_with('<') || v.starts_with("{{") || v.contains("$(") {
279 return true;
280 }
281 let lower = v.to_ascii_lowercase();
282 const NEEDLES: &[&str] = &[
283 "changeme",
284 "change-me",
285 "your_",
286 "your-",
287 "yourkey",
288 "placeholder",
289 "example",
290 "sample",
291 "dummy",
292 "redacted",
293 "xxxx",
294 "todo",
295 "secret_here",
296 "replace",
297 "notreal",
298 "fake",
299 ];
300 if NEEDLES.iter().any(|n| lower.contains(n)) {
301 return true;
302 }
303 let first = v.chars().next().unwrap();
305 v.chars().all(|c| c == first)
306}
307
308fn redact(line: &str, secret: &str) -> String {
311 let masked = mask(secret);
312 let replaced = line.replacen(secret, &masked, 1);
313 let trimmed = replaced.trim();
314 if trimmed.len() > 120 {
315 format!("{}...", &trimmed[..117])
316 } else {
317 trimmed.to_string()
318 }
319}
320
321fn mask(secret: &str) -> String {
322 let chars: Vec<char> = secret.chars().collect();
323 if chars.len() <= 6 {
324 return "*".repeat(chars.len().max(3));
325 }
326 let head: String = chars[..3].iter().collect();
327 let tail: String = chars[chars.len() - 2..].iter().collect();
328 format!("{head}***{tail}")
329}
330
331pub fn max_severity(findings: &[Finding]) -> Option<Severity> {
333 findings.iter().map(|f| f.severity).max()
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn detects_aws_and_github() {
342 let s = Scanner::new(vec![]);
343 let text = "key=AKIAIOSFODNN7EXAMPLE\ntoken=ghp_0123456789abcdefghijklmnopqrstuvwxyz\n";
344 let f = s.scan_text("x", text);
345 assert!(f.iter().any(|x| x.rule == "AWS access key ID"));
346 assert!(f.iter().any(|x| x.rule == "GitHub token"));
347 assert!(f
349 .iter()
350 .all(|x| !x.snippet.contains("AKIAIOSFODNN7EXAMPLE")));
351 }
352
353 #[test]
354 fn detects_known_secret_value() {
355 let s = Scanner::new(vec!["sup3r-s3cret-db-pass".to_string()]);
356 let f = s.scan_text("cfg", "url=postgres://u:sup3r-s3cret-db-pass@host/db");
357 assert!(f.iter().any(|x| x.rule == "Known secret value"));
358 assert!(f
359 .iter()
360 .all(|x| !x.snippet.contains("sup3r-s3cret-db-pass")));
361 }
362
363 #[test]
364 fn ignores_placeholders() {
365 let s = Scanner::new(vec!["changeme".to_string()]);
366 let f = s.scan_text("x", "API_KEY=changeme\nPASSWORD=your_password_here\n");
367 assert!(f.is_empty(), "placeholders should not be flagged: {f:?}");
368 }
369
370 #[test]
371 fn sensitive_filenames() {
372 assert!(sensitive_file_finding(".env").is_some());
373 assert!(sensitive_file_finding("config/.env.local").is_some());
374 assert!(sensitive_file_finding(".env.production.local").is_some());
375 assert!(sensitive_file_finding(".env.secrets").is_some());
376 assert!(sensitive_file_finding("server.pem").is_some());
377 assert!(sensitive_file_finding(".env.staging").is_none());
379 assert!(sensitive_file_finding(".env.secrets.enc").is_none());
380 assert!(sensitive_file_finding(".env.example").is_none());
381 assert!(sensitive_file_finding(".envo").is_none());
382 }
383}