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}