Skip to main content

specfold_core/guards/
secrets.rs

1//! Built-in secret patterns. Deliberately narrow: each one is a format with a fixed prefix or
2//! header, so a hit is almost always real. Two shapes need a second look: a connection string
3//! (`scheme://user:password@host`) is skipped when its host is an example or loopback host or its
4//! password is a placeholder, and a JWT is skipped when it is the well-known jwt.io sample.
5
6use regex::Regex;
7use std::sync::OnceLock;
8
9pub struct SecretPattern {
10    pub name: &'static str,
11    pub re: Regex,
12}
13
14const PATTERNS: &[(&str, &str)] = &[
15    ("private key", r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----"),
16    ("AWS access key id", r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
17    ("GitHub token", r"\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b"),
18    ("GitLab token", r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
19    ("Slack token", r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
20    ("Google API key", r"\bAIza[0-9A-Za-z_-]{35}\b"),
21    ("Stripe live key", r"\b(?:sk|rk)_live_[0-9A-Za-z]{20,}\b"),
22    ("npm token", r"\bnpm_[A-Za-z0-9]{36}\b"),
23    ("Anthropic API key", r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"),
24    ("OpenAI API key", r"\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}\b"),
25    ("Hugging Face token", r"\bhf_[A-Za-z]{34}\b"),
26    (
27        CONNECTION,
28        r#"\b[A-Za-z][A-Za-z0-9+.-]{1,30}://([^\s:/?#@'"<>\[\]]+):([^\s/?#@'"<>]{4,})@(\[[0-9A-Fa-f:.]+\]|[^\s/?#@:'"<>]+)"#,
29    ),
30    ("JWT", r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}"),
31];
32
33const CONNECTION: &str = "connection string with a password";
34
35/// The signature of the sample token on jwt.io, pasted into countless docs.
36const JWT_SAMPLE_SIGNATURE: &str = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
37
38/// Hosts no real credential points at: documentation domains (RFC 2606, RFC 6761) and loopback.
39fn example_host(host: &str) -> bool {
40    let h = host.trim_end_matches('.').to_ascii_lowercase();
41    let h = h.trim_start_matches('[').trim_end_matches(']');
42    let under = |d: &str| h == d || h.ends_with(&format!(".{d}"));
43    ["example.com", "example.org", "example.net", "example", "test", "invalid", "localhost"].iter().any(|d| under(d))
44        || h.starts_with("127.")
45        || h == "0.0.0.0"
46        || h == "::1"
47}
48
49/// Passwords written to be replaced: `${VAR}`, `$VAR`, `%VAR%`, `{{var}}`, `<var>`, a run of one
50/// character (`xxxx`, `****`), or a word that only names the slot.
51fn placeholder(pw: &str, user: &str) -> bool {
52    const WORDS: &[&str] = &[
53        "password",
54        "passwd",
55        "pass",
56        "pwd",
57        "changeme",
58        "change_me",
59        "change-me",
60        "secret",
61        "example",
62        "placeholder",
63        "dummy",
64        "redacted",
65        "your_password",
66        "yourpassword",
67        "your-password",
68        "mypassword",
69        "my_password",
70        "pass123",
71    ];
72    let l = pw.to_ascii_lowercase();
73    let mut chars = pw.chars();
74    let first = chars.next().unwrap_or(' ');
75    pw.starts_with('$')
76        || pw.starts_with("{{")
77        || pw.starts_with('<')
78        || (pw.starts_with('%') && pw.ends_with('%'))
79        || chars.all(|c| c == first)
80        || WORDS.contains(&l.as_str())
81        || l == user.to_ascii_lowercase()
82}
83
84/// One built-in secret in a text, by byte range.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Hit {
87    pub start: usize,
88    pub end: usize,
89    pub name: &'static str,
90}
91
92/// Every built-in secret in `text`, in order, without the example and placeholder shapes above.
93pub fn find(text: &str) -> Vec<Hit> {
94    let mut out = vec![];
95    for p in all() {
96        for c in p.re.captures_iter(text) {
97            let m = c.get(0).expect("group 0 is the match");
98            let keep = match p.name {
99                CONNECTION => {
100                    let (user, pw, host) = (&c[1], &c[2], &c[3]);
101                    !example_host(host) && !placeholder(pw, user)
102                }
103                "JWT" => !m.as_str().ends_with(JWT_SAMPLE_SIGNATURE),
104                _ => true,
105            };
106            if keep {
107                out.push(Hit { start: m.start(), end: m.end(), name: p.name });
108            }
109        }
110    }
111    out.sort_by_key(|h| (h.start, std::cmp::Reverse(h.end)));
112    out
113}
114
115/// `text` with each built-in secret replaced by `[redacted: <name>]`, and how many were replaced.
116pub fn redact(text: &str) -> (String, usize) {
117    let hits = find(text);
118    if hits.is_empty() {
119        return (text.to_string(), 0);
120    }
121    let (mut out, mut at, mut n) = (String::with_capacity(text.len()), 0, 0);
122    for h in &hits {
123        if h.start < at {
124            continue;
125        }
126        out.push_str(&text[at..h.start]);
127        out.push_str(&format!("{MARKER}{}]", h.name));
128        at = h.end;
129        n += 1;
130    }
131    out.push_str(&text[at..]);
132    (out, n)
133}
134
135/// How a redacted value starts; a body holding it was read through redaction.
136pub const MARKER: &str = "[redacted: ";
137
138pub fn all() -> &'static [SecretPattern] {
139    static P: OnceLock<Vec<SecretPattern>> = OnceLock::new();
140    P.get_or_init(|| {
141        PATTERNS.iter().filter_map(|(name, re)| Regex::new(re).ok().map(|re| SecretPattern { name, re })).collect()
142    })
143}
144
145/// A document's repository attachments that the secrets check reads: bundle files and `files`
146/// entries kept in the repository, at most `max` bytes, and text (no NUL in the first 8 KiB).
147/// Stored files are not here; `spec attach` checks them before upload.
148pub fn scanned_attachments(root: &std::path::Path, d: &crate::model::Doc, max: u64) -> Vec<String> {
149    attachment_scan(root, d, max).0
150}
151
152/// The attachments the secrets check reads, and the ones it skips because they are over `max`
153/// bytes or binary. Missing files are in neither list; E2 reports them.
154pub fn attachment_scan(root: &std::path::Path, d: &crate::model::Doc, max: u64) -> (Vec<String>, Vec<String>) {
155    let base = d.base_dir();
156    let mut paths: Vec<String> = d.bundle_files.clone();
157    for e in d.files().into_iter().filter(|e| e.store != "store") {
158        let p = if base.is_empty() { e.path } else { format!("{base}/{}", e.path) };
159        if !paths.contains(&p) {
160            paths.push(p);
161        }
162    }
163    let (mut read, mut skipped) = (vec![], vec![]);
164    for p in paths {
165        let full = root.join(&p);
166        let Ok(meta) = std::fs::metadata(&full) else { continue };
167        if !meta.is_file() {
168            continue;
169        }
170        if meta.len() > max {
171            skipped.push(p);
172            continue;
173        }
174        let Ok(bytes) = std::fs::read(&full) else { continue };
175        if bytes[..bytes.len().min(8192)].contains(&0) {
176            skipped.push(p);
177        } else {
178            read.push(p);
179        }
180    }
181    (read, skipped)
182}
183
184/// One line naming attachments the secrets check did not read, or None when it read them all.
185/// Only files over `max` bytes are told to raise [files] scan_max_bytes; binary files are never read.
186pub fn skipped_note(root: &std::path::Path, skipped: &[String], max: u64) -> Option<String> {
187    if skipped.is_empty() {
188        return None;
189    }
190    let list = |v: &[&String]| {
191        let mut names: Vec<&str> = v.iter().map(|s| s.as_str()).take(5).collect();
192        names.sort_unstable();
193        let more = v.len().saturating_sub(5);
194        let more = if more > 0 { format!(" (+{more} more)") } else { String::new() };
195        format!("{}{more}", names.join(", "))
196    };
197    let (big, binary): (Vec<&String>, Vec<&String>) =
198        skipped.iter().partition(|p| std::fs::metadata(root.join(p)).is_ok_and(|m| m.len() > max));
199    let mut parts = vec![];
200    if !big.is_empty() {
201        parts.push(format!(
202            "{} over [files] scan_max_bytes: {} (raise scan_max_bytes in spec.toml to read them)",
203            big.len(),
204            list(&big)
205        ));
206    }
207    if !binary.is_empty() {
208        parts.push(format!("{} binary: {}", binary.len(), list(&binary)));
209    }
210    Some(format!("{} attachment(s) not scanned for secrets: {}", skipped.len(), parts.join("; ")))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    // Samples are assembled at run time so this file never holds a string that looks like a
218    // real credential to secret scanners.
219    #[test]
220    fn detects_known_formats_only() {
221        let hit = |s: &str| all().iter().find(|p| p.re.is_match(s)).map(|p| p.name);
222        assert_eq!(hit(&format!("-----BEGIN OPENSSH {}-----", "PRIVATE KEY")), Some("private key"));
223        assert_eq!(hit(&format!("key {}{} here", "AK", "IA".to_string() + &"X".repeat(16))), Some("AWS access key id"));
224        assert_eq!(hit(&format!("{}_{}", "ghp", "a".repeat(36))), Some("GitHub token"));
225        assert_eq!(hit("the word secret and sk_test_123"), None);
226    }
227
228    fn names(s: &str) -> Vec<&'static str> {
229        find(s).into_iter().map(|h| h.name).collect()
230    }
231
232    #[test]
233    fn connection_strings_with_real_hosts_hit() {
234        let pw = format!("{}{}", "s3cr", "Et-77x");
235        for scheme in ["postgres", "postgresql", "mysql", "mongodb+srv", "redis", "amqp"] {
236            let s = format!("url = \"{scheme}://app:{pw}@db.internal.acme.io:5432/main\"");
237            assert_eq!(names(&s), [CONNECTION], "{s}");
238        }
239        assert_eq!(names(&format!("redis:{}default:{pw}@10.0.4.2:6379", "//")), [CONNECTION]);
240    }
241
242    #[test]
243    fn connection_string_false_positives_pass() {
244        let must_pass = [
245            "postgres://app:hunter2x@example.com/db",
246            "postgres://app:hunter2x@db.example.org/db",
247            "mysql://root:hunter2x@localhost:3306/x",
248            "mysql://root:hunter2x@127.0.0.1/x",
249            "redis://u:hunter2x@[::1]:6379",
250            "redis://u:hunter2x@cache.test",
251            "mongodb://u:hunter2x@db.invalid",
252            "postgres://app:${DB_PASSWORD}@db.acme.io/x",
253            "postgres://app:$DB_PASSWORD@db.acme.io/x",
254            "postgres://app:{{ db_password }}@db.acme.io/x",
255            "postgres://app:<password>@db.acme.io/x",
256            "postgres://app:%PGPASS%@db.acme.io/x",
257            "postgres://user:password@db.acme.io/x",
258            "postgres://user:changeme@db.acme.io/x",
259            "postgres://postgres:postgres@db.acme.io/x",
260            "postgres://u:xxxx@db.acme.io/x",
261            "postgres://u:****@db.acme.io/x",
262            "postgres://u:abc@db.acme.io/x",
263            "postgres://app@db.acme.io/x",
264            "https://db.acme.io/x?u=a:b@c",
265            "see a:b@c and user@example.com",
266        ];
267        for s in must_pass {
268            assert!(names(s).is_empty(), "{s} -> {:?}", names(s));
269        }
270    }
271
272    #[test]
273    fn jwt_hits_but_not_the_jwt_io_sample() {
274        let part = |n: usize| "a".repeat(n);
275        let jwt =
276            format!("{}{}.{}{}.{}", "ey", "J".to_string() + &part(20), "ey", "J".to_string() + &part(30), part(43));
277        assert_eq!(names(&format!("Authorization: Bearer {jwt}")), ["JWT"]);
278        let sample = format!(
279            "{}{}.{}{}.{JWT_SAMPLE_SIGNATURE}",
280            "ey",
281            "J".to_string() + &part(20),
282            "ey",
283            "J".to_string() + &part(30)
284        );
285        assert!(names(&sample).is_empty());
286        assert!(names("eyJhbGciOi only one part").is_empty());
287    }
288
289    #[test]
290    fn redact_replaces_each_hit() {
291        let pw = format!("{}{}", "s3cr", "Et-77x");
292        let t = format!("a postgres:{}app:{pw}@db.acme.io/x b {}_{} c", "//", "ghp", "a".repeat(36));
293        let (r, n) = redact(&t);
294        assert_eq!(n, 2);
295        assert_eq!(r, format!("a {MARKER}{CONNECTION}]/x b {MARKER}GitHub token] c"));
296        assert_eq!(redact("nothing here"), ("nothing here".to_string(), 0));
297    }
298}