Skip to main content

keyhog/
config.rs

1//! Configuration file handling for the KeyHog CLI.
2
3use crate::args::ScanArgs;
4use crate::value_parsers::{parse_dedup_scope, parse_output_format, parse_severity_filter};
5use std::path::PathBuf;
6
7/// On-disk `.keyhog.toml` configuration file that mirrors CLI arguments.
8/// CLI flags always override values from the config file.
9#[derive(Debug, Default, serde::Deserialize)]
10#[serde(default)]
11pub struct ConfigFile {
12    /// Path to detector TOMLs directory.
13    pub detectors: Option<String>,
14    /// Minimum severity to report: info, low, medium, high, critical.
15    pub severity: Option<String>,
16    /// Output format: text, json, jsonl, sarif.
17    pub format: Option<String>,
18    /// Enable fast mode (pattern matching only).
19    pub fast: Option<bool>,
20    /// Enable deep mode (all features).
21    pub deep: Option<bool>,
22    /// Skip decode-through scanning.
23    pub no_decode: Option<bool>,
24    /// Skip entropy-based detection.
25    pub no_entropy: Option<bool>,
26    /// Minimum confidence score (0.0 - 1.0).
27    pub min_confidence: Option<f64>,
28    /// Number of parallel scanning threads.
29    pub threads: Option<usize>,
30    /// Deduplication scope: credential, file, none.
31    pub dedup: Option<String>,
32    /// Whether to verify discovered credentials.
33    pub verify: Option<bool>,
34    /// Verification timeout in seconds.
35    pub timeout: Option<u64>,
36    /// Max concurrent verification requests per service.
37    pub rate: Option<usize>,
38    /// Maximum git commits to traverse.
39    pub max_commits: Option<usize>,
40    /// Show full credentials (not redacted).
41    pub show_secrets: Option<bool>,
42    /// Maximum depth for recursive decoding (1-10, default: 10 — the canonical
43    /// `ScanConfig::default().max_decode_depth`).
44    pub decode_depth: Option<usize>,
45    /// Maximum file size for decode-through scanning (default: 512KB — the
46    /// canonical `ScanConfig::default().max_decode_bytes`).
47    pub decode_size_limit: Option<String>,
48    /// Enable entropy scanning in source code files.
49    pub entropy_source_files: Option<bool>,
50    /// Entropy threshold in bits per byte (default: 4.5).
51    pub entropy_threshold: Option<f64>,
52    /// Disable Unicode normalization.
53    pub no_unicode_norm: Option<bool>,
54    /// Disable ML-based confidence scoring.
55    pub no_ml: Option<bool>,
56    /// Explicit paths or glob patterns to exclude from scanning.
57    pub exclude_paths: Option<Vec<String>>,
58    /// Maximum file size to scan (can be string like '1MB' or bytes).
59    pub max_file_size: Option<String>,
60    /// Per-regex lazy-DFA cache CEILING, e.g. "256KB" / "1MB" (default 1 MiB).
61    /// Worst-case bound for pathological patterns, not a general memory lever
62    /// (typical detectors stay under it). The `--regex-dfa-limit` CLI flag
63    /// overrides this.
64    pub regex_dfa_limit: Option<String>,
65    /// ML weight for confidence scoring, 0.0-1.0 (default: 0.5 — the canonical
66    /// `ScanConfig::default().ml_weight`).
67    pub ml_weight: Option<f64>,
68    /// Known secret prefixes used to boost confidence.
69    pub known_prefixes: Option<Vec<String>>,
70    /// Keywords indicating a secret context (e.g. "api_key", "token").
71    pub secret_keywords: Option<Vec<String>>,
72    /// Keywords indicating a test/mock context (e.g. "test", "fake").
73    pub test_keywords: Option<Vec<String>>,
74    /// Keywords indicating a placeholder value (e.g. "change_me", "todo").
75    pub placeholder_keywords: Option<Vec<String>>,
76
77    // ─── Documented nested sections ─────────────────────────────────
78    // The README documents `[scan]`, `[detector.X]`, and `[lockdown]`
79    // nested tables; all three are now WIRED in `apply_config_file`
80    // (`[scan]` -> the flat scalar args, `[detector.X] enabled` -> the
81    // disabled-detector set, `[detector.X] min_confidence` -> the
82    // per-detector confidence floor applied in scan post-processing,
83    // `[lockdown] require` -> ConfigOutcome). They were previously
84    // parsed-and-silently-ignored - a user copying the README believed
85    // e.g. lockdown enforcement was active when it never reached the
86    // runtime.
87    //
88    // The `[detector.X]` floors/disables additionally ship in the binary via
89    // the compiled Tier-A defaults (`SHIPPED_DETECTOR_FLOORS` /
90    // `SHIPPED_DISABLED_DETECTORS`), so they apply on the bench/default path
91    // too - not only when a user authors a `.keyhog.toml`. A file
92    // `[detector.X]` entry overrides the compiled floor for that id. This is
93    // the fix for the "tuned != benched != shipped" leak: the only Tier-A knob
94    // that can suppress a specific noisy detector now reaches the very runs
95    // that set the headline metric.
96    //
97    // `[allowlist]` is still parse-only: its governance flags
98    // (require_reason / require_approved_by / max_expires_days) need the
99    // allowlist evaluator to enforce them, which is not yet built, so the
100    // README no longer presents it as active. Suppression itself works via
101    // `.keyhogignore`. New nested fields must ship with BOTH a parser entry
102    // here AND the wire-up in apply_config_file - never parse-only.
103    /// `[scan]` - runtime scan policy. Mirrors top-level scalar fields.
104    pub scan: Option<ScanSection>,
105    /// `[allowlist]` - `.keyhogignore` discovery + governance metadata.
106    pub allowlist: Option<AllowlistSection>,
107    /// `[detector.<id>]` - per-detector overrides keyed by detector_id.
108    pub detector: Option<std::collections::HashMap<String, DetectorSection>>,
109    /// `[lockdown]` - refuse to start unless explicit `--lockdown` flag.
110    pub lockdown: Option<LockdownSection>,
111}
112
113/// `[scan]` nested table. Fields here map 1:1 to the flat top-level
114/// scalars and override them when both are present. Issue #5: README
115/// documented `[scan]` as the canonical surface; we now accept both
116/// shapes and warn-on-mismatch.
117#[derive(Debug, Default, serde::Deserialize)]
118#[serde(default)]
119pub struct ScanSection {
120    pub severity: Option<String>,
121    pub min_confidence: Option<f64>,
122    pub format: Option<String>,
123    pub exclude: Option<Vec<String>>,
124    pub threads: Option<usize>,
125    pub dedup: Option<String>,
126}
127
128/// `[allowlist]` nested table. Issue #5: README documents `file`,
129/// `require_reason`, `require_approved_by`, `max_expires_days`. The
130/// allowlist enforcement layer reads `.keyhogignore` directly so the
131/// `file` override is the wiring point; the governance flags are
132/// surfaced to the allowlist evaluator post-parse.
133#[derive(Debug, Default, serde::Deserialize)]
134#[serde(default)]
135pub struct AllowlistSection {
136    pub file: Option<String>,
137    pub require_reason: Option<bool>,
138    pub require_approved_by: Option<bool>,
139    pub max_expires_days: Option<u64>,
140}
141
142/// `[detector.<id>]` per-detector override. `enabled = false` drops the
143/// detector from the corpus (wired via `ConfigOutcome::disabled_detectors`).
144/// `min_confidence = <f>` sets a per-detector confidence floor applied in
145/// scan post-processing (wired via `ConfigOutcome::detector_min_confidence`),
146/// taking precedence over the global `--min-confidence`. Both are
147/// README-documented and now reach the runtime.
148#[derive(Debug, Default, serde::Deserialize)]
149#[serde(default)]
150pub struct DetectorSection {
151    pub enabled: Option<bool>,
152    pub min_confidence: Option<f64>,
153}
154
155/// `[lockdown]` enforcement. `require = true` refuses to run unless
156/// the operator passes `--lockdown` on the CLI. Issue #5: README example
157/// implied this was active; pre-fix the table was discarded silently.
158#[derive(Debug, Default, serde::Deserialize)]
159#[serde(default)]
160pub struct LockdownSection {
161    pub require: Option<bool>,
162}
163
164/// Compiled-in Tier-A per-detector confidence floors that ship inside the
165/// binary, independent of any on-disk `.keyhog.toml`. This is the fix for the
166/// "tuned != benched != shipped" leak: `[detector.<id>] min_confidence`
167/// overrides used to exist ONLY in a user-authored `.keyhog.toml`, so the
168/// bench and every default scan (which find no such file and short-circuit to
169/// `ConfigOutcome::default()`) never exercised them. Floors listed here are
170/// seeded into every `ConfigOutcome` regardless of whether a config file is
171/// present, so the benched/default path runs the same per-detector tuning the
172/// shipped binary carries. A user `.keyhog.toml` `[detector.<id>]
173/// min_confidence` overrides the compiled value for that id (operator intent
174/// wins per-detector); ids only listed here still apply on the no-file path.
175///
176/// Entries are `(detector_id, floor)`. Edit this table to raise the floor on a
177/// specific noisy detector (e.g. loosened twilio / connection-string ones)
178/// without requiring the operator to author a TOML; the change ships in the
179/// binary and the bench picks it up automatically. Tier B (the detector
180/// corpus) stays in `rules/`; this is the Tier-A scalar knob.
181pub const SHIPPED_DETECTOR_FLOORS: &[(&str, f64)] = &[];
182
183/// Compiled-in Tier-A detector disables that ship inside the binary, same
184/// rationale as [`SHIPPED_DETECTOR_FLOORS`]: a detector listed here is dropped
185/// from the loaded corpus on every path, including the no-config bench/default
186/// path. A user `.keyhog.toml` `[detector.<id>] enabled = true` cannot
187/// re-enable a compiled disable today (the merge is additive); keep this table
188/// for detectors that must never fire by default.
189pub const SHIPPED_DISABLED_DETECTORS: &[&str] = &[];
190
191/// Build the baseline [`ConfigOutcome`] from the compiled-in Tier-A defaults.
192/// Every return path of [`apply_config_file`] starts from this (not the empty
193/// `ConfigOutcome::default()`), so the per-detector floors / disables that ship
194/// in the binary reach the benched and default scans even when no
195/// `.keyhog.toml` exists on disk.
196fn shipped_config_outcome() -> ConfigOutcome {
197    ConfigOutcome {
198        disabled_detectors: SHIPPED_DISABLED_DETECTORS
199            .iter()
200            .map(|id| (*id).to_string())
201            .collect(),
202        require_lockdown: false,
203        detector_min_confidence: SHIPPED_DETECTOR_FLOORS
204            .iter()
205            .map(|(id, floor)| ((*id).to_string(), *floor))
206            .collect(),
207    }
208}
209
210/// Search for `.keyhog.toml` starting from the scan root, walking up to the
211/// filesystem root. Returns `None` when no config file is found.
212pub fn find_config_file(start: Option<&std::path::Path>) -> Option<PathBuf> {
213    let mut dir = start
214        .and_then(|p| {
215            if p.is_dir() {
216                Some(p.to_path_buf())
217            } else {
218                p.parent().map(std::path::Path::to_path_buf)
219            }
220        })
221        .or_else(|| std::env::current_dir().ok())?;
222
223    loop {
224        let candidate = dir.join(".keyhog.toml");
225        if candidate.is_file() {
226            return Some(candidate);
227        }
228        if !dir.pop() {
229            break;
230        }
231    }
232    None
233}
234
235/// Outcome of merging `.keyhog.toml` into `ScanArgs`, beyond the in-place
236/// `args` mutations: the things the caller must still act on.
237///
238/// Prefer [`crate::orchestrator_config::resolve_scan_config`] over calling
239/// [`apply_config_file`] directly: it runs this same merge and then folds the
240/// result into a single [`crate::orchestrator_config::ResolvedScanConfig`] - the
241/// engine `ScannerConfig` PLUS the post-scan floors - so the live worker reads
242/// one resolved struct instead of re-deriving the confidence floor from raw
243/// `args` (the "tuned != benched != shipped" leak). `detector_min_confidence`
244/// here is the source the resolved struct carries through to post-processing.
245#[derive(Debug, Default)]
246pub struct ConfigOutcome {
247    /// Detector ids disabled via `[detector.<id>] enabled = false`; the caller
248    /// drops these from the loaded corpus.
249    pub disabled_detectors: Vec<String>,
250    /// `[lockdown] require = true`: this repo's config DEMANDS lockdown mode.
251    /// The caller must refuse to run unless `--lockdown` was passed. Documented
252    /// in the README ("refuse to run without --lockdown") but, before this
253    /// wiring, parsed and silently ignored - a security control that looked
254    /// active but never enforced.
255    pub require_lockdown: bool,
256    /// Per-detector `[detector.<id>] min_confidence = <f>` overrides keyed by
257    /// detector id. Applied in scan post-processing: a finding from detector
258    /// `id` is dropped when its confidence is below this threshold, taking
259    /// precedence over the global `--min-confidence`. Was parsed into
260    /// `DetectorSection.min_confidence` and silently ignored before this
261    /// wiring (the README documents it as active).
262    pub detector_min_confidence: std::collections::HashMap<String, f64>,
263}
264
265/// Load and merge a `.keyhog.toml` config file into the parsed `ScanArgs`.
266/// CLI flags always take precedence over the config file.
267///
268/// Returns a [`ConfigOutcome`] the caller must act on: detector ids disabled
269/// via `[detector.<id>] enabled = false` (dropped from the corpus) and whether
270/// `[lockdown] require = true` demands `--lockdown`. Both are README-documented
271/// but were parsed-and-silently-ignored before this wiring.
272pub fn apply_config_file(args: &mut ScanArgs) -> ConfigOutcome {
273    apply_config_file_impl(args, true)
274}
275
276/// Diagnostics-free variant for the daemon-routing PROBE in
277/// [`crate::subcommands::scan`]'s `EffectivePolicy::resolve`, which applies the
278/// config to a THROWAWAY clone of the args solely to read the resolved routing
279/// knobs (min_confidence / show_secrets / severity). The real orchestrator merge
280/// then runs [`apply_config_file`] and emits any read/parse warning exactly once.
281/// Without this, the probe + the real call each printed the
282/// "Failed to parse .keyhog.toml" warning, so a malformed config warned TWICE on
283/// the daemon route (HUNT-2). Keep the emission on the real path; only the probe
284/// is silenced.
285pub fn apply_config_file_quiet(args: &mut ScanArgs) -> ConfigOutcome {
286    apply_config_file_impl(args, false)
287}
288
289#[allow(clippy::collapsible_if, clippy::cmp_owned)]
290fn apply_config_file_impl(args: &mut ScanArgs, emit_diagnostics: bool) -> ConfigOutcome {
291    let config_path = args
292        .config
293        .clone()
294        .or_else(|| find_config_file(args.path.as_deref()));
295
296    let config_path = match config_path {
297        Some(path) => path,
298        // No `.keyhog.toml` on the walk-up path (the bench/default case): still
299        // ship the compiled Tier-A floors/disables so tuned == benched ==
300        // shipped, instead of the empty `ConfigOutcome::default()`.
301        None => return shipped_config_outcome(),
302    };
303
304    let raw = match std::fs::read_to_string(&config_path) {
305        Ok(content) => content,
306        Err(error) => {
307            if emit_diagnostics {
308                tracing::warn!(
309                    path = %config_path.display(),
310                    "failed to read .keyhog.toml: {error}"
311                );
312            }
313            return shipped_config_outcome();
314        }
315    };
316
317    let config: ConfigFile = match toml::from_str(&raw) {
318        Ok(parsed) => parsed,
319        Err(error) => {
320            // Emitted exactly once on the real orchestrator merge; the daemon
321            // routing probe passes `emit_diagnostics = false` so a malformed
322            // config does not warn twice (HUNT-2).
323            if emit_diagnostics {
324                eprintln!(
325                    "⚠️  WARNING: Failed to parse .keyhog.toml at {}: {}",
326                    config_path.display(),
327                    error
328                );
329                tracing::warn!(
330                    path = %config_path.display(),
331                    "failed to parse .keyhog.toml: {error}"
332                );
333            }
334            return shipped_config_outcome();
335        }
336    };
337
338    tracing::debug!(path = %config_path.display(), "loaded .keyhog.toml");
339
340    // Apply config values only when no explicit CLI flag was given.
341    if let Some(ref detectors_str) = config.detectors {
342        if args.detectors == PathBuf::from("detectors") {
343            args.detectors = PathBuf::from(detectors_str);
344        }
345    }
346
347    if let Some(ref format_str) = config.format {
348        // Only override if the user didn't set --format (defaults to Text).
349        if matches!(args.format, crate::args::OutputFormat::Text) {
350            if let Some(fmt) = parse_output_format(format_str) {
351                args.format = fmt;
352            }
353        }
354    }
355
356    if let Some(ref severity_str) = config.severity {
357        if args.severity.is_none() {
358            args.severity = parse_severity_filter(severity_str);
359        }
360    }
361
362    if let Some(fast) = config.fast {
363        if !args.fast && !args.deep {
364            args.fast = fast;
365        }
366    }
367
368    if let Some(deep) = config.deep {
369        if !args.fast && !args.deep {
370            args.deep = deep;
371        }
372    }
373
374    if let Some(no_decode) = config.no_decode {
375        if !args.no_decode {
376            args.no_decode = no_decode;
377        }
378    }
379
380    if let Some(_no_entropy) = config.no_entropy {
381        if !args.no_entropy {
382            args.no_entropy = _no_entropy;
383        }
384    }
385
386    if let Some(min_conf) = config.min_confidence {
387        if args.min_confidence.is_none() {
388            args.min_confidence = Some(min_conf);
389        }
390    }
391
392    if let Some(threads) = config.threads {
393        if args.threads.is_none() {
394            args.threads = Some(threads);
395        }
396    }
397
398    if let Some(ref dedup_str) = config.dedup {
399        // credential is the clap default
400        if matches!(args.dedup, crate::args::CliDedupScope::Credential) {
401            if let Some(scope) = parse_dedup_scope(dedup_str) {
402                args.dedup = scope;
403            }
404        }
405    }
406
407    if let Some(_verify) = config.verify {
408        #[cfg(feature = "verify")]
409        if !args.verify {
410            args.verify = _verify;
411        }
412    }
413
414    if let Some(timeout) = config.timeout {
415        if args.timeout == 5 {
416            args.timeout = timeout;
417        }
418    }
419
420    if let Some(rate) = config.rate {
421        if args.rate == 5 {
422            args.rate = rate;
423        }
424    }
425
426    if let Some(_max_commits) = config.max_commits {
427        #[cfg(feature = "git")]
428        if args.max_commits == 1000 {
429            args.max_commits = _max_commits;
430        }
431    }
432
433    if let Some(show_secrets) = config.show_secrets {
434        if !args.show_secrets {
435            args.show_secrets = show_secrets;
436        }
437    }
438
439    if let Some(depth) = config.decode_depth {
440        if args.decode_depth.is_none() {
441            args.decode_depth = Some(depth);
442        }
443    }
444
445    if let Some(ref limit_str) = config.decode_size_limit {
446        if args.decode_size_limit.is_none() {
447            if let Ok(size) = crate::value_parsers::parse_byte_size(limit_str) {
448                args.decode_size_limit = Some(size);
449            }
450        }
451    }
452
453    if let Some(_entropy_source) = config.entropy_source_files {
454        if !args.entropy_source_files {
455            args.entropy_source_files = _entropy_source;
456        }
457    }
458
459    if let Some(_entropy_threshold) = config.entropy_threshold {
460        if args.entropy_threshold.is_none() {
461            args.entropy_threshold = Some(_entropy_threshold);
462        }
463    }
464
465    if let Some(no_unicode_norm) = config.no_unicode_norm {
466        if !args.no_unicode_norm {
467            args.no_unicode_norm = no_unicode_norm;
468        }
469    }
470
471    if let Some(no_ml) = config.no_ml {
472        if !args.no_ml {
473            args.no_ml = no_ml;
474        }
475    }
476
477    if let Some(ml_weight) = config.ml_weight {
478        if args.ml_weight.is_none() {
479            args.ml_weight = Some(ml_weight);
480        }
481    }
482
483    if let Some(ref limit_str) = config.max_file_size {
484        if args.max_file_size.is_none() {
485            if let Ok(size) = crate::value_parsers::parse_byte_size(limit_str) {
486                args.max_file_size = Some(size);
487            }
488        }
489    }
490
491    if let Some(ref limit_str) = config.regex_dfa_limit {
492        if args.regex_dfa_limit.is_none() {
493            if let Ok(size) = crate::value_parsers::parse_byte_size(limit_str) {
494                args.regex_dfa_limit = Some(size);
495            }
496        }
497    }
498
499    if let Some(paths) = config.exclude_paths {
500        if args.exclude_paths.is_none() {
501            args.exclude_paths = Some(paths);
502        }
503    }
504
505    if let Some(prefixes) = config.known_prefixes {
506        args.known_prefixes = prefixes;
507    }
508    if let Some(keywords) = config.secret_keywords {
509        args.secret_keywords = keywords;
510    }
511    if let Some(keywords) = config.test_keywords {
512        args.test_keywords = keywords;
513    }
514    if let Some(keywords) = config.placeholder_keywords {
515        args.placeholder_keywords = keywords;
516    }
517
518    // `[scan]` nested table - the surface the README documents as canonical.
519    // Mirrors the flat top-level scalars and fills only fields still at their
520    // default (so the flat form wins if both are present, and a `[scan]`-only
521    // config now actually takes effect instead of being silently dropped).
522    if let Some(scan) = config.scan {
523        if args.severity.is_none() {
524            if let Some(ref s) = scan.severity {
525                args.severity = parse_severity_filter(s);
526            }
527        }
528        if args.min_confidence.is_none() {
529            args.min_confidence = scan.min_confidence;
530        }
531        if matches!(args.format, crate::args::OutputFormat::Text) {
532            if let Some(ref f) = scan.format {
533                if let Some(fmt) = parse_output_format(f) {
534                    args.format = fmt;
535                }
536            }
537        }
538        if args.exclude_paths.is_none() {
539            args.exclude_paths = scan.exclude;
540        }
541        if args.threads.is_none() {
542            args.threads = scan.threads;
543        }
544        if matches!(args.dedup, crate::args::CliDedupScope::Credential) {
545            if let Some(ref d) = scan.dedup {
546                if let Some(scope) = parse_dedup_scope(d) {
547                    args.dedup = scope;
548                }
549            }
550        }
551    }
552
553    // `[lockdown] require = true` -> the caller refuses to run unless
554    // `--lockdown` was passed (README: "refuse to run without --lockdown").
555    let require_lockdown = config
556        .lockdown
557        .as_ref()
558        .and_then(|l| l.require)
559        .unwrap_or(false);
560
561    // `[detector.<id>]` table: `enabled = false` drops the detector from the
562    // loaded corpus after `load_detectors`; `min_confidence = <f>` becomes a
563    // per-detector confidence floor applied in scan post-processing. Both keys
564    // were README-documented; the confidence floor used to be parsed and
565    // silently ignored (the disabled toggle was wired earlier). Drain the map
566    // once into both outputs.
567    //
568    // Start from the compiled Tier-A defaults (`shipped_config_outcome`) so the
569    // shipped floors/disables apply even when the `.keyhog.toml` does not
570    // mention that detector, then layer the file on top: a file
571    // `min_confidence` overrides the compiled floor for that id, and file
572    // disables union with the compiled disables.
573    let baseline = shipped_config_outcome();
574    let mut disabled_detectors = baseline.disabled_detectors;
575    let mut detector_min_confidence = baseline.detector_min_confidence;
576    if let Some(map) = config.detector {
577        for (id, section) in map {
578            if section.enabled == Some(false) && !disabled_detectors.contains(&id) {
579                disabled_detectors.push(id.clone());
580            }
581            if let Some(conf) = section.min_confidence {
582                detector_min_confidence.insert(id, conf);
583            }
584        }
585    }
586
587    ConfigOutcome {
588        disabled_detectors,
589        require_lockdown,
590        detector_min_confidence,
591    }
592}