keyhog 0.5.38

keyhog: detects leaked credentials in source trees, git history, and cloud storage
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
//! Command-line argument parsing for KeyHog.

mod scan;

pub use scan::ScanArgs;

use clap::{Parser, ValueEnum};
use keyhog_core::DedupScope;
use std::path::PathBuf;

#[derive(Parser)]
#[command(
    name = "keyhog",
    about = "KeyHog: The developer-first secret scanner.\nFind leaked credentials in your code before hackers do. Fast, accurate, and verifying.",
    after_help = "EXIT CODES:\n  0   Success (no secrets found)\n  1   Secrets found (unverified or verification skipped)\n  2   User error (e.g., config error, unreadable path)\n  3   System error (local environment failure or detector-corpus audit failure)\n  4   Health/self-test failure (doctor unhealthy / repair could not restore a working binary / backend --self-test failed)\n  10  Live credentials found (requires --verify)\n  11  Scanner thread panicked mid-scan (state is unreliable)",
    disable_version_flag = true
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Option<Command>,

    /// Print version, build information, and statistics
    #[arg(short = 'V', long)]
    pub version: bool,
}

#[derive(clap::Subcommand)]
pub enum Command {
    /// 🔍 Scan files, directories, or repositories for secrets
    #[command(verbatim_doc_comment)]
    Scan(Box<ScanArgs>),

    /// 🪝 Manage git pre-commit hooks
    #[command(verbatim_doc_comment)]
    Hook {
        #[command(subcommand)]
        command: crate::subcommands::hook::HookCommand,
    },

    /// 📋 List all loaded secret detectors
    #[command(verbatim_doc_comment)]
    Detectors(DetectorArgs),

    /// 📖 Explain a detector: spec, regex, severity, rotation guide
    #[command(verbatim_doc_comment)]
    Explain(ExplainArgs),

    /// 🔀 Diff two baseline JSON files: show NEW / RESOLVED / UNCHANGED
    #[command(verbatim_doc_comment)]
    Diff(DiffArgs),

    /// 📊 Show or update per-detector Bayesian calibration counters
    #[command(verbatim_doc_comment)]
    Calibrate(CalibrateArgs),

    /// 👁  Watch a directory and scan files as they change (daemon mode)
    #[command(verbatim_doc_comment)]
    Watch(WatchArgs),

    /// 🔧 Print shell completion script (bash, zsh, fish, powershell, elvish)
    #[command(verbatim_doc_comment)]
    Completion(CompletionArgs),

    /// ⚙️  Inspect detected hardware + the auto-selected scan backend
    #[command(verbatim_doc_comment)]
    Backend(BackendArgs),

    /// 🩺 Health-check the install: host, PATH, detector corpus, scan self-test
    #[command(verbatim_doc_comment)]
    Doctor(DoctorArgs),

    /// ⬆️  Update keyhog to the latest release: verified download + self-replace
    #[command(verbatim_doc_comment)]
    Update(UpdateArgs),

    /// 🔧 Repair a broken install: reinstall a known-good binary, then verify
    #[command(verbatim_doc_comment)]
    Repair(RepairArgs),

    /// 🗑  Uninstall keyhog: remove the binary (dry run unless --yes)
    #[command(verbatim_doc_comment)]
    Uninstall(UninstallArgs),

    /// 🛰  Recursive system-wide scan: every mounted drive, every git history
    #[command(verbatim_doc_comment)]
    ScanSystem(ScanSystemArgs),

    /// 🔌 Manage the long-lived `keyhog daemon` (start, stop, status)
    #[command(verbatim_doc_comment)]
    Daemon(DaemonArgs),

    /// 🖥  Live TUI dashboard: scan a path with a real-time finding feed
    #[cfg(feature = "tui")]
    #[command(verbatim_doc_comment)]
    Tui(TuiArgs),
}

/// Arguments for the `keyhog tui` subcommand. Intentionally minimal: the
/// TUI is a demo / interactive surface, not a CI gate. Use `keyhog scan`
/// for headless / scriptable runs.
#[cfg(feature = "tui")]
#[derive(Parser)]
pub struct TuiArgs {
    /// Path to scan. Defaults to the current directory.
    #[arg(value_name = "PATH", default_value = ".")]
    pub path: PathBuf,

    /// Limit the number of files scanned. Useful for long demos where
    /// you want a fixed-duration loop. 0 = unlimited.
    #[arg(long, value_name = "N", default_value_t = 0)]
    pub max_files: usize,

    /// Cap the finding feed depth (recent N findings kept). Default 200.
    #[arg(long, value_name = "N", default_value_t = 200)]
    pub feed_depth: usize,

    /// Sleep N milliseconds between files. Slows the live feed so demo
    /// recordings actually capture findings streaming in. Default 0
    /// (scan as fast as possible). Use --throttle-ms 60 for a steady
    /// ~16 findings/sec feed on small corpora.
    #[arg(long, value_name = "MS", default_value_t = 0)]
    pub throttle_ms: u64,
}

/// Subcommand args for `keyhog daemon {start, stop, status}`.
#[derive(Parser)]
pub struct DaemonArgs {
    #[command(subcommand)]
    pub action: DaemonAction,
}

#[derive(clap::Subcommand)]
pub enum DaemonAction {
    /// Start a daemon process that holds a compiled scanner and
    /// serves scan requests over a Unix socket. Blocks until
    /// `daemon stop` is invoked.
    Start {
        /// Override the default socket path
        /// ($XDG_RUNTIME_DIR/keyhog.sock or ~/.cache/keyhog/server.sock).
        #[arg(long, value_name = "PATH")]
        socket: Option<PathBuf>,
        /// Detector directory (same default as `keyhog scan --detectors`).
        #[arg(long, default_value = "detectors")]
        detectors: PathBuf,
    },
    /// Stop the running daemon by sending it a `Shutdown` over the socket.
    Stop {
        #[arg(long, value_name = "PATH")]
        socket: Option<PathBuf>,
    },
    /// Print uptime, scans served, active scans, and detector count.
    Status {
        #[arg(long, value_name = "PATH")]
        socket: Option<PathBuf>,
    },
}

#[derive(Parser)]
pub struct ScanSystemArgs {
    /// Hard ceiling on total bytes scanned. Walker tracks running total
    /// and stops when the next file would push past this. Examples:
    ///   --space 50G   --space 1T   --space 500M
    /// Default 50 GiB; enough to cover most home directories without
    /// drowning the scan on a NAS-mount.
    #[arg(long, default_value = "50G", value_parser = parse_space_bytes)]
    pub space: u64,

    /// Include network-mounted filesystems (NFS, SMB, sshfs). Off by
    /// default; these are typically slow and contain other people's
    /// secrets the user hasn't authorized scanning.
    #[arg(long, default_value_t = false)]
    pub include_network: bool,

    /// Skip auto-discovery of `.git` directories. By default scan-system
    /// finds every git repo on every walked drive and runs --git-history
    /// on each, including bare repos and submodules. Disable to save time
    /// when you only care about working-tree state.
    #[arg(long, default_value_t = false)]
    pub no_git_history: bool,

    /// Honor `.gitignore` like `keyhog scan` does. Default OFF; system
    /// scans are paranoid because an attacker stashing a leaked key
    /// would `.gitignore` it. Set this to behave like a normal scan.
    #[arg(long, default_value_t = false)]
    pub respect_gitignore: bool,

    /// Output JSON path. Defaults to stderr (text format) if unset.
    #[arg(long)]
    pub output: Option<PathBuf>,

    /// Detector directory (same as `keyhog scan --detectors`).
    #[arg(long, default_value = "detectors")]
    pub detectors: PathBuf,

    /// Number of parallel scanning threads (default: number of CPU cores).
    #[arg(long, value_name = "N", value_parser = crate::value_parsers::parse_positive_thread_count)]
    pub threads: Option<usize>,

    /// Apply hardening protections (mlocked + coredump-blocked) and
    /// refuse the operations that weaken detection or expand attack
    /// surface. See `keyhog scan --lockdown` for the full list.
    #[arg(long, default_value_t = false)]
    pub lockdown: bool,
}

/// Parse human-readable byte sizes for `--space` (`50G`, `1T`, `500M`, `1024K`).
///
/// Thin `u64`-returning adapter over the single source of truth in
/// `crate::value_parsers::parse_byte_size` (overflow-checked, unit-required,
/// NaN/negative-guarded, with committed test fixtures). `ScanSystemArgs::space`
/// is a `u64`; the shared parser yields a sanity-capped `usize` (< usize::MAX/2),
/// so the widening cast is lossless on every supported platform.
#[doc(hidden)]
pub fn parse_space_bytes(s: &str) -> Result<u64, String> {
    crate::value_parsers::parse_byte_size(s).map(|bytes| bytes as u64)
}

#[derive(Parser)]
pub struct CompletionArgs {
    /// Shell to generate completions for.
    #[arg(value_enum)]
    pub shell: clap_complete::Shell,
}

#[derive(Parser)]
pub struct BackendArgs {
    /// Probe the workload size that would route to a different backend.
    /// E.g. `--probe-bytes $((256 * 1024 * 1024))` to confirm GPU is picked
    /// at the 256 MiB threshold.
    #[arg(long)]
    pub probe_bytes: Option<u64>,

    /// Compiled pattern count to use for the routing-simulation matrix.
    /// This is a what-if knob: it does not change the loaded corpus, only
    /// the pattern_count fed to the backend-routing thresholds so you can
    /// probe how a larger/smaller corpus would route. The default is a
    /// representative full-corpus figure; pass an explicit value to test a
    /// specific threshold boundary.
    #[arg(long, default_value_t = 1509)]
    pub patterns: usize,

    /// Run the GPU self-tests (MoE compute kernel + vyre literal-set
    /// diagnostic + production AC-kernel dispatch). Prints PASS/FAIL
    /// with adapter info and exits with code 4 on failure so CI can
    /// gate a release on real GPU functionality. No-op on systems
    /// without a non-software adapter.
    #[arg(long)]
    pub self_test: bool,

    /// Emit `backend --self-test` as stable JSON for CI health gates.
    #[arg(long, requires = "self_test")]
    pub json: bool,
}

/// Arguments for `keyhog doctor`. The health check is fully automatic; no
/// flags are needed today. The struct exists so the command can grow options
/// (e.g. `--json`) without a breaking signature change.
#[derive(Parser)]
pub struct DoctorArgs {}

/// Arguments for `keyhog update` (self-update from GitHub releases).
#[derive(Parser)]
pub struct UpdateArgs {
    /// Only check whether a newer release is available; do not install.
    /// Exits 10 when an update is available, 0 when already current.
    #[arg(long)]
    pub check: bool,

    /// Install a specific release tag instead of the latest (e.g. `v0.5.34`).
    /// Use this to pin a version or downgrade.
    #[arg(long)]
    pub version: Option<String>,

    /// Asset variant: `cuda` selects the CUDA-accelerated Linux build;
    /// otherwise the portable WGPU+SIMD build is installed (the default,
    /// which still uses the GPU via WGPU and runs everywhere).
    #[arg(long)]
    pub variant: Option<String>,
}

/// Arguments for `keyhog repair` (reinstall a known-good binary from releases).
#[derive(Parser)]
pub struct RepairArgs {
    /// Reinstall even if the scan-engine self-test currently passes.
    #[arg(long)]
    pub force: bool,

    /// Reinstall a specific release tag instead of the latest (e.g. `v0.5.34`).
    #[arg(long)]
    pub version: Option<String>,

    /// Asset variant: `cuda` for the CUDA Linux build; otherwise the portable
    /// WGPU+SIMD build (default).
    #[arg(long)]
    pub variant: Option<String>,
}

/// Arguments for `keyhog uninstall`.
#[derive(Parser)]
pub struct UninstallArgs {
    /// Actually remove the binary. Without this, uninstall is a safe dry run
    /// that only reports what would be removed.
    #[arg(long)]
    pub yes: bool,
}

#[derive(Parser)]
pub struct WatchArgs {
    /// Directory to watch recursively. Defaults to the current directory.
    #[arg(value_name = "PATH", default_value = ".")]
    pub path: PathBuf,
    /// Detector TOML directory. Falls back to embedded corpus if missing.
    #[arg(short, long, default_value = "detectors")]
    pub detectors: PathBuf,
    /// Quiet mode: only print findings (suppress "watching X" status).
    #[arg(long)]
    pub quiet: bool,
}

#[derive(Parser)]
pub struct CalibrateArgs {
    /// Mark these detector IDs as confirmed true positives (α += 1 each).
    /// Use `--tp` repeatedly: `--tp aws-access-key --tp github-pat`.
    #[arg(long, value_name = "DETECTOR_ID")]
    pub tp: Vec<String>,
    /// Mark these detector IDs as confirmed false positives (β += 1 each).
    #[arg(long, value_name = "DETECTOR_ID")]
    pub fp: Vec<String>,
    /// Print every recorded counter and exit (no updates). Read-only: it cannot
    /// be combined with the `--tp`/`--fp` update flags (mixing "show me the
    /// state" with "mutate the state" is contradictory and silently ran the
    /// update before — clap now rejects it with exit 2).
    #[arg(long, conflicts_with_all = ["tp", "fp"])]
    pub show: bool,
    /// Override the calibration cache path. Defaults to
    /// $XDG_CACHE_HOME/keyhog/calibration.json.
    #[arg(long, value_name = "PATH")]
    pub cache: Option<PathBuf>,
}

#[derive(Parser)]
pub struct DiffArgs {
    /// Baseline file A (the "before" / older state).
    pub before: PathBuf,
    /// Baseline file B (the "after" / newer state).
    pub after: PathBuf,
    /// Suppress the `UNCHANGED` section (default: shown).
    #[arg(long)]
    pub hide_unchanged: bool,
    /// Emit results as JSON instead of human-readable text. Useful for CI
    /// that wants to gate merges on regressions programmatically.
    #[arg(long)]
    pub json: bool,
}

#[derive(Parser)]
pub struct ExplainArgs {
    /// Detector ID to explain (e.g. `aws-access-key`, `github-pat`).
    /// Use `keyhog detectors` to list available IDs.
    pub detector_id: String,

    /// Detector TOML directory; falls back to the embedded corpus when
    /// missing. Same semantics as `keyhog detectors --detectors`.
    #[arg(short, long, default_value = "detectors")]
    pub detectors: PathBuf,
}

#[derive(Parser)]
pub struct DetectorArgs {
    /// Optional verb. `keyhog detectors` lists detectors by default, so the
    /// only accepted positional is the explicit `list` (a no-op alias kept for
    /// muscle-memory and for the historically-suggested
    /// `keyhog detectors list --detectors <DIR>` invocation). Any other token
    /// is rejected with a precise message rather than misparsed.
    #[arg(value_name = "VERB", value_parser = crate::value_parsers::parse_detectors_verb)]
    pub verb: Option<String>,
    /// Detector TOML directory
    #[arg(short, long, default_value = "detectors")]
    pub detectors: PathBuf,
    /// Filter detectors by substring match (case-insensitive) against id,
    /// name, service, and keywords (e.g. `keyhog detectors --search aws`).
    ///
    /// The short `--help` line is intentionally count-free; the long `--help`
    /// (rendered via [`crate::args::command`]) injects the live embedded
    /// detector count so the cited corpus size can never drift from the
    /// detectors actually compiled into this binary.
    #[arg(short, long)]
    pub search: Option<String>,
    /// Print full detector spec (regex, prefixes, keywords) instead of
    /// the grouped service summary. Pairs naturally with `--search`.
    #[arg(short, long, default_value_t = false)]
    pub verbose: bool,
    /// Audit detectors against the quality gate (`keyhog_core::validate_detector`).
    /// Prints every issue grouped by detector and exits non-zero (3) if any
    /// `Error`-severity issue was found. Warnings are reported but do not
    /// fail the run. Pairs with `--detectors <DIR>` for CI gating.
    #[arg(long, conflicts_with = "fix")]
    pub audit: bool,
    /// Apply safe automated fixes to the detector TOMLs in `--detectors`.
    /// Currently rewrites single-brace template references (`{name}`) to
    /// the double-brace form (`{{name}}`) within `[detector.verify*]`
    /// blocks: the one fix the interpolator's contract makes safe to
    /// perform mechanically. Other validator findings are left alone
    /// (they need human judgement). Use `--dry-run` to preview rewrites
    /// without touching the filesystem.
    #[arg(long, conflicts_with = "audit")]
    pub fix: bool,
    /// Show the rewrites `--fix` *would* make without writing them. No-op
    /// unless `--fix` is also set.
    #[arg(long, requires = "fix")]
    pub dry_run: bool,
    /// Emit the detector listing as a JSON array on stdout instead of the
    /// human-readable grouped summary. Pairs with `--search` for filtered
    /// programmatic discovery (CI gates, bench harnesses, IDE plugins).
    /// Mutually exclusive with `--audit` / `--fix` since those emit their
    /// own structured output formats. JSON shape mirrors the human surface:
    /// `[{ "id", "name", "service", "severity", "keywords": [..],
    /// "patterns": [{ "regex", "description", "group" }, ..],
    /// "companions": [{ "name", "regex", "within_lines", "required" }, ..],
    /// "verify": <bool> }, ..]`.
    #[arg(long, conflicts_with_all = ["audit", "fix"])]
    pub json: bool,
}

#[derive(Clone, ValueEnum)]
pub enum SeverityFilter {
    Info,
    Low,
    Medium,
    High,
    Critical,
}

impl SeverityFilter {
    pub fn to_severity(&self) -> keyhog_core::Severity {
        match self {
            Self::Info => keyhog_core::Severity::Info,
            Self::Low => keyhog_core::Severity::Low,
            Self::Medium => keyhog_core::Severity::Medium,
            Self::High => keyhog_core::Severity::High,
            Self::Critical => keyhog_core::Severity::Critical,
        }
    }
}

#[derive(Clone, ValueEnum)]
pub enum OutputFormat {
    Text,
    Json,
    Jsonl,
    Sarif,
    Csv,
    Html,
    Junit,
}

#[derive(Clone, ValueEnum, PartialEq)]
pub enum CliDedupScope {
    Credential,
    File,
    None,
}

impl CliDedupScope {
    pub fn to_core(&self) -> DedupScope {
        match self {
            Self::Credential => DedupScope::Credential,
            Self::File => DedupScope::File,
            Self::None => DedupScope::None,
        }
    }
}

/// Build the top-level clap [`clap::Command`] with the runtime-derived detector
/// count injected into the `detectors --search` long help.
///
/// The static `///` doc-comment on [`DetectorArgs::search`] is deliberately
/// count-free: clap doc-comments are compile-time string literals and cannot
/// embed the embedded-detector count without going stale (this is exactly the
/// drift AUD-coherence-1 documented — a hardcoded "894-strong" while the binary
/// loaded 899). Instead we render the long help here, at runtime, from
/// [`keyhog_core::embedded_detector_count`] — the *same* slice that backs
/// `keyhog detectors --json`. The cited corpus size therefore tracks the real
/// corpus exactly and can never undercount it.
///
/// Both `Cli::parse()`-equivalent paths and the `print_help` / completion paths
/// must route through this function so the dynamic help is always present.
pub fn command() -> clap::Command {
    use clap::CommandFactory;
    let count = keyhog_core::embedded_detector_count();
    let long_help = format!(
        "Filter detectors by substring match (case-insensitive) against id, \
         name, service, and keywords. Useful for finding detectors in the \
         {count}-strong corpus (e.g. `keyhog detectors --search aws`)."
    );
    Cli::command().mut_subcommand("detectors", move |sub| {
        sub.mut_arg("search", move |arg| arg.long_help(long_help.clone()))
    })
}

/// Parse the CLI from `std::env::args_os`, using the dynamic [`command`] so the
/// rendered `--help` carries the live detector count and the full exit-code
/// contract. Mirrors `Cli::parse()` but with the runtime help wiring.
pub fn parse() -> Cli {
    use clap::FromArgMatches;
    let matches = command().get_matches();
    match Cli::from_arg_matches(&matches) {
        Ok(cli) => cli,
        // clap's own error rendering already exited for parse failures; this
        // branch only triggers on a derive/runtime mismatch, which is a bug.
        Err(err) => err.exit(),
    }
}