Skip to main content

amont_runtime/hooks/
secrets.rs

1//! Secrets never leave the machine — caught at commit, and again at push.
2//!
3//! The severity-matches-irreversibility argument at its most extreme. A
4//! staged credential is a ten-second fix: unstage it. A PUSHED credential
5//! is not a history problem, it is an incident — the secret is compromised
6//! the moment it leaves the machine, and the remedy stops being `git
7//! commit --amend` and becomes rotation. So this check has two halves:
8//!
9//! - **pre-commit** scans the STAGED content (which, under the staged-only
10//!   hold, is exactly what the working tree holds) and blocks;
11//! - **pre-push** scans the content every pushed commit ADDS — including
12//!   commits made with `--no-verify`, from other tools, or three commits
13//!   ago — because the push is the boundary that cannot be taken back.
14//!
15//! Detection is a curated set of literal token shapes, not entropy: private
16//! key headers, cloud access key ids, the well-known API token prefixes.
17//! Entropy heuristics are where secret scanners get noisy, and a noisy
18//! blocker is a blocker people learn to delete. A line that is a KNOWN
19//! false positive (a test fixture, documentation) opts out with the pragma
20//! `amont:allow-secret` on the same line — visible in review, greppable,
21//! and narrower than skipping the whole check.
22//!
23//! Findings are REDACTED: the report names the kind and the place, never
24//! the matched text. A hook that echoes a secret into scrollback (and into
25//! CI logs, and into anything recording the terminal) has widened the leak
26//! it exists to prevent.
27//!
28//! The token shapes below are assembled with `concat!` so this source file
29//! never contains a contiguous matchable pattern — the check must survive
30//! scanning its own repository (see `the_scanner_does_not_flag_its_own_source`).
31
32use crate::check::Outcome;
33use crate::pushrefs::PushRef;
34
35use super::common;
36
37/// Skip lines carrying this pragma — the surgical opt-out for fixtures.
38const ALLOW: &str = "amont:allow-secret";
39
40/// Per-file ceiling: a secret in the first two megabytes is a secret found,
41/// and a generated bundle beyond it is noise this check has no business in.
42const MAX_BYTES: usize = 2 * 1024 * 1024;
43
44/// What was found — the KIND is all the report ever says about it.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(crate) enum Kind {
47    PrivateKey,
48    AwsAccessKeyId,
49    GithubToken,
50    SlackToken,
51    GoogleApiKey,
52    StripeLiveKey,
53    NpmToken,
54    ApiKey,
55}
56
57impl Kind {
58    fn name(self) -> &'static str {
59        match self {
60            Kind::PrivateKey => "a private key",
61            Kind::AwsAccessKeyId => "an AWS access key id",
62            Kind::GithubToken => "a GitHub token",
63            Kind::SlackToken => "a Slack token",
64            Kind::GoogleApiKey => "a Google API key",
65            Kind::StripeLiveKey => "a Stripe live key",
66            Kind::NpmToken => "an npm token",
67            Kind::ApiKey => "an API key",
68        }
69    }
70}
71
72fn is_token_char(b: u8) -> bool {
73    b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
74}
75
76/// At least `n` token characters follow `text[at..]`.
77fn token_run(text: &str, at: usize, n: usize) -> bool {
78    text.as_bytes()[at..]
79        .iter()
80        .take_while(|b| is_token_char(**b))
81        .count()
82        >= n
83}
84
85/// The byte before a match must not itself be a token character — `XAKIA…`
86/// is part of some longer word, not a key id.
87fn boundary_before(text: &str, at: usize) -> bool {
88    at == 0 || !is_token_char(text.as_bytes()[at - 1])
89}
90
91/// Every occurrence of `prefix` followed by ≥ `min` token characters.
92fn has_prefixed_token(line: &str, prefix: &str, min: usize) -> bool {
93    let mut from = 0;
94    while let Some(i) = line[from..].find(prefix) {
95        let at = from + i;
96        if boundary_before(line, at) && token_run(line, at + prefix.len(), min) {
97            return true;
98        }
99        from = at + prefix.len();
100    }
101    false
102}
103
104/// What this line carries, if anything. Pure — the whole detector is this
105/// function, and the tests drive it directly.
106pub(crate) fn sniff(line: &str) -> Option<Kind> {
107    if line.contains(ALLOW) {
108        return None;
109    }
110    // The PEM header: a BEGIN marker and the private-key tail on one
111    // line. RSA, EC, DSA, OPENSSH, PGP, and the bare form all share it.
112    // (Spelled via concat! so this file cannot flag itself — comments
113    // included, since a secret in a comment is still a secret.)
114    if line.contains(concat!("-----", "BEGIN ")) && line.contains(concat!("PRIVATE", " KEY-----")) {
115        return Some(Kind::PrivateKey);
116    }
117    // AWS access key ids: AKIA (long-term) / ASIA (temporary) + 16 more.
118    for p in [concat!("AK", "IA"), concat!("AS", "IA")] {
119        let mut from = 0;
120        while let Some(i) = line[from..].find(p) {
121            let at = from + i;
122            let rest = &line.as_bytes()[at + 4..];
123            if boundary_before(line, at)
124                && rest.len() >= 16
125                && rest[..16]
126                    .iter()
127                    .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
128            {
129                return Some(Kind::AwsAccessKeyId);
130            }
131            from = at + 4;
132        }
133    }
134    // GitHub: classic ghp_/gho_/ghu_/ghs_/ghr_ + 36, fine-grained
135    // github_pat_ + a long tail.
136    for p in [
137        concat!("gh", "p_"),
138        concat!("gh", "o_"),
139        concat!("gh", "u_"),
140        concat!("gh", "s_"),
141        concat!("gh", "r_"),
142    ] {
143        if has_prefixed_token(line, p, 36) {
144            return Some(Kind::GithubToken);
145        }
146    }
147    if has_prefixed_token(line, concat!("github_", "pat_"), 60) {
148        return Some(Kind::GithubToken);
149    }
150    // Slack: xoxb-/xoxp-/xoxa-/xoxr-/xoxs- + a real tail.
151    for p in [
152        concat!("xox", "b-"),
153        concat!("xox", "p-"),
154        concat!("xox", "a-"),
155        concat!("xox", "r-"),
156        concat!("xox", "s-"),
157    ] {
158        if has_prefixed_token(line, p, 10) {
159            return Some(Kind::SlackToken);
160        }
161    }
162    // Google API keys are AIza + exactly 35 more; ≥ 30 keeps rotated
163    // variants without matching prose.
164    if has_prefixed_token(line, concat!("AI", "za"), 30) {
165        return Some(Kind::GoogleApiKey);
166    }
167    // Stripe LIVE keys only — sk_test_ is designed to be committed.
168    for p in [concat!("sk_", "live_"), concat!("rk_", "live_")] {
169        if has_prefixed_token(line, p, 20) {
170            return Some(Kind::StripeLiveKey);
171        }
172    }
173    if has_prefixed_token(line, concat!("np", "m_"), 36) {
174        return Some(Kind::NpmToken);
175    }
176    // OpenAI / Anthropic project and API keys. The bare `sk-` prefix is
177    // too common in ordinary identifiers to gate on; the vendored forms
178    // are unambiguous.
179    for p in [concat!("sk-", "proj-"), concat!("sk-", "ant-")] {
180        if has_prefixed_token(line, p, 20) {
181            return Some(Kind::ApiKey);
182        }
183    }
184    None
185}
186
187/// git's own binary heuristic: a NUL in the first 8000 bytes.
188fn looks_binary(bytes: &[u8]) -> bool {
189    bytes.iter().take(8000).any(|b| *b == 0)
190}
191
192/// Scan one text, collecting redacted findings as `(line-number, kind)`.
193fn scan(text: &str) -> Vec<(usize, Kind)> {
194    text.lines()
195        .enumerate()
196        .filter_map(|(i, line)| sniff(line).map(|k| (i + 1, k)))
197        .collect()
198}
199
200/// pre-commit: the staged content. Under the staged-only hold the working
201/// tree IS the commit's content, so reading the files is reading the stage.
202pub fn staged() -> Outcome {
203    let files = common::staged_files(&[]);
204    let root = common::repo_root();
205    let mut found = false;
206    for f in &files {
207        let path = std::path::Path::new(&root).join(f);
208        let Ok(bytes) = std::fs::read(&path) else {
209            continue; // deleted or unreadable: nothing staged to leak
210        };
211        if looks_binary(&bytes) || bytes.len() > MAX_BYTES {
212            continue;
213        }
214        let text = String::from_utf8_lossy(&bytes);
215        for (line, kind) in scan(&text) {
216            found = true;
217            common::fail(&format!(
218                "secrets: {} at {}:{line} — unstage it; once pushed it is \
219                 not history, it is an incident",
220                kind.name(),
221                crate::ui::sanitize(f),
222            ));
223        }
224    }
225    if found {
226        return Outcome::Failed;
227    }
228    common::ok("No secrets staged");
229    Outcome::Passed
230}
231
232/// pre-push: every line every pushed commit ADDS — the last moment a
233/// secret is recoverable at all. `--no-verify` skipped the commit half;
234/// it does not skip this one.
235pub fn pushed(refs: &[PushRef]) -> Outcome {
236    let zero = crate::git::stdout(&["hash-object", "--stdin"])
237        .map(|h| "0".repeat(h.len()))
238        .unwrap_or_else(|| "0".repeat(40));
239    let mut found = false;
240    let mut checked_any_ref = false;
241    for r in refs {
242        if r.local_oid == zero {
243            continue; // deleting a ref pushes no content
244        }
245        let commits: Vec<String> = crate::pushrefs::commits_and_files_for(r, &zero)
246            .into_iter()
247            .map(|(c, _)| c)
248            .collect();
249        if commits.is_empty() && r.remote_oid != zero {
250            // An up-to-date or forced-same push; nothing new leaves.
251            continue;
252        }
253        checked_any_ref = true;
254        for commit in &commits {
255            let Some(diff) = crate::git::stdout(&["show", "--no-color", "--format=", commit])
256            else {
257                common::warn(
258                    "secrets: git would not show a pushed commit — the push was \
259                     NOT fully scanned",
260                );
261                return Outcome::Unavailable;
262            };
263            let mut file = String::from("?");
264            for line in diff.lines() {
265                if let Some(rest) = line.strip_prefix("+++ b/") {
266                    file = rest.to_string();
267                    continue;
268                }
269                let Some(added) = line.strip_prefix('+') else {
270                    continue;
271                };
272                if let Some(kind) = sniff(added) {
273                    found = true;
274                    common::fail(&format!(
275                        "secrets: {} added by commit {} in {} — this push would \
276                         publish it; rewrite the history first (the secret may \
277                         already need rotating)",
278                        kind.name(),
279                        &commit[..commit.len().min(12)],
280                        crate::ui::sanitize(&file),
281                    ));
282                }
283            }
284        }
285    }
286    if found {
287        return Outcome::Failed;
288    }
289    let _ = checked_any_ref; // a push of nothing is a clean push
290    common::ok("No secrets in the pushed commits");
291    Outcome::Passed
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    /// Fixtures are ASSEMBLED so this file never contains a contiguous
299    /// secret shape — the same trick the shipped patterns use.
300    fn pem() -> String {
301        format!("{}{} RSA {}{}", "-----", "BEGIN", "PRIVATE", " KEY-----")
302    }
303    fn aws() -> String {
304        format!("{}{}{}", "AK", "IA", "IOSFODNN7EXAMPLE")
305    }
306    fn gh() -> String {
307        format!("{}{}{}", "gh", "p_", "a".repeat(36))
308    }
309
310    #[test]
311    fn the_known_shapes_are_recognised() {
312        assert_eq!(sniff(&pem()), Some(Kind::PrivateKey));
313        assert_eq!(
314            sniff(&format!("key = {}", aws())),
315            Some(Kind::AwsAccessKeyId)
316        );
317        assert_eq!(sniff(&format!("token: {}", gh())), Some(Kind::GithubToken));
318        assert_eq!(
319            sniff(&format!("SLACK={}{}", "xox", "b-1234567890-abc")),
320            Some(Kind::SlackToken)
321        );
322        assert_eq!(
323            sniff(&format!("{}{}", "AI", "za".to_owned() + &"D".repeat(35))),
324            Some(Kind::GoogleApiKey)
325        );
326        assert_eq!(
327            sniff(&format!("{}{}{}", "sk_", "live_", "a".repeat(24))),
328            Some(Kind::StripeLiveKey)
329        );
330        assert_eq!(
331            sniff(&format!(
332                "{}{}{}",
333                "sk-",
334                "ant-",
335                "api03-".to_owned() + &"x".repeat(20)
336            )),
337            Some(Kind::ApiKey)
338        );
339    }
340
341    /// The shapes are shapes, not prefixes: too short, wrong charset, or
342    /// glued to a longer word is prose, not a credential.
343    #[test]
344    fn lookalikes_are_left_alone() {
345        assert_eq!(sniff("AKIAI is the prefix"), None); // too short
346        assert_eq!(sniff(&format!("X{}", aws())), None); // no boundary before
347        assert_eq!(sniff("ghp_short"), None);
348        assert_eq!(sniff("the sk-1234 identifier"), None); // bare sk- is not gated
349                                                           // Stripe TEST keys are designed to be committed — and assembled
350                                                           // here, because GitHub's own push protection flags the contiguous
351                                                           // spelling even inside the test that proves we ignore it.
352        assert_eq!(
353            sniff(&format!("{}{}{}", "sk_", "test_", "a".repeat(24))),
354            None
355        );
356        assert_eq!(sniff("xoxb- alone"), None);
357        assert_eq!(sniff(""), None);
358    }
359
360    /// The pragma is the surgical opt-out — same line, visible in review.
361    #[test]
362    fn the_allow_pragma_skips_the_line() {
363        let line = format!("{} // {}", aws(), ALLOW);
364        assert_eq!(sniff(&line), None);
365    }
366
367    /// The check must survive its own repository: the shipped source
368    /// assembles every pattern, so scanning this very file finds nothing.
369    #[test]
370    fn the_scanner_does_not_flag_its_own_source() {
371        let own = include_str!("secrets.rs");
372        assert!(
373            scan(own).is_empty(),
374            "the scanner flagged its own source: {:?}",
375            scan(own)
376        );
377    }
378
379    /// A NUL says binary, and binary is out of scope.
380    #[test]
381    fn binary_content_is_skipped() {
382        assert!(looks_binary(b"\x00PNG"));
383        assert!(!looks_binary(b"just text"));
384    }
385
386    /// Line numbers are 1-based and every finding is kept.
387    #[test]
388    fn scan_reports_each_line_once() {
389        let text = format!("clean\n{}\nclean\n{}\n", pem(), aws());
390        let hits = scan(&text);
391        assert_eq!(hits.len(), 2);
392        assert_eq!(hits[0], (2, Kind::PrivateKey));
393        assert_eq!(hits[1], (4, Kind::AwsAccessKeyId));
394    }
395}