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;