Skip to main content

kernel/secret/
scan.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Secret scanning: shape-table-first, entropy-second, float-free.
7//! The part `crates/kernel/spec/Secret.lean` specifies this module with the
8//! rest of `kernel::secret`.
9
10use crate::consts_external::{SECRET_SHAPES, SecretCharset};
11use crate::consts_policy::SECRET_ENTROPY_MIN;
12
13use super::hex_run::is_labelled_hex_secret;
14use super::span::SecretSpan;
15
16fn charset_admits(charset: SecretCharset, byte: u8) -> bool {
17    match charset {
18        SecretCharset::Base62 => byte.is_ascii_alphanumeric(),
19        SecretCharset::Base64Url => {
20            byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'='
21        }
22        SecretCharset::HexLower => byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte),
23        SecretCharset::UpperBase36 => byte.is_ascii_uppercase() || byte.is_ascii_digit(),
24    }
25}
26
27/// Entropy-detector alphabet: the union of common token charsets.
28/// `=` is deliberately absent: as base64 padding it only trails (the
29/// pre-padding run still crosses the length floor), while as an
30/// assignment sign it welds two identifiers into one fake token
31/// (`NAME=value` — a false-positive class).
32fn token_byte(byte: u8) -> bool {
33    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+' | b'/')
34}
35
36/// The entropy detector only fires on mixed-alphabet runs (upper AND
37/// lower AND digit): unknown-shape API keys are mixed-case base62/64,
38/// while the city's own artifacts — blake3 hex, uuids, digit runs — are
39/// single-case by construction and must not light up every ledger line.
40/// An all-lowercase secret evades this detector; the shape table stays
41/// the primary net and rotation the last remedy.
42fn mixed_alphabet(bytes: &[u8]) -> bool {
43    let has_upper = bytes.iter().any(u8::is_ascii_uppercase);
44    let has_lower = bytes.iter().any(u8::is_ascii_lowercase);
45    let has_digit = bytes.iter().any(u8::is_ascii_digit);
46    has_upper && has_lower && has_digit
47}
48
49/// Minimum span for the entropy detector: mainstream API keys start
50/// around 20 chars; anything shorter is prose noise. Internal affair —
51/// changing it changes recall, so it moves with this SPEC only.
52pub(crate) const ENTROPY_SPAN_MIN_BYTES: usize = 20;
53
54/// log2 in Q10 fixed point (10 fractional bits), shift-and-square.
55/// Zero input returns zero (callers guard); the loop bound is the
56/// constant 10, so termination is by construction (V5).
57fn log2_q10(x: u64) -> u64 {
58    if x == 0 {
59        return 0;
60    }
61    let int_part = u64::from(x.ilog2());
62    // Normalize the mantissa into [1, 2) as Q32 in u128.
63    let mantissa_q32: u128 = (u128::from(x) << 32) >> x.ilog2();
64    let mut y = mantissa_q32;
65    let mut frac: u64 = 0;
66    for _ in 0..10 {
67        y = (y.saturating_mul(y)) >> 32;
68        frac <<= 1;
69        if y >= (2u128 << 32) {
70            frac |= 1;
71            y >>= 1;
72        }
73    }
74    (int_part << 10) | frac
75}
76
77/// Shannon entropy per char in millibits (1/1000 bit). Saturation can
78/// only over-approximate, which errs toward detection — the recoverable
79/// direction (entrance replaces, never refuses).
80pub(super) fn entropy_millibits_per_char(bytes: &[u8]) -> u64 {
81    let Ok(n) = u64::try_from(bytes.len()) else {
82        return 0; // unreachable on real targets; zero reads as low entropy
83    };
84    if n == 0 {
85        return 0;
86    }
87    let mut counts = [0u64; 256];
88    for byte in bytes {
89        let slot = counts.get_mut(usize::from(*byte));
90        if let Some(c) = slot {
91            *c = c.saturating_add(1);
92        }
93    }
94    let log_n = log2_q10(n);
95    let mut sum_q10: u64 = 0;
96    for count in counts {
97        if count == 0 {
98            continue;
99        }
100        let term = count.saturating_mul(log_n.saturating_sub(log2_q10(count)));
101        sum_q10 = sum_q10.saturating_add(term);
102    }
103    // Q10 -> millibits: * 1000 / 1024, then / n for the per-char figure.
104    sum_q10
105        .saturating_mul(1000)
106        .checked_div(1024)
107        .and_then(|mb| mb.checked_div(n))
108        .unwrap_or(0)
109}
110
111fn entropy_passes(bytes: &[u8]) -> bool {
112    let mb = entropy_millibits_per_char(bytes);
113    let num = u64::from(SECRET_ENTROPY_MIN.num);
114    let den = u64::from(SECRET_ENTROPY_MIN.den);
115    // mb >= (num / den) * 1000  <=>  mb * den >= num * 1000
116    mb.saturating_mul(den) >= num.saturating_mul(1000)
117}
118
119fn find_shape_hits(bytes: &[u8]) -> Vec<SecretSpan> {
120    let mut hits = Vec::new();
121    for shape in &SECRET_SHAPES {
122        let prefix = shape.prefix.as_bytes();
123        if prefix.is_empty() || bytes.len() < prefix.len() {
124            continue;
125        }
126        let mut at = 0usize;
127        while let Some(window) = bytes.get(at..) {
128            let Some(rel) = window.windows(prefix.len()).position(|w| w == prefix) else {
129                break;
130            };
131            let start = at.saturating_add(rel);
132            let body_start = start.saturating_add(prefix.len());
133            let mut end = body_start;
134            while bytes
135                .get(end)
136                .is_some_and(|b| charset_admits(shape.charset, *b))
137            {
138                end = end.saturating_add(1);
139            }
140            let total = end.saturating_sub(start);
141            let (min_len, max_len) = (usize::from(shape.len.0), usize::from(shape.len.1));
142            if total >= min_len {
143                hits.push(SecretSpan {
144                    start,
145                    len: total.min(max_len),
146                    provider: Some(shape.provider),
147                });
148            }
149            at = body_start;
150        }
151    }
152    hits.sort_by_key(|h| (h.start, h.len));
153    hits
154}
155
156fn overlaps(a: &SecretSpan, start: usize, len: usize) -> bool {
157    let a_end = a.start.saturating_add(a.len);
158    let b_end = start.saturating_add(len);
159    a.start < b_end && start < a_end
160}
161
162/// Custody's detection half. Shape table first;
163/// entropy second over token runs of at least [`ENTROPY_SPAN_MIN_BYTES`]
164/// that no shape already claimed. Pure, total, deterministic.
165pub fn scan(bytes: &[u8]) -> Vec<SecretSpan> {
166    let mut hits = find_shape_hits(bytes);
167    let mut at = 0usize;
168    while at < bytes.len() {
169        if !bytes.get(at).copied().is_some_and(token_byte) {
170            at = at.saturating_add(1);
171            continue;
172        }
173        let mut end = at;
174        while bytes.get(end).copied().is_some_and(token_byte) {
175            end = end.saturating_add(1);
176        }
177        let len = end.saturating_sub(at);
178        if len >= ENTROPY_SPAN_MIN_BYTES
179            && !hits.iter().any(|h| overlaps(h, at, len))
180            && (bytes
181                .get(at..end)
182                .is_some_and(|run| mixed_alphabet(run) && entropy_passes(run))
183                || is_labelled_hex_secret(bytes, at, end))
184        {
185            hits.push(SecretSpan {
186                start: at,
187                len,
188                provider: None,
189            });
190        }
191        at = end;
192    }
193    hits.sort_by_key(|h| (h.start, h.len));
194    hits
195}
196
197/// Whether a *name* reads as the name of a credential.
198///
199/// The other half of this module judges bytes that might be a secret;
200/// this half judges a label that would sit in front of one, which is
201/// what a configuration file offers when it declares an environment
202/// variable. Both live here so "what looks like a credential" has one
203/// authority rather than two.
204///
205/// Case-insensitive substring match against
206/// [`crate::consts_policy::CREDENTIAL_NAME_MARKERS`]. Over-inclusive on
207/// purpose: the caller refuses with an alternative, and a false refusal
208/// costs a person one line of configuration while a missed one leaks a
209/// key into every child process the run starts.
210#[must_use]
211pub fn names_a_credential(name: &str) -> bool {
212    let folded = name.to_ascii_lowercase();
213    crate::consts_policy::CREDENTIAL_NAME_MARKERS
214        .iter()
215        .any(|marker| folded.contains(marker))
216}
217
218#[cfg(test)]
219#[allow(
220    clippy::unwrap_used,
221    clippy::expect_used,
222    clippy::panic,
223    clippy::indexing_slicing,
224    reason = "test code"
225)]
226mod tests {
227    use super::*;
228    use proptest::prelude::*;
229    #[test]
230    fn known_provider_shapes_are_found_with_offsets_only() {
231        let text = format!("config = \"sk-ant-{}\" # key", "a1B2c3D4e5".repeat(9));
232        let hits = scan(text.as_bytes());
233        assert!(!hits.is_empty());
234        let hit = &hits[0];
235        assert_eq!(hit.provider, Some("anthropic"));
236        let prefix_end = hit.start.checked_add(7).unwrap();
237        assert_eq!(&text.as_bytes()[hit.start..prefix_end], b"sk-ant-");
238    }
239
240    #[test]
241    fn high_entropy_tokens_hit_and_prose_does_not() {
242        // The probe token is assembled at runtime so the source file
243        // itself never holds a scannable span (xtask secret runs on us).
244        let token = ["kJ8vQ2xR9m", "W4nZ7pL3sT", "6yB1cD5fG0", "hN8aE2iU4o"].concat();
245        let noisy = format!("token = {token} ok");
246        let hits = scan(noisy.as_bytes());
247        assert!(hits.iter().any(|h| h.provider.is_none()));
248        let prose = b"the quick brown fox jumps over the lazy dog again and again";
249        assert!(scan(prose).is_empty());
250        let repeated = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
251        assert!(scan(repeated).is_empty(), "zero entropy never hits");
252    }
253
254    #[test]
255    fn city_native_artifacts_do_not_light_up() {
256        // blake3 hex64: all-lowercase hex — no mixed alphabet, no hit.
257        let hex = format!("prev: {}", "ab".repeat(32));
258        assert!(scan(hex.as_bytes()).is_empty());
259        // uuid: lowercase hex + hyphens.
260        let uuid = b"run: 0198f6a2-7c4a-7bbb-9d1e-000000000001";
261        assert!(scan(uuid).is_empty());
262        // digit runs (timestamps, sizes).
263        let digits = b"t: 17555443211234567890123";
264        assert!(scan(digits).is_empty());
265    }
266
267    #[test]
268    fn shape_hits_swallow_overlapping_entropy_hits() {
269        let text = format!("sk-ant-{}", "a1B2c3D4e5".repeat(9));
270        let hits = scan(text.as_bytes());
271        assert_eq!(hits.len(), 1);
272        assert_eq!(hits[0].provider, Some("anthropic"));
273    }
274
275    #[test]
276    fn aggregator_tokens_with_lowercase_hex_bodies_are_found() {
277        // Every probe is assembled at runtime: `xtask secret` scans this
278        // file, and a literal token here would be a finding in itself.
279        //
280        // These three matter because the entropy detector cannot reach
281        // them. Their bodies are all-lowercase hex, so `mixed_alphabet`
282        // is false and the second detector never fires - the shape table
283        // is the only net.
284        let hex64 = "0f1e2d3c".repeat(8);
285        for (prefix, provider) in [("sk-or-v1-", "openrouter"), ("sk-ai-v1-", "zenmux")] {
286            let text = format!("KEY={prefix}{hex64}");
287            let found: Vec<_> = scan(text.as_bytes()).iter().map(|h| h.provider).collect();
288            assert_eq!(found, vec![Some(provider)], "{prefix} is one shape hit");
289        }
290        let groq = format!("KEY=gsk_{}", "a1B2c3D4e5".repeat(5));
291        assert_eq!(
292            scan(groq.as_bytes()).first().and_then(|h| h.provider),
293            Some("groq")
294        );
295    }
296
297    #[test]
298    fn a_bare_hex_run_still_does_not_hit_without_its_prefix() {
299        // The guard on the entry above: these shapes must not turn every
300        // blake3 hash in the ledger into a finding.
301        let hex64 = "0f1e2d3c".repeat(8);
302        assert!(scan(format!("prev: {hex64}").as_bytes()).is_empty());
303    }
304
305    #[test]
306    fn log2_q10_matches_known_points() {
307        assert_eq!(log2_q10(1), 0);
308        assert_eq!(log2_q10(2), 1 << 10);
309        assert_eq!(log2_q10(4), 2 << 10);
310        // log2(3) = 1.58496...; Q10 => 1623.0 => 1623 (truncated toward 0)
311        let three = log2_q10(3);
312        assert!((1622..=1624).contains(&three), "{three}");
313    }
314
315    #[test]
316    fn uniform_bytes_have_full_entropy() {
317        // 64 distinct byte values once each: log2(64) = 6 bits/char.
318        let bytes: Vec<u8> = (0u8..64).collect();
319        let mb = entropy_millibits_per_char(&bytes);
320        assert!((5900..=6000).contains(&mb), "{mb}");
321    }
322
323    proptest! {
324        /// Total on arbitrary inputs, spans in bounds.
325        #[test]
326        fn scan_is_total_and_in_bounds(bytes in proptest::collection::vec(any::<u8>(), 0..256)) {
327            for hit in scan(&bytes) {
328                prop_assert!(hit.start.checked_add(hit.len).unwrap() <= bytes.len());
329            }
330        }
331    }
332}
333
334#[cfg(kani)]
335mod verification {
336    //! V5: the fixed-point logarithm terminates and never panics on any
337    //! `u64`. The loop bound is the constant ten, so CBMC derives it and
338    //! no global unwind is passed.
339    //!
340    //! The whole-function property stops here on purpose:
341    //! `entropy_millibits_per_char` calls `log2_q10` once per slot of a
342    //! 256-slot count table and each call squares a `u128` ten times, so
343    //! a harness over the table hands the solver some 2,560 symbolic
344    //! non-linear multiplications and returns no verdict. Totality of
345    //! the whole scan is held by the proptest above; proving it here
346    //! would need a per-slot function this module does not have
347    //! (`crates/kernel/Spec.lean` §2).
348
349    use super::*;
350
351    #[kani::proof]
352    fn log2_q10_is_total() {
353        let x: u64 = kani::any();
354        let _ = log2_q10(x);
355    }
356}