Skip to main content

envo/
scan.rs

1//! Secret-leak detection: regex rules for well-known credential formats, plus
2//! verbatim matching of the values of `secret`-typed schema variables.
3
4use 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    /// For rules whose capture group 1 holds the candidate secret, skip the
37    /// match if that value looks like an obvious placeholder.
38    placeholder_guarded: bool,
39}
40
41pub struct Scanner {
42    rules: Vec<Rule>,
43    secret_values: Vec<String>,
44}
45
46impl Scanner {
47    /// Build a scanner. `secret_values` are concrete values of secret-typed
48    /// variables that should never appear in scanned files.
49    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            // Skip pathologically long lines (minified bundles) to bound regex cost.
65            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
115/// Flag a committed file whose *name* implies it carries secrets.
116///
117/// Deliberately high-confidence: a bare `.env`, any `*.local`, plaintext
118/// `*.secrets`, and private-key material. Profile files like `.env.staging`
119/// are legitimately committed, so they are caught by content scanning only.
120/// Encrypted (`.enc`), example, and schema files are always allowed.
121pub 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    // Generic "NAME = value" assignment of something secret-looking. Lower
255    // severity and placeholder-guarded because it is the most false-positive
256    // prone rule.
257    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    // Template/interpolation markers are never real secrets.
278    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    // All one repeated char (e.g. "********").
304    let first = v.chars().next().unwrap();
305    v.chars().all(|c| c == first)
306}
307
308/// Replace the sensitive substring inside `line` with a redacted form so the
309/// reported snippet never echoes a full secret.
310fn 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
331/// Highest severity in a set of findings, if any.
332pub 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        // Snippet must not contain the raw secret.
348        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        // Profile files are committed intentionally; content scan covers them.
378        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}