Skip to main content

keyhog_scanner/entropy/
mod.rs

1//! Shannon entropy analysis for distinguishing secrets from ordinary text.
2//!
3//! Real secrets have high entropy (4.5+), while hashes, UUIDs, and placeholders
4//! have characteristic entropy profiles that help separate true positives.
5
6/// BPE "rare-not-random" precision gate (tiktoken cl100k_base bytes-per-token).
7/// Gated on `entropy`: the tokenizer dep rides that feature.
8#[cfg(feature = "entropy")]
9pub(crate) mod bpe;
10mod isolated;
11pub(crate) mod keywords;
12pub(crate) mod plausibility;
13pub(crate) mod policy;
14pub(crate) mod scanner;
15
16// Fast Shannon-entropy primitives, relocated here from the crate root so all
17// entropy code shares one home. `fast` is the scalar dispatcher that routes to
18// the SIMD impls by runtime capability; the impls are arch-gated.
19/// AVX-512 optimized entropy calculation.
20pub(crate) mod avx512;
21/// Fast scalar entropy dispatcher (routes to the SIMD impls below).
22pub(crate) mod fast;
23#[cfg(target_arch = "aarch64")]
24pub(crate) mod fast_neon;
25#[cfg(target_arch = "x86_64")]
26pub(crate) mod fast_x86;
27
28#[cfg(feature = "entropy")]
29pub(crate) use scanner::KEYWORD_FREE_LABEL;
30pub use scanner::{find_entropy_secrets, find_entropy_secrets_with_threshold};
31
32/// Threshold for keyword-context entropy detection.
33///
34/// Preserved as the public baseline for callers that explicitly request the
35/// historical generic threshold. Shipped entropy owners declare and compile
36/// their own `DetectorSpec::entropy_low` value.
37pub const LOW_ENTROPY_THRESHOLD: f64 = 3.0;
38
39/// Default threshold for keyword-independent entropy detection.
40///
41/// This remains the global operator-override and confidence baseline. Shipped
42/// entropy owners declare and compile their own `DetectorSpec::entropy_high`.
43pub const HIGH_ENTROPY_THRESHOLD: f64 = 4.5;
44
45pub(crate) const ISOLATED_BARE_ENTROPY_LABEL: &str = "none (isolated-token)";
46
47/// Threshold for keyword-independent entropy detection.
48///
49/// This remains the detector-neutral confidence and ML feature tier. Shipped
50/// entropy owners declare and compile their own `DetectorSpec::entropy_very_high`.
51pub const VERY_HIGH_ENTROPY_THRESHOLD: f64 = 5.8;
52
53/// One-based offset added to a zero-based `text.lines()` index to produce the
54/// [`EntropyMatch::line`] source line number. Single canonical owner for the
55/// 0→1 line-base convention shared by the line-scoped scanner
56/// (`scanner::find_entropy_secrets_with_threshold`) and the isolated-bare token
57/// path (`isolated::collect_isolated_bare_candidates`), both add it to their
58/// enumerated line index, so the convention lives in exactly one place.
59pub(crate) const FIRST_SOURCE_LINE_NUMBER: usize = 1;
60
61/// Config/secret file extensions that mark a path as entropy-appropriate. Single
62/// owner: both the direct extension check and the stem+extension check in
63/// [`is_entropy_appropriate_inner`] reference this set (the latter also allows
64/// the [`extra_stem_config_extensions`] tail).
65#[derive(serde::Deserialize)]
66struct ConfigFileExtensionsFile {
67    extensions: Vec<String>,
68    stem_only_extensions: Vec<String>,
69}
70
71fn parse_config_file_extensions(raw: &str) -> Result<(Vec<Vec<u8>>, Vec<Vec<u8>>), String> {
72    toml::from_str::<ConfigFileExtensionsFile>(raw)
73        .map(|parsed| {
74            (
75                parsed
76                    .extensions
77                    .into_iter()
78                    .map(String::into_bytes)
79                    .collect(),
80                parsed
81                    .stem_only_extensions
82                    .into_iter()
83                    .map(String::into_bytes)
84                    .collect(),
85            )
86        })
87        .map_err(|error| error.to_string())
88}
89
90/// `(direct extensions, stem-only extensions)`, loaded once from the bundled
91/// Tier-B `rules/config-file-extensions.toml`. `include_str!` embeds the file at
92/// compile time, so a parse failure is a build defect in the bundled data, not
93/// a runtime hostile-input risk (and fails closed (Law 10), naming the file).
94static CONFIG_EXTENSION_LISTS: std::sync::LazyLock<(Vec<Vec<u8>>, Vec<Vec<u8>>)> =
95    std::sync::LazyLock::new(|| {
96        match parse_config_file_extensions(include_str!(concat!(
97            env!("CARGO_MANIFEST_DIR"),
98            "/rules/config-file-extensions.toml"
99        ))) {
100            Ok(lists) => lists,
101            Err(error) => panic!(
102                "rules/config-file-extensions.toml is invalid: {error}. Fix the bundled Tier-B \
103                 config-file-extension list."
104            ),
105        }
106    });
107
108/// Config/secrets file extensions matched directly on any filename tail.
109fn config_file_extensions() -> &'static [Vec<u8>] {
110    &CONFIG_EXTENSION_LISTS.0
111}
112
113/// Extra extensions accepted only after a known credential stem
114/// (`secrets.enc`, `credentials.vault`, …), on top of [`config_file_extensions`].
115fn extra_stem_config_extensions() -> &'static [Vec<u8>] {
116    &CONFIG_EXTENSION_LISTS.1
117}
118
119#[derive(serde::Deserialize)]
120struct CredentialFileNamesFile {
121    prefix_match: Vec<String>,
122    exact_or_config_ext: Vec<String>,
123}
124
125/// Parse the Tier-B credential-store filename lists. Returns an error (rather
126/// than panicking) so the single `CREDENTIAL_FILE_NAME_LISTS` owner below is the
127/// one fail-closed site, and enforces that BOTH lists are non-empty.
128fn parse_credential_file_names(raw: &str) -> Result<(Vec<Vec<u8>>, Vec<Vec<u8>>), String> {
129    let parsed: CredentialFileNamesFile = toml::from_str(raw).map_err(|error| error.to_string())?;
130    if parsed.prefix_match.is_empty() || parsed.exact_or_config_ext.is_empty() {
131        return Err("prefix_match and exact_or_config_ext must both be non-empty".to_string());
132    }
133    Ok((
134        parsed
135            .prefix_match
136            .into_iter()
137            .map(String::into_bytes)
138            .collect(),
139        parsed
140            .exact_or_config_ext
141            .into_iter()
142            .map(String::into_bytes)
143            .collect(),
144    ))
145}
146
147/// `(prefix-match names, exact-or-config-ext names)`: the credential-store
148/// filenames the entropy fallback treats as secret files, loaded once from the
149/// bundled Tier-B `rules/credential-file-names.toml`. Fails closed (Law 10),
150/// naming the file, since a parse failure is a build defect in bundled data.
151static CREDENTIAL_FILE_NAME_LISTS: std::sync::LazyLock<(Vec<Vec<u8>>, Vec<Vec<u8>>)> =
152    std::sync::LazyLock::new(|| {
153        match parse_credential_file_names(include_str!(concat!(
154            env!("CARGO_MANIFEST_DIR"),
155            "/rules/credential-file-names.toml"
156        ))) {
157            Ok(lists) => lists,
158            Err(error) => panic!(
159                "rules/credential-file-names.toml is invalid: {error}. Fix the bundled Tier-B \
160                 credential-file-name list."
161            ),
162        }
163    });
164
165/// Shannon entropy in bits per byte, with thread-local caching for repeat
166/// inputs ≤1KB (typical credential size). Cache evicts wholesale when full
167/// to bound memory under adversarial input.
168pub fn shannon_entropy(data: &[u8]) -> f64 {
169    // Length gate: don't cache entropy for massive buffers (e.g. minified JS)
170    // that won't repeat exactly. Just calculate directly.
171    if data.len() > 1024 {
172        return shannon_entropy_uncached(data);
173    }
174
175    use std::cell::RefCell;
176    use std::collections::HashMap;
177
178    thread_local! {
179        static CACHE: RefCell<HashMap<u64, f64>> = RefCell::new(HashMap::with_capacity(256));
180    }
181
182    // FNV-1a content key, shared seed with every other per-scan cache.
183    let hash = crate::util_hash::hash_fast(data);
184    crate::util_hash::memoize_by_hash(
185        &CACHE,
186        hash,
187        crate::util_hash::DEFAULT_MAX_CACHE_ENTRIES,
188        || shannon_entropy_uncached(data),
189    )
190}
191
192fn shannon_entropy_uncached(data: &[u8]) -> f64 {
193    crate::entropy::fast::shannon_entropy_simd(data)
194}
195
196/// Number of DISTINCT byte values present in `data` (`0..=256`), via a single
197/// pass over a 256-entry presence table.
198///
199/// The shared primitive behind three byte-identical copies of this loop:
200/// [`normalized_entropy`]'s `log2(unique)` denominator, the confidence shape
201/// gate `confidence::penalties::char_diversity`, and the ML feature
202/// `ml_scorer::ml_features::unique_byte_count`. Both consumers live downstream
203/// of `entropy` (each already imports from it), so this is the natural home.
204pub(crate) fn unique_byte_count(data: &[u8]) -> usize {
205    let mut seen = [false; 256];
206    let mut count = 0usize;
207    for &byte in data {
208        let slot = &mut seen[byte as usize];
209        if !*slot {
210            *slot = true;
211            count += 1;
212        }
213    }
214    count
215}
216
217/// Shannon entropy rescaled to `0.0..=1.0` by dividing by `log2(unique_bytes)`.
218pub fn normalized_entropy(data: &[u8]) -> f64 {
219    if data.is_empty() {
220        return 0.0;
221    }
222
223    let unique_chars = unique_byte_count(data);
224
225    if unique_chars <= 1 {
226        return 0.0;
227    }
228
229    let max_entropy = (unique_chars as f64).log2();
230    if max_entropy == 0.0 {
231        return 0.0;
232    }
233
234    shannon_entropy(data) / max_entropy
235}
236
237/// Entropy-based candidate match returned by fallback secret detection.
238#[derive(Debug, Clone)]
239pub struct EntropyMatch {
240    /// The candidate string that exceeded the entropy threshold.
241    pub value: String,
242    /// Shannon entropy measured for `value`.
243    pub entropy: f64,
244    /// The keyword context that caused the candidate to be evaluated.
245    pub keyword: String,
246    /// One-based source line number for the match.
247    pub line: usize,
248    /// Byte offset used to locate the match in preprocessed text. Most
249    /// line-scoped entropy candidates use the containing line start; isolated
250    /// token candidates use the token start.
251    pub offset: usize,
252}
253
254#[derive(Debug, Clone)]
255pub(crate) struct ClassifiedEntropyMatch {
256    pub(crate) matched: EntropyMatch,
257    pub(crate) is_credential_context: bool,
258    pub(crate) is_same_line_credential_context: bool,
259}
260
261/// True if the file at `path` is worth running entropy scanning on.
262///
263/// Path-only gate: `.json` and all source-code extensions are hard-OFF here.
264/// For the keyword-anchored lift of those hard-OFFs (a `.json` body or a
265/// source file that carries a secret-keyword assignment line still holds
266/// real, unprefixed high-entropy secrets), call
267/// [`is_entropy_appropriate_with_content`], which the entropy fallback uses.
268pub fn is_entropy_appropriate(path: Option<&str>, allow_source_files: bool) -> bool {
269    is_entropy_appropriate_inner(path, allow_source_files, false)
270}
271
272/// Content-aware variant of [`is_entropy_appropriate`].
273///
274/// `has_secret_keyword_line` is true when the chunk text contains at least one
275/// secret-keyword assignment line. For config/data files this uses the same
276/// broader predicate the entropy scanner uses to seed keyword contexts. For
277/// source-code files it is intentionally narrower: only a same-line credential
278/// assignment surface such as `apiKey = "..."` lifts the source-file hard-OFF.
279/// Ordinary compiler/parser code is full of `Token`, `key`, `signature`, and
280/// `digest` identifiers next to `=`/`:`; treating those as credential context
281/// turns the whole source chunk into entropy noise. When set, two path-only
282/// hard-OFFs are lifted:
283///
284///   * `.json` files (the single biggest FN wrapper - `{"auth": "<40-char
285///     base64>"}` was scoring 0 while the identical `auth: "<same>"` in
286///     `.yaml` was caught), and
287///   * source-code files when `allow_source_files` is false (the dominant
288///     go/rust/js FN shape `const apiKey = "<base64-40>"` lives in a quoted
289///     RHS of a const/assignment with a secret keyword).
290///
291/// Both lifts are contract-safe: the keyword-assignment anchor confines the
292/// recall expansion to credential-shaped lines, away from prose / identifiers,
293/// and the per-candidate suppression gates on the emit path
294/// (pure-identifier, prose, kebab, filename-shape, ...) still run.
295///
296/// `.lock` / `.map` / minified bundles stay hard-OFF unconditionally - they
297/// are not credential wrappers, only alphabet-coincidence noise.
298pub fn is_entropy_appropriate_with_content(
299    path: Option<&str>,
300    allow_source_files: bool,
301    text: &str,
302    secret_keywords: &[String],
303) -> bool {
304    if is_entropy_appropriate(path, allow_source_files) {
305        return true;
306    }
307    let has_secret_keyword_line =
308        content_has_secret_keyword_line(path, allow_source_files, text.lines(), secret_keywords);
309    is_entropy_appropriate_inner(path, allow_source_files, has_secret_keyword_line)
310}
311
312fn content_has_secret_keyword_line<'a>(
313    path: Option<&str>,
314    allow_source_files: bool,
315    mut lines: impl Iterator<Item = &'a str>,
316    secret_keywords: &[String],
317) -> bool {
318    if crate::decode::caesar::is_program_source_code_path(path) && !allow_source_files {
319        lines.any(keywords::line_has_credential_assignment_surface)
320    } else {
321        lines.any(|line| keywords::is_keyword_assignment_line(line, secret_keywords))
322    }
323}
324
325pub(crate) fn is_entropy_appropriate_inner(
326    path: Option<&str>,
327    allow_source_files: bool,
328    has_secret_keyword_line: bool,
329) -> bool {
330    let Some(path) = path else { return true };
331    // ASCII case-insensitive byte comparison - no whole-path lowercase
332    // allocation per call. Hot path on every chunk during a scan.
333    let bytes = path.as_bytes();
334    let ends_ci = |suffix: &[u8]| -> bool {
335        bytes.len() >= suffix.len()
336            && bytes[bytes.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
337    };
338
339    // `.lock` / `.map` are never credential wrappers - stay hard-OFF even with
340    // a keyword line. `.json` is lifted when a secret-keyword assignment line
341    // is present (part (a) of the FN-recall fix): JSON is the biggest FN
342    // wrapper, but only the keyword-anchored bodies hold real secrets.
343    for extension in [b".lock".as_slice(), b".map"] {
344        if ends_ci(extension) {
345            return false;
346        }
347    }
348    if ends_ci(b".json") && !has_secret_keyword_line {
349        return false;
350    }
351    if ends_ci(b".min.js") || ends_ci(b".min.css") {
352        return false;
353    }
354    if allow_source_files {
355        return true;
356    }
357
358    // Last segment after `/` or `\` - index into bytes, no alloc.
359    let last_sep = bytes
360        .iter()
361        .rposition(|&b| b == b'/' || b == b'\\')
362        .map(|i| i + 1)
363        .unwrap_or(0); // LAW10: empty/absent => documented numeric/sentinel default, recall-safe
364    let filename = &bytes[last_sep..];
365
366    // Package-manifest exclusion: Cargo.toml / package.json / pyproject.toml
367    // / Pipfile / Gemfile / pom.xml / build.gradle have [package.keywords]
368    // / "keywords" / "categories" array data that look like high-entropy
369    // strings but are package metadata, not credentials. Entropy fires on
370    // ["compression", "encryption", "history"] as `entropy-api-key`
371    // because the array literal happens to clear the keyword + entropy
372    // thresholds. Suppress on stem match, ASCII case-insensitive.
373    // #15 regression: envseal dogfood, ~10 FPs per Cargo.toml.
374    for stem in [
375        b"Cargo.toml".as_slice(),
376        b"package.json",
377        b"pyproject.toml",
378        b"composer.json",
379        b"Pipfile",
380        b"Gemfile",
381        b"pom.xml",
382        b"build.gradle",
383        b"build.gradle.kts",
384        b"build.sbt",
385        b"mix.exs",
386    ] {
387        if filename.eq_ignore_ascii_case(stem) {
388            return false;
389        }
390    }
391
392    for extension in config_file_extensions() {
393        if ends_ci(extension) {
394            return true;
395        }
396    }
397
398    // Filename-prefix match: `.env-staging`, `.env.production` should count
399    // as a secret file. But `secrets.rs`, `credentials.py`, `apikeys.go`
400    // are source code ABOUT credentials, not credential files - the
401    // surrounding code uses `secret` / `credential` / `apikey` as
402    // identifiers, and the entropy fallback was misclassifying every
403    // identifier-shaped value on those lines as `entropy-api-key`.
404    //
405    // Split policy:
406    //   - `.env` keeps the prefix-match semantics (legitimate variants
407    //     exist: `.env-staging`, `.env.production`, `.envfile`).
408    //   - All other names require an EXACT filename match (no extension)
409    //     OR a prefix match followed by a known config extension
410    //     (`secrets.env`, `credentials.yaml`, `apikeys.toml`).
411    //
412    // #15 regression: envseal/cli/src/tui/secrets.rs fired entropy on
413    // every `Style`/`Paragraph::new` call because filename prefix
414    // "secrets" matched. After this filter, scanning a `secrets.rs`
415    // requires `--entropy-source-files`.
416    for name in &CREDENTIAL_FILE_NAME_LISTS.0 {
417        let starts_ci =
418            filename.len() >= name.len() && filename[..name.len()].eq_ignore_ascii_case(name);
419        if starts_ci {
420            return true;
421        }
422    }
423
424    for name in &CREDENTIAL_FILE_NAME_LISTS.1 {
425        if filename.eq_ignore_ascii_case(name) {
426            return true;
427        }
428        // Prefix + config extension: `secrets.yaml`, `credentials.env`,
429        // `apikeys.toml`, `secrets-prod.toml`. The trailing extension
430        // gate keeps `secrets.rs`, `credentials.py`, etc. on the
431        // source-code path (skipped unless --entropy-source-files).
432        if filename.len() > name.len() && filename[..name.len()].eq_ignore_ascii_case(name) {
433            let tail = &filename[name.len()..];
434            for ext in config_file_extensions()
435                .iter()
436                .chain(extra_stem_config_extensions())
437            {
438                if tail.len() >= ext.len()
439                    && tail[tail.len() - ext.len()..].eq_ignore_ascii_case(ext)
440                {
441                    return true;
442                }
443            }
444        }
445    }
446
447    // Source-file lift (part (b) of the FN-recall fix). Everything that
448    // reaches here is a genuine source-code file (`.rs`, `.go`, `.js`,
449    // `.py`, ...) that is neither a recognized config/secret file nor a
450    // package manifest (both returned earlier). The dominant go/rust/js
451    // FN shape is a quoted RHS of a const/assignment with a secret keyword,
452    // `const apiKey = "<base64-40>"`. When the chunk carries such a
453    // secret-keyword assignment line, allow entropy scanning here even
454    // without `--entropy-source-files`; the per-candidate emit gates
455    // (pure-identifier, prose, kebab, filename-shape, ...) reject the
456    // identifier noise that motivated the source-file hard-OFF, so the
457    // keyword anchor keeps this contract-safe. Manifests are unaffected -
458    // they already returned `false` above, so a `name = "my-secret"` line
459    // in `Cargo.toml` cannot re-enable scanning here.
460    has_secret_keyword_line
461}
462
463#[cfg(test)]
464#[path = "../../tests/unit/entropy_inline.rs"]
465mod tests;