Skip to main content

keyhog_core/
config.rs

1//! Configuration for KeyHog scanning and verification.
2//!
3//! Provides the [`ScanConfig`] struct used to control decoding depth,
4//! entropy thresholds, deduplication strategy, and performance tuning.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use crate::DedupScope;
10
11/// Shipped filesystem per-file scan cap.
12///
13/// This is the single default used by the core config surface and by
14/// `keyhog-sources::FilesystemSource::new`. A caller can still pass
15/// `max_file_size = 0` through the source layer to mean "unlimited".
16pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = 100 * 1024 * 1024;
17
18/// Single owner of the Tier-A generic-entropy gate default (the
19/// `ScanConfig::default().entropy_threshold` knob). The scanner's adjudicate
20/// fallback (`generic_entropy_floor`) resolves an absent per-scan value to this
21/// SAME number, so it references this const rather than re-spelling `4.5`: the
22/// two must stay equal by construction, not by two hand-kept literals.
23pub const DEFAULT_ENTROPY_THRESHOLD: f64 = 4.5;
24
25/// Single owner of the BPE "rare-not-random" gate default (the
26/// `ScanConfig::default().entropy_bpe_max_bytes_per_token` knob). An entropy /
27/// generic candidate whose `cl100k_base` bytes-per-token is STRICTLY GREATER
28/// than this compresses into few common subword tokens, word-like (a probable
29/// false positive: dotted API paths, prose, XML) rather than a random secret
30/// and is suppressed. `2.2` is the empirical CredData F1 peak (see
31/// `keyhog_scanner::entropy::bpe`; the offline A/B lifted F1 0.368→0.424).
32///
33/// This lives in `keyhog-core` (not next to the gate in `keyhog-scanner`) so
34/// `ScanConfig` can default to it WITHOUT a scanner↔core dependency cycle, the
35/// gate imports it back. There is exactly ONE definitional home for the value;
36/// the scanner re-exports this const under its historical name for the gate's
37/// compiled default and its tests, so the two can never drift.
38pub const DEFAULT_ENTROPY_BPE_MAX_BYTES_PER_TOKEN: f64 = 2.2;
39
40/// Configuration for a scan run.
41#[derive(Clone, Debug, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct ScanConfig {
44    /// Minimum confidence (0.0 to 1.0) required to report a finding.
45    pub min_confidence: f64,
46    /// Maximum recursive decoding depth (e.g. Base64(Hex(URL(secret)))).
47    pub max_decode_depth: usize,
48    /// Whether to enable Shannon entropy analysis for unknown high-entropy strings.
49    pub entropy_enabled: bool,
50    /// Whether to enable entropy analysis even in standard source code files.
51    pub entropy_in_source_files: bool,
52    /// Global enable for detector-owned entropy ML policy. Each entropy owner
53    /// still chooses `disabled`, `lift`, `blend`, or `authoritative` in its TOML;
54    /// this switch can only disable those compiled modes for a scan. Opt out
55    /// with `--no-entropy-ml-scoring`. No-op when entropy or ML is disabled.
56    #[serde(default = "default_entropy_ml_authoritative")]
57    pub entropy_ml_authoritative: bool,
58    /// When the generic keyword bridge (`PASSWORD=`, `*_PASS=`, `secret:`,
59    /// `api_key=` ...) extracts a value, admit it on a far lower entropy floor
60    /// (the `generic-keyword-secret` base, ~1.5 bits) than the bare
61    /// `generic-secret` path (2.8/3.2/3.5). The credential KEYWORD in the key is
62    /// the evidence; precision is carried by the MoE + shape filters, not by
63    /// entropy. Default on: this is what lets keyhog surface the real-world
64    /// low-entropy credentials (config passwords, `*_PASS=` values) that pin
65    /// CredData recall near zero when gated on entropy alone. Opt out with
66    /// `--no-keyword-low-entropy` to restore the high-entropy-only generic gate.
67    /// No-op unless the keyword bridge fires.
68    #[serde(default = "default_generic_keyword_low_entropy")]
69    pub generic_keyword_low_entropy: bool,
70    /// Shannon entropy threshold (typical secrets are 4.5+).
71    pub entropy_threshold: f64,
72    /// BPE "rare-not-random" precision bound: a surviving entropy / generic
73    /// candidate whose `cl100k_base` bytes-per-token is STRICTLY GREATER than
74    /// this is treated as word-like (a probable false positive, dotted API
75    /// paths, prose, XML) and suppressed. Lower = more aggressive suppression
76    /// (higher precision, lower recall); higher = looser (a very large value
77    /// effectively disables the gate). The compiled default is
78    /// [`DEFAULT_ENTROPY_BPE_MAX_BYTES_PER_TOKEN`]. Operators trade precision
79    /// for recall per corpus via the `[scan]` TOML key or `--entropy-bpe-max-bytes-per-token`.
80    /// A `#[serde(default)]` keeps configs that predate the field on the shipped
81    /// bound instead of `f64`'s `0.0`: which would suppress EVERY candidate
82    /// (bytes-per-token is always > 0), a silent recall wipeout. No-op unless the
83    /// entropy feature is compiled and the gate is reached.
84    #[serde(default = "default_entropy_bpe_max_bytes_per_token")]
85    pub entropy_bpe_max_bytes_per_token: f64,
86    /// Minimum credential length for entropy-based secret detection.
87    ///
88    /// Named detectors keep their own shape-specific lengths; this floor is
89    /// consumed by the scanner's entropy fallback (`--min-secret-len` /
90    /// `min_secret_len` in `.keyhog.toml`).
91    pub min_secret_len: usize,
92    /// Maximum file size to scan (bytes). Large files are skipped or sampled.
93    ///
94    /// NOTE: not read here on the live path. The effective cap is set
95    /// at the source walker (`FilesystemSource::with_max_file_size`,
96    /// fed from `ScanArgs.max_file_size`); this field is retained for
97    /// the canonical config surface but is not carried into
98    /// `ScannerConfig`.
99    pub max_file_size: u64,
100    /// Deduplication strategy.
101    ///
102    /// NOTE: not read here on the live path. The effective scope comes
103    /// from `ScanArgs.dedup` and is applied by the verifier via
104    /// `DedupScope`; this field is not carried into `ScannerConfig`.
105    pub dedup: DedupScope,
106
107    /// Whether to enable ML-based probabilistic gating.
108    pub ml_enabled: bool,
109    /// Weight given to the ML score (0.0 to 1.0).
110    pub ml_weight: f64,
111    /// Whether to normalize Unicode characters before scanning.
112    pub unicode_normalization: bool,
113    /// Whether to validate decoded strings (e.g. that decoded base64 is
114    /// UTF-8) before recursing into them.
115    pub validate_decode: bool,
116    /// Maximum bytes allowed from recursive decoding. Same field name on
117    /// `ScannerConfig` so `From<ScanConfig>` is a 1:1 carry, not a rename.
118    pub max_decode_bytes: usize,
119    /// Maximum matches allowed per chunk to prevent OOM.
120    pub max_matches_per_chunk: usize,
121
122    /// When `true`, credentials inside source-code comments
123    /// (//, #, /* */, <!-- -->) get the same confidence treatment as
124    /// credentials in regular code. Default `false` - comment context
125    /// downgrades confidence on the theory that examples are the
126    /// common case. CLI exposes this as `--scan-comments`; opt-in
127    /// because the rate of EXAMPLE secrets pasted into doc comments
128    /// vastly outweighs the rate of real ones.
129    #[serde(default)]
130    pub scan_comments: bool,
131
132    /// List of common secret prefixes to prioritize.
133    pub known_prefixes: Vec<String>,
134    /// List of keywords that strongly indicate a secret.
135    pub secret_keywords: Vec<String>,
136    /// Keywords used in test environments.
137    pub test_keywords: Vec<String>,
138    /// Keywords for placeholders and documentation.
139    pub placeholder_keywords: Vec<String>,
140}
141
142/// Limits for decoding to prevent infinite recursion or memory exhaustion.
143pub(crate) const MAX_DECODE_DEPTH_LIMIT: usize = 10;
144
145/// Maximum recursive decode passes accepted from CLI and TOML config.
146pub const fn max_decode_depth_limit() -> usize {
147    MAX_DECODE_DEPTH_LIMIT
148}
149
150/// Serde default for [`ScanConfig::entropy_ml_authoritative`]: a config
151/// deserialized from a TOML that predates the field gets the shipped default
152/// (on) rather than `bool`'s `false`, so old configs don't silently disable it.
153fn default_entropy_ml_authoritative() -> bool {
154    true
155}
156
157/// Serde default for [`ScanConfig::generic_keyword_low_entropy`]: configs that
158/// predate the field get the shipped default (on) rather than `bool`'s `false`,
159/// so old TOMLs don't silently fall back to the high-entropy-only generic gate.
160fn default_generic_keyword_low_entropy() -> bool {
161    true
162}
163
164/// Serde default for [`ScanConfig::entropy_bpe_max_bytes_per_token`]: configs
165/// that predate the field get the shipped bound
166/// [`DEFAULT_ENTROPY_BPE_MAX_BYTES_PER_TOKEN`] rather than `f64`'s `0.0`: a
167/// `0.0` bound would treat every non-empty candidate as word-like and suppress
168/// the entire entropy/generic surface (bytes-per-token is always > 0), a silent
169/// recall wipeout on old configs. One owner for the value (the core const).
170fn default_entropy_bpe_max_bytes_per_token() -> f64 {
171    DEFAULT_ENTROPY_BPE_MAX_BYTES_PER_TOKEN
172}
173
174/// Errors returned while validating a scan configuration.
175#[derive(Debug, Error)]
176pub enum ConfigError {
177    /// `min_confidence` was outside the closed unit interval `[0.0, 1.0]`.
178    #[error("min_confidence must be between 0.0 and 1.0, found {0}")]
179    InvalidConfidence(f64),
180    /// `max_decode_depth` exceeded the safety ceiling
181    /// [`MAX_DECODE_DEPTH_LIMIT`].
182    #[error("max_decode_depth exceeds limit of {MAX_DECODE_DEPTH_LIMIT}, found {0}")]
183    DepthTooHigh(usize),
184    /// `ml_weight` was outside the closed unit interval `[0.0, 1.0]`. The score
185    /// blend multiplies by this weight; a value above 1.0 over-weights the model
186    /// and a negative one inverts it (both silently distort every confidence).
187    #[error("ml_weight must be between 0.0 and 1.0, found {0}")]
188    InvalidMlWeight(f64),
189    /// `entropy_bpe_max_bytes_per_token` was not a finite value strictly greater
190    /// than zero. A `0.0` (or negative / NaN) bound treats EVERY candidate as
191    /// word-like and suppresses the entire entropy/generic surface, a silent
192    /// recall wipeout (the `#[serde(default)]` guards only configs that OMIT the
193    /// key, not an explicit out-of-range value).
194    #[error("entropy_bpe_max_bytes_per_token must be a finite value > 0.0, found {0}")]
195    InvalidBpeBound(f64),
196    /// `entropy_threshold` was not finite. A `NaN` threshold makes every
197    /// `entropy >= threshold` comparison false, silently suppressing every
198    /// entropy candidate; `±inf` is equally nonsensical as a bits-per-byte floor.
199    #[error("entropy_threshold must be a finite number, found {0}")]
200    NonFiniteEntropyThreshold(f64),
201    /// `entropy_threshold` exceeded the mathematical byte-entropy range.
202    #[error("entropy_threshold must be between 0.0 and 8.0 bits per byte, found {0}")]
203    InvalidEntropyThreshold(f64),
204    /// A field this config carries for schema completeness but the scanner
205    /// never reads was set to something other than its default. Honouring it
206    /// silently would be a lie: the value has no effect on any scan. The error
207    /// names the surface that does own the behaviour.
208    #[error(
209        "{field} is not read by the scanner; the effective value comes from {owner}. \
210         Fix: leave {field} at its default and set {owner} instead"
211    )]
212    NoEffectField {
213        /// The `ScanConfig` field that was set.
214        field: &'static str,
215        /// The surface that actually applies the behaviour.
216        owner: &'static str,
217    },
218    /// The TOML text could not be deserialized into a [`ScanConfig`] (syntax
219    /// error, wrong type, or an unknown field, the struct is
220    /// `#[serde(deny_unknown_fields)]`). The inner string is the `toml` crate's
221    /// diagnostic, kept as a `String` so the public error type does not leak the
222    /// `toml` version into the API.
223    #[error("failed to parse ScanConfig TOML: {0}")]
224    Parse(String),
225}
226
227impl Default for ScanConfig {
228    fn default() -> Self {
229        Self {
230            // Bench-tuned floor (SecretBench mirror grid-sweep 2026-05-30):
231            // 0.40 maximises F1 (0.8642, P=0.984, FP=37) and is the precision
232            // sweet spot. 0.30 admits a low-confidence FP band (FP 174); 0.50
233            // is WORSE on both axes because the scan-time/ML confidence
234            // interaction is non-monotonic in FP.
235            // This is the canonical tuned == benched == shipped floor; the
236            // post-scan gate (orchestrator/postprocess.rs) and the scan-time
237            // generic gate (engine/phase2_generic.rs) both resolve to it.
238            min_confidence: 0.40,
239            // Aligned with CLI / scanner defaults (`ScannerConfig` derives from this).
240            max_decode_depth: 10,
241            entropy_enabled: true,
242            entropy_in_source_files: false,
243            entropy_ml_authoritative: true,
244            generic_keyword_low_entropy: true,
245            entropy_threshold: DEFAULT_ENTROPY_THRESHOLD,
246            entropy_bpe_max_bytes_per_token: DEFAULT_ENTROPY_BPE_MAX_BYTES_PER_TOKEN,
247            min_secret_len: 16,
248            max_file_size: DEFAULT_MAX_FILE_SIZE_BYTES,
249            dedup: DedupScope::Credential,
250            ml_enabled: true,
251            ml_weight: 0.5,
252            unicode_normalization: true,
253            validate_decode: true,
254            // Per-chunk decode-through ceiling (conservative vs multi-MiB blobs).
255            max_decode_bytes: 512 * 1024,
256            max_matches_per_chunk: 1000,
257            scan_comments: false,
258            known_prefixes: crate::embedded::CONFIG_KNOWN_PREFIXES
259                .iter()
260                .map(|value| (*value).to_string())
261                .collect(),
262            secret_keywords: crate::embedded::CONFIG_SECRET_KEYWORDS
263                .iter()
264                .map(|value| (*value).to_string())
265                .collect(),
266            test_keywords: crate::embedded::CONFIG_TEST_KEYWORDS
267                .iter()
268                .map(|value| (*value).to_string())
269                .collect(),
270            placeholder_keywords: crate::embedded::CONFIG_PLACEHOLDER_KEYWORDS
271                .iter()
272                .map(|value| (*value).to_string())
273                .collect(),
274        }
275    }
276}
277
278impl ScanConfig {
279    // PRESET SINGLE SOURCE OF TRUTH (MC-05): the operator-facing presets
280    // (`--fast` / `--deep` / `--precision`) live on `ScannerConfig`
281    // (`scanner/src/scanner_config.rs::{fast,thorough,high_precision}`), the
282    // one path the CLI's `build_scanner_config` actually selects. Earlier this
283    // crate also carried `ScanConfig::fast/thorough/paranoid`, but they had ZERO
284    // production callers and their values DIVERGED from the shipped ones (e.g.
285    // fast decode-depth 2 vs the shipped 0), so a reader auditing "what --fast
286    // does" got the wrong answer here. They are deleted rather than re-pointed:
287    // until MC-01 collapses `ScannerConfig` back into `ScanConfig`, the presets
288    // stay with the config the engine runs, and there is exactly one preset path.
289
290    /// Validate the configuration parameters, failing closed on any value that
291    /// would silently break scanning. This is the "separate later step" the
292    /// deserialize path deliberately omits (see the `regression_scan_config_fields`
293    /// contract): [`ScanConfig::from_toml_str`] composes deserialize + this into
294    /// one validated load, and a library consumer who builds a [`ScanConfig`] by
295    /// hand calls it directly before handing the config to the engine.
296    ///
297    /// Every check is NaN-safe: `RangeInclusive::contains` is `false` for `NaN`
298    /// (so a `NaN` bound is rejected, not silently admitted), and the entropy
299    /// checks reject non-finite values explicitly.
300    pub fn validate(&self) -> Result<(), ConfigError> {
301        if !(0.0..=1.0).contains(&self.min_confidence) {
302            return Err(ConfigError::InvalidConfidence(self.min_confidence));
303        }
304        if self.max_decode_depth > MAX_DECODE_DEPTH_LIMIT {
305            return Err(ConfigError::DepthTooHigh(self.max_decode_depth));
306        }
307        if !(0.0..=1.0).contains(&self.ml_weight) {
308            return Err(ConfigError::InvalidMlWeight(self.ml_weight));
309        }
310        // A very large FINITE bound is legal (it "effectively disables the gate"
311        // per the field docs); only 0.0, negatives, NaN, and ±inf are rejected.
312        if !self.entropy_bpe_max_bytes_per_token.is_finite()
313            || self.entropy_bpe_max_bytes_per_token <= 0.0
314        {
315            return Err(ConfigError::InvalidBpeBound(
316                self.entropy_bpe_max_bytes_per_token,
317            ));
318        }
319        // A finite negative threshold is merely permissive (every string clears
320        // it); only non-finite values (NaN/±inf) are rejected, because NaN
321        // silently suppresses the entire entropy surface.
322        if !self.entropy_threshold.is_finite() {
323            return Err(ConfigError::NonFiniteEntropyThreshold(
324                self.entropy_threshold,
325            ));
326        }
327        if !(0.0..=8.0).contains(&self.entropy_threshold) {
328            return Err(ConfigError::InvalidEntropyThreshold(self.entropy_threshold));
329        }
330        // Two fields exist for schema completeness and are never read by the
331        // scanner. A caller who sets one is expressing an intent that would be
332        // dropped in silence, so say so and name the surface that owns it.
333        if self.max_file_size != DEFAULT_MAX_FILE_SIZE_BYTES {
334            return Err(ConfigError::NoEffectField {
335                field: "max_file_size",
336                owner: "the source walker (FilesystemSource::with_max_file_size)",
337            });
338        }
339        if self.dedup != DedupScope::Credential {
340            return Err(ConfigError::NoEffectField {
341                field: "dedup",
342                owner: "the report dedup scope applied by keyhog_core::dedup",
343            });
344        }
345        Ok(())
346    }
347
348    /// Deserialize a [`ScanConfig`] from a TOML string and validate it, failing
349    /// closed on either a parse error or an out-of-range value.
350    ///
351    /// This is the public, fail-closed loader for the published `keyhog-core`
352    /// config surface. `ScanConfig` derives `Deserialize`, so a consumer can
353    /// always `toml::from_str` it directly, but that path is UNVALIDATED by
354    /// design (validation is a separate step). `from_toml_str` is the ONE place
355    /// that composes both, so an external `min_confidence = 5.0` / `ml_weight =
356    /// 2.0` / `entropy_bpe_max_bytes_per_token = 0.0` is rejected here exactly as
357    /// the CLI rejects it, instead of being silently honored and zeroing recall
358    /// downstream. Callers that already hold a `ScanConfig` (e.g. built field by
359    /// field) should call [`ScanConfig::validate`] directly.
360    pub fn from_toml_str(raw: &str) -> Result<Self, ConfigError> {
361        let config: ScanConfig =
362            toml::from_str(raw).map_err(|error| ConfigError::Parse(error.to_string()))?;
363        config.validate()?;
364        Ok(config)
365    }
366}