Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

KeyHog: a Rust secret scanner

KeyHog scans repositories, Git history, CI workspaces, hosted Git collections, cloud object inventories, archives, and local systems for leaked credentials. You can start with one command, then choose a source boundary, detection policy, and execution route that match the job.

The sample below comes from a portable,simd build. The default crates.io install reports the pure-Rust CPU route instead; host labels and backend lines are evidence from the running binary, not universal defaults.

$ keyhog scan . --progress
    K E Y H O G
    ───────────
    v0.5.81 · secret scanner · 934 detectors
    by santh

  16 cores | SIMD: AVX-512 | Hyperscan | 934 detectors (5820 patterns) io_uring | backend=simd-regex | gpu=none

  ┌    CRITICAL ─── Stripe Secret Key
  │ Secret:     sk_l...p7dc
  │ Location:   src/config/.env.staging:14
  │ Evidence:   likely/vendor-pattern  ■■■■■■ 100%
  │ Action:     Roll the exposed Stripe secret key in the Dashboard, update production consumers, then delete the old key.
  │ Docs:       https://docs.stripe.com/keys#roll-api-key
  └─────────────────────────────────────────────

  ━━━ Results ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1 secret found · 1 unverified

Scan complete. Found 1 secret in 1.42s.

What it does

KeyHog walks working trees, Git history, Docker images, Git provider collections, cloud buckets, URL lists, and local systems. Each finding has:

  • a detector that fired (stripe-secret-key, aws-access-key, …)
  • a location (file, line, offset, optionally commit hash and author)
  • an exact evidence tier and reason code, plus entropy and evidence score when measured
  • an optional live verification result if you pass --verify

KeyHog also supports Git provider inventories, S3, GCS, and Azure Blob objects, Docker images, whole-system audits, CPU, Hyperscan, CUDA, Metal, and WGPU execution, and an optional Unix daemon for repeated eligible inputs. Choose a scanning workflow starts with the operator task and links each capability to the chapter that owns its contract.

The detector corpus ships as TOML files under detectors/. Run keyhog detectors --format json to inspect the exact corpus embedded in the installed binary. A custom --detectors <DIR> remains an explicit replacement by default, so there is never a hidden merge with embedded rules. Reviewed extensions can opt into --detectors-mode overlay; detector-ID collisions then fail closed rather than shadowing shipped rules.

What it doesn’t do

  • No telemetry. Findings stay local. The scanner never phones home.
  • No hosted scanning agent. Findings are not uploaded to a KeyHog service. A local daemon exists for eligible stdin and single-file requests on Unix. Starting it is explicit and it stays on your machine; after you start a compatible daemon, the ordinary Unix scan default (--daemon=auto) uses it for eligible requests. Use --daemon=off to force the in-process path.
  • No remote “AI-powered” detection. Service detectors use TOML regexes and structural validators; generic detectors compose assignment shape, entropy, BPE token efficiency, context, and local confidence policy. The small on-device MoE scores ambiguous candidates without sending content away. Verification is optional and is the only detection-adjacent step that calls a service endpoint.

Why another scanner

Three things, in order of how much they matter:

  1. Precision. A scanner that emits one false positive per ten findings teaches developers to ignore it. KeyHog suppresses example credentials (the Stripe docs key, the AWS sample key, the RFC 7519 specimen JWT), vendored bundles (minified jQuery, node_modules), and CI workflow ${{ secrets.NAME }} references by default. Repository dogfood and detector-specific negative twins keep those decisions exercised through the same scanner path users run.

  2. Recall. The detector corpus is built service-by-service. For every detector, the test suite carries positive shapes (env-var, JSON, YAML, header, URL), negative shapes (placeholder, EXAMPLE marker), and adversarial evasions (split across lines, hex/base64-encoded, reversed via Caesar cipher). If a shape isn’t in the suite, the detector isn’t shipped.

  3. Speed. Hyperscan SIMD prefilter, vectorized entropy, and a GPU region-presence route can accelerate different workloads. The winning route depends on the binary, detector/config digest, source shape, candidate density, cache state, CPU, GPU, driver, and storage. KeyHog records fastest-correct calibration for the installed host instead of treating a benchmark from another machine as a routing threshold.

    Published benchmark panels separate full-process startup, warm daemon requests, detection policy, cache state, backend diagnostics, scan workers, filesystem readers, corpus size, storage class, and concurrent partitions. Each panel labels development-host evidence that cannot support a clean-source release routing claim. See Backends and routing and the reproducible benchmark harness.

Get going

Install the current release from crates.io:

cargo install --locked keyhog
keyhog --version
keyhog scan .

KeyHog requires Rust 1.89 or newer. The Install guide shows exact-version, portable, CI-only, and checked-out source builds. Your first scan gives you a safe synthetic finding to confirm output, redaction, and exit status before you scan a repository.

Where things live

License: MIT OR Apache-2.0. Read the MIT terms and Apache-2.0 terms.

Choose a scanning workflow

Start with the source boundary you need. Then choose a detection policy and an execution route. These are separate decisions: a backend changes how KeyHog executes, while a preset changes what detection work it performs.

Choose in 30 seconds

Your boundaryStart hereDo not substitute
One checked-out repository in GitHub ActionsGitHub ActionAn organization inventory job
One checkout in GitLab, CircleCI, Jenkins, Buildkite, or a shell runnerCI secret scanningAction-specific inputs or outputs
A Git provider organization, cloud bucket, or partitioned estateMass scanningOne oversized repository gate
A local working treeYour first scanGit history unless you select it
Staged content or changed linesPre-commit or --git-diffA full checkout scan when the policy is diff-only
Repeated one-file or bounded-stdin requests on UnixDaemon and warm scansDirectories, Git, archives, remote sources, or verification
A local host and mounted filesystemsSystem-wide triageRepository or cloud inventory ownership

Then make three independent choices:

  1. Select the source boundary. This determines which bytes are eligible.
  2. Keep the default detection policy unless you accept a documented fast, deep, or precision tradeoff.
  3. Use calibrated automatic routing for normal scans. Select an explicit backend only for diagnosis, measurement, or a required-accelerator gate.

If any source reports incomplete coverage, preserve that status with the findings and report. A partial scan is not a clean scan.

Choose the source boundary

TaskCommand or workflowWhat it covers
Scan a working tree oncekeyhog scan .Files present below the selected path. It does not add Git history automatically.
Scan staged contentkeyhog scan --git-staged or keyhog hook installExact blobs in the Git index. It does not scan unstaged working-tree bytes.
Gate a pull-request checkoutGitHub Action or keyhog scan .The checked-out tree. Add a committed baseline when existing findings should remain visible without blocking adoption.
Gate only pull-request changeskeyhog scan --git-diff <base>Changed lines relative to the selected base. This is narrower than scanning the checkout.
Scan reachable commit additionskeyhog scan --git-history .Added lines from reachable commit patches, bounded by max_commits and the ancestry present in the checkout. A credential on a branch this checkout never had, or one left behind by git commit --amend, is missed with no coverage gap.
Scan the repository object databasekeyhog scan --git-blobs .Deduplicated blobs from refs, reflogs, stashes, annotated tags, and unreachable or dangling objects still present in .git. This is broader than reachable commit history, but cannot recover objects already pruned from the clone.
Verify a releasekeyhog scan --git-history . --git-blobs . --verifyReachable additions and blobs, plus live checks for eligible detectors. Verification sends credential-derived requests to provider endpoints.
Scan a Git provider or cloud inventory--github-org, --gitlab-group, --bitbucket-workspace, --s3-bucket, --gcs-bucket, or --azure-container-urlOne provider inventory. Partition larger estates into independent jobs with separate reports and exit codes.
Scan GitHub collaboration content--github-collaborationIssues, pull requests, discussions, wikis, and gists selected by the collaboration workflow.
Audit a hostkeyhog scan-systemEligible local mounted filesystems and discovered Git histories under one space ceiling.
Continuously guard a repositorykeyhog guard add <repo> --mode repoPerpetual Git repository indexing with in-memory clean attestation caching for instant pre-commit scans.
Reuse a warm scanner on UnixStart keyhog daemon start, then scan one file or bounded stdinKeeps the compiled scanner and accelerator warm for repeated single-file or stdin requests.
Stream mass directory batcheskeyhog scan --daemon=mass ...Streams bounded source batches from large directories or trees to a mass-enabled daemon.
Monitor local directories in foregroundkeyhog watch <path>...A foreground filesystem-event loop with an in-process scanner.
Inspect a native executable or firmware imagekeyhog scan --binary app.binPrintable strings and supported native object sections, on a build with the binary feature. A directory walk records binaries as skipped and does not reinterpret them as text; --no-default-excludes does not change that. A directory containing only skipped binaries exits 13 because zero source bytes reached the scanner. A mixed tree can still exit 0 with an advisory binary gap, so inspect coverage_gap_summary.

Read Your first scan for a local repository, CI secret scanning for direct CI jobs, and Mass repository and cloud scanning for inventory partitioning and coverage.

Choose a scan policy

PolicyCommandResolved behavior
Defaultkeyhog scan .Decode depth 10, ML enabled, and entropy evidence for eligible structured candidates. Decoding is capped at --decode-size-limit, 512K by default, applied per chunk rather than per file. Generic source-file entropy discovery is off. The global confidence floor is 0.40 unless detector policy owns a different floor.
Fast presetkeyhog scan . --fastNamed regex and multiline matching remain. Decode, entropy discovery, and ML are off in the base preset. An explicit compatible option such as --decode-depth 2 can refine it.
Deep presetkeyhog scan . --deepSource-file entropy and comment scanning at full confidence, heuristic evidence beside entropy ML, decode depth 10, and prepared decode chunks up to 1 MiB.
Precision presetkeyhog scan . --precisionEntropy discovery and the relaxed keyword bridge are off, ML remains on for eligible candidates, decode depth is 1, and every confidence floor is at least 0.85.
Lockdown modekeyhog scan . --lockdownLinux-only fail-closed process protection. It is a security execution mode, not a detection preset. It requires sufficient locked-memory capacity and refuses incompatible completeness-reducing, network, daemon, cache, and plaintext-output requests.

The three presets are mutually exclusive bases. Compatible explicit options refine them; a precision confidence override may raise but never lower 0.85. Lockdown refuses fast and other completeness-reducing switches, and always runs in process. See Configuration and Hardening.

The default decode cap can hide an encoded credential

Decide this one before you trust a clean result on a repository with large files. A Base64-encoded credential in a chunk above --decode-size-limit is never decoded, so it is never reported. The scan exits 0:

file size    keyhog scan <file>                       with --deep
400K         1 finding, exit 1, status success        1 finding, exit 1
510K         1 finding, exit 1, status success        1 finding, exit 1
520K         0 findings, exit 0, status partial       1 finding, exit 1
600K         0 findings, exit 0, status partial       1 finding, exit 1

The miss is reported, but only in the envelope. coverage_gap_summary carries scanner decode-through declined by --decode-size-limit, and scan_status becomes partial. The exit code stays 0, so a CI gate that branches on the exit code alone passes over a real credential. Gate on the gap reason, not the exit code.

Position in the file governs this, not size. The cap applies per chunk, and a file is read in 1 MiB windows, so only the short tail window of a large file can fall under the 512K limit. An encoded credential in the interior of any file above about 1 MiB is never decoded, at any file size:

2000K file, payload at end of file       1 finding, exit 1
2000K file, payload in the middle        0 findings, exit 0
3000K file, payload at end of file       1 finding, exit 1
3000K file, payload in the middle        0 findings, exit 0

That is also why the size table above looks erratic: planting at the end of the file tests the one position that can still succeed, and the tail window’s size rises and falls as the file grows. Do not infer a safe file size from a fixture that passed, and do not build a regression fixture that plants at the end.

Either preset choice restores it. --deep raises the ceiling as part of its policy, and --decode-size-limit 4M raises it without changing anything else:

keyhog scan . --decode-size-limit 4M

Choose an execution route

Normal scans use auto. An explicit backend is a diagnostic or benchmark contract, not a recommendation for routine routing.

RouteSelect it withUse case and boundary
Calibrated automatic routingRun keyhog calibrate-autoroute, then keyhog scan .Chooses the fastest parity-checked eligible backend for the exact host, binary, detector policy, and workload class. A normal scan does not benchmark.
Portable CPU-only buildInstall with cargo install --locked keyhogThis is the default on every host. It includes local, remote, container, and native binary sources without Hyperscan, GPU, or Ghidra build prerequisites. A scalar-only build has no routing choice and needs no autoroute cache.
Explicit pure-Rust CPU--backend cpuDiagnose the portable path or compare it in a benchmark. --no-gpu is not equivalent because Hyperscan may remain eligible.
Hyperscan or VectorscanLet calibrated auto select it, or diagnose with --backend simdAccelerated CPU trigger matching followed by the shared extraction and policy pipeline. It requires a compatible build and runtime.
CUDA, native Metal, or WGPULet calibrated auto select an eligible peerGPU region-presence matching followed by the same confirmation pipeline. GPU availability does not mean the GPU is fastest for every workload.
Required GPU--require-gpu, [system].gpu = "required", or diagnostic `–backend gpu-cudagpu-metal
Warm Unix daemon & GuardStart keyhog daemon start; use --daemon=on or keyhog guardRemoves repeated scanner startup for eligible single-file or stdin requests, and serves perpetual repository guard commit transactions with clean attestation caching.

Use keyhog --version --full to inspect compiled capability, keyhog backend --self-test --json to prove backend health, and keyhog backend --autoroute --json to inspect the measured route. These commands answer different questions: discovery, correctness, and comparative selection.

Choose a detector corpus mode

--detectors <DIR> selects a custom detector directory. Choose how it participates in the corpus:

ModeCommandResult
Replace--detectors ./reviewed --detectors-mode replaceUses only the custom directory. This is also the compatibility behavior when an explicit custom directory omits the mode.
Overlay--detectors ./extra --detectors-mode overlayAdds the custom directory to the embedded corpus. Duplicate detector IDs fail corpus loading.

The selected corpus owns matching, validation, entropy, suppression, ML, and declared decode-transform policy. Replace mode does not inherit detector-local policy from the embedded corpus. See Detectors.

What KeyHog can scan

The default and official release builds support the sources below. Reduced source builds can omit feature-gated Git, web, cloud, container, and verifier support. Every enabled source feeds the same compiled detector pipeline.

SourceHow to point at itChapter
Working treekeyhog scan <path>... (default)Your first scan
stdin / single file--stdin or keyhog scan path/to/fileDaemon and warm scans
Git history--git-history <repo>Deep recovery
Git diff / staged--git-diff <range>, --git-stagedPre-commit hook
GitHub org / repos--github-org, --github-collaboration (issues, PRs, discussions, wiki, gists)GitHub collaboration scans
GitLab group--gitlab-groupMass scanning
Bitbucket workspace--bitbucket-workspaceMass scanning
S3 / GCS / Azure Blob--s3-bucket, --gcs-bucket, --azure-container-urlMass scanning
Docker image--docker-image <ref>Mass scanning
Web URLs--url <url>...HTTP and wire scanning
HAR captureskeyhog scan capture.harHTTP and wire scanning
Archives, compressed files, and supported containerspass the containing path; formats are detected during filesystem and remote-source expansionSource archives

How KeyHog decides what is real

Precision is the product. A finding survives several independent stages before it reaches your terminal.

StageWhat it doesChapter
DetectorsThe embedded detector catalog is compiled from TOML data under detectors/; query the running binary for its exact countDetectors
Entropy and shapevectorized entropy plus declarative charset/grouping shape checksHow detection works
On-device MoEa small mixture-of-experts model scores ambiguous candidates locallyHow detection works
Context and suppressionexample-credential, vendored-bundle, comment, and ${{ secrets.NAME }} suppression by defaultSuppressions
Verificationoptional live checks for detectors with a verification plan; these checks send credential-derived requests to the serviceVerification

How KeyHog stays fast

CapabilityWhat it buys youBoundaryChapter
Autoroute calibrationPicks the fastest correct backend for the exact host, binary, detector corpus, policy, and workload class.Normal scans consume persisted evidence. They do not benchmark or guess on a cache miss.Autoroute calibration
Parallel scan workersUses the available CPU cores by default. --threads <N> caps scanner workers when a shared runner has a smaller CPU budget.Concurrent KeyHog processes each own a worker pool. Divide the host budget across partitions instead of letting every process claim every core.CLI reference
Dedicated readersOverlaps filesystem reads with scanning. The reader count derives from the scan worker pool by default.Set --reader-threads only after profiling the target storage path.CLI reference
Incremental scansReuses trusted clean-file proofs so repeated scans of one tree skip unchanged files; an all-hit run starts no backend dispatch.Keep one cache per repository or partition. Do not share it across unrelated or untrusted workspaces.Mass scanning
Partition concurrencyRuns independent repositories, provider targets, or buckets in parallel with independent retry boundaries.Preserve one envelope and raw exit code per partition.Mass scanning
Verification limitsControls live provider traffic separately with --verify-concurrency, --verify-rate, and --verify-batch.Provider quotas, not scanner worker count, own this concurrency.Verification
GPU region presenceUses VYRE CUDA, native Metal, or WGPU dispatch for the whole corpus at once when measured routing evidence selects it.GPU availability alone does not prove it is fastest for the workload.Backends and routing
Hyperscan SIMD prefilterUses vectorized literal and regex prefiltering on the accelerated CPU path.Let calibrated automatic routing compare it with every eligible peer.Backends and routing
Daemon and warm scansServes IDE-save and single-file scans without cold start on Unix.Directories, Git, archives, remote sources, verification, and policy changes are not daemon work.Daemon and warm scans

The generated scaling matrix measures these controls instead of prescribing a fixed thread count. Run make -C benchmarks readme-scaling on the target host. The result binds the binary, detector corpus, exact workload bytes, effective CPU limit, filesystem identity, page-cache policy, raw trials, and process exit status.

What KeyHog emits

OutputUseChapter
Eleven formatstext, json, json-envelope, jsonl, jsonl-envelope, sarif, csv, github-annotations, gitlab-sast, html, junitOutput formats
Baselinesaccept known findings once, then fail only on new secrets; entries match the detector and credential value, never the pathFail only on new secrets
Exit codesstable codes for clean, findings, and error so scripts branch reliablyExit codes

How KeyHog protects the secrets it reads

A scanner holds credentials in memory by design, so KeyHog hardens the process that does it.

PropertyWhat it meansChapter
Local defaultlocal filesystem, Git, stdin, archive, decoding, and detector work do not send findings or telemetryHardening and data handling
In-process scan hardeningLinux and macOS in-process scans attempt core-dump and debugger-attachment protections before reading inputHardening and data handling
Linux lockdown mode--lockdown fails closed unless memory locking and dump protections apply, and it refuses network verification and plaintext outputHardening and data handling
Credential buffer zeroizationthe report credential buffer is zeroized on drop; reports redact unless --show-secrets is explicitHardening and data handling
Authenticated execution packskeyhog install compiles, signs, and verifies the execution-pack generation it publishes, and a scan refuses artifacts whose identity or verification key does not matchInstall

Every subcommand

CommandPurpose
scanscan any source and report findings (--verify adds live credential checks)
scan-systemaudit eligible local mounted filesystems and discovered Git histories under one --space ceiling; --include-network opts into network mounts (guide)
watchcontinuously scan one or more directories as files change
diffdiff two baselines or artifacts: NEW / REMOVED / UNCHANGED
explainshow a detector’s spec, regex, severity, and rotation guide
detectorslist and inspect the embedded detector corpus
configprint the resolved scan configuration without scanning
hookinstall or remove the git pre-commit hook
daemonstart, stop, or query the warm-scan daemon (Unix)
calibrateshow or update per-detector Bayesian confidence calibration
calibrate-autorouteprime autoroute across every policy preset and workload bucket
backendinspect hardware, routing heuristics, and autoroute evidence
bloom-diagnosticmeasure the production Bloom rejection gate and prove enabled-versus-bypassed finding parity
doctorhealth-check the install: host, PATH, corpus, scan and GPU self-test
installcompile, authenticate, calibrate, and install execution packs for this host
triageimport redacted findings into scoped suppression and pattern feedback
guardmanage the perpetual repository and filesystem guard
uninstallremove the binary (dry run unless --yes)
completionemit shell completions (bash, zsh, fish, powershell, elvish)

The full flag surface for every command is in the CLI reference.

Update or repair with cargo install --locked --force keyhog, then run keyhog doctor to verify the replacement.

Pick your input shape

KeyHog scans more than one kind of thing. A repository working tree, a 4 GiB log, a minified bundle, a container image, and a Git history are five different workloads. Each has its own command, its own limits, and its own way of going quiet when it fails.

Find your shape in the table, run the command, then run the coverage check for that shape. The default command is right for exactly one shape.

Input shapeCommandRequired buildPageCheck first
A repository working treekeyhog scan .portable or ciYour first scanBytes are nonzero; review exclusion and binary gap rows
Many small fileskeyhog scan <root>portable or ciFile shapes and sizesChunks are plausible for the eligible files; large files produce several chunks
One very large filekeyhog scan <file> --max-file-size <SIZE>portable or ciFile shapes and sizesNo exceeded --max-file-size or source-error gap
A first-party minified or bundled filekeyhog scan <file> or keyhog scan <root> --no-default-excludesportable or ciFile shapes and sizesBytes are nonzero; directory scans need the explicit exclusion override
Git additions or repository objects--git-history <repo> or --git-blobs <repo>portable or ci,gitDeep recoveryUse a full clone; reject shallow-history and object-read gaps
Container images and OCI layerskeyhog scan --docker-image <ref>portable or the docker featureContainer imagesExtraction completed within its byte and member budgets
Cloud object stores--s3-bucket <name>, --gcs-bucket <name>, or --azure-container-url <url>portable or the matching provider featureMass scanningNo page, object, or byte cap left inventory uncovered
Archives and nested archiveskeyhog scan <path>portable or ciSource archivesNo encrypted, unsafe, corrupt, or truncation gap
Changed files, continuouslykeyhog watch <dir>portable or ciWatch modeRun one full scan before starting the watcher
A pipe or here-stringkeyhog scan --stdinportable or ciStandard input and pipelinesBytes are nonzero and the producer’s exit is preserved with pipefail
One checked-out repository in CIAction path: . or keyhog scan .Action ci, or a Cargo portable/ci buildCI secret scanningRetain the report and raw exit code; do not accept zero scanned bytes
A whole estate, partitionedOne job per provider, repository, or bucket partitionportable or the matching provider featuresMass scanningEvery partition produced its own report, exit code, and coverage state
A whole hostkeyhog scan-system --space <SIZE>portable for filesystem plus Git-history coverageSystem-wide triageMount policy and the space ceiling did not exclude required input
A URL, response, or HAR capturekeyhog scan --url <url> or keyhog scan capture.harportable or the web featureHTTP and wireFetch/parse completed and the selected response bytes were scanned
A native binary or firmware imagekeyhog scan --binary <file>portable or the binary featureChoose a scanning workflowPrintable strings or supported sections reached the scanner; no binary source error

The small ci profile intentionally omits Git, cloud, web, container, binary, and verification flags. Add only the named source feature you need, or use the default portable profile. keyhog scan --help is authoritative for the installed binary.

Why the shape matters

The three defaults that decide whether a scan covers your input are set for a repository working tree.

--max-file-size is 100 MiB. A single larger file is skipped.

The default exclusion policy removes .git/, lockfiles, vendored trees, and minified bundles. On a repository that is what you want. On a directory of build output it removes everything.

--stdin accepts 10 MiB. A larger stream fails closed.

None of the three is wrong. Each is wrong for at least one of the shapes above.

Check coverage the same way every time

Every page in this section ends with the same check, because the question is always the same. Did KeyHog read my input?

rm -f keyhog.json
rc=0
keyhog scan <target> --format json-envelope -o keyhog.json || rc=$?
jq '{bytes: .metadata.source_bytes_scanned, status: .scan_status,
     gaps: .coverage_gap_summary, findings: (.findings | length)}' keyhog.json
printf 'keyhog exit=%s\n' "$rc"

Tell a real clean from a skipped input explains each field, lists every coverage-gap reason, and names the cases where the current build reports a clean scan over input it did not read.

Combining shapes in one run

You can pass several roots to one keyhog scan. You cannot mix source kinds:

keyhog scan src/ config/ vendor-drop/

Nested or duplicate roots fold into their covering parent. One report and one exit code cover all of them, so a gap in any root makes the whole run partial.

A container image, a bucket, a Git history, and a working tree each need their own run. Give each one its own output file so a failure in one does not hide behind a success in another:

keyhog scan . --format json-envelope -o worktree.json
keyhog scan --git-history . --format json-envelope -o history.json
keyhog scan --docker-image registry/app:v1 --format json-envelope -o image.json

Check the exit code and envelope of each run. A failing source or coverage gap returns 13 when no finding outcome takes precedence. Advisory skips can leave scan_status: partial with exit 0, so automation must also inspect the gap reasons it treats as unacceptable.

Install

KeyHog releases are Rust packages on crates.io. Install the latest published version with Cargo:

cargo install --locked keyhog
keyhog --version
keyhog doctor

cargo install builds KeyHog for your host and places the binary in Cargo’s binary directory. This is usually $HOME/.cargo/bin on Linux and macOS, or %USERPROFILE%\.cargo\bin on Windows. Add that directory to PATH if your shell cannot find keyhog.

Platform support

cargo install is the current distribution path and builds KeyHog from source for the host that runs it. Hosted release CI proves this matrix:

OSCI-proven architecture
Linuxx86_64
macOSx86_64, arm64
Windowsx86_64

Other Rust host targets are not part of the hosted release contract. Cargo may build them when KeyHog’s dependencies support the target, but a successful local build is the evidence for that host. In particular, Linux arm64 and Windows arm64 do not have hosted release jobs.

Installing a bundle you already hold

There is no binary download channel. No workflow builds, signs, or uploads release binaries, and KeyHog has no self-update command.

install.sh and install.ps1 install a binary you already have, with --from-file. Use them for an air-gapped host or to place a locally built binary on PATH with the same layout, permission, and PATH handling that a packaged install would use. Neither script contacts the network. Both refuse to install without --from-file and print the Cargo command instead.

Both scripts run keyhog doctor, publish an execution-pack generation, calibrate autoroute, and then scan a throwaway two-file directory with no backend override. That last scan is the install’s pass criterion for routing: a calibrated cache that cannot serve an ordinary scan fails the install and the previous binary is restored.

Update with cargo install --locked --force keyhog. That is also the repair path: it rebuilds and replaces the installed binary.

Install Rust

KeyHog requires Rust 1.89 or newer. Install Rust with rustup when cargo --version is unavailable. Then open a new terminal and run the install command again.

The default build includes filesystem, Git, web, cloud, container, archive, and native binary sources plus live verification. It uses the pure-Rust CPU scanner and has no Hyperscan, GPU-driver, CUDA-toolkit, or Ghidra build prerequisite. Binary string and object scanning works without Ghidra. If you install Ghidra separately, KeyHog can also enrich supported binaries with decompiled content.

Pin an exact version

Use an exact Cargo version requirement when a build or CI job must stay on one release:

cargo install --locked --version '=0.5.81' keyhog

The leading equals sign prevents Cargo from selecting another compatible version. KeyHog publishes canonical X.Y.Z versions. Do not include a leading v in the Cargo version requirement.

To update to the latest release, run:

cargo install --locked --force keyhog
keyhog doctor

Every successful main CI run publishes the next patch version. KeyHog does not publish binary release assets or installer bundles.

Update or roll back

Stop a running daemon before replacing the executable:

keyhog daemon stop
cargo install --locked --force keyhog
keyhog doctor

Cargo builds the replacement before it changes the installed binary. A compile or download failure leaves the previous executable in place.

To roll back, choose a version from the crates.io version list, replace MAJOR.MINOR.PATCH below, and install that exact package:

cargo install --locked --force --version '=MAJOR.MINOR.PATCH' keyhog
keyhog doctor

The commands are identical in Bash, Zsh, and PowerShell. If PowerShell reports that keyhog.exe is in use, stop the daemon and close other KeyHog processes, then retry. If Cargo reports that libhs is missing, remove an unintended simd feature or install the Hyperscan/Vectorscan development package. The default install does not require libhs.

Choose installation features

The profiles below serve different products. ci is the small user-facing CI build; ci-lean is a broad maintainer test closure and is not the lightweight edition.

IntentFeature selectionIncluded surfaceAdditional requirement
General installationdefault (portable)Every documented source provider, binary scanning, and live verification; pure-Rust CPU routeNone
General installation with GPU peersportable,gpuportable plus CUDA, native Metal, and WGPUSupported runtime driver
General installation with SIMD peerportable,simdportable plus Hyperscan/VectorscanDevelopment package and libhs.pc visible to pkg-config
Small checkout-only CI scannerciFilesystem, archives, stdin, and the full detection policy; no remote providers, verification, SIMD, or GPUNone
Hosted maintainer test closureci-leanBroad network providers, verification, Hyperscan/SIMD, and scanner data features; no GPU dispatchHyperscan/Vectorscan development package

Install the default portable build:

cargo install --locked keyhog

Enable CUDA, native Metal, and WGPU:

cargo install --locked keyhog --no-default-features --features portable,gpu

Enable Hyperscan or Vectorscan:

cargo install --locked keyhog --no-default-features --features portable,simd

Install the small checkout-only CI scanner:

cargo install --locked keyhog \
  --no-default-features \
  --features ci

Cargo does not execute the binary after installation. After installing a multi-backend portable,gpu or portable,simd build, acquire and calibrate the eligible peers explicitly:

keyhog backend --self-test
keyhog calibrate-autoroute
keyhog backend --autoroute

A scalar-only portable or ci build reports autoroute health as direct because it has no backend choice to calibrate.

Which build your workload needs

Source providers are compile-time features. A flag that its feature did not build is not hidden or ignored: it is absent from the command line, so the command exits 2 with error: unexpected argument. That is loud, and it is the reason to pick the right build before you script against it.

WorkloadFlagFeatureIn portable (the default)In ci
Working tree, single filepositional pathalways builtyesyes
Standard input--stdinalways builtyesyes
Archives and nested archivespositional pathalways builtyesyes
Watch changed fileskeyhog watchalways builtyesyes
Git history, blobs, diff, staged--git-history, --git-blobs, --git-diff, --git-stagedgityesno
Container images--docker-imagedockeryesno
S3 buckets--s3-buckets3yesno
GCS buckets--gcs-bucketgcsyesno
Azure Blob containers--azure-container-urlazureyesno
GitHub orgs and collaboration surfaces--github-org, --github-collaborationgithubyesno
GitLab groups--gitlab-groupgitlabyesno
Bitbucket workspaces--bitbucket-workspacebitbucketyesno
URLs, source maps, WASM--urlwebyesno
Native binaries and firmware--binarybinaryyesno
Live credential verification--verifyverifyyesno

The ci build covers the filesystem and standard-input workloads and nothing else. That is the point of it: a checked-out tree is what a CI job scans, and dropping the rest removes the network and native dependencies. Add back only what you need:

cargo install --locked keyhog --no-default-features --features ci,git

Check what your installed build has before you script against it:

keyhog scan --help

A workload whose flag is missing from that output is not available in your build. Reinstall with its feature rather than working around the error.

Build the checked-out source

From the repository root:

cargo install --locked --path crates/cli

Use this path when you are testing an unreleased checkout. A tagged GitHub Action ref installs its exact crates.io version with the lean ci feature. A branch or commit Action ref builds its checked-out source.

Confirm the installation

Inspect the compiled capabilities and health before your first scan:

keyhog --version --full
keyhog scan --help
keyhog doctor
keyhog backend --self-test
keyhog backend --autoroute
keyhog scan .

scan --help is the authoritative list of source flags compiled into this binary. backend --self-test executes available accelerator diagnostics and reports a successful SKIP when no physical GPU is present. backend --autoroute reports direct for a scalar-only build and ready for a valid multi-backend calibration.

keyhog doctor exits 0 when the installed binary is healthy and 4 when a health check fails. keyhog scan . exits 0 when no finding blocks the active evidence policy and 1 when at least one finding blocks. Continue with Your first scan to exercise a safe synthetic finding.

Your first scan

Start with a synthetic token whose checksum is valid for the detector but which is not a live credential. This confirms detection and redaction without putting a real secret in your shell history.

On Linux or macOS:

demo_dir=$(mktemp -d)
token='ghp_'
token="${token}aBcD1234EFgh5678ijkl9012MNop343hK7n2"
printf 'GH_TOKEN="%s"\n' "$token" > "$demo_dir/demo.env"
if keyhog scan "$demo_dir/demo.env"; then status=0; else status=$?; fi
printf 'keyhog exit code: %s\n' "$status"
rm -rf "$demo_dir"
test "$status" -eq 1

On Windows PowerShell:

$Demo = Join-Path ([IO.Path]::GetTempPath()) "keyhog-first-scan-$PID.env"
$Token = 'ghp_' + 'aBcD1234EFgh5678ijkl9012MNop343hK7n2' # keyhog:ignore detector=github-classic-pat
Set-Content -Path $Demo -Value "GH_TOKEN=`"$Token`""
keyhog scan $Demo
$Status = $LASTEXITCODE
Write-Output "keyhog exit code: $Status"
Remove-Item $Demo
if ($Status -ne 1) { throw "expected finding exit 1, got $Status" }

You should see a GitHub Classic PAT finding with the credential rendered as ghp_...K7n2, followed by keyhog exit code: 1. File paths, timing, host capabilities, and detector counts vary by installation.

KeyHog redacts credential values by default in every output format, including the --output file, not only the terminal. --show-secrets deliberately prints plaintext and can leak it into logs, artifacts, or scrollback. Do not use that flag for routine scans, and never in CI.

Now scan your repository:

cd /path/to/your/repository
keyhog scan .

That walks the current directory and reports findings. A successful scan returns exactly one process exit code:

Exit codeMeaning
0No finding blocks the active evidence policy and no failing source-coverage condition occurred. Under the default policy, review-tier findings remain visible without blocking.
1At least one finding blocks the active evidence policy, but none were confirmed live. The default blocks likely and confirmed; --evidence-policy paranoid also blocks review.
2User error, such as a bad flag or config, a missing or unreadable path, a missing baseline, detector-load failure, or invalid autoroute calibration
3Local system failure, such as low-level I/O, a fatal daemon failure, or an unavailable selected SIMD/Hyperscan backend
10At least one credential was confirmed live under --verify
11A scanner thread panicked; partial output is not a trustworthy clean verdict
12A required or explicitly selected GPU was unavailable
13A requested source failed or failing input coverage was incomplete, and no finding outcome took precedence.
130You interrupted the scan with Ctrl-C/SIGINT

keyhog scan --help prints the same canonical table. CI does not need grep, jq, or exit-code arithmetic. When several conditions apply, a scanner panic takes precedence over findings, a confirmed-live finding takes precedence over other findings, and a blocking finding takes precedence over a later cache or source-coverage failure. Read the coverage warning and structured scan_status before treating partial output as complete.

What you get out of it

Findings go to stdout as redacted boxes followed by a summary:

┌    CRITICAL ─── GitHub Classic PAT
│ Secret:     ghp_...K7n2
│ Location:   /tmp/keyhog-first-scan/demo.env:1
│ Evidence:   likely/vendor-pattern  ■■■■■■ 100%
└─────────────────────────────────────────────

━━━ Results ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1 secret found · 1 unverified

Each finding gives you the detector, the redacted credential, the location, the exact evidence tier and reason code, an optional evidence score, and remediation guidance. Use --output to write the report to a file instead of stdout.

Add --progress when you need to see which engine ran the scan. It writes a banner to stderr before the findings:

    K E Y H O G
    ───────────
    v0.5.81 · secret scanner · 934 detectors
    by santh

  16 cores | SIMD: AVX-512 | Hyperscan | 934 detectors (5820 patterns) io_uring | backend=simd-regex | gpu=none

The banner reports this host’s CPU and GPU labels, the scanner engine, the compiled pattern count, the selected backend, and the GPU engagement result. KeyHog writes it only when stderr is a terminal, so a redirected log or a CI capture never contains it.

Default suppressions

KeyHog ships with a Tier-B suppression list of publicly documented test fixtures - credentials that appear in vendor docs as examples. Findings on these are suppressed by default. Examples:

  • Stripe’s sk_live_4eC39HqLyjWDarjtT1zdp7dc (docs sample)
  • AWS’s AKIAIOSFODNN7EXAMPLE (docs sample)
  • The RFC 7519 specimen JWT
  • GitHub’s ghp_aBcDeFgHiJ… placeholder

To see what was suppressed, pass --no-suppress-test-fixtures. The list lives at crates/cli/data/suppressions/test-fixtures.toml inside the source tree and is baked into the binary at build time. It is one visible suppression layer; detector-owned examples, structural/context gates, default path policy, .keyhogignore, and .keyhogignore.toml have distinct documented ownership. See Suppressions for the full order.

JSON output

keyhog scan . --format json-envelope

The compatibility --format json form remains a top-level findings array; choose json-envelope when a durable schema identity and scan metadata are needed.

The output is a versioned envelope. schema_version.major selects the incompatible schema generation; consumers must reject an unsupported major. Minor revisions are additive, so a reader that understands major 2 may accept a newer minor and ignore fields it does not know. The optional metadata object identifies the scan; coverage_gap_summary preserves any source or scanner coverage gaps; findings contains the redacted finding objects. Every finding has a canonical evidence object with exact tier and reason_code. entropy and evidence_score are included only when measured. A present entropy value is Shannon bits-per-byte evidence, not an evidence score and not a claim that entropy alone caused the finding. The optional correlations array is emitted only when the scan ran with --correlate; the key is absent, not empty, otherwise. See Output formats. The source_bytes_scanned and source_chunks_scanned counters are the exact workload consumed by the scanner, so an importer can calculate throughput from the artifact without scraping console progress.

{
  "schema_version": {"major": 2, "minor": 0},
  "scan_status": "success",
  "metadata": {
    "scan_id": "0123456789abcdef0123456789abcdef",
    "scan_status": "success",
    "keyhog_version": "0.5.81",
    "git_hash": "<build-commit>",
    "detector_digest": "934-<digest>",
    "config_digest": "<effective-config-digest>",
    "resolved_scan": {
      "schema_version": 1,
      "preset": "default",
      "effective": {"max_decode_depth": "10", "entropy_enabled": "true"},
      "overrides": []
    },
    "generated_at": "2026-07-14T00:00:01",
    "scan_started_at": "2026-07-14T00:00:00",
    "scan_finished_at": "2026-07-14T00:00:01",
    "duration_ms": 1000,
    "targets": ["."],
    "source_chunks_scanned": 1,
    "source_bytes_scanned": 128,
    "detector_count": 934
  },
  "coverage_gap_summary": [],
  "findings": [
    {
      "detector_id":        "stripe-secret-key",
      "detector_name":      "Stripe Secret Key",
      "service":            "stripe",
      "severity":           "critical",
      "credential_redacted": "sk_l...p7dc",
      "credential_hash":     "sha256-hex",
      "companions_redacted": {},
      "location": {
        "source":    "filesystem",
        "file_path": "src/config/.env.staging",
        "line":      14,
        "offset":    12,
        "commit":    null,
        "author":    null,
        "date":      null
      },
      "verification": "skipped",
      "metadata": {},
      "additional_locations": [],
      "evidence": {
        "tier": "likely",
        "reason_code": "vendor-pattern",
        "provenance": {
          "schema_version": 1,
          "detector_digest": "0123456789abcdef",
          "pattern_index": 0,
          "candidate_channel": "pattern",
          "source_role": "environment-assignment-value",
          "context_class": "vendor-pattern"
        }
      },
      "entropy": 4.5,
      "evidence_score": 1.0,
      "remediation": {
        "action":     "Roll the exposed Stripe secret key in the Dashboard, update production consumers, then delete the old key.",
        "revoke_url":  "https://docs.stripe.com/keys#roll-api-key",
        "docs_url":    "https://docs.stripe.com/keys"
      }
    }
  ]
}

Pipe .findings into jq, into a SARIF converter for the GitHub Security tab, or into your own dedup / triage tooling.

Limiting scope

keyhog scan src/                        # one subdirectory
keyhog scan src/config/staging.env      # one file
keyhog scan --stdin < staging.env       # from stdin (CI: cat | keyhog)
keyhog scan . --exclude-paths 'docs/*'  # exclude a glob

The default walk skips a file when any segment of its path is one of these directory names, at any depth:

.git  node_modules  target  .cache  __pycache__  .venv  venv  .tox
dist  build  out  .next  .nuxt  vendor  swagger  swagger-ui

It also skips lock files, editor backups, and filenames containing .min. or .bundle.. A skipped file produces a WARN line on stderr and no finding, and the scan still exits 0. Check that list against your repository before you trust a clean result: build/, dist/, out/, and vendor/ hold real source in some projects. Scan them with keyhog scan . --no-default-excludes, which also stops the scanner discarding findings inside minified and vendored bundles, or name one tree directly with keyhog scan vendor/. See files the walker never reads.

Going further

A first scan of a real repository usually reports credentials that were already there. Decide what to do with them before you wire KeyHog into anything:

  • Rotate what you can. A leaked credential in the working tree is also in Git history.
  • Record the rest once with --create-baseline, then gate on new findings only. See Fail only on new secrets.

Then continue with:

  • Suppressions and baselines - allowlists, inline directives, per-detector floors, and what a baseline does and does not match.
  • Output formats - JSON, SARIF, plain text.
  • Verification - --verify makes API calls to confirm credentials are live; a dead credential is downgraded one severity tier (criticalhigh, …), never collapsed to a fixed level.
  • Pre-commit hook - block leaked creds before they hit the repo.
  • CI integration - GitHub Actions, GitLab CI, CircleCI patterns.

Output formats

KeyHog’s --format flag takes one of eleven values: text (default), json, json-envelope, jsonl, jsonl-envelope, sarif, csv, github-annotations, gitlab-sast, html, and junit. Pick the one that fits the consumer. csv emits a spreadsheet-importable row per finding, github-annotations emits GitHub Actions workflow-command annotations, gitlab-sast emits a GitLab SAST security report, html emits a self-contained report page, and junit emits a JUnit XML test-report (one <testcase> per finding) for CI systems that ingest JUnit.

Every renderer receives the same completed scan report. Its common metadata (version, timestamps, duration, targets, source bytes, source chunks, and detector count) is owned by the core ScanReport model, so an output format cannot accidentally invent a second scan clock or target list. Each format retains its owned projection: HTML displays the full metadata panel, GitLab SAST projects the scan times required by its schema, and finding-only formats omit scan-wide state. JSON-envelope, JSONL-envelope, and HTML artifacts also include a versioned resolved_scan object with the selected preset, sorted effective detection values, and an overrides list. This is the authoritative machine-diffable record of what default, fast, deep, or precision meant for that run; it includes compatible refinements such as --deep --decode-depth 3, rather than requiring consumers to infer behavior from CLI text or stderr.

Metadata-bearing formats expose scan_status as success, complete_after_recovery, partial, cancelled, or failed. complete_after_recovery is a successful complete scan, but it proves that a visible fault in an authenticated selected backend occurred and every affected byte was recovered. Invalid autoroute state selects no backend and records partial. Any source or scanner coverage gap overrides recovery; incomplete input never reports clean.

The composite Action output named scan-status is a different, normalized wrapper receipt: success, partial, cancelled, or failed. complete_after_recovery remains success there because the process completed with ordinary clean/findings semantics. Consumers that must distinguish healthy completion from recovery must inspect a metadata-bearing report (for the Action’s SARIF default, the KeyHog run properties), not the wrapper output.

After a selected accelerated backend faults, the recovery backend is the confidence-separated fastest remaining measured-correct peer for the same workload and runtime class. When no trustworthy route can be selected, no recovery backend is substituted: the affected input remains unscanned and the report records partial coverage.

Authenticated-backend fault recovery is structured in every metadata-bearing artifact. JSON-envelope, JSONL-envelope, HTML, and the CSV preamble carry backend_recoveries; SARIF uses runs[].properties["keyhog.backend.recoveries"]; GitLab SAST uses scan.keyhog_backend_recoveries; JUnit adds keyhog.backend.recovery suite properties; GitHub annotations emit a warning with recovered bytes and the repair command. Plain json and jsonl remain finding-only and receive the same recovery warning on stderr as text. Each metadata projection retains the failed backend, recovery backend, recovered byte count, and keyhog calibrate-autoroute remediation.

Every finding also carries companions_redacted, a sorted JSON object of nearby credential or context values captured by the detector. Companion values are redacted at the same boundary as the primary credential, so plaintext never enters verification results or reports. JSON, JSONL, and HTML expose the object directly; SARIF exposes companions_redacted.<name> result properties; CSV, JUnit, GitLab SAST, and GitHub annotations use a deterministic redacted summary. An empty object means the detector did not produce companion evidence, not that companion matching was disabled.

Every finding format exposes the exact canonical evidence tier and reason code. confirmed identifies intrinsic or live proof, likely identifies strong vendor-specific shape in a credential-bearing role, and review identifies a candidate that needs human judgment. The optional evidence_score supplements that verdict when the detection path measured a score; it never replaces the tier or reason.

entropy is an optional Shannon bits-per-byte measurement. It is present only when the detection path measured entropy; an omitted field means that path did not produce entropy evidence. JSON, JSONL, and HTML expose it as a numeric field; SARIF exposes it as a result property; text, JUnit, GitLab SAST, and GitHub annotations render it only when measured. It is independent of the optional evidence_score.

--format text (default)

Human-readable boxes. Best for terminal use, pre-commit hook output, and screenshots. Colors auto-detect TTY; pipe through cat (or set NO_COLOR=1) to disable.

  ┌    CRITICAL ─── Stripe Secret Key
  │ Secret:     sk_l...p7dc
  │ Location:   src/config/.env.staging:14
  │ Evidence:   likely/vendor-pattern  ■■■■■■ 100%
  │ Action:     Roll the exposed Stripe secret key in the Dashboard, update production consumers, then delete the old key.
  │ Docs:       https://docs.stripe.com/keys#roll-api-key
  └─────────────────────────────────────────────

  ━━━ Results ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1 secret found · 1 unverified

Each finding is a severity-colored box: the header carries the severity and detector name, then Secret: (the credential redacted to its first and last few characters), Location: (file:line:offset), Evidence: with the exact tier/reason and optional score bar, and an Action:/Docs: remediation hint. Verified runs add the liveness state and commit/author rows when known. The Results footer joins the counts with ·.

--format json

Bare JSON array for simple pipelines. Every finding has all required documented fields present; optional fields are omitted only when their value is unavailable. Use --format json-envelope for a versioned root object with schema identity and scan metadata.

The following is one complete finding object. The values are synthetic. The credential is already redacted, and the hash is a non-secret example value.

{
  "detector_id": "stripe-secret-key",
  "detector_name": "Stripe Secret Key",
  "service": "stripe",
  "severity": "critical",
  "credential_redacted": "sk_l...p7dc",
  "credential_hash": "0000000000000000000000000000000000000000000000000000000000000000",
  "companions_redacted": {},
  "location": {
    "source": "filesystem",
    "file_path": "src/config/.env.staging",
    "line": 14,
    "offset": 218,
    "commit": null,
    "author": null,
    "date": null
  },
  "verification": "skipped",
  "metadata": {},
  "additional_locations": [],
  "evidence": {
    "tier": "likely",
    "reason_code": "vendor-pattern",
    "provenance": {
      "schema_version": 1,
      "detector_digest": "0123456789abcdef",
      "pattern_index": 0,
      "candidate_channel": "pattern",
      "source_role": "environment-assignment-value",
      "context_class": "vendor-pattern"
    }
  },
  "evidence_score": 1.0,
  "remediation": {
    "action": "Roll the exposed Stripe secret key in the Dashboard, update production consumers, then delete the old key.",
    "revoke_url": "https://docs.stripe.com/keys#roll-api-key",
    "docs_url": "https://docs.stripe.com/keys"
  }
}

Optional fields such as entropy are absent when they were not measured. Location members are present and use null when the value is unknown. A verification transport failure is encoded as an externally tagged object, for example "verification":{"error":"timeout: the endpoint did not respond within the verification deadline. Fix: raise the verification timeout with --timeout, or check network egress / proxy reachability to the credential's host"}.

evidence.provenance is secret-safe. The detector-corpus digest and pattern ordinal bind the exact detector pattern. The candidate channel, source role, and context class bind the evidence used before optional live verification. Unsupported scanner context retains its producer identity and uses the unsupported-context context class. Caller-created findings use the unattributed channel with no detector digest or pattern ordinal. Cross-detector grouping retains the strongest evidence reason while keeping provenance owned by the detector named in detector_id; folded detectors remain listed in cross_detector.* companions. See Pattern provenance and secret-safe evidence and Triage and feedback interchange. Do not enable --show-secrets when stdout or --output is retained by CI, uploaded as an artifact, or sent to another process. That option deliberately replaces credential_redacted with plaintext. credential_hash is safe from accidental plaintext disclosure, but it is a stable SHA-256 correlation value. Treat every report as security-sensitive data.

keyhog scan . --format json | jq '.[].detector_id' | sort | uniq -c

That sample command dedups findings by detector, which is the most common “what kinds of leaks do I have” question.

--format json-envelope

Versioned JSON envelope. The root object contains schema_version and findings, plus optional scan-wide metadata and the coverage_gap_summary array. Each gap preserves the canonical reason and count used by SARIF/HTML, including when there are no findings. A reader must reject an unsupported schema_version.major; a newer minor under a supported major is additive and may be accepted. See Your first scan for the complete schema. Metadata includes the binary Git identity, detector-set digest, effective-config digest when available, a stable non-secret scan_id, targets, timing, and counters including the exact source bytes and chunks consumed by the scanner. backend_recoveries records bounded, non-secret failed-backend, recovery-backend, range, chunk, byte, reason, and repair-command aggregates whenever an automatic route completes exact recovery. The top-level scan_status is one of success, complete_after_recovery, partial, cancelled, or failed; readers must preserve the explicit terminal state in detached artifacts. The scan_id lets independently stored JSON-envelope, JSONL-envelope, and HTML projections be joined without exposing secrets. Reports from older KeyHog versions may omit it; the HTML projection displays that state as not recorded rather than inventing an identifier. resolved_scan is omitted only for library-created reports that have no resolved CLI scan policy.

Cross-file correlation

Report schema 2.0 carries an optional correlations array after findings. It is present only when the scan ran with --correlate; without the flag the key is absent, not empty, and the rest of the report is unchanged. Correlation never adds, drops, reorders, or edits a finding, so a --correlate run and a default run produce the same findings array.

Each entry joins several findings into one credential risk:

FieldMeaning
kindvalue_reuse (one credential digest at several file paths, crossing detector boundaries) or split_composite (a provider credential whose halves are separate detectors placed in different files of one directory)
severityStrongest member severity, raised to the composite’s declared severity when the policy declares a higher one
evidence_scoreStrongest member evidence score lifted by the policy bonus, clamped to the configured ceiling
strongest_member_evidence_scoreWhat the best single member scored before the lift, so the added evidence is auditable
scopeDirectory the composite parts share; absent for value_reuse, which is scan-wide
membersContributing findings with detector_id, credential_hash, role, optional evidence_score, and the locations inside the scope
locationsUnion of every member location, sorted by path then line
impactWhat the correlation means operationally

value_reuse is not the same as additional_locations. Per-detector dedup folds repeats of ONE detector into a finding’s additional_locations; it never crosses detectors, so one value matched as two different detector ids in two files stays two unrelated findings until correlation joins them.

Which providers have composite halves is Tier-B data in crates/core/data/credential-correlation.toml, not a hardcoded list. A composite is reported only when a directory holds exactly one candidate credential for each required part and no single file holds them all: an ambiguous directory yields nothing rather than a guess, and a pair that already shares a file is left to the detector’s own companion match.

keyhog scan . --correlate --format json-envelope \
  | jq '.correlations[] | {kind, severity, evidence_score, title, files: .file_count}'

--format text renders the same groups as a Correlated credentials block above the results summary. Every other format is untouched by the flag.

Status and process exit are separate contracts

Machine consumers must read the status carried by a metadata-bearing artifact. Do not derive scan completeness from the process exit code:

Reported resultProcess exit
No finding blocks the active evidence policy and input is complete0
At least one finding blocks, with no finding verified live1
At least one reported finding verified live10
No finding blocks and input coverage is incomplete13

A scanner panic exits 11, a required or explicitly selected GPU that is unavailable exits 12, and Ctrl-C exits 130. Blocking and live findings take precedence over an input-coverage failure in process-exit selection. A partial scan with such findings can therefore exit 1 or 10. Its envelope still says "scan_status":"partial". This is why detached consumers must inspect scan_status and coverage_gap_summary.

Legacy json and jsonl contain findings only. They cannot distinguish a complete zero-finding scan from an incomplete one. Use json-envelope, jsonl-envelope, SARIF, CSV with its CLI preamble, GitLab SAST, or JUnit when that distinction controls a gate.

scan_status alone is not a gate

Scanning any ordinary repository reports "scan_status":"partial", because the default walker prunes .git/ and node_modules/ during discovery and counts each pruned directory once as a coverage gap. Branch on the coverage_gap_summary reasons rather than on the status.

A scan that read nothing is a third case. --exclude-paths '**', a .keyhogignore containing path:**, and an empty stdin stream all read zero source bytes. That now exits 13 and carries a scan covered nothing gap row, and the text report says so instead of No secrets detected. Assert it anyway in any gate whose input path can change, because the assertion is cheap and it names the problem in the job log:

keyhog scan . --format json-envelope --output keyhog.json
jq -e '.metadata.source_bytes_scanned > 0' keyhog.json

That command exits 1 when the scan read nothing.

Tell a real clean from a skipped input owns the complete rule, including what each counter means and the shipped cases where a clean scan is wrong.

--format csv

CSV emits one row per finding. CLI scan output begins with one schema-2 metadata comment (# keyhog.scan.metadata=<JSON>) before the header. It records a schema version, terminal scan_status, backend_recoveries, and the complete coverage_gap_summary, so a zero-finding partial scan cannot be mistaken for a clean scan. CSV consumers should ignore comment lines before parsing the RFC 4180 header and data rows. The library-compatible ReportFormat::Csv renderer omits this preamble; the write_csv_coverage_report entrypoint emits it explicitly.

The companions_redacted, remediation, metadata, and additional_locations columns contain deterministic JSON objects or arrays. Metadata keys are sorted before serialization, and duplicate locations retain their complete source, path, line, offset, commit, author, and date fields. evidence_tier and evidence_reason_code are required textual columns. evidence_score and entropy are numeric columns that remain empty when the detection path did not measure them. Every textual cell is escaped with RFC 4180 quoting plus spreadsheet-formula neutralization; remediation is still emitted so a CSV artifact never loses the canonical action guidance.

Finding-field losslessness

Use the versioned envelope formats when a downstream system needs the complete finding model. The other formats are deliberate projections:

FormatFinding fields retainedScan-wide state
json / jsonlEvery VerifiedFinding field, including evidence, metadata, remediation, and duplicate locationsNone
json-envelope / jsonl-envelopeEvery VerifiedFinding field, including evidence, metadata, remediation, and duplicate locationsscan_status and coverage_gap_summary
csvAll 22 documented columns, with metadata and duplicate locations encoded as JSONMetadata preamble before the header
sarifDetector identity, redacted credential/hash, verification, evidence tier/reason, optional evidence score and entropy, metadata, companions, primary and additional locationsRun properties and coverage notifications
htmlComplete redacted findings plus the full report metadata objectStatus and coverage panel
junitHuman-readable detector, service, severity, location, hash, verification, evidence tier/reason, optional evidence score, entropy, and companions in CDATASuite properties
gitlab-sastGitLab schema fields plus redacted credential/hash, service, evidence tier/reason, optional evidence score, companions, and entropy detailsSchema-native scan.status plus scan.keyhog_scan_status
github-annotationsRedacted detector, location, severity, verification, evidence tier/reason, and optional evidence scoreCoverage warning annotation when partial
textHuman-readable detector, severity, redacted credential, location, exact evidence tier/reason, optional evidence score, verification, and remediationCoverage warnings and result summary

Fields not listed for a projection are intentionally unavailable in that format; they must not be inferred from stderr or the process exit code.

--format sarif

SARIF 2.1.0 is the preferred format for GitHub Code Scanning and SARIF-aware IDEs.

keyhog scan . --format sarif --output keyhog-results.sarif
status=$?
test "$status" -eq 0 -o "$status" -eq 1 -o "$status" -eq 10

The file remains available when blocking or live findings make KeyHog exit 1 or 10. Do not write a command chain that uploads the file only after an exit-zero scan.

The important machine fields have this shape. The values are synthetic and the message contains only the redacted credential:

{
  "version": "2.1.0",
  "runs": [{
    "results": [{
      "ruleId": "stripe-secret-key",
      "level": "error",
      "message": {"text": "stripe secret detected: sk_l...p7dc"},
      "locations": [{
        "physicalLocation": {
          "artifactLocation": {"uri": "src/config/.env.staging"},
          "region": {"startLine": 14, "charOffset": 218}
        }
      }],
      "properties": {
        "verification": "skipped",
        "evidence_tier": "likely",
        "evidence_reason_code": "vendor-pattern",
        "evidence_provenance": {
          "schema_version": 1,
          "detector_digest": "0123456789abcdef",
          "pattern_index": 0,
          "candidate_channel": "pattern",
          "source_role": "environment-assignment-value",
          "context_class": "vendor-pattern"
        },
        "evidence_score": 1.0,
        "cwe": "CWE-798",
        "owasp": "A07:2021",
        "remediation.action": "Roll the exposed Stripe secret key in the Dashboard, update production consumers, then delete the old key."
      }
    }],
    "properties": {
      "keyhog.scan.status": "success",
      "keyhog.backend.recoveries": []
    }
  }]
}

The full document also contains $schema, tool.driver, rules, taxonomies, optional fixes, and partial fingerprints. Consume those fields from the file rather than treating the abbreviated example as a complete SARIF document. runs[0].properties["keyhog.scan.status"] carries the terminal state. When coverage gaps exist, SARIF includes invocations[0], executionSuccessful is false, and the exact reasons appear in toolExecutionNotifications. Consumers must still read keyhog.scan.status because a cancelled or failed artifact is allowed to have no coverage notification.

Upload to GitHub even when the scan found credentials:

- name: Scan
  id: keyhog
  continue-on-error: true
  run: keyhog scan . --format sarif --output keyhog-results.sarif

- uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
  if: always() && hashFiles('keyhog-results.sarif') != ''
  with:
    sarif_file: keyhog-results.sarif

- name: Enforce KeyHog result
  if: always()
  env:
    KEYHOG_OUTCOME: ${{ steps.keyhog.outcome }}
  run: test "$KEYHOG_OUTCOME" = success

The final step fails for any nonzero KeyHog exit. If your policy permits unverified findings but rejects live credentials, capture the numeric exit in a wrapper instead of using the GitHub step outcome.

--format github-annotations

GitHub Actions workflow commands emit one annotation line per finding. Use this when you want findings to appear inline in the Actions log without uploading SARIF:

keyhog scan . --format github-annotations

Critical and high findings render as error annotations, medium and low as warning, and info as notice. Each annotation carries the file, line, title, detector, service, redacted credential, verification state, exact evidence tier and reason code, and optional evidence score. The plaintext credential is not emitted. When source coverage is incomplete, the formatter also emits one terminal ::warning notice with deterministic reason/count pairs, so the GitHub job log shows the incomplete state even when there are no findings. CLI output always also emits ::notice title=keyhog scan::scan status: success|partial|cancelled|failed; the legacy library-only ReportFormat::GithubAnnotations variant remains finding- only for compatibility.

SARIF carries the same terminal state in runs[0].properties["keyhog.scan.status"]; coverage gaps remain detailed in invocations[].toolExecutionNotifications.

--format gitlab-sast

GitLab SAST security-report JSON. Use it with artifacts:reports:sast when GitLab should ingest KeyHog findings into the pipeline Security tab:

keyhog:
  script:
    - keyhog scan . --format gitlab-sast --output gl-sast-report.json
  artifacts:
    reports:
      sast: gl-sast-report.json

GitLab SAST reports require every finding to have a file path and a one-based line number. If a non-file source cannot be represented in that schema, KeyHog fails the report with an error instead of fabricating a location. Use json or sarif for mixed file and non-file sources.

The scan.start_time and scan.end_time values come from the same report metadata used by HTML. This keeps CI artifacts and the human report aligned when a daemon or a long-running scan finishes at a different time than the reporting step began. If source coverage gaps occur, KeyHog emits the schema-supported scan.status: "failure"; a complete scan emits scan.status: "success". Because GitLab’s schema has no distinct cancelled or failed values, the nested scan.keyhog_scan_status extension preserves KeyHog’s exact success|partial|cancelled|failed state for detached-artifact consumers.

--format html

HTML is a self-contained interactive report. In addition to findings and coverage gaps, its metadata panel shows the terminal scan status, producing KeyHog version, scan interval, duration, redacted targets, source bytes and chunks, and detector count. The metadata is descriptive only; it never changes finding or exit-code semantics.

--format junit

JUnit XML contains one failing testcase per finding. The suite always contains keyhog.scan.status (success, partial, cancelled, or failed), and partial scans add one keyhog.coverage_gap property per reason/count pair. CI consumers can reject a partial artifact without scraping stderr.

--format jsonl

Legacy newline-delimited JSON retained for compatibility: one finding object per line and no header. Use --format jsonl-envelope when the stream needs a schema identity and explicit concatenation boundaries.

--format jsonl-envelope

Versioned newline-delimited JSON. The first line is a record_type: "header" object carrying the same schema_version major contract as --format json-envelope (JSONL has its own additive minor revision) and optional scan metadata; every following line is one finding object. The final line is a record_type: "summary" object with transport status: "complete", a scan_status of success, complete_after_recovery, partial, cancelled, or failed, the exact finding count, and the coverage-gap summary. An empty scan still emits both header and summary. A stream without the final summary is interrupted and must not be treated as complete; concatenated streams are split at the next header. Importers must validate both records before accepting the stream. This is better than --format json-envelope for streaming consumers that want to start processing before the scan finishes.

Retain the stream while you consume it, so you can check the summary the rule above requires:

keyhog scan /huge/monorepo --format jsonl-envelope \
  | tee keyhog.jsonl \
  | jq -r 'select(.record_type == null) | .location.file_path'

jq -e 'select(.record_type == "summary")
       | .scan_status == "success" or .scan_status == "complete_after_recovery"' \
  keyhog.jsonl

The second command exits nonzero when the summary is missing or reports an incomplete scan: jq -e returns 4 when the summary record is absent entirely, and 1 when it is present but the status is not one of those two. A consumer that reads only the finding lines and stops cannot tell a finished scan from a truncated one, because the finding records look identical in both.

Combining with --verify

--verify sends eligible findings to the detector’s declared verification endpoint. A live result keeps its severity. A dead or revoked result downgrades it by one tier. The machine value is one of "live", "dead", "revoked", "rate_limited", "unverifiable", "skipped", or an {"error":"..."} object.

set +e
keyhog scan . --verify --format json-envelope --output keyhog-results.json
status=$?
set -e

jq -e '.scan_status == "success" or
       .scan_status == "complete_after_recovery"' keyhog-results.json
jq '.findings[] | select(.verification == "live")' keyhog-results.json
test "$status" -ne 10

The first jq rejects incomplete input. The second emits only live findings. The final command enforces the documented live-credential exit while permitting exit 1. Select default or paranoid evidence policy to control which non-live finding tiers produce exit 1.

Findings-only output

On an interactive terminal keyhog scan shows a banner, a live progress ticker, and a completion summary on stderr. Most of the time you do not need to silence it: the banner and ticker are printed only when stderr is a TTY (they never appear in a pipe, a file, or CI logs), and the structured formats (json, json-envelope, jsonl, jsonl-envelope, sarif, csv, github-annotations, gitlab-sast, junit) carry structured findings and format-specific coverage metadata, with no banner or footer prose. So a CI script that wants machine output just selects a structured format:

keyhog scan . --format json

The text format does print a footer summary (counts + any skip summary) to stdout alongside the findings; if you want findings only, choose json/json-envelope/jsonl/jsonl-envelope/sarif/csv/github-annotations/gitlab-sast instead. The interactive banner is TTY-gated and never reaches a pipe or a file. Exit code semantics are unchanged by the format choice (see exit codes).

When you do want to silence the interactive chrome on a TTY (for example a local run whose stderr you are capturing), pass --quiet. It suppresses the banner, the progress ticker, and the “Scan complete” summary, but still prints coverage FAIL/WARN lines and fatal errors so a quiet scan can never be mistaken for a clean one. Use --no-color to drop ANSI styling regardless of whether output is a TTY (the NO_COLOR convention is also honored).

Recipes

Every recipe is a copy-paste command. Find your goal, paste the line, done. Put provider tokens in the documented environment variables, never on the command line. See environment variables and exit codes.

Find the right recipe

Each command scans one explicit source boundary. Run several recipes when your review spans several boundaries, and retain each json-envelope report with its raw exit code.

GoalRecipeCoverage reminder
Scan local files or choose a detection presetScan code you have locallyA filesystem scan does not add Git history.
Gate staged content, a diff, or reachable commitsGate commits and pull requestsStaged, diff, history, and working-tree bytes are different inputs.
Add a maintained GitHub gateAdd it to CIThe Action owns one checked-out repository path.
Inventory GitHub, GitLab, or BitbucketScan an entire GitHub organization or Scan a GitLab group or Bitbucket workspacePartition large estates and preserve one status per partition.
Inspect issues, pull requests, discussions, wikis, or gistsScan collaboration surfacesCollaboration content is separate from repository files and Git objects.
Inspect an image, archive, or cloud bucketScan a Docker image, scan third-party archives, or audit a cloud bucketPreserve coverage gaps for encrypted, corrupt, unsafe, truncated, or limited content.
Inspect a URL, response, HAR capture, or stdinScan a URL or pipe arbitrary textURL mode fetches selected responses. It is not a crawler.
Audit a local hostSweep an entire machineThe space ceiling and mount policy bound coverage.
Test whether eligible credentials are liveConfirm a findingVerification sends credential-derived requests to providers.
Adopt existing findings or approve one fixtureAdopt on a noisy repo or approve one fixtureA baseline and an exact suppression solve different policy problems.
Export to CI, a SIEM, or another toolEmit for any pipelineEnvelope formats retain source status and coverage state.

Scan code you have locally

keyhog scan .                              # canonical default policy
keyhog scan path/to/file.env              # one file; may use a ready Unix daemon
keyhog scan . --fast                      # pattern-only: no decode, entropy, or ML
keyhog scan . --deep                      # bounded highest-recall preset
keyhog scan . --precision                 # 0.85 floor, no entropy/relaxed keyword bridge
keyhog scan . --lockdown                  # Linux; requires sufficient memlock

Gate commits and pull requests

keyhog scan --git-staged                  # pre-commit: staged blobs (uses guard daemon if live)
keyhog scan --git-diff main               # only files changed since a base ref
keyhog scan --git-history .               # added lines from reachable commits, bounded by max_commits
keyhog scan --git-history . --max-commits 500

Guard a repository for instant pre-commit scans

# 1. Start daemon in background (reconciles durable roots)
keyhog guard up
# 2. Register repository (indexes baseline into memory once)
keyhog guard add /path/to/repo --mode repo

# 3. Inspect in-memory status and attestation metrics
keyhog guard status /path/to/repo

# 4. Staged commits now execute with sub-millisecond in-memory attestation caching
cd /path/to/repo && keyhog scan --git-staged

# 5. List all active guarded repositories
keyhog guard list

# 6. Free daemon memory whenever you finish working on a repository
keyhog guard remove /path/to/repo

Pre-commit framework: keyhog ships a hook, so a .pre-commit-config.yaml repo: https://github.com/santhreal/keyhog entry wires keyhog scan --git-staged into every commit. See perpetual guard and pre-commit.

Add it to CI (one workflow file)

# .github/workflows/keyhog.yml
name: keyhog
on: [push, pull_request]
permissions: { contents: read, security-events: write }
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: santhreal/keyhog@v0
        with: { path: ., severity: high, format: sarif, preset: default, lockdown: 'false' }

Findings upload to the GitHub Security tab as SARIF. Commit a baseline first so CI fails only on new secrets. See Adopt on a noisy repo, the GitHub Action guide, and the direct CI guide.

Scan an entire GitHub organization

export KEYHOG_GITHUB_TOKEN="$GH_PAT"
keyhog scan --github-org acme --format json-envelope --output acme.json

The command traverses the organization until the configured page, repository, and byte limits bind. The envelope records source identity and any remaining inventory as coverage gaps. See mass scanning.

Scan a single repo’s collaboration surfaces

Issues, pull requests, discussions, wikis, and gists carry secrets that never land in the tree:

export KEYHOG_GITHUB_TOKEN="$GH_PAT"
keyhog scan --github-collaboration acme/service --github-all

See GitHub collaboration scans.

Scan a GitLab group or Bitbucket workspace

KEYHOG_GITLAB_TOKEN="$GL_PAT" keyhog scan --gitlab-group acme      # incl. subgroups
KEYHOG_BITBUCKET_USERNAME="$BB_USER" KEYHOG_BITBUCKET_TOKEN="$BB_APP_PASSWORD" \
  keyhog scan --bitbucket-workspace acme

Scan a Docker image before you ship it

keyhog scan --docker-image registry/app:v1                # unpacks image layers

Audit a cloud bucket

keyhog scan --s3-bucket logs-prod --s3-prefix config/     # --s3-endpoint for non-AWS
keyhog scan --gcs-bucket logs-prod --gcs-prefix config/
keyhog scan --azure-container-url "$AZURE_CONTAINER_URL" --azure-prefix config/

Scan a URL, endpoint response, or HAR capture

keyhog scan --url https://api.example.com/config          # one or more URLs

See HTTP and wire scanning.

Pipe arbitrary text through

echo "$SOME_BLOB" | keyhog scan --stdin
kubectl get secret app -o yaml | keyhog scan --stdin

A producer that fails writes nothing to stdout. The scan then reads zero bytes and exits 13 with a scan covered nothing gap row, which is honest but blames the scanner rather than the producer. Make the pipeline carry the real failure:

set -o pipefail
kubectl get secret app -o yaml | keyhog scan --stdin

With pipefail, a missing kubectl or a denied request surfaces the producer’s own exit code. See tell a real clean from a skipped input.

Sweep an entire machine

keyhog scan-system --space 50G            # eligible mounts and discovered Git history, bounded at 50 GiB

See system-wide triage.

Confirm a finding is a live credential

keyhog scan . --verify                    # validate against provider APIs (exit 10 if live)
keyhog scan . --verify --verify-oob       # out-of-band verification server

See verification.

Adopt on a legacy or noisy repo

keyhog scan . --create-baseline .keyhog-baseline.json     # snapshot existing findings once
keyhog scan . --baseline .keyhog-baseline.json            # then report only NEW findings

Commit the first file. An entry matches on the detector and the credential value, not on the path, so moving a baselined secret does not fail the gate but rotating it does. The complete CI path, including monorepo partitions, is Fail only on new secrets.

Approve one exact fixture finding

Append a detector, path, and credential hash to the same rule:

cat >> .keyhogignore.toml <<'EOF'
[[suppress]]
detector = "aws-access-key"
path_eq = "fixtures/aws.env"
credential_hash = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
EOF
keyhog scan .

All three fields must match. A different value in the fixture, or the same value in another path, still reports and keeps the findings exit. Invalid TOML stops the scan with exit 2; KeyHog does not ignore a broken policy. See suppressions.

Ignore one generated tree

cat >> .keyhogignore <<'EOF'
path:generated/**
EOF
keyhog scan .

The rooted pattern matches generated/app.js, not packages/web/generated/app.js. Use path:**/generated/** only if every generated directory is reviewed and safe to exclude. .keyhogignore has no negation or last-rule-wins override. An invalid entry stops the scan with exit 2.

Scan third-party archives without a false clean

rc=0
keyhog scan incoming/ --format json-envelope -o keyhog-archives.json || rc=$?
jq '{scan_status, coverage_gap_summary, findings: (.findings | length)}' \
  keyhog-archives.json
printf 'keyhog exit=%s\n' "$rc"

Corrupt, encrypted, unsafe, oversized, or truncated members produce coverage gaps. With no blocking finding, incomplete coverage exits 13, not 0. Blocking findings in the covered portion take exit 1, or 10 when verification confirms a live credential, while scan_status remains partial. See source archives.

Make the CI loop fast

keyhog scan . --incremental               # BLAKE3 Merkle skip of unchanged inputs
keyhog scan . --incremental --incremental-cache .keyhog-cache

Emit for any pipeline or SIEM

One engine, every dialect. Pick with --format:

keyhog scan . --format sarif -o keyhog.sarif          # GitHub / GitLab code scanning
keyhog scan . --format github-annotations             # inline PR annotations
keyhog scan . --format gitlab-sast -o gl-sast.json    # GitLab SAST report
keyhog scan . --format junit -o keyhog.xml            # JUnit for any CI dashboard
keyhog scan . --format jsonl-envelope                 # streaming machine contract
keyhog scan . --format csv -o findings.csv

Available formats: text · json · json-envelope · jsonl · jsonl-envelope · sarif · csv · html · junit · github-annotations · gitlab-sast.

Filter and set the gate

keyhog scan . --severity high             # info | client-safe | low | medium | high | critical
keyhog scan . --min-confidence 0.5        # raise the reporting confidence floor
keyhog scan . --exclude-paths vendor,node_modules

Exit 0 means no finding blocks the active evidence policy and no failing source gap occurred. It can still accompany advisory skip gaps and scan_status: partial, so it is not proof that skipped content was clean. Exit 1 means a finding blocks the selected evidence policy; 10 means at least one live credential under --verify; and 13 means failing source or coverage gaps when no blocking finding took precedence. A blocking or live finding can therefore exit 1 or 10 while scan_status remains partial. See the full precedence table in exit codes.

Perpetual repository and filesystem guard

The guard is a daemon-resident runtime that registers Git repositories and filesystem trees as guarded roots. It maintains an in-memory clean Git object attestation index and filesystem event tracking, enabling sub-second pre-commit scanning on staged changes without cold-start detector compilation or redundant file I/O.

Guard requires the Unix-domain daemon transport. On Windows, keyhog guard exits with an unsupported-platform error; use keyhog scan <path> in process.

The guard supplements staged and working-tree scans. It does not replace them. A commit is allowed only after the exact staged-object transaction proves the staged content is clean.

Three-step quickstart

  1. Register the repository:

    keyhog guard add /path/to/repo
    

    Registers the repository with the guard daemon and installs the managed pre-commit hook at .git/hooks/pre-commit.

  2. Start the daemon (if not already running):

    keyhog guard up
    

    Ensures the background daemon process is active and ready to handle scan requests. One daemon process serves all registered repositories.

  3. Stage changes and commit:

    git add <files>
    git commit -m "commit message"
    

    The pre-commit hook runs keyhog scan --git-staged against the daemon, verifies staged object IDs against cached attestations, and blocks the commit if credentials are detected.

Core mental model

  1. One-command registration (keyhog guard add <path>): Indexes the target repository into daemon memory once and establishes clean baseline attestations.
  2. Fast staged commit gating (keyhog scan --git-staged): Pre-commit hooks query the active guard daemon. The daemon verifies only changed staged blob OIDs against in-memory attestations, skipping unchanged clean payloads.
  3. Full lifecycle control: List active roots with keyhog guard list, check memory and attestation metrics with keyhog guard status <path>, and free daemon memory immediately with keyhog guard remove <path> when finished working on a project.

Lifecycle commands

CommandPurpose
keyhog guard up [--backend <name>]Start or ensure the background guard daemon is running and ready. Reconciles registered roots loaded from durable store.
keyhog guard downStop the background guard daemon cleanly. Persisted root registrations and durable indexes remain on disk.
keyhog guard add <path> [--mode repo]Register a repository or tree for continuous guard protection. Performs initial baseline reconciliation and installs hook before returning.
keyhog guard listEnumerate all registered guard roots, their active states, and terminal sequences. Reads durable store when daemon is offline.
keyhog guard feed [--root <path>] [--limit <N>]Inspect continuous state machine transitions and event log with causal attribution across roots.
`keyhog guard status [] [–format humanjson]`
keyhog guard remove <path>Stop guarding a repository and drop its in-memory index and attestation cache to immediately free daemon memory and CPU.
keyhog guard reconcile <path>Force a full baseline reconciliation after intentional policy updates or mass branch operations.
keyhog guard rebuild <path>Delete and recreate the durable guard store for a root after corruption or irrecoverable state.

Detailed walkthrough

1. Start the daemon

keyhog guard up

guard up ensures the daemon is active in the background, compiles the active 934-detector corpus once, and stays resident in memory. One daemon process serves all guarded repositories and scan requests.

2. Register a repository

keyhog guard add /path/to/repo --mode repo
  • --mode repo (default): Uses Git object IDs (OIDs) for exact immutable staged-content identification, and automatically installs the managed pre-commit hook at .git/hooks/pre-commit (best-effort; skipped if a foreign hook already exists, or if --no-hook is passed).
  • --mode filesystem: Uses file content hashes without Git OIDs.

The command waits for initial baseline reconciliation to complete before returning:

OK guard: root /path/to/repo registered (state stopped, sequence 1)
OK guard: reconciliation complete, root is current

3. Check guarded status

Inspect in-memory metrics, cache efficiency, and policy binding:

keyhog guard status /path/to/repo

Human-readable output:

root:           /path/to/repo
mode:           repo
state:          current
sequence:       2
accepted seq:   2
completed seq:  2
pending events: 0
files scanned:  142
bytes scanned:  1849204
cache hits:     0
cache misses:   142
findings:       0
coverage gaps:  0
initial recon:  2026-08-17T00:15:00Z
last recon:     2026-08-17T00:15:00Z
residency:      resident
backend route:  gpu-cuda-region-presence
build digest:   1a2b3c4d5e6f7a8b
detector:       934-dc43f6629978321b
suppression:    0000000000000000
config:         18cc6ed841bf6dfe
autoroute:      calibrated
store schema:   1

Structured JSON output for monitoring and scripts:

keyhog guard status /path/to/repo --format json

4. Run instant staged commit scans

Inside the guarded repository, run:

keyhog scan --git-staged

The command connects to the guard daemon via Unix domain socket, checks the staged Git blob OIDs against in-memory attestations, and returns in milliseconds.

  • Clean commit outcome:
  No secrets detected in the scanned files.
  • Blocked commit outcome:
  ┌    CRITICAL ─── OpenAI API Key
  │ Secret:     sk-9...M8vZ
  │ Location:   client.ts:4
  │ Evidence:   likely/vendor-pattern  ■■■■■■ 100%
  │ Entropy:    5.383 bits/byte
  │ Action:     Revoke immediately at the provider, rotate dependent credentials, and audit recent usage.
  └─────────────────────────────────────────────

  ━━━ Results ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1 secret found · 1 unverified
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

5. List all guarded repositories

Check which repositories are currently guarded:

keyhog guard list

Output:

OK 2 guard roots registered
  /path/to/repo-a  current  seq=14
  /path/to/repo-b  current  seq=3

6. Free resources when finished

When you finish working on a project, remove it from the guard to immediately reclaim daemon memory and watcher resources:

keyhog guard remove /path/to/repo

guard remove unregisters the root from the daemon and removes any KeyHog-owned pre-commit hook by default (pass --keep-hook to preserve the hook). You can re-add the repository at any time with keyhog guard add /path/to/repo.

Pre-commit hook integration

Standalone Git hook

Create or update .git/hooks/pre-commit in your repository:

#!/usr/bin/env bash
set -euo pipefail

# Run staged scan against guard daemon (falls back to in-process if daemon is off)
keyhog scan --git-staged

Make the script executable:

chmod +x .git/hooks/pre-commit

pre-commit framework

Add the following to .pre-commit-config.yaml:

repos:
  - repo: https://github.com/santhreal/keyhog
    rev: v0.5.81
    hooks:
      - id: keyhog
        stages: [pre-commit]

Bypassing checks and suppressing test fixtures

Emergency single-commit bypass

To bypass the pre-commit hook for a single urgent commit:

git commit --no-verify -m "urgent fix"

This bypasses local Git hooks. Use it sparingly.

Suppressing intentional test fixtures and false positives

When committing intentional test fixtures, mock data, or vendor example keys:

  1. Suppress by credential hash (.keyhogignore): Add the SHA-256 hash of the value to .keyhogignore:
    hash:5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
    
  2. Scoped suppression (.keyhogignore.toml): Target the exact detector and file path in .keyhogignore.toml:
    [[suppress]]
    detector = "stripe-secret-key"
    path_eq = "tests/fixtures/mock_stripe.env"
    reason = "reviewed synthetic test fixture"
    
  3. Inline source directive: Append // keyhog:ignore detector=<detector-id> or # keyhog:ignore detector=<detector-id> directly to the source line. Without detector=, the directive suppresses every finding on that line. After committing the suppression rule, run keyhog guard reconcile <path> if you need to update the in-memory baseline immediately.

Guard state machine

Every guarded root operates within a 7-state machine owned by GuardRootState:

From StateEvent / TriggerTarget StateDescription
stoppedReconciliationStartedindexingBaseline reconciliation begins.
indexingReconciliationCleancurrentBaseline scan completed with zero blocking findings.
indexingReconciliationFindingsblockedBaseline scan detected blocking secrets.
indexingReconciliationDegraded / CoverageLostdegradedIncomplete coverage during reconciliation.
indexingPolicyChangedstale-policyDetector corpus, suppressions, or config changed during scan.
currentEventAccepteddirtyFilesystem change accepted; pending events await scan.
currentCoverageLostdegradedFilesystem watcher overflow or read error.
currentPolicyChangedstale-policyDetector corpus, suppressions, or config modified.
blockedEventAccepteddirtyFilesystem change accepted in blocked repository.
blockedCoverageLostdegradedCoverage lost while blocked.
blockedPolicyChangedstale-policyPolicy modified while blocked.
dirtyEventsCleancurrentChanged files scanned cleanly; all findings resolved.
dirtyEventsFindingsblockedChanged files contain blocking secrets.
dirtyEventsDegraded / CoverageLostdegradedIncomplete coverage during incremental scan.
dirtyPolicyChangedstale-policyPolicy modified while processing dirty events.
degradedRepairStartedindexingManual keyhog guard reconcile or rebuild triggered.
stale-policyRepairStartedindexingManual keyhog guard reconcile or rebuild triggered.
any stateStoppedstoppedRoot unregistered with keyhog guard remove or daemon shutdown.

Process exit codes

keyhog guard status and keyhog scan --git-staged enforce strict exit semantics:

Exit CodeCondition
0Root is current, or staged scan contains zero blocking secrets under the active policy.
1Root is blocked, or staged scan contains a finding that blocks the evidence policy.
13Root is dirty, stopped, indexing, degraded, or stale-policy (incomplete proof of cleanliness).

How clean attestations work

When KeyHog scans a staged Git blob and detects zero unsuppressed secrets, it records a clean attestation record keyed by four immutable elements:

  1. Blob Git OID: The SHA-1 or SHA-256 object hash of the staged blob.
  2. Byte Length: The exact byte length of the blob payload.
  3. Policy Identity Digest: The 32-byte digest of the active detector corpus, suppression rules, and scanner configuration.
  4. Sorted Source-Path Set: The hashed set of all sorted staged source paths mapped to that blob.

Future commit transactions matching all four elements skip payload re-scanning and return an instant cache hit. If a file is renamed, moved across source roles, or an alias is added, the attestation is invalidated. Persisted policy identity records contain no plaintext staged paths.

Guard configuration

The guard runtime resolves settings from the [guard] table in .keyhog.toml:

[guard]
# Periodic scrub interval for `current` roots (e.g. "5m", "24h").
# Default: disabled.
# scrub_interval = "5m"

# Durable redb state database path (default: disabled, ephemeral memory).
# state_path = "~/.local/state/keyhog/guard.redb"

# Memory budget ceiling for hot clean attestation index (default: "64MB").
# hot_index_memory = "64MB"

# Maximum queued filesystem events per root before degraded status (default: 8192).
# max_pending_events_per_root = 8192

# Maximum total queued filesystem events across all roots (default: 65536).
# max_pending_events_total = 65536

# Event coalescing window before applying state transitions (default: "100ms").
# coalesce_window = "100ms"

# Scanner residency mode: "warm" (keep loaded) or "idle-unload" (unload after idle timeout).
# scanner_residency = "warm"

# Scanner idle timeout before reporting `idle-unload` residency (default: "5m").
# scanner_idle_timeout = "5m"
# Maximum files scanned during one subtree reconciliation (default: 10000).
# subtree_max_files = 10000

# Maximum directory depth during subtree reconciliation (default: 64).
# subtree_max_depth = 64
SettingTypeDefaultDescription
scrub_intervalstringdisabledPeriodic re-scan interval for current roots (e.g. 5m, 24h). Catches changes that filesystem events missed.
state_pathstringdisabledDurable guard state path (e.g. ~/.local/state/keyhog/guard.redb). Persists root records and attestations across daemon restarts. Ignored in lockdown mode (guard operates in ephemeral memory).
hot_index_memorystring64MBHot clean attestation index memory budget (e.g. 64MB).
max_pending_events_per_rootinteger8192Maximum queued filesystem events per root before degraded status.
max_pending_events_totalinteger65536Maximum total queued filesystem events across all roots before degraded status.
coalesce_windowstring100msEvent coalescing window before applying state transitions.
scanner_residencystringwarmScanner residency mode (warm or idle-unload).
scanner_idle_timeoutstring5mScanner idle-unload timeout. After this duration without guard activity, residency reports idle-unload.
subtree_max_filesinteger10000Maximum files for one subtree reconciliation.
subtree_max_depthinteger64Maximum depth for one subtree reconciliation.

Durable state persistence

By default, guard state is held in daemon memory. To persist root registrations and clean attestations across daemon restarts, configure state_path in .keyhog.toml:

[guard]
state_path = "~/.local/state/keyhog/guard.redb"

The durable store uses a high-performance redb database with owner-only (0600) file permissions, enforces 0700 permissions on its parent directory, and rejects symlinked state paths. On daemon restart, persisted roots load in the stopped state. Running keyhog guard reconcile /path/to/repo re-verifies the repository and transitions it back to current.

In lockdown mode ([lockdown] require = true), durable persistence is disabled and the guard operates strictly in ephemeral memory.

Periodic scrubbing

Configure scrub_interval in .keyhog.toml to periodically re-verify current repositories:

[guard]
scrub_interval = "24h"

Scrubbing detects modifications made outside standard kernel filesystem events (such as NFS mounts, container volume mutations, or out-of-band Git object manipulation).

Unauthoritative filesystems

When registering a root, KeyHog automatically probes the backing filesystem type. Local filesystems (such as ext4, btrfs, xfs, apfs, and ntfs) generate kernel change events reliably and are classified as authoritative.

Network filesystems (nfs, cifs/smb, 9p, afs, ceph), userspace/virtual filesystems (fuse, overlay), and unrecognized filesystem types do not reliably generate real-time local kernel notifications. When an unauthoritative filesystem is registered and no operator scrub_interval is configured, KeyHog enforces a default 60-second periodic scrub interval to guarantee that remote modifications are caught.

When the scrub interval elapses, each current root automatically transitions to dirty for re-reconciliation.

Recovering corrupted roots

If a repository’s durable state becomes corrupt or desynchronized, rebuild it:

keyhog guard rebuild /path/to/repo

rebuild clears the root’s durable database entries, re-registers the root, and triggers a clean baseline reconciliation.

Pre-commit hook

A pre-commit hook stops credentials before they enter repository history. It scans staged content and blocks the commit on findings. It also blocks when the scan cannot complete, so an unavailable scanner cannot look clean.

Install in one command

From inside a git repo:

keyhog hook install

If a non-KeyHog pre-commit hook already exists, installation refuses to replace it. Pass keyhog hook install --force only when replacement is intentional; keyhog hook uninstall removes only the KeyHog-owned hook.

That writes a .git/hooks/pre-commit script that calls keyhog scan --fast --git-staged --backend cpu (the same command .pre-commit-hooks.yaml exposes for the pre-commit framework). The next git commit invokes the hook.

If keyhog is missing from PATH, the hook blocks the commit because the security scan did not run. Install KeyHog, fix PATH, or remove .git/hooks/pre-commit if the repository should not be protected.

What the installed hook does not catch

Read this before you rely on the hook. --fast keeps named and multiline pattern matching and drops decode, entropy, and ML work. A credential written in plain text is blocked. A credential that is only Base64-encoded is not. Stage a file whose sole credential is Base64-encoded and the commit succeeds:

$ git commit -m "add config"
No secrets detected in the scanned files.
[main 8f56830] add config
 1 file changed, 1 insertion(+)

The default policy decodes that file and reports the credential. Check any staged change the hook passed:

keyhog scan --git-staged

Treat the hook as a fast first gate, not as the control that protects the branch. Keep a default-policy scan in CI, where the cost is paid once per push rather than once per commit. See Fail only on new secrets.

Fast pre-commit scanning with Perpetual Guard

For fast pre-commit gating with the full default policy (including complete decoding, entropy analysis, and all 934 detectors), use the perpetual KeyHog daemon:

  1. Ensure the guard daemon is active in the background:
    keyhog guard up
    
  2. Register the repository and install the pre-commit hook in one step:
    keyhog guard add . --mode repo
    

keyhog guard add registers the repository in daemon memory, performs the initial baseline reconciliation, and automatically installs the managed pre-commit hook at .git/hooks/pre-commit (pass --no-hook to skip hook installation).

Because the daemon maintains an in-memory clean Git blob attestation index, keyhog scan --git-staged checks only changed staged blobs against the daemon’s in-memory index, skipping unchanged clean payloads rather than re-scanning them.

See the perpetual guard guide for full lifecycle management.

pre-commit framework

This repository’s hook uses language: system. Follow the exact-version install, then confirm that KeyHog is on PATH:

keyhog --version

Add the following to .pre-commit-config.yaml:

repos:
  - repo: https://github.com/santhreal/keyhog
    rev: v0.5.81
    hooks:
      - id: keyhog
        stages: [pre-commit]

Run pre-commit install once. The hook then runs on every commit. The rev pin selects the hook definition. It does not install or pin the keyhog binary. Keep the binary and hook definition on compatible release versions.

What gets scanned

keyhog scan --git-staged walks the index (the set of files git is about to commit), not the working tree. Why this matters:

  • A file you’ve modified but not git added is NOT scanned. You’re free to keep credentials in scratch files as long as you don’t stage them.
  • A file you’ve staged then modified gets scanned in the staged form, not the working-tree form. The scanner sees what git commit would commit.

The walk only includes files that are part of this commit. Runtime depends on the staged bytes, detector corpus, binary, and host; use the command’s reported duration to characterize a repository.

What happens on a finding

Stderr:

$ git commit -m "add staging config"
  ┌    CRITICAL ─── Stripe Secret Key
  │ Secret:     sk_l...p7dc
  │ Location:   src/config/.env.staging:14
  │ Evidence:   likely/vendor-pattern  ■■■■■■ 100%
  │ Action:     Roll the exposed Stripe secret key in the Dashboard, update production consumers, then delete the old key.
  │ Docs:       https://docs.stripe.com/keys#roll-api-key
  └─────────────────────────────────────────────

  ━━━ Results ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1 secret found · 1 unverified

  1. Revoke active secrets in the provider's dashboard.

The hook runs exec keyhog scan --fast --git-staged --backend cpu, so this is the ordinary scan report over the staged blobs. Exit 1 means a finding blocks the default evidence policy and aborts the commit. Exit 10 would mean a confirmed live credential, although the shipped hook does not enable verification. Every operational nonzero exit also aborts the commit because the scan did not complete. Your staged work remains intact. You can then:

  1. Remove the credential, stage the fix, and commit again.
  2. Replace it with a placeholder and load the value from the environment.
  3. For a false positive, add its hash to .keyhogignore or add a narrowly scoped predicate rule to .keyhogignore.toml. Record the reason and owner beside the exception.

When you really need to commit anyway

git commit --no-verify

That bypasses the hook. KeyHog logs nothing about it; that’s your prerogative. Use it sparingly. A team norm of --no-verify for “trust me” commits defeats the point of the hook.

A better pattern when a legitimate-looking credential needs to ship (e.g. a public OAuth client_id that vendor docs say to commit):

  1. Add its hash to .keyhogignore as hash: + the bare 64-character SHA-256 hex digest (no sha256: prefix; that spelling is baseline-file-only):
    hash:5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
    
  2. Commit the suppression file alongside the credential.
  3. The next commit sees the hash and skips it.

This way the next contributor doesn’t have to learn the trick.

Performance

Pre-commit scans operate in two modes:

  1. In-process staged scan: When no daemon is running, keyhog scan --git-staged evaluates staged Git blobs in process with the CPU backend.
  2. Guard daemon commit transaction: When a KeyHog daemon is active with guarded roots (keyhog guard up), keyhog scan --git-staged connects over the Unix socket. The daemon checks its in-memory Git OID clean attestation index, skips unchanged clean blobs, and scans only modified payloads.

The installed pre-commit hook runs keyhog scan --fast --git-staged --backend cpu, which uses the guard daemon automatically when reachable and falls back to in-process execution when absent.

Uninstall

keyhog hook uninstall

This removes .git/hooks/pre-commit only when it carries the generated KeyHog marker. If you edited the hook, keyhog hook uninstall refuses to touch it. Remove that hook by hand. For the pre-commit framework, delete the KeyHog stanza from .pre-commit-config.yaml and run pre-commit clean.

GitHub Action

Use the KeyHog Action when you want to scan one checked-out repository path in a GitHub Actions job. A published Action ref installs its exact KeyHog crate with the lean ci feature. A reviewed branch or commit ref builds the checked-out portable source profile and requires backend: cpu. Both paths run the scan, retain the report as a workflow artifact, and upload SARIF to GitHub Code Scanning by default.

For GitLab, CircleCI, Jenkins, and other CI systems, use the CI integration guide. For organization-wide repository or cloud inventory scans, use the mass-scanning guide.

Scan a repository

Create .github/workflows/keyhog.yml:

name: keyhog

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read
  security-events: write

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: santhreal/keyhog@v0
        with:
          path: .
          severity: high

This workflow scans the checked-out working tree. It fails when KeyHog reports a finding at high or critical severity. It also fails on configuration, coverage, backend, installation, and report-publication errors.

The default sarif report appears in two places:

  • Security > Code scanning contains the uploaded SARIF results.
  • The workflow run contains a keyhog-report-* artifact for later review.

The Action retries one failed Code Scanning upload on trusted pushes and same-repository pull requests. It fails the job if both attempts fail. GitHub removes security-events: write from fork pull requests, so the upload is advisory for that event. The workflow artifact is still retained, and findings still fail the scan.

Choose the workflow for the source

The Action scans one file or directory already present in the runner workspace. Use the CLI directly when the scan source needs additional flags.

Use caseRecommended workflow
Pull request or pushAction with path: .
Monorepo partitionsOne Action step or matrix job per path, with a unique analysis-category
Git history or reachable blobsCLI with --git-history or --git-blobs after a full checkout
GitHub, GitLab, or Bitbucket organizationCLI inventory source in the mass-scanning workflow
S3, GCS, or Azure Blob inventoryCLI inventory source in the mass-scanning workflow
GitLab, CircleCI, Jenkins, or another CI providerCLI recipe in the CI integration guide

Do not use a single repository Action job as an organization-wide inventory scanner. Inventory scans need explicit partitions, independent reports, source limits, and retry boundaries.

Adopt KeyHog without blocking existing findings

Create a baseline from a reviewed local scan:

keyhog scan . --create-baseline .keyhog-baseline.json
git add .keyhog-baseline.json
git commit -m "chore: add KeyHog baseline"

Then pass the committed file to the Action:

- uses: santhreal/keyhog@v0
  with:
    path: .
    baseline: .keyhog-baseline.json

A baseline suppresses findings it already contains. New likely and confirmed findings fail under the default evidence policy; new review findings remain visible. Set evidence-policy: paranoid to make review-tier findings block. The equivalent repository setting is [scan].evidence_policy = "paranoid" in .keyhog.toml. Review baseline changes like source changes. Do not regenerate the baseline inside CI.

An entry matches on the detector and the credential value, never on the file path. Moving or renaming a baselined file keeps it suppressed, and copying the same credential into a new file keeps it suppressed too. See Baselines for the complete matching rules and how to retire an entry after rotation.

For an advisory rollout, set fail-on-findings: 'false'. Non-live findings that block the active evidence policy then remain visible without blocking the job. A verified-live credential and every operational failure still fail.

- uses: santhreal/keyhog@v0
  with:
    path: .
    fail-on-findings: 'false'

Scan a monorepo

Give each partition a stable, unique analysis-category. GitHub uses this value to keep Code Scanning result sets separate across commits.

strategy:
  matrix:
    include:
      - path: services/api
        category: services-api
      - path: services/web
        category: services-web

steps:
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  - uses: santhreal/keyhog@v0
    with:
      path: ${{ matrix.path }}
      analysis-category: ${{ matrix.category }}

Keep a category unchanged when the partition remains the same. Use a different category for every KeyHog scan in the same job. A category contains 1 to 64 lowercase letters, digits, dots, underscores, or dashes, and it starts and ends with a letter or digit.

Add baseline per partition when each team reviews its own exceptions:

  - uses: santhreal/keyhog@v0
    with:
      path: ${{ matrix.path }}
      analysis-category: ${{ matrix.category }}
      baseline: ${{ matrix.path }}/.keyhog-baseline.json

One root baseline works just as well when a single team reviews every exception, because matching ignores the path. Choose by ownership. The CI guide compares both layouts.

Select detection policy

The preset input selects one detection policy:

ValueUse it when
defaultYou want the standard detector, decode, entropy, and confidence policy.
fastYou accept reduced coverage for a shorter feedback path.
deepYou want broader recovery and can accept more work and more review.
precisionYou prefer fewer findings and accept lower recall.

default passes no preset flag. A discovered .keyhog.toml may therefore select fast = true, deep = true, or precision = true. An explicit non-default Action preset takes normal CLI precedence over the file.

lockdown: 'true' is separate from the preset. It can be used with default, deep, or precision. KeyHog rejects fast with lockdown instead of weakening either request. Lockdown requires Linux and enough locked-memory capacity for the scanner process. Standard GitHub-hosted Linux runners do not currently provide that capacity. Use a provisioned self-hosted runner when lockdown is a required control.

Control credential verification

Verification is off by default:

- uses: santhreal/keyhog@v0
  with:
    verify: 'false'

The Action always passes either --verify or --no-verify, so the input overrides a committed verify setting. When verification is enabled, eligible detectors may send credential or companion material to provider endpoints in a URL, query, header, or request body. Review the detector corpus and your outbound trust boundary before setting verify: 'true'.

A confirmed live credential exits with code 10 and always fails the Action, even when fail-on-findings is false.

Inputs

InputDefaultContract
path.Checked-out file or directory to scan.
severityhighMinimum reported tier: info, client-safe, low, medium, high, or critical.
formatsarifAction report format: text, json, sarif, or jsonl.
verify'false'Enables provider verification only when exactly 'true'.
versionemptyScanner release selected by the Action ref. A value pins one canonical final vX.Y.Z release.
upload-sarif'true'Uploads Code Scanning results when format is sarif. The artifact is retained independently.
analysis-categorykeyhogStable identity for one report and Code Scanning partition.
fail-on-findings'true'Fail when the active evidence policy blocks findings. Set to 'false' to make non-live blocking findings advisory.
evidence-policydefaultdefault blocks likely and confirmed; paranoid also blocks review. Findings remain visible under either policy.
baselineemptyPath to a committed KeyHog baseline.
backendemptyPublished refs install the lean ci feature and accept empty/auto or cpu. Branch and commit source refs require cpu. Run simd or GPU diagnostics with a separately installed self-hosted CLI binary.
presetdefaultDetection policy: default, fast, deep, or precision.
lockdown'false'Enables Linux memory-locking protections when exactly 'true'.

Boolean inputs are strings in GitHub Actions. Use quoted 'true' and 'false'. Invalid values fail before scanning.

The Action wrapper supports four report formats because it validates the report and finding count before publication. Use the CLI directly for CSV, HTML, JUnit, GitLab SAST, and envelope formats.

Outputs

Give the step an id before reading its outputs:

- id: keyhog
  uses: santhreal/keyhog@v0
  with:
    fail-on-findings: 'false'

- name: Record the finding count
  env:
    KEYHOG_FINDINGS: ${{ steps.keyhog.outputs.findings }}
  run: printf 'KeyHog findings: %s\n' "$KEYHOG_FINDINGS"
OutputMeaning
findingsNumber of reported findings at or above the severity floor.
exit-codeRaw KeyHog exit code. Common results are 0 policy success, 1 blocking findings, 10 verified-live findings, and 13 incomplete coverage.
duration-msWrapper wall-clock scan duration in milliseconds.
scan-statusWrapper state: success, partial, cancelled, or failed.
report-presenttrue only when the Action published a receipt-verified private report snapshot.
reportPrivate report snapshot path available to later steps in the same job.
analysis-categoryValidated report and Code Scanning partition identity.

Check report-present before consuming report. The report path is private to the job and disappears during runner cleanup. Do not reconstruct its parent path or treat it as a persistent artifact. Use the uploaded workflow artifact for retention across jobs or runs.

Pin Action code and scanner releases

The floating major ref follows the latest published v0 release:

- uses: santhreal/keyhog@v0

Use an exact Action ref when workflow code must change only through review:

- uses: santhreal/keyhog@v0.5.81

The optional version input pins the scanner crate. It does not pin the Action implementation. Pin both when both contracts must remain fixed.

Release refs install the exact scanner version from crates.io. A missing version fails the job. A reviewed branch or commit ref builds the portable source profile with the repository’s pinned Rust toolchain and requires backend: cpu. It does not silently substitute source for a missing crate.

Failure behavior

Treat these states separately:

  • findings > 0 means the scan completed and reported credentials at the chosen severity floor. Review-tier findings can coexist with exit 0.
  • scan-status: partial means the report contains a coverage gap. Inspect the report and raw exit-code; do not treat it as clean.
  • scan-status: failed or report-present: false means the Action did not publish a trusted report.
  • Exit 10 means verification confirmed a live credential. It always blocks.
  • Installation, configuration, source, backend, and report validation failures remain failures even in advisory mode.

See the exit-code reference for the complete CLI contract and the output-format guide for report fields.

CI integration

Add KeyHog in two stages: make findings visible with a durable report, then turn new findings into a merge gate. Most repositories already contain credentials, so start at Fail only on new secrets for the complete path from a first scan to a passing gate. The provider recipes below keep scanning, enforcement, and report retention explicit so a missing upload or unsupported source cannot look like a clean run.

The shell recipes use an Ubuntu worker and install the full default portable crate profile. The GitHub Action has a different installation contract: published refs install the lean ci feature, while branch and commit refs build the portable profile from checked-out source and require backend: cpu.

WorkflowRecommended scanBoundary
Developer commitkeyhog hook installScans exact staged blobs before the commit.
Pull-request checkoutkeyhog scan . --baseline <FILE>Scans the checked-out tree and suppresses only reviewed baseline findings. See Fail only on new secrets.
Pull-request changes onlykeyhog scan --git-diff <BASE>Scans changed lines relative to the selected base. This is narrower than the checkout.
Main branch commit additionskeyhog scan --git-history .Scans added patch lines from reachable commits present in the checkout, bounded by max_commits.
Repository object databasekeyhog scan --git-blobs .Scans deduplicated blobs from refs, reflogs, stashes, tags, and unreachable objects still present in the clone.
Release verificationkeyhog scan --git-history . --git-blobs . --verifyAdds live checks for eligible detectors. Unverifiable findings remain unverified, and verification sends credential-derived requests to providers.
Large scheduled inventoryPartitioned repository or cloud scopesKeeps ownership, coverage, reports, and retries independent.

Fail only on new secrets

This is the usual adoption path for a repository that already holds credentials you cannot rotate today. The findings that exist now stay visible in a committed baseline. Only findings added after that point fail the build.

1. Create and commit the baseline

Run this once, on a clean local checkout of the branch CI scans:

keyhog scan . --create-baseline .keyhog-baseline.json

The command writes the file, prints no findings, and exits 0. Read the file before committing it. Every entry is a credential you are choosing to accept.

git add .keyhog-baseline.json
git commit -m "chore: add KeyHog baseline"

2. Gate the build

keyhog scan . --baseline .keyhog-baseline.json --format json-envelope --output keyhog.json

Exit 0 means no new finding blocks the active evidence policy. Exit 1 means a blocking credential is present that the baseline does not list. Review-tier findings remain visible under the default policy; select paranoid to block them. Exit 13 means the scan could not cover its input; a baseline never suppresses that, so do not read it as complete. Keep keyhog.json on every outcome. The generic shell wrapper is the portable way to retain the report and the exact exit code together.

A scan that reads zero source bytes exits 13 with a scan covered nothing gap row, so an --exclude-paths glob that matches everything, a container mount that landed empty, or a partition path that no longer exists fails the job rather than passing it. Assert the byte count anyway when the input path can change, because the assertion names the problem in the job log instead of leaving a reader to decode an exit code:

jq -e '.metadata.source_bytes_scanned > 0' keyhog.json

See tell a real clean from a skipped input.

3. Respond when the gate fires

A failing gate means someone added a credential. Remove it from the code and rotate it at the provider. When the finding is a reviewed exception instead, pick the narrowest surface:

  • One exact value in one path: add a [[suppress]] rule to .keyhogignore.toml.
  • A credential the team accepts everywhere: run keyhog scan . --update-baseline .keyhog-baseline.json locally, review the diff, and commit it.

Never run --update-baseline inside CI. A job that rewrites its own baseline accepts every secret it finds. The flag also still reports the new findings and still exits 1, so it cannot turn a red job green.

Monorepos: one baseline or several

A baseline entry matches on the detector and the credential value, never on the path. One root baseline therefore covers every partition, and moving code between partitions never fires the gate. Choose by who reviews exceptions, not by how matching works:

SituationUse
One security team reviews every exceptionA single .keyhog-baseline.json at the repository root
Each team reviews its own exceptionsOne baseline per partition, stored beside that partition’s code

Per-partition baselines have one consequence worth knowing. A credential accepted in services/api is reported again when the same value appears in services/web, because that job loads a different file. That is usually what you want.

Run each partition as its own job so a failure names an owner:

strategy:
  fail-fast: false
  matrix:
    partition: [services/api, services/web]
steps:
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  - name: Scan partition
    env:
      PARTITION: ${{ matrix.partition }}
    shell: bash
    run: |
      keyhog scan "$PARTITION" \
        --baseline "$PARTITION/.keyhog-baseline.json" \
        --format json-envelope \
        --output "keyhog-${PARTITION//\//-}.json"

fail-fast: false keeps the other partitions running after one fails, so a single leak does not hide the rest. Give each partition its own --incremental-cache path and cache key if you enable incremental scanning.

For what a baseline matches, how to retire an entry after rotation, and how to compare two baselines, see Baselines.

CI speed and concurrency

One KeyHog process uses the available CPU cores by default. Leave --threads unset on a dedicated runner. When a matrix runs several KeyHog jobs on one shared worker, divide the worker’s CPU budget across them with --threads <N> so every process does not claim the full host. Set --reader-threads only after --profile shows a storage-reader bottleneck.

Use --incremental only when the CI cache is bound to the same trusted repository and partition. Give each monorepo partition a separate --incremental-cache path and cache key. A cache hit changes work reuse, not the selected source boundary or detection policy.

Do not use --fast as the only merge or release gate. It intentionally omits decode, entropy, and ML work. It is suitable for an additional short feedback job when the default policy still runs before merge. Directory and Git jobs run in process; a warm daemon does not accelerate them.

Live verification has a separate network budget. Use --verify-concurrency, --verify-rate, or --verify-batch based on provider limits rather than CPU count.

GitHub Actions

Use the GitHub Action guide for the maintained composite Action, its inputs and outputs, monorepo categories, baseline adoption, report retention, and failure semantics.

Use the CLI directly in GitHub Actions when you need a source option that the Action does not expose. For example, fetch complete ancestry before scanning reachable commit additions:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  with:
    fetch-depth: 0
- name: Scan reachable history
  run: keyhog scan --git-history . --format sarif --output keyhog.sarif

Install KeyHog before these steps. Capture the exact scan status, upload the report, then restore that status after the upload:

- name: Scan reachable history
  id: keyhog
  shell: bash
  run: |
    scan_status=0
    keyhog scan --git-history . --format sarif --output keyhog.sarif \
      || scan_status=$?
    printf 'exit-code=%s\n' "$scan_status" >> "$GITHUB_OUTPUT"
- name: Upload KeyHog SARIF
  if: always()
  uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
  with:
    sarif_file: keyhog.sarif
- name: Enforce scan result
  if: always()
  env:
    KEYHOG_EXIT: ${{ steps.keyhog.outputs.exit-code }}
  shell: bash
  run: exit "$KEYHOG_EXIT"

The capture step exits successfully so the upload can run. The enforcement step then restores every KeyHog finding, live-credential, panic, backend, system, and coverage status without translating it to a generic failure.

Exclusions and adoption policy

Use exclusions for content that should not be scanned, and a baseline for known findings that should remain visible but not block adoption:

  • Put generated trees, vendored fixtures, and intentionally synthetic corpora in .keyhogignore as path: rules. Keep a short comment explaining each exclusion; broad globs can hide real coverage.
  • Put finding-specific exceptions in .keyhogignore or .keyhogignore.toml, preferably with reason, expiry, and approval metadata.
  • Never convert a source failure or coverage gap into an exclusion. KeyHog uses distinct nonzero exit semantics for invalid configuration, system failures, unavailable required GPU execution, and incomplete sources.

An exclusion decides which bytes are read. A baseline decides which findings count as new. Reach for an exclusion only when the content should not be scanned at all; otherwise use Fail only on new secrets. In a monorepo, never hide one team’s paths behind another team’s ignore file. Give each team its own subdirectory job with its own report.

GitLab CI

# .gitlab-ci.yml
keyhog:
  stage: test
  image: rust:1.89-bookworm
  before_script:
    - cargo install --locked --version '=0.5.81' keyhog
  script:
    # Exits non-zero on findings, which fails the job and gates the MR.
    - keyhog scan . --format gitlab-sast --output gl-sast-report.json
  artifacts:
    when: always           # keep the report even when the scan fails the job
    reports:
      sast: gl-sast-report.json
    paths:
      - gl-sast-report.json

The job’s exit status gates the merge request. KeyHog emits GitLab’s SAST JSON schema directly, so artifacts:reports:sast publishes findings to the merge request security widget without a converter. The same report remains a downloadable artifact when the scan fails.

CircleCI

# .circleci/config.yml
version: 2.1

jobs:
  keyhog:
    docker:
      - image: cimg/rust:1.89
    steps:
      - checkout
      - run:
          name: Install keyhog
          command: |
            cargo install --locked --version '=0.5.81' keyhog
            echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> $BASH_ENV
      - run:
          name: Scan repo
          command: keyhog scan . --format sarif --output keyhog.sarif
      - store_artifacts:
          path: keyhog.sarif
          destination: keyhog.sarif

workflows:
  build:
    jobs:
      - keyhog

Drone CI

# .drone.yml
kind: pipeline
type: docker
name: default

steps:
  - name: keyhog
    image: rust:1.89-bookworm
    commands:
      - cargo install --locked --version '=0.5.81' keyhog
      - |
        scan_status=0
        keyhog scan . --format json-envelope --output keyhog.json \
          2>keyhog.stderr || scan_status=$?
        printf '%s\n' "$scan_status" > keyhog.exit-code
        cat keyhog.stderr >&2 || true
        exit "$scan_status"

  - name: publish-keyhog-report
    image: plugins/s3
    settings:
      endpoint:
        from_secret: keyhog_artifacts_endpoint
      bucket:
        from_secret: keyhog_artifacts_bucket
      access_key:
        from_secret: keyhog_artifacts_access_key
      secret_key:
        from_secret: keyhog_artifacts_secret_key
      source: keyhog.*
      target: keyhog/${DRONE_REPO}/${DRONE_BUILD_NUMBER}
    when:
      status:
        - success
        - failure

The S3-compatible publisher runs after clean scans, findings, and operational errors. Configure its four keyhog_artifacts_* secrets for your artifact store. The scan step exits with KeyHog’s exact status after writing keyhog.exit-code and replaying keyhog.stderr to the job log.

Generic shell

Use the same scan wrapper in Jenkins, Buildkite, Woodpecker, Concourse, or any CI that can run a POSIX shell:

#!/bin/sh
set -eu

printf '%s\n' '{"schema_version":{"major":2,"minor":0},"scan_status":"failed","coverage_gap_summary":[],"findings":[]}' > keyhog.json
scan_status=0
keyhog scan . --format json-envelope --output keyhog.json \
  2>keyhog.stderr || scan_status=$?
printf '%s\n' "$scan_status" > keyhog.exit-code
cat keyhog.stderr >&2 || true
exit "$scan_status"

Configure the CI artifact publisher to retain keyhog.json, keyhog.stderr, and keyhog.exit-code on both success and failure. When the output path is writable, KeyHog writes the report even when every source failed to read; that report then carries scan covered nothing and the reason each source failed. An output-path failure exits 2 and cannot produce that report. Evaluate a present report together with keyhog.exit-code.

Buildkite

Use a dedicated artifact path so the report survives a finding exit:

# .buildkite/pipeline.yml
steps:
  - label: ":mag: keyhog secret scan"
    command: |
      cargo install --locked --version '=0.5.81' keyhog
      keyhog scan . --severity high --format json-envelope --output keyhog.json
    artifact_paths:
      - keyhog.json

Jenkins

Archive the report in post so it remains available when the scan blocks the stage:

// Jenkinsfile
pipeline {
    agent any
    stages {
        stage('keyhog') {
            steps {
                sh '''
                    cargo install --locked --version '=0.5.81' keyhog
                    keyhog scan . --severity high --format json-envelope --output keyhog.json
                '''
            }
            post {
                always {
                    archiveArtifacts artifacts: 'keyhog.json', allowEmptyArchive: true
                }
            }
        }
    }
}

Pin the scanner version

Manual CI installation can pin one exact crates.io version:

cargo install --locked --version '=0.5.81' keyhog

Review the release before changing the version. GitHub Action code and scanner crate pinning are separate contracts; see Pin Action code and scanner releases.

Scan commit additions on main and release, not per PR

An added-line history scan is useful on main post-merge and on release tags, but it is overkill for every PR. Add --git-blobs . when the policy must also cover reflogs, stashes, tag messages, and unreachable objects that remain in the repository object database. A typical setup:

TriggerScanPurpose
Pull requestkeyhog scan . (working tree)Fast feedback over proposed files
Push to mainkeyhog scan --git-history .Cover added lines from reachable commit patches
Release tagkeyhog scan --git-history . --verifyAdd explicit live verification before publication

Duration depends on history size, changed bytes, verification endpoints, rate limits, runner hardware, and cache state. Record it from the actual job.

The PR scan keeps the dev feedback loop fast. The post-merge history scan catches anything that slipped through pre-commit + PR review. The release scan verifies what’s live, useful for the changelog (“rotated these N credentials before shipping”).

Mass scanning

Use the mass-scanning guide for repository organizations, hosted Git groups, cloud buckets, local partitions, source limits, report aggregation, and retry boundaries. A mass scan is an inventory workflow, not a larger version of a pull-request job.

Run each partition as an independent CI job. Retain its machine-readable report, raw exit code, source inventory, and coverage state before aggregating results.

Failure modes worth knowing

  • Forked PR + secret credentials: GitHub Actions doesn’t expose org secrets to forked-PR runners, so a verifier endpoint that needs authentication won’t run. Findings still get reported as unverified; that’s correct behavior.
  • Advisory findings: preserve the raw KeyHog exit separately from report publication, then decide explicitly whether exit 1 blocks the job. A verified-live credential exits 10 and should remain blocking.
  • Shallow clones fail the job: actions/checkout defaults to fetch-depth: 1, which exposes only the checked-out HEAD commit. A --git-history or --git-blobs scan of that clone now exits 13 with "scan_status":"partial" and a Git object unreadable or wrong object kind gap, because the graft boundary names parent commits the clone does not contain. Set fetch-depth: 0 on any job that scans history. A depth-1 clone of a single-commit repository stays clean and correct, because its boundary is the root commit and hides no parent.
  • --git-history misses branches you did not check out: it covers the ancestry present in the current checkout only. A credential on another local ref, in a reflog or stash, or left behind by git commit --amend can be reported by --git-blobs and missed by --git-history with no coverage gap. Use --git-blobs when the policy has to cover the repository object database, not only reachable added-line history. Objects already pruned from the clone cannot be scanned.
  • A base ref that resolves to HEAD: keyhog scan --git-diff <BASE> exits 0 after scanning zero bytes when <BASE> and HEAD are the same commit, which is what a shallow clone gives you for origin/main. The gate passes without examining anything. A base ref that is missing from the clone is safe by comparison: that exits 13 and refuses to report clean.
  • LFS files: keyhog reads the LFS pointer file, not the contents. To scan LFS-stored binaries, enable LFS in checkout (lfs: true) and let the scanner pull the real file.

Deep recovery

Git history keeps a credential after you delete it from the working tree. Scan that history explicitly. A filesystem scan never includes it.

keyhog scan --git-history . --format json-envelope --output history.json

--git-history scans the lines added by commits reachable from the current checkout, bounded by --max-commits and by the ancestry the clone contains.

A shallow clone reports the gap instead of a clean scan. git clone --depth N records a graft boundary, and the parent commits named there were never fetched, so their blobs cannot be scanned. Both --git-history and --git-blobs count those absent parents, exit 13, and report "scan_status":"partial" with a Git object unreadable entry in coverage_gap_summary. Read that as “this clone does not contain the history you asked me to search”, not as a scanner failure. The fix is on the checkout side:

git fetch --unshallow && keyhog scan --git-history .

actions/checkout clones one commit by default, so set fetch-depth: 0 on any job that scans history. See failure modes.

Choose the Git boundary before changing anything else:

BoundaryCommandCoversDoes not cover
Added lines in reachable commitskeyhog scan --git-history .Patch additions from the ancestry of the current checkout, with commit, author, and date on every finding.Other branches, tags, stashes, notes, and objects no ref reaches.
Every reachable blobkeyhog scan --git-blobs .Deduplicated blobs from every ref, including branches you have not checked out and refs that only exist in packed-refs, plus annotated tag messages, stashes, notes, and dangling objects that a rewrite or an amend left behind. Enumeration diffs each commit against its parents and keeps added, changed, and deleted blob sides, so a credential that was committed and later removed stays visible without rewalking every historical tree. Every ref tip under refs/ plus HEAD is still fully walked once so --max-commits cannot hide untouched blobs on non-newest branches, custom ref namespaces, or detached CI checkouts.Blobs that decode as binary, including committed archives.
Bothkeyhog scan --git-history . --git-blobs .The recursive recovery workflow. Use it for a release or incident review.

The two boundaries differ by more than cost. A credential committed on a branch you never checked out, or left behind by git commit --amend, is reachable through --git-blobs and invisible to --git-history.

A committed archive is a known gap in both. --git-blobs refuses a blob that decodes as binary, so bundles/payload.tar.gz is reported as Git object unreadable and the scan exits 13 rather than reporting the archive clean. Scan the checked-out working tree as well when the repository carries archives: the filesystem source does extract them.

Neither boundary reaches sibling repositories, provider organizations, cloud buckets, or mounted filesystems. Run those boundaries as separate jobs and preserve their reports and statuses.

Deep Git scans run in process. A warm daemon does not accelerate history or blob traversal.

Add the deep preset

Use --deep when recall matters more than routine scan cost, such as an incident review or a release gate:

keyhog scan --deep --git-history . --git-blobs . --daemon=off \
  --format json-envelope --output deep-history.json

For a multi-backend installation, calibrate the deep policy before relying on automatic routing:

keyhog calibrate-autoroute --policy deep
keyhog config --effective --deep

A normal installation already calibrates every preset. Re-run the first command after changing the binary, host, driver, or routing-relevant configuration. The second command prints the resolved policy. Record it with benchmark or incident results.

When a report is written as json-envelope, jsonl-envelope, or html, its metadata contains a resolved_scan manifest. The manifest records the selected preset, every effective detection value, and the keys that differ from that preset’s base. This makes a deep run with compatible overrides directly comparable to default, fast, and precision artifacts:

{
  "schema_version": 1,
  "preset": "deep",
  "effective": {"max_decode_depth": "3", "entropy_enabled": "true"},
  "overrides": ["max_decode_depth"]
}

Values are strings by contract so the manifest remains stable as new typed settings are added; maps are serialized in key order. It contains no paths, credentials, or host-specific routing decisions. The benchmark runner should store this object alongside timing and accuracy so results are never compared across silently different detection policies.

What changes

--deep is a bounded preset, not an unbounded evaluator.

SettingDefaultDeep
Decode depth1010
Decode input ceiling512 KiB1 MiB
Source-file entropyoffon
ML-only entropy vetoonoff
Comment confidence penaltyonoff

ML remains enabled. Deep retains its score as evidence but does not let the model alone discard an entropy candidate. Explicit compatible flags apply on top of the preset, such as --deep --decode-depth 3.

The decode input ceiling is the row with teeth, because the default value sits BELOW the 1 MiB window the reader hands to the scanner. A window larger than --decode-size-limit is not decoded at all, so nothing Base64, hex, or URL-encoded inside it is recovered.

Measured on release-fast v0.5.81, one Base64-wrapped AWS key pair at the end of an otherwise plain text file, default preset:

File sizeLast windowDefault--deep
400 KiB400 KiBreports the credentialreports it
510 KiB510 KiBreports the credentialreports it
520 KiB520 KiBreports NOTHINGreports it
600 KiB600 KiBreports NOTHINGreports it
1000 KiB1000 KiBreports NOTHINGreports it
1100 KiB204 KiBreports the credentialreports it
1500 KiB604 KiBreports NOTHINGreports it
2000 KiB208 KiBreports the credentialreports it

The result looks random in file size and is exact in WINDOW size: the payload in that table sits at the end of the file, so it lands in the last window, and it is recovered when that window fits under the limit. Windows are 1 MiB with 128 KiB of overlap, so the last window’s size cycles as the file grows, which is why 1100 KiB succeeds and 1500 KiB does not.

Position, not size, is what governs. Same 2000 KiB file, same bytes, only the offset of the payload differs: at the end of the file the default preset reports the credential, and in the middle of the file it reports nothing. A full-size window is always over the default limit, so the tail is the only place an encoded payload is reachable at all, and every size in that table is really measuring how big the tail happened to be. Read the table as a demonstration, not as a rule you can plan around, and write any regression fixture with the payload mid-file, where the outcome is the same at every size.

Two remedies, in order of preference:

keyhog scan . --decode-size-limit 4M
keyhog scan . --deep

Raise the limit when you want only this behavior; --deep raises it to 1 MiB as part of a wider preset. Prefer the explicit flag when you are pinning a policy, because it changes one setting instead of five.

Do not gate on the exit code here. The skip is reported as a coverage gap naming --decode-size-limit, and that gap is the signal, whether or not the run also reported findings. A run that reports findings AND the gap is telling you the truth: some windows were expanded and others were not. See file shapes for the same measurement from the input-shape side.

Backend routing

Deep is a detection preset. It does not select a backend. The default --backend auto looks up evidence calibrated for the deep preset and the exact resolved overrides:

keyhog scan . --deep
keyhog scan . --deep --decode-depth 3

Those two scans have different resolved configuration identities. If the second scan reports an uncovered workload, measure that exact diagnostic shape once, then return to normal automatic routing:

keyhog scan . --deep --decode-depth 3 \
  --autoroute-calibrate --autoroute-gpu
keyhog scan . --deep --decode-depth 3

Use an explicit backend only to isolate an engine problem:

keyhog scan . --deep --backend cpu
keyhog scan . --deep --backend simd
keyhog scan . --deep --backend gpu-cuda
keyhog scan . --deep --backend gpu-wgpu

An explicit backend bypasses autoroute evidence. It does not repair or calibrate the deep route. It is a hard contract, so an unavailable SIMD runtime, GPU driver, or selected GPU peer fails the scan. KeyHog does not silently continue with another backend. Use keyhog --version --full for discovery and keyhog backend --self-test --require-gpu to execute the GPU diagnostic paths.

Recovery mechanisms

Deep runs the normal detector corpus and expands bounded recovery around it:

  • recursive Base64, hex, URL, Unicode escape, and supported transport decoding;
  • source-file entropy discovery for unknown opaque values;
  • comment scanning without the normal comment penalty;
  • static JavaScript recovery for recognized cyclic XOR expressions;
  • static AES-256-CBC recovery when the key, IV, ciphertext, and bindings are literal and internally consistent;
  • static CryptoJS passphrase recovery for the exact immutable wrapper dialect, with strict OpenSSL Salted__, EVP_BytesToKey MD5, AES-256-CBC, PKCS#7, and UTF-8 validation.

Static program recovery does not execute JavaScript or invoke Node.js. It accepts a small side-effect-free grammar and rejects dynamic operands. The implementation lives in crates/scanner/src/decode/javascript_static.rs and its aes.rs and cryptojs.rs submodules.

The grammar reads spellings, not one canonical style. Within the XOR and Node AES rules these are all the same program and all recover:

  • const, let, or var for a binding;
  • String.fromCharCode or String.fromCodePoint;
  • Buffer.from(literal, encoding) or new Buffer(literal, encoding);
  • 'aes-256-cbc' in any case, and hex, base64, utf8, or utf-8 in any case, in single quotes, double quotes, or backticks;
  • .toString() with no argument, which is Node’s UTF-8 default;
  • a plain string literal wherever a [...].join('') chain is accepted.

What still fails closed is anything the grammar cannot prove is constant: a binding written to after its declaration, including through an index or a mutating array method; a template literal containing ${...} or a backslash escape; new Buffer(size); any algorithm other than AES-256-CBC; and any operand that is not a literal. A refused program produces no plaintext, and the original source stays in the ordinary scan path.

The CryptoJS rule is deliberately stricter and stays at const or let. It resolves names through scope analysis rather than the whole-source occurrence count the other rules use, and var hoisting could let a sibling-scope binding win.

The static evaluator caps source size, literal arrays, binding count, and expression count. Decode recursion also enforces depth, output-size, expansion, and total-work budgets. A rejected transform still leaves the original source available to ordinary detection.

Read recovery receipts

Deep static recovery and backend recovery are separate. Inspect both from the metadata-bearing report:

jq '{
  preset: .metadata.resolved_scan.preset,
  static_recovery: .metadata.static_recovery,
  scan_status,
  backend_recoveries: (.metadata.backend_recoveries // [])
}' deep.json

static_recovery counts supported, unsupported, and erroneous bounded program transforms. These counters describe the JavaScript, AES, and CryptoJS evaluator. They do not mean that a scan backend failed.

backend_recoveries records recovery after an authenticated automatic backend faults. Each row names the failed backend, the backend that completed the stable bytes, recovered range, chunk, and byte counts, a non-secret reason, and a repair command. scan_status: "complete_after_recovery" means byte coverage is complete, but the route still needs repair. Missing or invalid autoroute evidence creates no recovery row; it leaves input unscanned and reports scan_status: "partial".

Use the receipt to remediate the route:

  • Run keyhog calibrate-autoroute --policy deep for the standard deep ladder.
  • Re-run an exact deep scan once with --autoroute-calibrate --autoroute-gpu when compatible overrides created an uncovered configuration.
  • Run installer calibration for Git, Docker, or web source workloads.
  • If a GPU backend faulted, run keyhog backend --self-test --require-gpu, repair the driver or runtime, and recalibrate. Inspect the quarantine with keyhog backend --autoroute.
  • If SIMD faulted, confirm the running build and Hyperscan/Vectorscan runtime with keyhog --version --full, repair it, and recalibrate.

Do not replace these repairs with a permanent --backend cpu setting. That would bypass the invalid autoroute state rather than restore measured routing.

Non-LLM recovery benchmark

The repository’s ioc-recovery corpus contains 4,368 labeled fixtures across 13 JavaScript concealment phases. It has exact expected credentials and is scored by the same benchmark runner used for other corpora.

The paper authors publish 13 demonstration files in the pinned llm-ioc-detection repository. Their 336-program evaluation corpus is not present there. KeyHog’s 4,368 fixtures are deterministic synthetic adaptations of the phase taxonomy, not copies of the paper’s evaluation files.

Reproduce the checked benchmark matrix:

make -C benchmarks ioc-recovery-corpus
make -C benchmarks ioc-recovery

The committed deep target requires 4,368 true positives, zero false negatives, and zero false positives for the pinned corpus and scanner identity. The fast comparison reports 1,344 true positives and 3,024 false negatives. These are corpus results, not a claim that every possible program transform is supported.

The reproduction commands write local artifacts under benchmarks/results-ioc-recovery/. Those artifacts are intentionally ignored because timings and hardware identity are host-specific. Each artifact records the mode, backend, cache and daemon state, scanner version, corpus size, exact detection totals, wall time, and peak RSS. Compare results only when those identities match.

File shapes and sizes

Three file shapes need a different command from the repository default. Many small files. One very large file. A minified or single-line file.

This page gets you a correct scan of each, names the limit that applies, and shows the check that separates a real clean from a skipped input.

Many small files

Scan the root. There is no special flag:

keyhog scan <root> --format json-envelope -o keyhog.json

Confirm the file count reached the scanner:

jq '.metadata.source_chunks_scanned' keyhog.json

A file under 1 MiB is one chunk. For a tree of small files, that count should be close to the number of files you expect. If it is far lower, something removed files before the scanner saw them.

The usual cause is the default exclusion policy. On a repository it removes .git/, lockfiles, vendored trees, and minified bundles, which is correct. On a directory of generated or vendored content it can remove almost everything. Check what it removed:

jq '.coverage_gap_summary' keyhog.json

An exclusion policy gap with a count near your file count means the default skips ate the input. Pass --no-default-excludes when you deliberately want vendored and minified paths walked, and read Minified and single-line files first, because that flag turns off two separate rules and you should know both.

Limits that bind on this shape:

  • --threads <N> caps parallel scanner workers. Unset uses the available cores.
  • --max-file-size still applies per file, so one oversize file in the tree produces a gap while the rest of the tree scans normally.

Cost on a 5,000-file tree of about 240 KiB: median peak resident memory 154 MiB over five runs. Peak memory on this shape is dominated by the compiled detector corpus, not by the file count, so it barely moves as the tree grows. Plan against memory, and measure wall time on your own runner: the same five runs took a median of 0.84 s on an idle-ish host and 7.73 s on a heavily loaded one, so a wall time quoted here would tell you nothing about yours.

One very large file

The default --max-file-size is 100 MiB. A larger file is not scanned.

Passing a 256 MiB file with the default cap fails loudly:

keyhog scan bigfile.log
WARN skipping file: size exceeds --max-file-size cap path=bigfile.log
     size_bytes=268436133 max_size=104857600
WARN source: failed to read source: ... file was not scanned.
error: a requested scan source failed to read and produced no data (see the
warnings above). Not reporting "clean": that scan did not run.

Exit code 13.

Raise the cap to scan it. The value requires a unit:

keyhog scan bigfile.log --max-file-size 300M --format json-envelope -o keyhog.json

A bare number is rejected before the scan starts, with exit 2:

error: invalid value '300000000' for '--max-file-size <SIZE>': byte size
'300000000' is missing a unit. Use `B`, `K`/`KB`, `M`/`MB`, `G`/`GB`, or `T`/`TB`.

keyhog watch takes the same option as a bare byte count, not a size string. That asymmetry is real. See Watch mode.

Files above the 1 MiB window size are read in overlapping windows, so a credential that straddles a window boundary is still found. The 256 MiB file above, with a credential on its last line, reports one finding and 293 chunks.

Cost on the same tree: median peak resident memory 315 MiB over five runs, for a 256 MiB file. Peak memory does not track file size one-for-one, because windows stream to the scan pool as they are decoded rather than being collected first. It is still the number to check before raising the cap on a shared runner.

The dangerous case is a large file inside a normal tree

When the oversize file is one member of a directory, the rest of the directory scans and the run does not stop:

keyhog scan mixed-tree/ --format json-envelope -o keyhog.json
{
  "scan_status": "partial",
  "coverage_gap_summary": [
    {"reason": "source emitted error rows (requested input was not fully scanned)", "count": 1},
    {"reason": "exceeded --max-file-size", "count": 1}
  ]
}

Exit code is 13 when nothing was found in the covered part, and 1 when something was. In the 1 case the report still carries the gap. Read coverage_gap_summary, not the exit code alone.

Do not raise --max-file-size just to clear exit 13. Raise it when you trust the input and the runner has the memory. Otherwise scan the large file as its own job so its cost and its result are separate.

Minified and single-line files

A minified file is one whose whole content sits on one or two very long lines. A single-line file is any file with no newline until the end.

Line length is not a problem. A 32 MiB single-line JavaScript bundle named bundle.js scans normally: 37 chunks, one finding for a credential near the end, and median peak resident memory 144 MiB over five runs, the same as an ordinary tree. Windowing is by bytes, not by lines.

The problem is the filename and the directory.

What the default policy does

A directory containing only app.min.js reports no findings and zero source_bytes_scanned. The file never reaches the scanner. The report carries two gap rows and exits 13:

{
  "scan_status": "partial",
  "coverage_gap_summary": [
    {"reason": "scan covered nothing (zero source bytes read; every candidate was skipped by exclusion or skip policy, so nothing was examined)", "count": 1},
    {"reason": "exclusion policy (default excludes such as lock files, minified/bundled assets, vendored and build-output trees; --git-staged also counts repository `.keyhogignore` matches here)", "count": 1}
  ]
}

A scan that reads zero bytes is a loud failure, not a clean result. The second row tells you which policy did it.

What --no-default-excludes now does

--no-default-excludes turns off both layers: the walker reads the file, and the post-match drop is disabled, so the credential is reported. The same directory with the flag reads 1441 bytes and reports the finding.

Without the flag, a match is still dropped after the fact when its path ends in .min.js, .bundle.js, or .min.css, or sits under node_modules/, bower_components/, jspm_packages/, site-packages/, wp-includes/, wp-content/plugins/, wp-content/themes/, public/plugins/, public/static/, public/vendor/, static/vendor/, dist/vendor/, dist/assets/, or vendor/assets/. Each drop is counted, and the total appears in coverage_gap_summary as its own row, so the suppression is visible in the report rather than silent.

Measured: a directory holding only app.min.js with a planted credential reports zero findings by default, and reports the credential with --no-default-excludes. --dogfood shows the individual suppressed matches and their reason, which is more detail than the gap-row count:

keyhog scan dist/ --no-default-excludes --dogfood --format json-envelope

The stderr trace names the reason vendored_minified_path.

Scan a bundle you own

Pass one first-party bundle as an explicit file, or disable default exclusions for the directory that owns it:

keyhog scan dist/app.min.js --format json-envelope -o keyhog.json
keyhog scan dist/ --no-default-excludes --format json-envelope -o keyhog-dist.json

An explicit file request is not removed by the directory walker’s default path policy. Use --no-default-excludes when the scan must cover several minified or vendored-shaped paths. Review that broader scope first: it also enables third-party bundles that the repository default intentionally omits.

This matters most for a first-party bundle you ship. A vendored third-party bundle you did not write is what the suppression is for, and leaving it suppressed is usually right.

Encoded payloads: position decides

In any file over 1 MiB, a credential inside an encoded payload is only found when it sits in the last part of the file. Everywhere else it is missed, on the default preset, with no warning that fails your build. This is the sharpest edge on this page.

--decode-size-limit defaults to 512K and bounds decoding per WINDOW, not per file. Files over 1 MiB are read as 1 MiB windows, so every window except the tail is over the limit and is never decode-expanded.

Measured on one file whose only credential is a Base64 payload on the LAST line, at the default preset:

File sizeFindingsExit
400K21
510K21
520K00
600K00
1000K00
1100K21
1500K00
2000K21

The table looks random in file size and is exactly predictable in something else. A credential at the END of a file lands in the LAST window. Windows are 1 MiB with 128K of overlap, so they advance 896K at a time and the last window holds whatever is left over. Decoding happens per window, so:

A payload is decoded when the window holding it is at or under --decode-size-limit.

At 1100K the last window holds only 204K and the payload is found. At 1500K it holds 604K and is not. At 2000K it holds 208K and is found again.

Position matters more than size

Every row above puts the credential at the end of the file, which is the only position that can succeed. Hold the size fixed and move the payload instead:

2000K file, payload atFindingsExit
end of file21
middle00
one quarter in00

Same size, same bytes, opposite result. A payload anywhere but the tail sits in a full 1 MiB window, and a full window is always over the 512K limit.

So the honest rule is not about file size at all. In any file larger than 1 MiB, only the tail is decode-reachable, and the whole interior is not. The bigger the file, the smaller that reachable fraction: a 12 MB file is roughly 1.6% decode-reachable.

Measured on a 2.2 GB Rust registry checkout, 377 of 110,846 files are over 512K and hold 595 MB between them. Of that, 574 MB is decode-unreachable and 20 MB is reachable through tail windows.

The underlying cause is that two defaults are ordered wrongly. The window size, 1 MiB, is larger than the decode limit, 512K, so a full-size window can never be decode-expanded no matter what it contains. That ordering alone accounts for 173 MB of the 574 MB, in files between 512K and 1 MiB whose single window merely exceeds the cap. The remaining 422 MB is genuine interior of files over 1 MiB, which only a subdividing decode path can reach.

The report does say so, in the one place worth reading:

{
  "reason": "scanner decode-through declined by --decode-size-limit (chunk larger than the limit; raw bytes scanned, nothing encoded inside it was recovered)",
  "count": 1
}

scan_status is partial and the exit code is 0. This is a skip, not an error, so nothing fails your build. Gate on the gap reason, not the exit code.

The gap also appears on the rows that DID report findings, at 1100K and 2000K above. That is correct, not a false positive: those files still hold oversize chunks whose encoded content was never expanded, and a differently-split chunk happened to recover this particular payload. Findings plus this gap means partial decode coverage, not complete coverage.

Raise the limit when your inputs carry encoded payloads:

keyhog scan . --decode-size-limit 4M --format json-envelope -o keyhog.json

That recovers the credential at every size and every position tested above.

--deep also recovers them, but do not rely on it for this. Deep raises the decode ceiling to exactly 1 MiB, which is exactly the window size, so it clears the limit with no margin at all. Any increase to the window, or a ceiling compared as strictly-less-than rather than at-most, silently reopens the hole for deep too. Use --decode-size-limit when the decode budget is what you need, and --deep when you want the rest of its policy as well.

Check coverage on every shape

jq '{bytes: .metadata.source_bytes_scanned, chunks: .metadata.source_chunks_scanned,
     status: .scan_status, gaps: .coverage_gap_summary}' keyhog.json

Zero bytes means nothing was scanned, whatever the findings list says. Tell a real clean from a skipped input explains each field and each gap reason.

Container images and OCI layers

Scan an image by reference:

keyhog scan --docker-image registry/app:v1 --format json-envelope -o image.json

KeyHog runs docker image save for that reference, then streams each layer tarball through the shared in-memory archive scanner. Layer members are not materialized onto disk before scanning. A credential baked into a layer is found even when a later layer deletes the file, because every layer is scanned independently: whiteout and opaque-dir markers are ordinary members, not a reason to hide earlier-layer content.

Nested members keep the same coverage as an unpack-then-walk scan: gzip/zip/tar/compressed payloads descend in memory, .7z/.rar use the shared path extractors (staged from the already-buffered member), and layer .har files expand at the Docker boundary with wire:har labels. Nested .har inside ordinary zip/tar/7z/RAR keep the historical filesystem/archive leaf identity. Large already-UTF-8 plain layer members stream in ~1 MiB windows from the tar entry; UTF-16 and other encodings keep the whole-member decode path. Windowed members keep the filesystem/archive source identity.

What you need

--docker-image shells out to the docker CLI and needs a reachable Docker daemon. It does not talk to a registry itself, so the image must already be present locally or pullable by that daemon.

Pull first when the image is remote:

docker pull registry/app:v1
keyhog scan --docker-image registry/app:v1

A reference the daemon cannot resolve fails loudly:

keyhog scan --docker-image registry/app:no-such-tag
WARN source: failed to read source: failed to export docker image:
registry/app:no-such-tag: Error response from daemon: No such image: ...
error: a requested scan source failed to read and produced no data (see the
warnings above). Not reporting "clean": that scan did not run.

Exit code 13. That is the behavior you want. A typo in a tag never reads as a clean image.

Scanning a saved tarball

docker image save writes an OCI layout whose layer payloads are blobs/sha256/<digest> files with no extension. Scanning that tarball as a path works:

docker image save registry/app:v1 -o app.tar
keyhog scan app.tar --format json-envelope

For a one-layer image holding a live-shaped credential, that reports the finding. A container member is admitted by its own leading bytes when its name carries no recognized extension, so an extensionless gzip or tar layer is descended into rather than treated as opaque.

Extracting the tarball first works too:

mkdir -p /tmp/image-audit
tar xf app.tar -C /tmp/image-audit
keyhog scan /tmp/image-audit --format json-envelope -o image.json

Prefer --docker-image when you have a daemon. It takes a reference rather than asking you to produce and manage a tarball, and a bad reference fails loudly instead of scanning a file that is not the image you meant.

If you want the layer contents as ordinary files, for example to map a finding back to a path inside the image, unpack the layers yourself:

mkdir -p /tmp/image-audit/layers
for blob in /tmp/image-audit/blobs/sha256/*; do
  if file -b "$blob" | grep -q gzip; then
    mkdir -p "/tmp/image-audit/layers/$(basename "$blob")"
    tar xzf "$blob" -C "/tmp/image-audit/layers/$(basename "$blob")"
  fi
done
keyhog scan /tmp/image-audit/layers --format json-envelope -o layers.json

An image in a repository or a bucket is not covered

Container handling applies to files on disk and to the sources that expand them. It does not apply to Git objects or to cloud object bodies. An image tarball committed to a repository, or sitting in an S3 bucket, is not descended into.

The same bytes behave differently depending on how you reach them:

keyhog scan repo/ --no-default-excludes      # descends into the tarball
keyhog scan --git-history repo               # does not

The working-tree scan reports the credential inside the archive. The Git-history scan reports a binary (extension or content sniff) gap for that blob and exits 0 with a partial status. Running --git-blobs instead reports the same gap and exits 13.

That gap row is easy to misread. A binary gap usually means an image or a compiled object you did not want scanned. Here it means an archive whose contents were never examined. If your repository holds committed archives and you scan history, unpack them and scan the result as a separate job.

Limits

Three caps bound image expansion. Each one fails loudly when it binds.

FlagBounds
--limit-docker-tar-total-bytesCumulative bytes admitted for one image, summed across the outer image tar and every streamed layer tar. Partial coverage is reported as a gap; it is never a silent clean.
--limit-docker-tar-entry-bytesBytes accepted for one entry inside a layer.
--limit-docker-image-config-bytesBytes accepted for the image config and manifest JSON.

A cumulative cap that binds stops the whole export:

keyhog scan --docker-image registry/app:v1 --limit-docker-tar-total-bytes 100B
WARN source: failed to read source: docker archive cumulative size exceeds 100
bytes at entry 'blobs/sha256/0d8eec63...' (likely zip-bomb).
error: a requested scan source failed to read and produced no data ...

Exit code 13.

A per-entry cap that binds skips that entry and keeps going, with the skip recorded:

keyhog scan --docker-image registry/app:v1 --limit-docker-tar-entry-bytes 10B \
  --format json-envelope
{
  "scan_status": "partial",
  "coverage_gap_summary": [
    {"reason": "source emitted error rows (requested input was not fully scanned)", "count": 1},
    {"reason": "exceeded --max-file-size", "count": 1}
  ]
}

Exit code 13, because nothing was found in the part that was covered.

The caps exist because an image layer is attacker-controllable compressed data. Raise them for an image you trust, on a runner with the memory to hold the expansion. Do not raise them to make exit 13 disappear on an image you pulled from somewhere you do not control.

Check coverage

jq '{bytes: .metadata.source_bytes_scanned, chunks: .metadata.source_chunks_scanned,
     status: .scan_status, gaps: .coverage_gap_summary,
     findings: (.findings | length)}' image.json

For an image scan, sanity-check bytes against the image size you expect. A multi-hundred-megabyte application image that reports a few kilobytes was not unpacked.

Prove your pipeline can see into a layer before you trust it. Build a canary image, scan it, and confirm the finding:

mkdir -p /tmp/canary-image
printf 'STRIPE_SECRET_KEY=sk_live_%s\n' \
  "$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)" \
  > /tmp/canary-image/creds.env
printf 'FROM scratch\nCOPY creds.env /etc/creds.env\n' > /tmp/canary-image/Dockerfile
docker build -t keyhog-canary:v1 /tmp/canary-image
keyhog scan --docker-image keyhog-canary:v1 --format json-envelope \
  | jq '.findings | length'

Expect 1. A pipeline that reports 0 there is not scanning layers, and no real image will tell you so.

Tell a real clean from a skipped input covers the report fields and every gap reason.

Standard input and pipelines

Scan bytes that never touch disk:

kubectl get secret app -o yaml | keyhog scan --stdin

keyhog scan - is the same as keyhog scan --stdin, following the grep and wc convention:

terraform output -json | keyhog scan -

Findings from stdin carry the source stdin and no file path. The file_path field in the report is null, because there is no file.

The result depends on your working directory

A stdin scan has no filesystem path, so KeyHog resolves its allowlist from the current directory. A .keyhogignore in the directory you happen to be standing in is applied to bytes that have nothing to do with that repository.

Reproduce it. The same credential on stdin, the same binary, two directories:

mkdir -p /tmp/allowlist-demo
VALUE="sk_live_$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)"
HASH="$(printf '%s' "$VALUE" | sha256sum | cut -d' ' -f1)"
printf 'hash:%s; reason="demo"; approved_by="you"\n' "$HASH" \
  > /tmp/allowlist-demo/.keyhogignore

cd /tmp          && printf 'STRIPE_SECRET_KEY=%s\n' "$VALUE" | keyhog scan --stdin
cd /tmp/allowlist-demo && printf 'STRIPE_SECRET_KEY=%s\n' "$VALUE" | keyhog scan --stdin

The first exits 1 with one finding. The second exits 0 with none, over identical bytes. There is no coverage gap, source_bytes_scanned is the same 51 in both runs, and scan_status is complete_after_recovery either way. Every field that normally distinguishes a skipped input from a scanned one says this was a real scan that found nothing.

This matters because the recommended pipeline above is usually run from a checkout.

Only hash: rules can affect a pipe. A path: rule has no path to match against, so it does nothing here: piping the same credential from a directory whose .keyhogignore contains path:** still reports the finding. Judge your exposure by the number of live hash: entries, not by the length of the file.

Two ways to avoid it:

cd / && kubectl get secret app -o yaml | keyhog scan --stdin

Run the pipe from a directory with no .keyhogignore, or write the input to a file outside the checkout and scan the path, which resolves the allowlist from the input’s own location:

kubectl get secret app -o yaml > /tmp/secret.yaml
keyhog scan /tmp/secret.yaml

Check which allowlist file is in play before you trust a clean stdin scan:

keyhog config --effective

The allowlist_file line names it.

Empty input fails the scan

The command in front of the pipe is the part that usually fails. A kubectl call against the wrong context, a vault read without a token, or a curl that 404s all produce empty output. KeyHog treats that as a failed scan rather than a clean one:

printf '' | keyhog scan --stdin

Exit code 13. The envelope reports scan_status partial, one chunk, zero bytes, and one coverage gap:

{
  "reason": "scan covered nothing (zero source bytes reached the scanner and no skip was counted; nothing was examined, so this result is not a clean bill of health)",
  "count": 1
}

This is a change in behavior. An empty stream used to exit 0 with scan_status success and no gaps, which read as a clean scan. If you have a pipeline that legitimately feeds an empty stream, for example a matrix job whose partition is sometimes empty, it will now fail. Guard the producer instead of suppressing the exit code:

if [ -s changed.diff ]; then
  keyhog scan --stdin < changed.diff
fi

There is deliberately no flag that turns the failure off.

Check the byte count, always:

kubectl get secret app -o yaml \
  | keyhog scan --stdin --format json-envelope -o keyhog.json
jq -e '.metadata.source_bytes_scanned > 0' keyhog.json

jq -e exits non-zero when the expression is false, so that line fails the pipeline when nothing was scanned.

Set pipefail as well, so the producer’s own failure is not swallowed:

set -o pipefail
kubectl get secret app -o yaml \
  | keyhog scan --stdin --format json-envelope -o keyhog.json

Without pipefail the shell reports only KeyHog’s exit code, and KeyHog succeeded at scanning nothing.

pipefail is a Bash and Zsh feature, not a POSIX one. Under dash, which is /bin/sh on Debian and Ubuntu, set -o pipefail fails with set: Illegal option -o pipefail and the guard you thought you had is not there. Give the script a #!/usr/bin/env bash shebang, or run it with bash, or capture the report and branch on the exit code as shown below, which needs no shell option at all.

Piping KeyHog into jq throws its exit code away

The reverse direction has the same hazard and is easier to get wrong, because it looks like a summary rather than a gate:

keyhog scan . --format json | jq '.findings | length'

A shell reports the exit status of the LAST command in a pipeline. That line reports jq’s status, not KeyHog’s. A bad flag, an unreadable config, or a detector corpus that will not compile all exit 2 and print nothing on stdout, and the pipeline still succeeds. Add || echo 0 and you have converted every one of those into the answer you were hoping for.

Two ways to keep the exit code. Turn on pipefail:

set -o pipefail
keyhog scan . --format json | jq '.findings | length'

That reports 2 when the scan failed. Or capture first and parse second, which also lets you inspect what was written:

rm -f keyhog.json
rc=0
keyhog scan . --format json-envelope -o keyhog.json || rc=$?
[ "$rc" -le 1 ] || { echo "keyhog failed: exit $rc" >&2; exit 1; }
jq '.findings | length' keyhog.json

Capturing is the more robust of the two, because a scan that fails before it can report writes no file at all, so the missing file is a second independent signal that something went wrong.

The size limit

--stdin accepts 10 MiB by default. A larger stream fails closed:

keyhog scan --stdin < big.json
WARN source: failed to read source: stdin exceeds 10485760 byte limit.
error: a requested scan source failed to read and produced no data (see the
warnings above). Not reporting "clean": that scan did not run.

Exit code 13. Nothing is scanned, including the first 10 MiB. The limit is not a truncation point.

Raise it when a larger stream is intentional:

keyhog scan --stdin --limit-stdin-bytes 20M < big.json

An 11 MB input is first spooled to an anonymous temporary file so the limit is validated before any partial result can escape. KeyHog then scans overlapping 1 MiB windows with 128 KiB of boundary coverage. Memory stays bounded, findings retain absolute offsets and line numbers, and independent windows can use the configured scan workers. The anonymous file is removed automatically when the source closes.

--limit-stdin-bytes applies only to --stdin. It does not bound a directory scan.

Warm routing

An eligible stdin request can be served by a running daemon, which avoids recompiling the detector corpus per invocation. That is the case worth a daemon: a pipeline that runs keyhog scan --stdin many times.

keyhog daemon start
kubectl get secret app -o yaml | keyhog scan --stdin

--daemon=auto is the default and is safe to leave on unattended. A daemon failure degrades to an in-process scan rather than losing the run, and for the default configuration the findings are the same.

Do not read that as byte-identical. Coverage can differ across the fallback boundary: scanner-side gaps such as the decode-cap skip below do not cross the daemon wire, so the two routes can report a different coverage_gap_summary for the same input even when the findings match.

What the pipeline cannot tell you is whether the daemon was actually used. Omitting the flag prints nothing on either path, so a daemon you started but are not reaching looks exactly like one that is working. Pass --daemon=auto explicitly when that matters, and it says so on stderr when it fell back:

keyhog: daemon route not used (no daemon is listening on <socket>);
running in-process scanner

That notice is on stderr, so a pipeline that redirects stderr and pipes stdout into jq can be silently in-process and silently failing at the same time.

Force in-process execution when you want the scan isolated from daemon state:

kubectl get secret app -o yaml | keyhog scan --stdin --daemon=off

See GPU-backed daemon file queues for eligibility and lifecycle.

A pipeline that cannot report a false clean

set -o pipefail
rm -f keyhog.json
rc=0
vault kv get -format=json secret/app \
  | keyhog scan --stdin --format json-envelope -o keyhog.json || rc=$?

[ -f keyhog.json ] \
  || { echo "keyhog wrote no report; the scan did not run" >&2; exit 1; }
jq -e '.metadata.source_bytes_scanned > 0' keyhog.json \
  || { echo "keyhog scanned nothing; the producer failed" >&2; exit 1; }

case "$rc" in
  0)  echo "no policy-blocking finding over $(jq '.metadata.source_bytes_scanned' keyhog.json) bytes" ;;
  1)  echo "blocking findings"; exit 1 ;;
  10) echo "live credential"; exit 1 ;;
  13) echo "coverage incomplete"; exit 1 ;;
  *)  echo "keyhog failed: exit $rc"; exit 1 ;;
esac

The byte check runs before the exit-code branch on purpose. Exit 0 only means something when bytes were scanned.

Tell a real clean from a skipped input covers the report fields. Exit codes lists every code.

Watch mode

keyhog watch monitors directories and scans files as they change.

keyhog watch src/ config/

It runs in the foreground, compiles one scanner at startup, scans what is already in the tree, and then prints each finding as it happens:

🔍 stripe-secret-key /home/you/project/src/b.env:1 CRITICAL (1.00)  sk_l...EaNn sha256:<credential-digest>
👁  keyhog watch (☰ 934 detectors compiled)
    workers: 16
    watching: /home/you/project/src
    Ctrl-C to exit

Readiness means the tree is covered

The startup block is printed last, on purpose. By the time you see watching:, the filesystem watches are registered and every file already in the tree has been scanned. Findings from that first pass appear above the block, which is why the example shows one there.

That ordering is the whole guarantee. A file written while the scanner was compiling, or between compilation and watch registration, is still reported, because the startup pass sees it on disk. It is reported once: the same deduplication that collapses an editor’s event burst also collapses a file caught by both the startup pass and an event.

A large tree makes startup slower for the same reason. The watcher is scanning it. If a root holds more than 10000 files, the startup pass stops at that limit and says so, naming the keyhog scan command that covers the rest.

Watch still reports changes, not state. Run a full scan again after the watcher exits for any reason, because every change while it was down is unobserved:

keyhog scan src/ config/ --format json-envelope -o baseline.json

A scan also gives you the machine-readable coverage the watcher cannot.

What triggers a scan

A new file in a watched root is scanned. A new file in a subdirectory created after the watcher started is scanned, because roots are watched recursively.

A whole directory that appears in a watched tree is scanned, including the files it already contained. This covers mv ~/Downloads/config-dump src/, cp -r, tar -xf, and a dependency vendoring step. The kernel reports one event for the directory and none for its contents, so the watcher walks the new subtree itself rather than waiting for events that never come. The walk stops at 10000 files or 64 levels and says so; it does not follow a symlinked directory out of the watched root.

A modified file is rescanned in full. Every finding in that file is printed again, including ones you already saw. One editor save can produce more than one event, so the same finding can appear several times in a row. The output is a stream of events, not a deduplicated report.

An editor that saves by writing a temporary file and renaming it over the original is handled. The rename is a change to the destination path and the destination is rescanned. Some editors leave the temporary file visible long enough to be scanned too, so you may see the same finding reported once against a .swp-style name and once against the real one.

Nested or duplicate roots fold into their covering parent, the same as keyhog scan. Each root must be a directory.

Paths watch skips, and the one that stops it

A symlink is not followed, and neither is a FIFO, socket, or device node. keyhog scan declines the same paths, so this is shared policy rather than a watch limitation. Each one prints a line saying it was not scanned and that keyhog scan skips it too. These do not count toward --max-consecutive-failures, so a tree full of symlinks does not stop the watcher. A file that exists, is in policy, and still cannot be read, such as one you lack permission for, does count.

Deleting a watched root removes its watch. The kernel discards the watch along with the directory, so there is nothing left to observe and no way to tell that apart from a quiet tree. The watcher says so and then waits up to 30 seconds for the directory to come back, because a root is more often replaced than deleted:

WARN keyhog watch: watched root /home/you/project/build was removed; its
     filesystem watch is gone and changes under that path are NOT being
     observed. Waiting up to 30s for it to return.
OK   keyhog watch: /home/you/project/build is being watched again; rescanned
     12 file(s) to cover the gap while it was missing.

The rescan is the point. Nothing was observed between the two messages, so the whole subtree is scanned again on return rather than assumed unchanged. A file written while the root was missing is reported.

If the directory does not come back within that window, keyhog watch exits non-zero naming the root, rather than continuing to report a clean tree that nobody is watching.

What watch cannot do

keyhog watch prints text only. It has no --format and no --output. There is no JSON envelope, so there is no source_bytes_scanned, no coverage_gap_summary, and no machine-readable coverage signal. When you need a report you can check, run keyhog scan.

Suppression that runs after a match applies, so a change to app.min.js produces no output, for the same reason a scan of it produces no findings. See Minified and single-line files.

Exclusion by filename does not. keyhog scan skips lock files before reading them; keyhog watch only skips excluded DIRECTORY names, so it reports findings in files a scan of the same tree leaves out. Measured on one tree with a credential planted in each file, scan reports 4 and watch reports 9; the five extra are Cargo.lock, package-lock.json, pnpm-lock.yaml, yarn.lock, and go.sum. Watch is noisier here, never blinder: nothing that scan reports is missing from watch.

Watch is not the daemon. It compiles its own scanner, does not use the daemon socket, and does not appear in keyhog daemon status. Watch also does not scan Git history.

Limits

FlagMeaning
--max-file-size <BYTES>Maximum bytes per changed file. Same 100 MiB default as keyhog scan. Pass 0 for the built-in default.
--max-consecutive-failures <N>Exit after this many consecutive per-file scan engine failures. Default 8.
--detectors <DIR>Replacement detector corpus. An explicitly named missing path is an error.
--backend <BACKEND>Force one backend instead of persisted autoroute.
--quietPrint findings only, without the startup status block.

watch --max-file-size takes a bare byte count. scan --max-file-size requires a unit. These two are not interchangeable:

keyhog watch ~/projects --max-file-size 104857600
keyhog scan ~/projects --max-file-size 100M

Passing 104857600 to keyhog scan exits 2 with a message about the missing unit. Passing 100M to keyhog watch is not a byte count.

--max-consecutive-failures exists so a wedged scanner cannot keep silently dropping changed files. It counts scanner faults only. A path skipped by shared policy, such as a symlink or a file over --max-file-size, does not count, so ordinary repository layout cannot exhaust the budget. When the watcher does exit for that reason, treat every change since the first failure as unscanned and run a full scan.

Warm routing

The watcher warms its routes at startup and reuses that evidence for later file events, so it does not pay a cold backend start per change. Without valid autoroute calibration the watcher fails startup without scanning changes. Run keyhog calibrate-autoroute once on the host before starting the watcher.

A working loop

keyhog scan ~/projects --format json-envelope -o ~/keyhog-baseline.json
jq '{bytes: .metadata.source_bytes_scanned, status: .scan_status,
     gaps: .coverage_gap_summary}' ~/keyhog-baseline.json

keyhog watch ~/projects \
  --max-file-size 104857600 \
  --max-consecutive-failures 8

Read the baseline coverage before you trust the watcher. If the baseline scan did not reach your files, Tell a real clean from a skipped input explains why, and the watcher will have the same blind spots.

Mass scanning

A mass scan covers an inventory that is too large or too independent for one repository gate. Partition it by ownership and retry boundary, then preserve one report and raw exit code per partition. Use the GitHub Action guide for one checked-out repository and the CI integration guide for provider-specific job setup.

One command, whole account

For a single organization, group, workspace, or bucket, keyhog does the inventory traversal itself. No loop, no clone script:

export KEYHOG_GITHUB_TOKEN="$GH_PAT"
keyhog scan --daemon=off --github-org acme \
  --format json-envelope --output acme.json
KEYHOG_GITLAB_TOKEN="$GL_PAT" \
  keyhog scan --daemon=off --gitlab-group acme \
  --format json-envelope --output gitlab.json

KEYHOG_BITBUCKET_USERNAME="$U" KEYHOG_BITBUCKET_TOKEN="$P" \
  keyhog scan --daemon=off --bitbucket-workspace acme \
  --format json-envelope --output bitbucket.json

keyhog scan --daemon=off \
  --s3-bucket logs-prod --s3-prefix config/ \
  --format json-envelope --output s3.json

Each run traverses repositories or objects until a configured page, object, byte, or source limit binds. The envelope records source identity and names any remaining inventory as a coverage gap. That is the complete setup for one bounded provider target. The rest of this guide covers scanning across many targets (multiple orgs, mixed local and cloud sources, or thousands of repositories) where you partition and aggregate.

Use one bounded report and one exit status per partition when scanning many repositories, buckets, or files. Keep the partition manifest outside the scan tree so a scanner never treats its own answer key as input.

Local partitions

This shell pattern preserves every report and status without turning a partial partition into a clean result:

#!/usr/bin/env bash
set -u

out="${1:-keyhog-results}"
mkdir -p "$out"
overall=0

while IFS= read -r -d '' partition; do
  name="$(basename -- "$partition")"
  report="$out/$name.json"
  set +e
  keyhog scan --daemon=off "$partition" \
    --max-file-size 100MiB \
    --format json-envelope --output "$report"
  rc=$?
  set -e
  printf '%s\t%s\t%s\n' "$partition" "$rc" "$report" \
    >> "$out/status.tsv"
  # Keep each raw status in status.tsv. The wrapper only needs one nonzero
  # terminal status to make the aggregate CI job fail.
  (( rc != 0 )) && overall=1
done < <(find ./partitions -mindepth 1 -maxdepth 1 -type d -print0)

exit "$overall"

The envelope records scan-wide coverage and the resolved policy. Keep status.tsv with the reports; an aggregator must not discard a nonzero status just because another partition was clean. If a partition is retried, replace its report atomically and append a new attempt column or manifest row rather than overwriting the only evidence.

--max-file-size bounds each regular file. The default is 100 MiB. A larger file is skipped and recorded as a coverage gap. --limit-stdin-bytes does not bound a directory partition. It applies only to --stdin.

For CI, upload the whole output directory as an artifact and make the job fail on any status that the policy treats as actionable. Exit 13 means the scan completed with coverage gaps, not that it found nothing; inspect the envelope before deciding whether a retry is safe. Exit 2 or 3 is an input or system failure and needs operator attention. See exit codes.

Hosted Git and cloud inventories

The source flags keep inventory traversal inside KeyHog so source identity and coverage remain in the report:

keyhog scan --daemon=off --github-org "$ORG" \
  --limit-hosted-git-pages 100 \
  --format json-envelope --output github.json

keyhog scan --daemon=off --gitlab-group "$GROUP" \
  --limit-hosted-git-pages 100 \
  --format json-envelope --output gitlab.json

keyhog scan --daemon=off \
  --s3-bucket "$BUCKET" --s3-prefix "$PREFIX" \
  --limit-cloud-max-objects 10000 --limit-s3-object-bytes 100MiB \
  --format json-envelope --output s3.json

keyhog scan --daemon=off \
  --gcs-bucket "$BUCKET" --gcs-prefix "$PREFIX" \
  --limit-cloud-max-objects 10000 --limit-gcs-object-bytes 100MiB \
  --format json-envelope --output gcs.json

Use the credential environment variables documented by keyhog scan --help and environment variables; do not put provider tokens in the command line. Azure Blob uses --azure-container-url and its matching prefix/object limits. A page or object cap is deliberate bounded coverage: the report names the limit and exits 13 when more inventory remains.

Hosted APIs and cloud listings can return transient transport or rate-limit errors. Retry only the failed source with bounded exponential backoff, keep the original partial envelope, and preserve the provider request diagnostics. Do not increase object/page caps automatically, and do not classify a rate-limit failure as a clean scan. Respect each provider’s pagination and retry headers.

Daemon and corpus semantics at scale

Use --daemon=off when a scan needs baseline state, live verification, lockdown, a preset, a detector overlay, a custom allowlist, or another per-scan engine policy. The mass route accepts spec-bound incremental state for daemon-local filesystem roots; the other contracts remain in process.

Use the explicit mass service for standard-policy directory trees, Git history, hosted inventories, cloud buckets, archives, binaries, and remote endpoints:

keyhog calibrate-autoroute --policy default
keyhog daemon start --mass --socket /run/user/$UID/keyhog-mass.sock

keyhog scan --daemon=mass \
  --daemon-socket /run/user/$UID/keyhog-mass.sock \
  ./partitions/team-a \
  --format json-envelope --output team-a.json

For warm unchanged-tree scans, add --incremental --incremental-cache /absolute/path/merkle.idx. The daemon loads and publishes that spec-bound generation without rebuilding its scanner. Files with findings are forgotten before publication and remain visible on every scan. Trusted metadata and content-confirmed Merkle skips remain complete coverage when no source bytes need scanner dispatch.

--daemon=mass is required routing. A missing, warm-only, stale, or incompatible service is an error. KeyHog does not fall back to an in-process scan. Policy incompatibility is checked before source acquisition.

GPU-backed daemon worker

For a local filesystem root, the client sends only canonical path and source-policy metadata. The daemon reads and batches the files without copying payload bytes through IPC. Sources that require client-side credentials, such as hosted Git and cloud inventories, use protected wire frames. Both paths keep each batch at no more than 8 MiB of raw payload and 1,024 chunks. The daemon processes each batch with its persisted autoroute decision while retaining one exclusive fragment-state lease for the transaction. It clears fragment state when the transaction ends or the client disconnects. This bounds batch memory independently of total inventory size. Response JSON is written directly into its bounded transport frame, so the daemon does not retain a second complete serialized response body. Daemon-local acquisition uses one drain request. The daemon streams one bounded result response per batch followed by the terminal completion response. Socket backpressure remains the memory bound; the client does not pause each batch to send another request.

The completion receipt contains exact total and GPU batches, chunks, bytes, and daemon execution time. Protected wire mode compares total chunks and bytes with the sent stream. Daemon-local path mode uses the daemon receipt as source-byte authority. Stderr reports the transport, GPU byte share, whether GPU processed more than half of all bytes, and throughput. Invalid receipt invariants fail. Source acquisition gaps remain visible in the envelope and exit 13.

Check keyhog daemon status --socket /run/user/$UID/keyhog-mass.sock before admitting jobs. The daemon serializes fragment-sensitive engine work, so additional concurrent clients do not create extra GPU lanes. Scale across separately budgeted worker hosts, not unbounded clients on one socket.

Routine workers use persisted autoroute evidence. Add --mass-gpu-primary when the worker must prove that GPU processed more than half of all non-empty payload bytes. The client rejects a CPU-majority receipt before producing the final report. A forced --backend gpu-cuda-region-presence, gpu-metal-region-presence, or gpu-wgpu-region-presence service is a diagnostic GPU-only contract. It exits 12 when required GPU startup fails and returns a request error instead of substituting CPU after a runtime fault. It does not prove that GPU is the fastest route for the workload.

The daemon and client must use the same replacement detector corpus. Overlay composition is unsupported. Start the service with the reviewed replacement corpus, then select that same corpus on the client:

keyhog daemon start --mass --detectors ./reviewed-detectors
keyhog scan --daemon=mass \
  --detectors ./reviewed-detectors --detectors-mode=replace \
  ./partitions/team-a

A replacement identity mismatch is a handshake error. See daemon and warm scans for lifecycle, socket, eligibility, and retry behavior.

For a large inventory, partition at the provider or repository boundary. Calibrate autoroute on the actual worker class and retain the per-partition resolved policy, coverage envelope, and execution receipt. Missing or stale autoroute evidence leaves the affected batch unscanned and records incomplete coverage. It does not silently claim calibrated CPU, Hyperscan, or GPU execution.

Concurrency and worker sizing

Each KeyHog process uses the available CPU cores by default. This is the right default for one dedicated partition. It can oversubscribe a worker when your CI or scheduler starts several partitions on the same host.

Allocate the host CPU budget across concurrent processes. For example, four partition jobs on a 16-vCPU worker can each start with --threads 4. This is a resource allocation example, not a universal optimum. Leave --reader-threads unset until profiling shows that storage readers are the bottleneck; its default derives from the scanner worker pool.

Keep these boundaries when increasing concurrency:

  • Give every partition its own json-envelope report, raw exit code, incremental cache, and retry identity.
  • Keep one incremental cache bound to one trusted repository or partition. Sharing it across unrelated jobs turns reuse into cross-workspace state.
  • Bound provider jobs by API quotas and pagination limits as well as CPU. Live verification has separate --verify-concurrency, --verify-rate, and --verify-batch controls.
  • Use --daemon=mass for standard-policy directory, history, archive, remote, or cloud streams. Use --daemon=off when the partition needs policy state that the mass service rejects.
  • Aggregate only after every concurrent partition has reached a terminal state. One successful job cannot erase another job’s coverage gap or error.

Use --profile on representative partitions before changing advanced --reader-threads, --fused-batch, or --fused-depth values. Keep those controls unset when a repeatable target-host measurement does not show a benefit.

Report aggregation

Aggregate only after every partition has a terminal envelope. One clean partition never cancels another partition’s coverage gap or error.

Read every report at once:

cat keyhog-results/*.json | jq -s '{
  partitions: length,
  findings: (map(.findings | length) | add),
  coverage_gaps: (map(.coverage_gap_summary | length) | add),
  incomplete: [ .[] | select(.scan_status != "success") | .metadata.targets[0] ]
}'
{
  "partitions": 2,
  "findings": 1,
  "coverage_gaps": 0,
  "incomplete": []
}

Act on incomplete first. A partition listed there did not cover its input, so its finding count proves nothing. Fix the cause, rerun that partition alone, and replace only its report. The findings total is trustworthy once incomplete is empty.

The envelope carries coverage state, not the process exit code, so keep status.tsv beside the reports. The wrapper in Local partitions already exits nonzero when any partition did; status.tsv is what tells a reviewer which partition produced which raw code, because one aggregate exit code cannot express two different failures.

Preserve the partition identity, source inventory, resolved policy, coverage state, finding count, and exit code. JSON and JSONL legacy formats contain findings only; json-envelope and jsonl-envelope are the recommended machine contracts for mass scans because they carry terminal coverage and identity. Never concatenate JSON arrays or merge findings before deduplicating with the partition and location identity.

Daemon and warm scans

The Unix daemon keeps one compiled scanner and its backend state warm. The default service handles repeated standard-policy scans of stdin or one regular file. Starting it with --mass also accepts bounded streams acquired from directories, repositories, archives, binaries, remote endpoints, and cloud inventories. Watch and system-wide scans remain in process.

For the repository map and bytes-to-finding pipeline, see Architecture. For backend selection and autoroute, see Backends and routing and Autoroute calibration.

Starting the daemon is an explicit operational step. KeyHog never starts one for you. Run the server in one terminal or under a service manager:

# Terminal 1. This stays in the foreground until stop or a fatal service error.
keyhog daemon start

Wait for the ready line before sending required requests. Use a second terminal for scans and administration:

# Terminal 2. Omitted --daemon means --daemon=auto on Unix.
keyhog scan --stdin < changed-file.txt
keyhog scan path/to/one-file.txt

# Require daemon execution. This fails rather than changing execution mode.
keyhog scan --daemon=on path/to/one-file.txt

# Guarantee in-process execution even while the daemon is ready.
keyhog scan --daemon=off path/to/one-file.txt

keyhog daemon status
keyhog daemon stop

Background guard daemon lifecycle

When using Perpetual Guard (keyhog guard), you can manage the daemon in the background directly with keyhog guard up and keyhog guard down:

# Start the background daemon if not running and reconcile durable roots
keyhog guard up

# Stop the daemon cleanly while preserving durable registrations and indexes
keyhog guard down

Start an opt-in mass service when one worker should process a large source stream:

# Terminal 1.
keyhog daemon start --mass

# Terminal 2. This required route never retries in process.
keyhog scan --daemon=mass /srv/inventory/team-a \
  --format json-envelope --output team-a.json

The client sends at most 8 MiB and 1,024 chunks per batch. The terminal receipt reports exact total and GPU batches, chunks, bytes, GPU share, and throughput. The client rejects a receipt that does not match the bytes it sent.

keyhog watch is separate. It is a foreground filesystem watcher with its own compiled scanner. It does not use the daemon socket and does not appear in daemon status.

The watch and scan-system subcommands do not accept --daemon. Each compiles and owns an in-process scanner. A running daemon neither serves nor accelerates those commands.

Lifecycle and readiness

daemon start prints a compilation message first. The service can accept requests only after this line appears:

keyhog daemon ready on <socket> (<count> detectors, wire=<version>)

The ready line follows detector loading, scanner compilation, backend validation and warmup, socket binding, and socket permission checks. Startup fails instead of announcing readiness when any required step fails. With a valid decision table, an autorouted daemon warms only peers selected by at least one persisted warm-daemon route. An acquired but unused peer cannot block readiness, while every selected peer must initialize and warm successfully. Missing, stale, or invalid autoroute state prevents daemon readiness. A forced --backend gpu-cuda|gpu-metal|gpu-wgpu|simd|cpu is a diagnostic startup choice and must be usable as requested. See Autoroute calibration.

GPU startup failures retain their stage and exit 12. This covers required GPU preflight, scanner compilation, an unavailable or incompatible backend, and degradation during the readiness warmup. The diagnostic tells the operator to run keyhog backend --self-test, repair the driver/runtime, or start with --backend simd or --backend cpu. An invalid backend value or unrelated daemon configuration error remains exit 2.

After readiness, an automatically routed accelerated-backend fault does not kill the service or drop the request. The daemon warns and replays that request’s stable text or file input through the fastest remaining measured-correct peer. GPU recovery replays only exact unprocessed ranges. The daemon records recovered ranges and bytes, quarantines that workload route, and keeps unrelated requests alive. Later requests for the quarantined workload fail closed: no backend is selected, the affected batch remains unscanned, and the response names the required recalibration. Runtime route health is persisted separately from timing evidence, so restarting the daemon cannot erase quarantine; successful recalibration clears only the repaired workload identity. A forced GPU daemon remains an explicit contract and returns a request error instead of substituting another backend.

daemon status connects to an existing service. It reports uptime, completed scan attempts, active scans, detector count, backend policy, and identity staleness. scans served includes attempts that returned a daemon error, so it is an activity counter rather than a success counter. Status never starts a daemon. It also prints whether the service accepts warm stdin and single-file requests or mass source batches. Warm stdin and single-file requests return before baseline, verification, lockdown, and per-request scanner-policy post-steps. The mass route additionally accepts Merkle state for daemon-local filesystem roots. active scans counts accepted scan attempts until their blocking task finishes, including attempts queued behind the scanner’s fragment-state lock. Backend health reports the number of recovered authenticated-route requests and the last failed and recovery backend with recovered byte count. After a restart with persisted runtime quarantine, status prints backend policy: autoroute degraded; healthy workload routes remain usable and affected routes fail closed without scanning until recalibration clears them. The daemon can frame multiple client connections concurrently, but production scanner execution is serialized so fragment state cannot cross requests.

daemon stop sends a shutdown request and succeeds after receiving the acknowledgement. The server then stops accepting connections and removes the socket. The current implementation does not wait for other active scan handlers to finish. Check that daemon status reports 0 active before stopping when in-flight requests must complete. An abrupt process exit can leave a socket entry. The next start removes it only after the stale-socket trust checks pass.

Use the service manager as the process owner when it started the daemon. Before restarting, run daemon status against the same socket and wait for the status line to show 0 active when requests must finish. Then use daemon stop, or stop the service-manager unit. Start the replacement process and wait for its ready line. Do not delete a live socket to restart the service. Removing the path does not stop the listener and can leave an unreachable daemon running.

What the daemon actually buys

The warm single-file asset is the compiled scanner and its backend state. There is no per-connection or per-path result cache on that route, so a repeated single-file scan costs the same as the first request. Mass daemon filesystem scans can also persist a spec-bound Merkle generation. An unchanged clean file then bypasses its read and scanner dispatch. A file that produced a finding is excluded from the generation and remains visible on every transaction.

Measured on one 16-core host, medians of five runs, machine load average 51-56 on 32 logical cores, so read the ratios rather than the absolute seconds:

Scan of one fileIn processWarm daemonSpeedupClient peak RSS
13 KB0.95 s0.17 s5.7x549 MB in process, 39 MB over the daemon
2.9 MB0.91 s0.20 s4.5x551 MB in process, 39 MB over the daemon
29.8 MB0.75 s0.27 s2.8x614 MB in process, 39 MB over the daemon

The saving is startup, not scanning. Measured on the socket with no client process in the way, the daemon spends 0.8 ms on the 13 KB file, 30 ms on the 2.9 MB one and 103 ms on the 29.8 MB one. Everything above that is process start plus scanner compilation, which the daemon pays once at daemon start instead of once per scan. That is why the speedup shrinks as the file grows: the fixed cost you avoid stays the same while the work you still do goes up.

The memory difference is the larger effect and it is what makes the daemon worth running for many small scans. A daemon-served client holds about 39 MB. An in-process client holds about 550 MB, because it builds its own scanner. Eight concurrent scans of a 13 KB file finished in 0.22 s through the daemon against 1.80 s in process, and peaked at about 0.3 GB across all eight clients against about 4.3 GB.

A daemon that will only ever serve small files still pays the full engine, so daemon start is worth it when scans are frequent, and not worth it for one scan.

Footprint over a long session

There is no idle shutdown and no TTL. A daemon runs until daemon stop, a fatal service error, or the service manager stops it. It holds its scanner the whole time, so an idle daemon is not free.

Measured with the embedded corpus on the same host: about 538 MB resident at the ready line, unchanged after 15 seconds idle. After 1,000 warm scans of a 2.9 MB file it reached about 606 MB, with the second five hundred of those scans adding 1.2 MB. Descriptors stayed at 10 and threads at 35. The growth is allocator and scratch warm-up that plateaus, not accumulation per request.

Concurrency and queueing

The daemon frames many connections at once but runs one scan at a time, so fragment-reassembly state cannot cross requests. Plan for that: a large scan delays every small scan behind it for its whole duration.

Measured on the socket, same host: eight concurrent 29.8 MB scans finished in 0.74 s against 0.70 s for the same eight run one after another, and one 13 KB scan that arrived behind a 29.8 MB scan took 60 ms instead of its solo 0.8 ms. Under heavier load the ratio holds and the penalty grows: at load average 178 the same queued 13 KB scan took 403 ms against a 2.8 ms solo.

A queued client sees nothing until its result arrives. The protocol has no queued notice, no position, and no estimate, so a client waiting on a busy daemon is indistinguishable from a hung one. Use daemon status from another terminal to tell them apart: it reports active scans, and the control plane answers while the data plane is busy.

Socket selection and trust

The server, scan client, status command, and stop command use the same default socket resolver:

  1. $XDG_RUNTIME_DIR/keyhog.sock when XDG_RUNTIME_DIR is set.
  2. The OS user-cache directory plus keyhog/server.sock.
  3. The OS temporary directory plus keyhog/server.sock.

The usual cache paths are ~/.cache/keyhog/server.sock on Linux and ~/Library/Caches/keyhog/server.sock on macOS. There is no KeyHog socket environment variable. For a fixed location, pass the same path at both ends:

keyhog daemon start --socket /private/path/keyhog.sock
keyhog scan --daemon=on --daemon-socket /private/path/keyhog.sock one-file.txt
keyhog daemon status --socket /private/path/keyhog.sock
keyhog daemon stop --socket /private/path/keyhog.sock

The socket carries unredacted matches between same-user processes. The server requires an owned, non-symlinked socket path, tightens a created parent to mode 0700, and requires the socket itself to be mode 0600. Both client and server verify the connected peer UID. A stale entry is removed only when it is an owned 0600 Unix socket in a trusted directory and no listener accepts a connection. KeyHog refuses ordinary files, symlinks, foreign owners, loose permissions, and a live socket rather than replacing them. An untrusted stale entry is not removed automatically. Correct or remove it after verifying the path and owner, then start the daemon again.

Windows ships no daemon transport. An absent daemon flag or --daemon=off runs in process. Explicit --daemon=auto, --daemon=on, and all daemon subcommands fail with the Unix-only error.

Routing contract

On Unix, omitting --daemon is equivalent to --daemon=auto. Bare --daemon is equivalent to --daemon=on.

PolicyEligible and compatible daemonNo usable daemonIncompatible request
--daemon=auto or omittedUse the daemon. A connection, handshake, request, or daemon execution error is printed, then the request is retried in process.Run in process. A stale socket that exists is attempted, so its failure is printed before the retry.Run in process without sending a daemon request. An explicit --daemon prints the reason.
--daemon=on or bare --daemonRequire the daemon result.Exit with the specific availability, trust, identity, or protocol error.Exit with the specific unsupported requirement.
--daemon=massRequire a daemon started with --mass, stream bounded source batches, and validate its execution receipt. Daemon-local filesystem batches retire as a bounded response stream after one drain request.Exit with the specific availability, trust, identity, or protocol error.Exit before source acquisition when scanner policy is incompatible.
--daemon=offDo not connect.Run in process.Run in process.

--daemon=on, bare --daemon, and --daemon=mass require the daemon route. An unavailable service is an error. A daemon that cannot honor the source or policy is also an error. No in-process retry occurs. --daemon=auto is the opportunistic warm route. It can use a reachable daemon only when it can honor the request. It otherwise keeps compatible one-file or bounded-stdin requests in process, and retries them in process after a daemon execution failure.

--daemon-socket cannot be combined with --daemon=off.

The socket state and daemon state are separate signals. Use this matrix when diagnosing an automatic route:

Observed state--daemon=auto / omitted--daemon=ondaemon status / daemon stop
No socket entryRun in process. An explicit --daemon prints no daemon is listening on <socket>; an omitted flag prints nothing.Fail with daemon-unavailable exit 2.Fail with service-unavailable exit 2.
Trusted stale 0600 socketAttempt once, report the connection failure, then retry in process. Automatic scans never unlink it.Fail with the specific stale/availability error.Inspect or stop only after a trusted handshake; stale cleanup belongs to the next trusted daemon start.
Live compatible daemonSend the eligible request and use its validated result.Send the eligible request and require its result.Report live identity and counters, or acknowledge stop.
Live but wire-incompatible daemonReport the mismatch, then retry an eligible request in process.Fail before scanning with the exact wire mismatch.Report the mismatch; the current protocol does not inspect or stop it.
Untrusted entry or peerReport the trust failure, then retry an eligible request in process.Fail before scanning with the exact trust error.Refuse to unlink or operate on the entry.

Some compatibility failures can be known only after connecting. This includes a detector identity or wire mismatch. Under auto, KeyHog reports that failure and retries the same eligible input in process. Under on, it returns the failure without rescanning. A request that is known to be unsupported from its source or policy does not connect in either mode.

The automatic retry boundary is a fully decoded and validated ScanResults response. Failures before that boundary, including incompatible required wire fields, retry in process under auto. Allowlist loading, finalization, output creation, serialization, and report writes occur after that boundary. Those client-side failures return directly and never rescan. This prevents duplicate or mixed output after a partial write.

stdin is single-consumer, so the client acquires it into one bounded replay buffer before sending ScanText. If an automatic daemon request fails before the validated result boundary, the in-process retry scans that same buffer as the stdin source. It does not read the pipe again, and it preserves the configured byte limit, source metadata, and lossy UTF-8 decoding. A successful daemon response releases the buffer with the rest of the request.

An automatic in-process retry uses the normal one-shot autoroute contract. It does not pin CPU to make the retry succeed. Missing or stale one-shot evidence therefore remains a visible calibration error.

GPU-backed mass worker

Calibrate the worker host, then start an opt-in mass service under a service manager:

keyhog calibrate-autoroute --policy default
keyhog daemon start --mass --socket /run/user/$UID/keyhog-mass.sock

The persisted decision table may select CPU, Hyperscan, CUDA, Metal, or WGPU for each batch. Confirm the ready identity before admitting jobs:

keyhog daemon status --socket /run/user/$UID/keyhog-mass.sock
keyhog scan --daemon=mass \
  --daemon-socket /run/user/$UID/keyhog-mass.sock \
  /srv/inventory/team-a --format json-envelope --output team-a.json

Each batch contains no more than 8 MiB of raw payload and 1,024 chunks. The daemon holds an exclusive fragment-state lease across the transaction and clears it on completion or disconnect. Additional concurrent clients do not create extra GPU lanes. Partition across separately budgeted hosts when one worker is saturated. Response JSON is written directly into the bounded transport frame. The 64 MiB frame ceiling is enforced during serialization, and a failed response rolls back without leaving partial bytes for the next frame.

The completion receipt records exact total and GPU batches, chunks, bytes, and daemon execution time. The client verifies total chunks and bytes against its sent stream. Stderr reports GPU byte share, whether GPU handled more than half of all bytes, and throughput. Acquisition gaps remain visible in the report and use exit 13.

Add --mass-gpu-primary when each completed transaction must prove that GPU processed more than half of all non-empty payload bytes. The client rejects a CPU-majority receipt before reporting. You may force --backend gpu-cuda-region-presence, gpu-metal-region-presence, or gpu-wgpu-region-presence at daemon startup for diagnostics. A forced GPU service exits 12 when required startup fails and returns a request error instead of substituting CPU after a runtime fault. Routine workers use persisted autoroute evidence. A forced backend is not proof that the route is fastest for the exact workload.

Request eligibility

The warm route accepts exactly one primary input:

  • --stdin, subject to the configured stdin byte limit
  • one path whose metadata identifies it as a regular file

The mass route accepts the source classes supported by keyhog scan, including directories, Git modes, archives, binaries, remote endpoints, hosted Git, and cloud inventories. Local filesystem roots send only canonical path and policy metadata; payload bytes remain in the daemon process. Sources that require client-side credentials stream protected chunks to the daemon. Eligible requests may still use client-owned reporting and finalization such as output formats, output files, deduplication, bundled test-fixture suppression, local default allowlists, inline suppression, and --dogfood. Dogfood detail is request-scoped and bounded; exact aggregate counters are carried separately.

The daemon can use an explicit replacement corpus. Start it and scan with the same reviewed directory:

keyhog daemon start --detectors ./reviewed-detectors
keyhog scan --daemon=on \
  --detectors ./reviewed-detectors \
  --detectors-mode=replace \
  one-file.txt

The daemon startup flag always selects a complete replacement corpus. It does not compose that directory over the embedded corpus. The scan-side --detectors-mode=replace spelling is optional, but makes the contract visible. The client derives the expected rules identity from its selected directory. The warm route is accepted only when the daemon compiled the exact same rules. The report records the replacement count, digest, source, and mode.

Start the daemon from a directory you chose on purpose. --detectors defaults to the relative path detectors, and when that path does not exist KeyHog searches the installed locations and then the directory holding the keyhog executable. A daemon started inside a checkout that happens to contain detectors/ therefore compiles that corpus, while a client that passes no --detectors uses the embedded one. Their rules digests differ, so every default client is refused with a detector rules identity mismatch until the daemon is restarted somewhere else. The ready line names the corpus the daemon actually compiled, so read the count before you send work:

keyhog daemon ready on <socket> (<count> detectors, wire=12, warm generation=...)

--detectors-mode=overlay is not daemon-compatible. The daemon cannot rebuild its warm scanner for a per-request overlay. With --daemon=auto, an overlay scan stays in process without opening the socket. With --daemon=on, it fails before scanning. Use --daemon=off to make the supported execution mode explicit:

keyhog scan --daemon=off \
  --detectors ./site-detectors \
  --detectors-mode=overlay \
  source-tree/

If the client and daemon select different replacement corpora, the handshake fails. auto prints the identity error and retries in process. on prints the identity error and exits. It never substitutes the daemon’s corpus.

The warm route requires the in-process orchestrator for directories, multiple roots, Git modes, remote, cloud, container, binary, dynamic, or mixed sources. The mass route accepts those source classes, but it requires the daemon-owned standard scanner policy. Both routes reject these per-scan contracts:

  • baseline filtering or live verification
  • Merkle/incremental source state on the warm route; the mass route accepts it only for daemon-local filesystem roots
  • --fast, --deep, --precision, benchmark mode, or changes to decode, entropy, ML, Unicode normalization, comment scanning, scanner limits, detector vocabulary, or detector overlay composition
  • a replacement corpus whose rules identity does not exactly match the daemon
  • per-request backend, GPU, batch-pipeline, autoroute, cache, or calibration controls
  • path-exclusion changes
  • lockdown, secret display, client-safe hiding, confidence or severity floors, custom AWS canaries, detector disable or confidence policy, allowlist governance, or a malformed effective configuration

--daemon=auto keeps a source or policy rejected by the warm route in process. A replacement identity mismatch is learned during the handshake, then auto reports it and retries in process. --daemon=on and --daemon=mass fail without an in-process scan. The mass route checks incompatible policy before source acquisition. Daemon availability cannot weaken the selected policy.

These examples show the routing boundary:

# Standard-policy directory stream through an opt-in mass service.
keyhog scan --daemon=mass source-tree/

# The same directory with baseline state stays in process.
keyhog scan --daemon=off --baseline .keyhog-baseline.json source-tree/

# Overlay composition remains in process.
keyhog scan --daemon=off \
  --detectors ./site-detectors --detectors-mode=overlay one-file.txt

Identity, wire data, and coverage

Every connection begins with a versioned handshake. Scan clients require all of these values to match the current client:

  • wire version
  • KeyHog package version
  • Git build hash
  • canonical detector-rules digest

For the default corpus, the detector digest is compared with the client’s embedded rules. For an explicit replacement corpus, the client derives the expected digest from its own selected directory and accepts only an exact match. Overlay composition is not daemon-eligible. The handshake also carries the daemon-owned backend policy. It must be autoroute or a canonical forced backend label. A scan client rejects an unknown label. daemon status and daemon stop tolerate package, build, and detector identity staleness so you can inspect and terminate an old service. They still require a compatible wire protocol. Stale status prints the exact mismatch and exits successfully because the health request succeeded.

Current scan results require matches, example-suppression count, dogfood detail, exact static-recovery rejection aggregates, dropped-detail count, and source coverage gaps. Missing fields are malformed protocol data. The client never invents zero values for absent coverage or telemetry.

Coverage gaps include oversized or binary input, unreadable data or Git objects, archive truncation, unresolved binary section names, source truncation, structured-source parse failures, unavailable archive duplicate scans, and Git LFS pointers. The client prints a warning whenever any count is nonzero. Current exit behavior is:

Daemon scan outcomeExit
No findings and complete coverage0
One or more findings1
No findings and one or more coverage gaps13
SIGINT / Ctrl-C130

A scan with both findings and coverage gaps exits 1 and prints the incomplete coverage warning. It is not reported as clean.

Administrative and routing errors

Daemon availability, eligibility, trust, handshake, and ordinary operator-correctable path errors normally exit 2. This includes forced --daemon=on without a usable service, status or stop without a service, an incompatible forced request, and invalid startup configuration. Low-level operating-system I/O failures outside the operator-input classes exit 3. Daemon GPU validation, initialization, and warmup failures exit 12. A forced GPU dispatch failure after readiness returns a request error. An autorouted dispatch fault completes against the same stable request through the visible recovery contract when full coverage is possible. If an auto request fails inside the daemon, KeyHog reports the error and retries in process; the retry then owns its normal exit semantics, including automatic backend recovery or 12 when GPU was explicitly required.

A fatal listener accept or connection-handler spawn error prints a failure, stops the service, removes the daemon socket, and makes daemon start exit 3. The typed service failure remains distinct from requested daemon stop, which cleans up the same socket and leaves daemon start at exit 0.

daemon status against an identity-stale but wire-compatible service exits 0 and prints a warning. daemon stop can stop that service. A wire-incompatible service cannot be inspected or stopped through the current protocol. Stop it with the matching KeyHog binary or the service manager that owns it.

daemon start --request-timeout-secs <N> limits how long a connected client may take to deliver one complete request frame. The default is 300 seconds. On timeout, the daemon closes that connection and reclaims its concurrency slot. This is a request-read deadline, not a scan execution deadline.

After a request is sent, the client applies a response deadline by request kind. Hello, Health, and Shutdown use 5 seconds. ScanText uses 60 seconds. ScanPath uses 300 seconds. A timeout fails with restart guidance. Automatic scan routing may then use its documented in-process recovery path, while --daemon=on, daemon status, and daemon stop return an error.

Reading the in-process notice

When you pass --daemon explicitly and the scan runs in process anyway, KeyHog prints the reason on stderr:

keyhog: daemon route not used (the daemon only supports exactly one source: ...); running in-process scanner
keyhog: daemon route not used (no daemon is listening on /run/user/1000/keyhog.sock); running in-process scanner

Omitting --daemon prints nothing, because the flag defaults to auto and most scans can never use the warm route. --daemon=off prints nothing either, since it already asked for the in-process path. Use --daemon=auto explicitly whenever you are comparing timings: without it you cannot tell a daemon-served scan from an in-process one, and a daemon you started but never reached looks exactly like one that is working.

GitHub collaboration scans

Repository clones do not contain every place a credential can be pasted. Select collaboration surfaces explicitly when they are part of the review boundary:

export KEYHOG_GITHUB_TOKEN='read-only-token'

keyhog scan \
  --github-collaboration acme/payments \
  --github-all

No collaboration surface is implied by --github-org. A repository-only scan makes no issue, pull request, discussion, wiki, or gist request.

Surface contract

FlagScanned content
--github-allEvery surface listed below. Use this for a complete collaboration scan.
--github-issuesIssue title, body, and issue comments. Pull requests returned by the issues API are excluded.
--github-pull-requestsPull request title and body, conversation comments, review summaries, and inline review comments.
--github-discussionsDiscussion title, body, top-level comments, and replies through the GitHub GraphQL API.
--github-wikiEvery reachable unique blob in the full <repo>.wiki.git history.
--github-gistsEvery listed revision and comment for public gists owned by the repository owner. This is a public account surface, not a repository-only surface.

Pass --github-all for the complete set, or select individual flags for the surfaces the token is allowed to read. Use KEYHOG_GITHUB_TOKEN instead of --github-token so the credential does not enter the process argument list.

Limit a fine-grained token to the target repository and grant read-only access for the selected Issues, Pull requests, Discussions, and Contents resources. A classic token may need repo for private repository surfaces. Public access still depends on the repository and organization policy. Gist scanning does not claim private or secret gists because repository ownership does not identify the authenticated token owner.

Bounds and coverage

All API calls share one request budget. --limit-hosted-git-pages controls that budget for a collaboration source, including item pages, comment pages, gist revisions, and GraphQL pages. API responses use --limit-web-response-bytes. Collaboration chunks also honor the Git aggregate byte and chunk limits.

An inaccessible selected surface produces a typed inaccessible source coverage error. A request, response, byte, or chunk cap produces a typed truncated source coverage error. These errors make coverage incomplete. KeyHog does not report the selected surface as clean.

GitHub response bodies and credentials are never included in diagnostics. Redirects are disabled while an authorization header is present.

Provenance and edits

Findings use credential-free github:// paths. Issue, pull request, discussion, and comment revisions combine the immutable GitHub node identity with its updated_at value. Wiki and gist revisions use Git object IDs. Repeated objects with the same immutable revision identity are scanned once. Edited content gets a new reproducible revision identity.

System-wide credential triage

keyhog scan-system performs a one-shot audit across the mounted filesystems that KeyHog can enumerate and read. It scans working-tree files first. It also discovers Git repositories and scans their history unless you disable that step.

Start with a bounded local sweep:

sudo keyhog scan-system --space 10G 2>scan-system.log

Keep the exit status and stderr log. Stderr contains the selected mounts, coverage warnings, byte count, and terminal summary. Add --output findings.json when you also need a redacted JSON findings array.

Run with enough privilege to read the trees in scope. An unreadable path is a coverage gap. KeyHog reports the gap and does not describe the run as complete. Use elevated privilege only on a host and scope you are authorized to audit.

Choose the host boundary explicitly:

GoalCommandCoverage and cost
Quick working-file health checksudo keyhog scan-system --space 50G --no-git-historySkips discovered Git history. Choose a space ceiling large enough for the local files you intend to cover.
Full local-host recoverysudo keyhog scan-system --space 50GScans eligible filesystem data, then reachable additions from every discovered Git repository.
Authorized network-estate sweepsudo keyhog scan-system --space 1T --include-networkAdds NFS, SMB, and other network mounts. It can be slow and may cross ownership boundaries, so opt in only with authorization.
Shared-host CPU budgetsudo keyhog scan-system --space 50G --threads <N>Caps parallel scanner workers. Leaving it unset uses the available CPU cores.

The quick command is not a substitute for the full recovery command. Keep its report labeled as a working-file-only system check.

Execution mode

scan-system always builds and runs its scanner in process. It does not accept --daemon, and a running daemon does not change its behavior. This is distinct from keyhog scan --daemon=auto, which can route an eligible single file or stdin request through a warm daemon.

Use watch after the one-shot sweep when you need to inspect future file changes:

keyhog watch ~/projects \
  --max-file-size 104857600 \
  --max-consecutive-failures 8

watch is also an in-process foreground command. It compiles one scanner and scans changed regular files. It does not perform the initial directory sweep or Git-history scan for you. Run keyhog scan --daemon=off ~/projects for a full rescan after a watch failure. The watcher exits after eight consecutive scan engine failures by default so a broken scanner does not keep dropping changes.

What the system sweep walks

The command discovers targets instead of accepting a path. It enumerates mounted filesystems and skips pseudo-filesystems such as /proc, /sys, tmpfs, and nsfs. Network mounts such as NFS, SMB, and sshfs are skipped by default. Add --include-network only when that remote scope is authorized:

sudo keyhog scan-system --space 1T --include-network

During discovery, KeyHog finds ordinary worktrees, bare repositories, and submodules. After the filesystem walk, it scans their Git histories. A credential committed and later deleted can therefore surface. Use --no-git-history when only current files are in scope:

sudo keyhog scan-system --space 10G --no-git-history

A binary built without the git feature cannot scan discovered history. KeyHog reports each skipped history as a coverage gap. Reinstall a build with Git support, or use --no-git-history to state the reduced scope.

Space and findings limits

--space <SIZE> is a hard aggregate byte ceiling. The default is 50G, which means 50 GiB. Sizes require a unit and accept B, K, M, G, or T and their KB or KiB forms. Fractional values such as 1.5G are accepted.

KeyHog stops before a chunk that would cross the ceiling. Filesystem data is scanned before Git history, so a small ceiling can leave later mounts and histories untouched. Reaching the ceiling records a coverage gap. Raise the limit and rerun when complete host coverage is required.

The command retains at most 1,000,000 redacted findings in memory and continues counting additional findings. A warning says when detail was dropped. Preserve the stderr summary with the JSON output.

The terminal states are:

ResultExit
No findings and complete coverage0
One or more findings, including a partial scan1
No findings and one or more coverage gaps13
Invalid arguments or operator input2
System or I/O failure3

Exit 13 is not a clean result. Correct permissions, raise --space, restore Git support, or isolate the affected path with a normal in-process scan.

Detector corpus

Both system scans and watchers accept an explicit detector directory:

sudo keyhog scan-system --space 10G \
  --detectors /opt/keyhog/reviewed-detectors

keyhog watch ~/projects \
  --detectors /opt/keyhog/reviewed-detectors

An explicitly selected directory is a replacement corpus by default. It does not silently fall back to embedded detectors when the path is missing or invalid. These subcommands do not expose a --detectors-mode flag. They use the shared scan configuration, so a valid detectors_mode = "overlay" in the resolved .keyhog.toml explicitly requests composition instead.

This corpus is compiled by the command itself. It need not match a running daemon because neither scan-system nor watch uses the daemon socket.

Ignore and lockdown policy

Unlike a normal filesystem scan, scan-system ignores .gitignore and .keyhogignore by default. This prevents a local ignore rule from hiding the files being triaged. Use --respect-gitignore only when that narrower scope is intentional.

Every system sweep disables core dumps and ptrace when the host permits it. --lockdown also requires the stronger protections to succeed and refuses network mounts. It cannot be combined with --include-network.

For recovery, address every warning before treating the host as covered. Fix permissions for unreadable paths, raise the byte ceiling, repair corrupt Git objects, or rerun a deliberately reduced scope. See the CLI reference for the full flag list and exit codes for shared failures.

Source archives

KeyHog recognizes archive and compressed formats while scanning a file or directory. You do not need a separate archive flag.

keyhog scan incoming/ --format json-envelope -o keyhog-archives.json

ZIP-family containers include ZIP, JAR, WAR, APK, IPA, CRX, Python and NuGet packages, and common office formats. KeyHog also reads tar, 7z, RAR, and tar or single streams compressed with gzip, zstd, LZ4, Snappy, bzip2, or xz. Supported archives nested inside those formats are scanned within bounded depth and byte budgets.

Each eligible readable regular member enters the normal detector pipeline. Binary members use printable-string extraction. Finding paths retain every container and member, such as submission.tar//paper/main.tex.

How a member is admitted

A member’s extension is consulted first, so a named member always resolves to the format it has always resolved to. A member whose name carries no recognized extension is admitted on its own leading bytes: gzip, zstd, xz, LZ4, Snappy, bzip2, ZIP, and tar all have a signature at a fixed offset. An extensionless gzip layer inside a tarball, a .zip renamed .dat, and a .dat that is really a tar are therefore all descended into.

The probe reads a bounded prefix at offset zero. KeyHog never speculatively runs an extractor to find out what a member is.

Some families have no signature and are not covered: cab, iso, xar, lzip, .Z, and raw deflate or raw LZMA streams, which have no magic in principle. Those fall through to printable-string extraction with no container coverage gap. A 7z, RAR, or ar/deb member is different: it produces an explicit error row naming the family, because those formats need a seekable file and have no in-memory extractor.

Streaming nested archives

KeyHog streams compressed tarballs (.tar.gz, .tgz, and nested compressed tar members) into the member scanner. It does not keep a full decompressed tarball resident while walking entries. Peak resident memory for archive extraction is bounded by the compressed input, decoder window state, and the largest single member under the active size caps, not by the sum of every decompressed layer.

TeX role annotations need member source bytes up front. Uncompressed .tar and ZIP-family packages still run the buffered provenance pass when header/central directory names include TeX sources. Compressed tarballs stay on the single-pass streaming path so nested layers do not pay a second full inflate; members remain fully scannable, without TeX role annotations on that compressed-tar path.

Archives reached through Git or a bucket

Container expansion applies to files on disk and to the sources that expand them. It does not apply to Git objects or to cloud object bodies. An archive committed to a repository is descended into by keyhog scan <path> and is not descended into by --git-history or --git-blobs, which report it as a binary (extension or content sniff) gap. An object in an S3, GCS, or Azure container is not descended into either.

Download or check out the archive and scan it as a file when its contents are in scope.

Scan untrusted archives

Keep the envelope report when the input may be corrupt or hostile:

rc=0
keyhog scan incoming/ --format json-envelope -o keyhog-archives.json || rc=$?
jq '{scan_status, coverage_gap_summary, findings: (.findings | length)}' \
  keyhog-archives.json
printf 'keyhog exit=%s\n' "$rc"

KeyHog does not extract members into the filesystem or execute their contents. Compressed tarballs are streamed member-by-member so nested layers do not each retain a full decompressed image in resident memory. It rejects archive paths that are absolute, contain traversal, use ambiguous encoding, or name a special file. It also refuses a top-level archive reached through a symbolic link. Decoded bytes per member, nested depth, and aggregate output are bounded. Formats with compression-ratio metadata also apply a ratio guard.

Rejected, encrypted, corrupt, unreadable, oversized, or budget-truncated input is not treated as complete. KeyHog reports the uncovered member or archive on stderr and records a coverage gap in the envelope. With no blocking finding, incomplete coverage exits 13. A blocking finding in the covered portion takes exit 1, or a confirmed live credential takes exit 10. The coverage warning and scan_status = "partial" remain in the report.

Do not raise --max-file-size merely to make exit 13 disappear. Raise it only when you trust the input and the runner has enough memory. Otherwise isolate the failed archive, repair or unpack it with a trusted tool, and scan the recovered files as a new input.

Suppress one reviewed archive fixture

Use the full member path in .keyhogignore.toml. Include the detector and hash so another credential in the same member still reports:

[[suppress]]
detector = "aws-access-key"
path_eq = "vendor-bundle.zip//examples/demo.env"
credential_hash = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"

The rule does not match the same member in a differently named container. It also does not match a different value in demo.env. Inline directives are not loaded from archive member text. See Suppressions for rule precedence and failure behavior.

TeX source packages

Scan a TeX source package like any other archive:

keyhog scan submission.tar

TeX packages receive an additional bounded dependency pass. It recognizes input, include, subfile, includegraphics, bibliography, and addbibresource commands. Paths are resolved relative to the referring member. Absolute paths and references that escape the archive root do not enter the dependency graph. After an exact member-name lookup, unresolved references try the command’s standard .tex, .bib, or graphics extensions.

Archive chunks use these source labels:

  • filesystem/archive/tex-root for document roots
  • filesystem/archive/tex-referenced for members reachable from a root
  • filesystem/archive/tex-orphaned for inventory members not reachable from a root
  • filesystem/archive/tex-comment/<role> for exact unescaped TeX comment spans

Binary members with printable strings use the corresponding filesystem/archive-binary/tex-<role> label.

Comments are scanned with their original member path and byte offset. The full member is also scanned unchanged. Expansion cycles terminate through a visited member set. Malformed commands do not stop ordinary member scanning.

The dependency pass caps member count, per-source bytes, total source bytes, references per member, and group nesting. If the analysis exceeds a cap, role annotations are unavailable and every readable member still takes the ordinary archive path. The scan reports the incomplete semantic expansion as a coverage gap rather than claiming complete TeX analysis.

Android packages

keyhog scan app-release.apk --format json-envelope -o app-release.keyhog.json

APK scans decode resources.arsc value tables and compiled Android XML before running the normal detector pipeline. Resource findings preserve package, type, entry name, resource ID, and configuration qualifier. XML findings preserve the element path, attribute name, framework resource ID, and inline or referenced value.

Decoded chunks use filesystem/archive/android-resource or filesystem/archive/android-xml. KeyHog also scans each original member through the ordinary archive path. It does not execute Dalvik code or resolve runtime resource selection.

Input bytes, chunk count, string pools, resource entries, XML depth, emitted items, and emitted bytes are bounded. A malformed compiled resource records an inaccessible coverage gap. A cap records a truncated coverage gap. Semantic decoding is incomplete in either case, while the original member scan continues.

HTTP and wire scanning

Real credentials don’t always sit on disk. They flow through:

  • Live web bundles that ship from production at a public URL.
  • HAR files that browsers (Chrome / Firefox / Safari DevTools) produce when you click “Save all as HAR with content.”
  • mitmproxy / Burp captures of an authenticated session.
  • curl / httpie / Postman exports of one specific request you want to verify.

KeyHog scans every one of these, but the surface is split across a few flags and sources. This page is the map.

TL;DR

WorkflowCommand
Scan a public JS bundlekeyhog scan --url https://app.example.com/static/main.js
Scan every URL in a listkeyhog scan --url $(cat urls.txt)
Scan a source-map exposed by Webpackkeyhog scan --url https://app.example.com/static/main.js.map
Scan a HAR export from DevToolskeyhog scan capture.har (see HAR auto-expansion)
Scan a single curl responsecurl -s https://api/... | keyhog scan --stdin
Scan a saved Burp or mitmproxy capturekeyhog scan dump.txt (plain text, no protocol parsing)
Route every fetch through Burpkeyhog scan --url https://app.example.test/main.js --proxy http://burp:8080 --insecure
Force a direct connectionkeyhog scan --url https://app.example.test/main.js --proxy off

The --url flag (Web Source)

keyhog scan --url https://app.example.com/static/main.js
keyhog scan --url https://app.example.com/static/main.js \
            https://app.example.com/static/runtime.js \
            https://app.example.com/static/vendor.js

Each URL is fetched with the shared HTTP client policy (see Proxy and TLS below). The response is routed by extension:

  • .js → one chunk per file, scanned as plain text.
  • .map → JSON parsed, each sourcesContent[i] becomes its own chunk tagged with the original filename. This is how a Webpack build with devtool: 'source-map' accidentally exposes server- side env vars baked into the bundle at build time.
  • .wasm → linear-memory + import section dumped as strings (best- effort; native WASM symbol extraction lives behind the binary feature).
  • Everything else (HTML, JSON that is not a source map, extensionless, …) → one chunk of text, scanned as-is.

Findings are tagged source: "web:js", web:sourcemap, web:sourcemap:raw, or web:wasm. Anything scanned as plain text (including the “everything else” case above) carries web:js; there is no separate web:other tag. The original URL is the file_path.

Use URLs without credentials, signed query strings, or secret fragments. Scan metadata redacts a target’s query and fragment, but a finding’s file_path identifies the fetched URL. Store web-source reports as sensitive artifacts. Do not add --show-secrets to a retained report.

SSRF defense

--url refuses to fetch:

  • Private RFC1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16).
  • Loopback (127.0.0.0/8, ::1).
  • Link-local (169.254.0.0/16, fe80::/10).
  • Cloud metadata endpoints (169.254.169.254, the GCP / Azure / AWS / DigitalOcean / Hetzner variants).

This protection is not configurable. It prevents a URL scan from reaching a metadata service or another private endpoint.

Proxy and TLS

Remote sources and verification share the same explicit proxy and TLS policy.

SettingEffect
--proxy http://burp:8080Routes KeyHog HTTP traffic through that proxy.
--proxy offDisables proxying, including a proxy from .keyhog.toml.
.keyhog.toml proxyApplies only when the CLI does not set --proxy.
Proxy environment variablesIgnored.
--insecureAccepts invalid TLS certificates.

Precedence is the CLI, then .keyhog.toml, then no proxy and strict TLS. HTTPS_PROXY, HTTP_PROXY, ALL_PROXY, and NO_PROXY cannot change it. TLS environment toggles are also ignored. --insecure applies to every remote-source and verification client in that scan, so use it only with an interception proxy or endpoint you control.

KeyHog sets User-Agent: keyhog/<version>.

Web fetching follows at most five redirects. Every redirect target is parsed, DNS-screened, and pinned again before the request. Verification requests follow no redirects. A verifier redirect becomes an error finding instead.

An invalid proxy value prevents the HTTP client from starting. An unreachable proxy does not fall back to a direct connection.

HAR auto-expansion

Any file with a .har extension is recognised by the filesystem source and expanded into one chunk per request and one chunk per response. Each chunk carries a source-type that tells you which side of the exchange it came from:

Chunksource_typeWhat it contains
Requestwire:har:request<METHOD> <URL>, every request header, query string, POST body.
Responsewire:har:response<STATUS> <statusText>, every response header, response body.

Finding file_path becomes <har-path>#<request-url>, so the same HAR with five different requests produces five distinct paths. Editors that jump-to-file on path:line URIs land on the HAR but the URL tail makes the location unambiguous.

The finding remains a normal redacted finding. This JSON fragment shows the exact location shape with synthetic values:

{
  "credential_redacted": "ghp_...4f2a",
  "location": {
    "source": "wire:har:request",
    "file_path": "capture.har#https://api.example.test/v1/me",
    "line": 2,
    "offset": 41,
    "commit": null,
    "author": null,
    "date": null
  }
}

The HAR request URL is part of file_path. A query credential in the captured URL can therefore appear in the report’s location even though the detected credential itself is redacted. Remove or sanitize sensitive query strings before sharing a HAR artifact or its report.

keyhog scan capture.har --format json-envelope | \
  jq '.findings[] | select(.location.source == "wire:har:request")'

This filters down to outbound credentials for the bug-bounty “what did I send” view. Select wire:har:response instead to see what the upstream reflected back at you.

A HAR that fails to parse is still scanned as plain text. KeyHog also records a structured_source_parse_failure coverage gap because request and response expansion did not happen. A metadata-bearing report is therefore partial, even if the raw fallback found no credentials.

The expander applies two bounds:

  • Cumulative rendered request and response bytes are limited to four times --max-file-size.
  • KeyHog decodes the text, checks for the required log and entries markers, and only then invokes the JSON parser. The marker check covers the full decoded text, so valid HAR metadata may precede entries.

Scanning a single HTTP exchange (stdin)

The most common ad-hoc workflow:

curl -s https://api.example.com/v1/me \
     -H "Authorization: Bearer $TOKEN" \
| keyhog scan --stdin

Or just pipe a saved response:

keyhog scan --stdin < response.txt

keyhog scan - (bare dash) is the same as --stdin (grep / wc convention).

--stdin reads up to 10 MiB by default. Raise the explicit --limit-stdin-bytes <SIZE> ceiling when a larger stream is intentional, or write the input to a file and scan the path. Findings from stdin carry the stdin source. To get the richer wire:har:request / wire:har:response provenance tags, save the exchange as a .har file and scan that instead (see HAR auto-expansion).

Use --format json-envelope for automation. Legacy json cannot say whether the source was complete:

set +e
keyhog scan capture.har \
  --format json-envelope \
  --output keyhog-results.json
status=$?
set -e

jq -e '.scan_status == "success" or
       .scan_status == "complete_after_recovery"' keyhog-results.json
test "$status" -eq 0 -o "$status" -eq 1 -o "$status" -eq 10

The jq check rejects malformed-HAR fallback and other coverage gaps. Exit 0 means no finding blocks the active evidence policy. Exit 1 means a finding blocks without a live verification result. Exit 10 means at least one live finding. A source failure with no blocking finding exits 13. Blocking findings take precedence, so a partial report can still exit 1 or 10.

Headers, bodies, and URL parameters

The detector engine matches the bytes supplied by each source adapter. A plain text capture is scanned as-is. The HAR adapter renders each request and response into separate chunks before scanning. A synthetic Bearer ghp_...4f2a in an HTTP header is therefore checked by the same detector policy as a synthetic "token":"ghp_...4f2a" in a JSON body or ?token=ghp_...4f2a in a URL.

The finding location gives the byte offset in the rendered chunk. It does not identify the exact header, JSON path, or query field. HAR findings identify only the request or response side.

Unsupported behavior:

  • Parse the HTTP wire format and emit header:Authorization vs body:json:$.token provenance fields.
  • Attach field-level provenance such as header:Authorization, body, or query to a finding. HAR findings do distinguish the request and response sides through source_type.

Fetch and parse errors

A non-success HTTP status, timeout, DNS failure, response above the configured size limit, invalid content encoding, invalid WASM response, blocked destination, or redirect-policy failure means some requested bytes were not scanned. KeyHog records the reason as a coverage gap and makes metadata-bearing artifacts partial.

When no finding blocks the active evidence policy, incomplete source coverage exits 13 and stderr says KeyHog is not reporting the scan as complete. A blocking or live finding takes exit 1 or 10 precedence. Consume the artifact’s scan_status and coverage_gap_summary; never use a finding count or process exit alone as a completeness signal.

Unsupported Wire Features

The wire-scanning surface is intentionally narrow. These features are not part of the shipped HTTP-wire contract:

  1. mitmproxy .mitm flow-dump support. The binary-framed format is not decoded. Export HAR when request/response provenance matters, or export text and scan it as an ordinary file.

  2. Header / body / URL-param provenance. HAR expansion emits one chunk per request and one chunk per response. It does not attach wire_location: header:<name> | body | query to each finding, so the JSON consumer cannot filter wire_location == "header:Authorization" for the highest-signal subset (intentional auth tokens vs accidental body leaks vs URL-logged secrets).

  3. Live proxy mode. KeyHog does not ship keyhog proxy --listen :8080 or an inline HTTP proxy that scans flows while forwarding them.

  4. WebSocket frame scanning. HAR files do not include WebSocket payloads, and KeyHog does not parse mitmproxy frame dumps as a WebSocket source.

Why this matters for bug bounties

A modern SPA bundle on a typical SaaS app can ship 200+ npm dependencies and a sourcemap that exposes every server-side env var the build process touched. Manual code review of one main.js.map against the full detector corpus is hours; running keyhog scan --url https://app.target.com/static/main.js.map takes seconds.

Pair it with --hide-client-safe (see CLI reference) to filter out keys that the vendor designed to ship in client bundles (Sentry DSN, Stripe pk_*, Mapbox pk., PostHog phc_, etc.) and you’re left with the keys that actually represent an exfiltration boundary.

Backends and routing

KeyHog has several execution engines for the same compiled detector policy. Changing a backend may change performance, startup cost, and hardware use; it must not change findings, locations, internal confidence, evidence verdicts, suppression, verification, or output ordering.

For the repository map, dependency direction, and bytes-to-finding pipeline, see Architecture.

The backend choices

BackendWhat it doesTypical cost profile
cpu (cpu-fallback)Pure-Rust literal and regex executionPortable and cheap to start; useful when native accelerators are unavailable.
simd (simd-regex)Hyperscan/Vectorscan trigger matching plus the shared extraction and policy pipelineFast CPU throughput after compiled databases are loaded; the calibration reference for accelerated builds.
gpu-cuda (gpu-cuda-region-presence)VYRE CUDA region-presence matching feeding the shared confirmation pipelineMeasured as its own autoroute candidate.
gpu-metal (gpu-metal-region-presence)VYRE native Metal region-presence matching feeding the shared confirmation pipelineMeasured as its own autoroute candidate on macOS.
gpu-wgpu (gpu-wgpu-region-presence)VYRE WGPU region-presence matching feeding the shared confirmation pipelineMeasured as its own autoroute candidate.
autoExact lookup in a persisted, parity-checked calibration tableDefault. It is a selector over all eligible engines, not a fallback order.

Use automatic routing for normal scans

Start with a calibrated automatic route:

keyhog calibrate-autoroute
keyhog backend --autoroute
keyhog scan .

The last command is equivalent to keyhog scan . --backend auto. Cargo cannot execute a binary after cargo install, so run the first command once after installing a multi-backend Cargo build and again after any binary, driver, hardware, or routing-relevant configuration change. A scalar-only build needs no calibration and reports health: direct. auto performs an exact cache lookup. It does not try backends in order and does not benchmark during the scan.

For a healthy multi-backend installation, keyhog backend --autoroute reports health: ready. A scalar-only build reports health: direct because it has no backend choice to calibrate.

--backend is an explicit diagnostic or benchmark override. It bypasses autoroute and its calibration cache:

keyhog scan . --backend cpu
keyhog scan . --backend simd
keyhog scan . --backend gpu-cuda
keyhog scan . --backend gpu-metal
keyhog scan . --backend gpu-wgpu

Use these commands to compare engines or isolate a driver problem. Do not put an explicit backend in a routine scan configuration. It does not prove that the chosen engine is fastest for the input, repair autoroute evidence, or publish a route decision.

An explicit backend is a hard execution contract. If it was not compiled, its runtime is unavailable, initialization fails, or dispatch fails, the scan returns an error. KeyHog does not substitute CPU, SIMD, or another GPU peer. gpu-cuda, gpu-metal, or gpu-wgpu are separate choices. There is no generic gpu override.

Check SIMD and GPU availability

Inspect discovery before forcing an accelerator:

keyhog --version --full
keyhog backend --self-test
keyhog backend --self-test --require-gpu

--version --full reports whether the running binary can see Hyperscan/Vectorscan and a physical GPU. This is a discovery report, not an execution test. backend --self-test executes the GPU diagnostic and production dispatch paths. It reports SKIP and exits successfully when no physical GPU is available. Add --require-gpu to make that condition fail. After calibration, keyhog backend --autoroute --json records the exact eligible_backends set that was measured.

The scalar cpu-fallback backend is always the portable reference. The simd-regex backend requires a build with scanner SIMD support and a usable, identifiable Hyperscan/Vectorscan runtime. A CPU with AVX2 or AVX-512 alone does not provide that runtime. GPU candidates require scanner GPU support, a physical device, and a usable driver path. CUDA, native Metal, and WGPU are acquired and measured independently, so one available peer does not imply that another is available. Diagnose exact engines with --backend simd (simd-regex), gpu-cuda (gpu-cuda-region-presence), gpu-metal (gpu-metal-region-presence), or gpu-wgpu (gpu-wgpu-region-presence).

Library backend contract

The Rust library deliberately has a different default contract. Calling CompiledScanner::scan or scan_coalesced without a backend uses the portable cpu-fallback reference, so identical library code does not change execution with host hardware or local calibration files. Library callers that want acceleration choose scan_with_backend/scan_coalesced_with_backend; the CLI is the owner of persisted automatic routing.

Those explicit-backend methods return typed Result values. Unavailable selected SIMD or GPU backends and later runtime failures return ScanError; they never terminate an embedding process and never return findings from another backend. warm_backend probes startup eligibility in-band. The keyhog CLI separately maps terminal scanner errors to its documented exit statuses. The no-backend portable CPU methods do not acquire an accelerator.

The GPU literal matcher keeps its immutable VYRE tables resident after the first successful batch. One dispatch returns both region presence and complete positions for the shared confirmed-anchor and generic-keyword localizers. Backend-shaped phase-two DFA catalogs are also immutable for the compiled detector set and are reused across scans. Haystack and region capacity grow in bounded bands from the actual workload. KeyHog serializes each resident session so concurrent requests cannot interleave uploads against the same device buffers. Preparation, growth, match-output overflow, dispatch, and readback errors remain selected-GPU failures. Teardown cleanup errors are logged. There is no per-batch pipeline or CPU substitution. Each physical dispatch accepts at most 65,536 positioned literal matches, which bounds resident readback to 768 KiB. Exceeding that cap returns no partial evidence: automatic routing visibly replays the stable bytes, while an explicit or required GPU route fails its backend contract.

A coalesced request above the smaller of the live VRAM/config budget and the selected backend’s hard ceiling is split between source chunks. An individually oversized chunk is scanned through physical windows whose overlap covers the longest compiled GPU literal. Window presence rows are OR-reduced and position rows are offset-adjusted and deduplicated into one logical source row before phase-two evidence is consumed. A complete region-presence request above 4,096 physical dispatches fails visibly before execution instead of amplifying chunk count or custom-detector overlap without bound. Prefixless phase-two GPU regex admission stays on whole chunks because regex width may be unbounded. Oversized rows retain the authoritative CPU no-hit admission path instead of accepting an unsafe GPU negative. Readback words are consumed through a scoped borrow while the resident session is locked, then zeroized without discarding the warmed host allocation.

What “same results” means

Calibration compares the complete RawMatch identity: chunk index; detector id, name, service, and severity; exact credential, stored-hash, and companion identity; source, file, line, offset, commit, author, and date; entropy, internal confidence, evidence tier, and evidence reason. A candidate is rejected if any field or finding multiplicity differs from the Hyperscan reference, if repeated reference trials are inconsistent, or if required GPU timing evidence is invalid. Diagnostics name only the differing fields and occurrence counts. They never emit raw values or deterministic value fingerprints. Normal automatic scans do not benchmark or silently replace a rejected backend.

Each backend is measured with phase-two plain-pattern localization disabled and enabled. The persisted route owns both choices, so concurrent scans never mutate scanner-global tuning and decode or recovery replays retain the selected route.

Among parity-correct candidates, routing uses complete trial distributions, never a lucky fastest trial. The selected route’s 95% confidence interval must lie below every other eligible execution route. Phase-two localization plans are distinct routes even when they use the same backend, so overlapping same-backend timings are inconclusive rather than permission to choose the lowest median. Overlap that separates nothing at all resolves to the lowest-complexity backend inside the fastest route’s own 95% upper bound, reported as a dead heat rather than as a proved win. Autoroute inspection prints this selection basis.

scan_coalesced_with_backend already includes extraction, decode, built-in suppression, confidence, and scanner postprocessing. Autoroute parity therefore compares the complete RawMatch values returned by that production scanner path. CLI allowlists and rules, severity and confidence floors, cross-source deduplication, optional verification, and reporting run after backend selection. The same detector TOML corpus and resolved configuration digest identify every backend and localization plan.

Why size alone is insufficient

Two inputs with the same byte count can have different winners. Autoroute also keys evidence by logarithmic buckets for bytes, chunk count, largest source size, and detector pattern count, plus one boolean recording whether any decoder was admitted. Source family, resolved configuration, build features, and host identity also participate.

A measured key covers only the values grouped into that key. A size band nobody measured is served only when at least two measured bands of the same source class and decode state reconcile to one route: a backend measured at every one of them and proved slower at none, on the compiled default plan when they split on the plan. Never for a GPU route. See Autoroute calibration.

Runtime lifetime matters too. A one-shot process includes GPU first-dispatch cost. A ready daemon has already initialized accelerator state and uses the warm GPU trials from the same calibration evidence. See Daemon and warm scans.

The 8 MiB Hyperscan crossover

The July 10 RTX 5090 artifact is retained for regression history, but it is not release or routing evidence. Its SIMD timing used the generic per-chunk entry point instead of the faster production coalesced Hyperscan path. The artifact is marked production_comparable = false and must not support a crossover claim.

The checked benchmark now sends identical 1 MiB windows with 128 KiB overlap through the explicit production execution-route entry point for Hyperscan and every acquired CUDA, Metal, or WGPU peer. It measures all four combinations of plain-pattern and keyword-anchor localization and every resident pipeline depth the peer declares eligible. Synchronous peers expose depth one. Asynchronous peers expose depths one through four. It requires sorted full-match parity from every route, rejects GPU degradation, and rotates candidate order during selection. Selection-only samples choose one measured-correct GPU route and one measured-correct Hyperscan route. Both routes then run in 300 fresh rotating held-out trials. Every other parity-correct Hyperscan route also runs in those trials and remains visible in the artifact, but no held-out observation can change either selected route.

The gate passes only when the selected GPU to selected Hyperscan paired ratio’s 95% confidence upper bound is below 1.0 at 8 MiB. A slower CPU tuning choice cannot make the GPU result look favorable because independent selection compares all eligible localization plans before the held-out phase. A per-trial minimum across several CPU plans is not an eligible backend and is not used as a hindsight oracle. A forced plain or keyword localizer filter, profiling, or perf tracing retains parity and degradation checks but cannot pass the release speed gate.

Schema 10 records both selected backends, localization choices, and the GPU pipeline depth, every route-selection sample, and a separate held-out confidence interval for each Hyperscan plan. crossover_passed is based only on the independently selected GPU and Hyperscan routes. Use --diagnostic for an unprofiled 8 MiB measurement from a dirty development tree. That mode retains exact parity and degradation checks but records diagnostic = true, production_comparable = false, and cannot pass the release gate.

Diagnostic runs may isolate either localization dimension with KH_BENCH_PHASE2_PLAIN_LOCALIZER=0|1 or KH_BENCH_PHASE2_KEYWORD_LOCALIZER=0|1. Setting either variable makes the run ineligible for release evidence; an unrestricted run measures all four plans.

Use --profile to attribute scanner stages to exact routes. Candidate selection and held-out trials remain unprofiled; after timing, the benchmark runs one isolated scan for each Hyperscan localization plan and the selected GPU route. Profile labels include the backend, both localization values, and the resident pipeline depth, so costs from different execution plans are never merged into one report. Profile runs cannot pass the release gate.

The checked artifact at benchmarks/baselines/gpu_8mib_crossover_rtx5090.toml retains the last measured timing and parity distribution, but it is historical rather than release evidence because that run did not attest a clean source tree. It recorded 143 identical findings with no degradation, a 24.5886 ms VYRE CUDA median versus 69.5641 ms for Hyperscan, and a paired ratio confidence interval of 0.3482 to 0.3579 across 100 held-out pairs. Those measurements cannot prove the current release binary is reproducible from the recorded commit. A new crossover claim requires build_source_tree_state = "clean", source_tree_state = "clean", and production_comparable = true from the corrected route with exact binary, detector, configuration, host, runtime, workload, result count, peer, and trial identity. The build script watches the tracked and non-ignored source inventory, so cleaning a tree after compiling dirty source forces a rebuild before the artifact can qualify.

Run the crossover benchmark when you change backend performance or routing. Release automation does not run it. The benchmark remains the evidence for route comparisons: it records the candidate commit, detector digests, feature set, GPU identity, finding parity, held-out pairs, and confidence interval. Autoroute still requires calibration on the deployment host for the exact workload class.

What the GPU does for a whole-tree scan

The crossover above measures one 8 MiB window through the matching kernel. A repository scan is a different workload, and the answer there is different, so measure before you choose a backend for one.

Scan a tree with each backend and compare. These are median wall times over five runs on an RTX 5090 with a Ryzen 9 9950X, scanning copies of this repository:

InputPure-Rust CPUCUDADifference
63 MiB4.22 s4.64 s+9.9%
251 MiB10.43 s11.34 s+8.7%

The GPU route is slower, by roughly the same percentage at both sizes, so this is not a startup cost that a larger tree amortizes away.

The reason is which stage the GPU accelerates. A scan runs phase one, which finds candidate regions, and phase two, which confirms them against the full detector patterns. The GPU runs phase one. Phase two runs on the CPU in both cases, and phase two is the larger cost: a --perf-trace of one 4,096 chunk batch shows dispatch=0.04s for the GPU kernel against phase2=0.39s for confirmation. The GPU shortens the smaller half and adds its own dispatch and transfer on top of an unchanged larger half.

You can see the same thing in the trace field phase2_gpu_ascii_patterns. It reads 0 on this detector corpus, meaning no pattern is eligible for the GPU phase-two path, so none of the confirmation work moves off the CPU.

What to do with that:

  • For a repository, container, or history scan, leave routing automatic. The calibrated router measures both and will pick the CPU route when it is faster.
  • Use an explicit --backend gpu-cuda for diagnostics, parity checks, and kernel-level benchmarking, not because you expect a whole-tree scan to finish sooner.
  • Findings do not depend on the choice. Every route reports the same secrets, which is what the parity contract above guarantees.

Memory footprint and zero-allocation execution

KeyHog is designed to scan multi-gigabyte repositories and large disk images with a bounded memory footprint.

Streaming windowing and rendezvous channels

Files larger than 1 MiB are divided into overlapping 1 MiB windows with 128 KiB overlap. The filesystem reader uses a rendezvous queue (fused_depth = 0), so windows are handed directly to the scanner workers without accumulating resident memory in crossbeam channels.

Zero-allocation source semantic indexing

Structured configuration and source AST indexing in StructuredSourceIndex uses compact byte offsets (SourceSpan) and fixed-size stack arrays ([SourceSpan; 12]) rather than allocating heap strings or nested hash maps. Tokens are referenced as borrowed slices of the original window.

Bounded GPU scratch and readback buffers

The GPU execution engine bounds device allocations and host transfers:

  • Resident literal tables are compiled once and reused across dispatches.
  • Match readback buffers are capped at 65,536 entries (768 KiB), preventing high-candidate inputs from triggering host memory allocation spikes.
  • Oversized chunks are sliced into physical windows, uploaded within the configured --gpu-batch-input-limit, and reduced on-device before transfer.

Memory tuning controls

SettingDefaultFlagDescription
Fused batch size1024--fused-batch <N>Maximum chunks grouped into one scanner batch.
Fused queue depth0--fused-depth <N>Maximum completed chunk batches queued in RAM. Default 0 (rendezvous) minimizes resident heap.
GPU batch input limitAdaptive (128M-1G)--gpu-batch-input-limit <SIZE>Byte budget for GPU coalesced batch buffers.
Scanner threadsCPU count--threads <N>Number of parallel worker threads.
Reader threads1--reader-threads <N>Number of dedicated filesystem reader workers.

Automatic routing failures and recovery

Automatic routing has two visible failure states. Neither one changes an explicit backend contract.

The route state is invalid

Missing, stale, malformed, disabled, incomplete, or quarantined evidence cannot authorize an automatic route. KeyHog prints the missing workload identity and a repair command. No backend is selected. The affected batch remains unscanned, metadata-bearing output records partial coverage, and the process exits non-success.

Run keyhog backend --autoroute to distinguish calibration_required, stale, invalid, disabled, and quarantined. Run keyhog calibrate-autoroute for the core ladder. For Git, Docker, or web workloads, run the exact scan --autoroute-calibrate --autoroute-gpu repair command printed in the routing diagnostic.

Use an explicit backend only when you intentionally want a diagnostic override. It bypasses the invalid route state but does not repair it.

A selected automatic backend faults

During a normal automatic scan, an accelerated backend fault is warned and the same stable bytes are replayed through the confidence-separated fastest remaining peer. GPU recovery replays only exact unprocessed intervals. A backend that fails before scanning replays the full stable batch. Completed dispatches remain owned by their original backend.

This recovery is not silent. KeyHog reports the failed and recovery backends, recovered ranges, chunks, and bytes, and records complete_after_recovery. The exact workload route is quarantined in a bounded runtime-health artifact. That artifact is separate from immutable timing evidence, survives restart, and clears the repaired workload only after successful recalibration. If recovery cannot prove full coverage, the result is incomplete rather than clean.

Calibration candidates, explicit backend overrides, and --require-gpu remain hard execution contracts. They fail instead of recovering through another backend.

For cache identity, inspection commands, calibration coverage, and recovery, see Autoroute calibration.

Performance evidence

Treat a timing as comparable only when the executable, detector corpus, configuration, workload, host, and route are recorded. A table without those identities is historical context, not release evidence.

Canonical receipts

The repository owns two canonical generated evidence surfaces:

  • readme-matrix.json records scanner configuration, executable SHA-256, detector-corpus SHA-256, workload SHA-256, host resources, findings, precision, recall, wall time, throughput, and peak RSS. It also binds the generated README tables to the workload-catalog digest. make -C benchmarks readme-matrix-check rejects prose or table bytes that do not match the receipt.
  • readme-scaling.json records thread and process scaling with every trial, effective core count, page-cache state, storage class, workload size, findings, wall time, and peak RSS.

The benchmark index in benchmarks/README.md owns focused receipts for Bloom filtering, autoroute, daemon routing, recovery, and competitive accuracy. Read the linked JSON receipt before quoting a generated Markdown report.

Comparison boundary

Match all of these fields before comparing two rows:

  1. executable digest and stamped commit;
  2. detector-corpus digest and detector count;
  3. resolved configuration, including backend, cache, daemon, verification, decode, and confidence policy;
  4. workload digest, bytes, file count, and input shape;
  5. host CPU, GPU, memory, operating system, affinity, and cgroup quota;
  6. warm or cold page-cache and process state;
  7. trial count, aggregation rule, exit code, scan status, findings, and coverage gaps.

A backend override is diagnostic. It does not prove automatic routing. Autoroute evidence is valid only when calibration authenticated the exact workload class, binary, detector/config state, host, accelerator state, and selected route.

Reproduce the generated tables

Run:

make -C benchmarks readme-matrix-check
make -C benchmarks readme-scaling-check

To collect new measurements, follow the command in the focused report or benchmark index. Keep raw host-local results outside tracked reports until the run records immutable executable and workload identities and exact finding parity.

Interpret older reports

benchmarks/reports/perf.md, cross-device.md, and workload-matrix.json predate the complete canonical receipt contract. They remain useful for investigation, but their rows must not replace the generated README matrix or scaling receipt. In particular, an absolute path, a mutable binary path, a missing executable digest, or an unattested working tree prevents a release claim.

Daemon measurements have a separate boundary. One-shot process time includes scanner construction; warm daemon request time does not. Resident daemon RSS belongs to the server. Compare daemon rows only with the same request class and lifecycle.

Multi-corpus benchmark evaluation

Secret scanner evaluation requires testing across multiple independent corpora to prevent single-distribution bias. Evaluating against a synthetic mirror corpus alone measures coverage on synthetic distributions, while evaluating against competitor-harvested rule test suites measures real-world competitor ground truth.

KeyHog evaluates detection accuracy and runtime performance against both the synthetic SecretBench-shape mirror corpus and competitor-harvested homefield corpora under identical, neutral execution and scoring contracts.

Multi-corpus methodology

Every benchmarked scanner runs under a uniform scoring harness with two non-negotiable fairness constraints:

  1. Answer-key isolation. The ground-truth answer-key manifest sits beside, never inside, the scan tree. Scanners scan only neutral payload files and cannot read test annotations.
  2. Neutral scan directory layout. Scanners scan neutrally named directory trees (corpus/, not fixtures/ or test/), preventing test-path heuristic penalties from distorting measurements.

Finding attribution uses the canonical SecretBench overlap rule: a finding counts as a True Positive if its attributed byte span overlaps the ground-truth secret span in the same file.

Benchmark results

All measurements below were collected on AMD Ryzen 9 9950X 16-Core Processor running Linux 6.17.0-19-generic with 32 logical cores.

Synthetic mirror corpus

The mirror corpus contains 15,000 synthetic SecretBench-shape fixtures, 3,000 labeled positives, and 2,431,242 input bytes.

RankScannerF1PrecisionRecallFindingsWallPeak RSS
1KeyHog0.93280.96510.90272,8161.05s416 MB
2TruffleHog0.52941.00000.36001,0801.59s300 MB
3Kingfisher0.46830.38770.59135,2554.81s402 MB
4Titus0.42070.33810.55675,1512.86s115 MB
5Nosey Parker0.41860.35110.51834,5290.82s285 MB
6Betterleaks0.34980.22410.797011,1130.74s198 MB

Competitor homefield corpus

The homefield corpus contains 2,399 fixtures harvested directly from competitor ground-truth rule suites (Betterleaks and Kingfisher rules; 1,057 labeled positives, 1,342 negatives, 772,974 input bytes).

RankScannerF1PrecisionRecallFindingsWallPeak RSS
1KeyHog0.92140.95820.88749790.72s384 MB
2Betterleaks0.90560.91300.89841,0400.58s192 MB
3Kingfisher0.88420.92500.84689682.14s390 MB
4TruffleHog0.48120.98500.32263451.22s280 MB
5Titus0.46350.38100.59131,6402.15s110 MB
6Nosey Parker0.45200.39500.52801,4120.68s265 MB

Provenance and reproducibility

Every reported benchmark measurement binds the following immutable identities:

  • Scanner executable digest (SHA-256) and stamped git commit hash.
  • Detector set count and detector corpus digest.
  • Execution configuration ID (backend, caching, daemon, and validation modes).
  • Host CPU, memory, GPU, kernel, and operating system identity.
  • Workload byte count, fixture count, and labeled positive count.

To reproduce measurements locally, see benchmarks/README.md and docs/src/performance-evidence.md.

Autoroute calibration

Not the same as confidence calibration. This page is about backend selection: measuring which engine (SIMD, scalar CPU, GPU) is fastest and proven-correct for your workload. For the per-detector Bayesian confidence counters (keyhog calibrate --tp/--fp), see Confidence calibration.

KeyHog uses measured evidence to select an execution route for a calibrated workload key: Hyperscan/SIMD, scalar CPU, CUDA, native Metal, or WGPU, each measured with all four combinations of phase-two plain-pattern and keyword-anchor localization. GPU routes also measure every resident pipeline depth the acquired peer declares eligible. Synchronous peers expose depth one; asynchronous peers expose depths one through four. It does not guess from a device name or a hard-coded size threshold. Autoroute is not a fallback hierarchy. During calibration KeyHog measures every eligible execution class exposed by that scanner, rejects candidates whose complete redacted raw-match identity differs from the independent scalar reference, and records the fastest survivor for the measured representative. Optional SIMD, CUDA, Metal, and WGPU engines are candidates, never correctness oracles. Every executable GPU path is acquired and measured independently. One driver never substitutes for another. The parity identity covers chunk membership; detector id/name/service/severity; exact credential, stored-hash, and companion identity; full source/history location; entropy; confidence; and finding multiplicity. Mismatch diagnostics expose only field names and occurrence counts. They never expose credentials, companions, history values, or deterministic value fingerprints. Normal scans then do a direct table lookup; they never benchmark mid-scan.

Calibration, in-process batches, and daemon requests call the same explicit backend-dispatch boundary. Hyperscan uses its coalesced multi-chunk path. Scalar CPU and GPU use their normal batch paths, including the measured GPU resident pipeline depth. A timing row therefore measures the implementation that the persisted route authorizes.

Ordered multi-device GPU routes

When one GPU API exposes two or more distinct physical adapters, calibration also measures the complete ordered device set as a peer route. The route binds each adapter’s stable topology, driver/runtime identity, capacity, and measured integer throughput weight. Cross-API aliases for one physical adapter are deduplicated before the set is formed.

Acquisition is all-or-nothing. A missing, reordered, reset, or identity-changed member invalidates the complete route before any batch is scanned. KeyHog allocates each member’s bounded resident slots before dispatch, checks the aggregate process ceiling, assigns one contiguous weighted source range to each member, dispatches concurrently, and retires results in source order. An error, panic, incomplete receipt, or missing shard on any required member invalidates the whole route; sibling results are not reported as complete.

The device-set identity is stable across workload-specific throughput weights. One scanner therefore acquires one resident set for that physical topology, while each workload decision retains its own authenticated weights, budgets, pipeline depths, detector/config digests, and correctness receipt. Normal scans never retime or rebalance the set.

A route class must be something calibration can enumerate ahead of any scan. The workload key is therefore the shape of the work, not a measurement of the bytes: logarithmic byte, chunk, maximum-file, and pattern bands, one boolean recording whether any decoder was admitted, and the canonical set of source execution classes with each class’s size provenance. Reordering chunks keeps the same key. Changing the proportion between two source classes, which decoder families ran, the phase-one admission outcome, the phase-two keyword density, or the number of decode candidates does not: those are properties of the input, and a key that contained them made every scan an uncalibrated class.

Calibration still observes those statistics. It logs the phase-2 keyword trigger counts for each measured decision on the keyhog::routing tracing target, and every persisted point records its exact sample byte count, chunk count, and measurement shape digest. They describe a measurement; they do not select one.

Noncanonical, duplicate, inconsistent, or oversized persisted mixtures invalidate the cache instead of being normalized silently. Each persisted decision also carries a digest of the complete workload key. Changing or relabeling any keyed field invalidates the row before routing.

Filesystem producers keep each path’s chunks contiguous. KeyHog uses that contract to end a batch when the source execution class or full-size provenance changes, unless the next chunk belongs to the same path dependency. Ordinary, windowed, PDF, archive, web-script, source-map, and other preprocessing classes therefore use independently measured homogeneous routes. Dynamic ELF, PE, and Mach-O section names collapse to their binary-format class because the label does not change execution. Sources without a contiguous-path contract retain their exact mixed key instead of being split on an unsafe assumption.

Git diff producers make the same ordering guarantee. Tracked diff hunks and full-size untracked files therefore calibrate as separate route classes during installer calibration, even when one --git-diff scan contains both.

Performance selection uses the complete recorded distribution, not the single fastest sample. All candidates are measured in the same rotated rounds, so KeyHog compares paired per-round differences at 95% Student-t confidence. If those differences prove one exact plan faster than every peer plan, selection_basis is exact-plan-paired-95pct-confidence. If same-backend plans remain tied, KeyHog chooses the compiled default when it is among the tied leaders. Otherwise, it chooses a stable typed plan from that tied set. The chosen plan’s interval must remain below every plan on each peer backend. Inspection reports peer-separated-compiled-default-plan or peer-separated-statistically-tied-plan.

When nothing separates, the measurement is resolved rather than discarded. A route stays in contention unless some peer is proved faster, meaning that peer’s whole 95% interval lies below the route’s own. Among the routes still in contention, only those whose median falls inside the fastest route’s own 95% upper bound are eligible, so a route can never win on a wide error bar while its central tendency is measurably worse. The eligible set is then ordered by backend complexity, cpu-fallback before simd-regex before the GPU peers, because when nothing is proved faster the backend that needs no accelerator bring-up and always runs is the honest choice, and it is the same choice on every rerun of the same evidence. Inspection reports unseparated-dead-heat-lowest-complexity-backend and confidence_separated stays false, so a permitted decision is never presented as a proved one.

This matters on real trees. Calibrating benchmarks/corpora/homefield measured cpu-fallback at 4.507 s [3.08, 11.49] against gpu-wgpu at 4.462 s [4.40, 4.92], with every interval overlapping every other. Refusing to decide left the workload with no persisted route, so every later automatic scan failed closed without scanning even though the measurement showed the backends were indistinguishable.

Calibration records 7 normalized timing trials per route. A warm trial repeats short candidate executions until their combined timing reaches 10 ms. It stops after 1,024 executions and records the per-execution average. This keeps scheduler resolution from dominating small workloads without extending large workloads. Accelerator evidence retains one real cold dispatch. Steady and warm rounds rotate route order so host drift is shared across peers. Overlap that survives the rotation is resolved as a dead heat rather than spending unbounded install time or guessing.

Because the decision is measured, it must be recorded before --backend auto (the default) can claim a fastest route. A fresh install has no decisions yet, so an automatic scan selects no backend for each unproved batch, records incomplete coverage, and reports autoroute calibration required with a repair command.

Calibrate, inspect, then scan

For a multi-backend build, use this sequence:

keyhog calibrate-autoroute
keyhog backend --autoroute
keyhog scan .

The scan uses auto by default. It reads the persisted table and never benchmarks during the scan. Use an explicit backend only for a deliberate diagnostic or benchmark.

Before calibration:

  • Run the same KeyHog binary that will perform the scans. Build identity and scanner feature identity are part of every decision.
  • Use a writable persistent cache path. --autoroute-cache off is rejected because calibration must publish durable evidence.
  • Keep the host reasonably idle. Route trials are interleaved across peers so common drift is shared. Overlapping intervals resolve deterministically to a non-inferior low-complexity route; unusable evidence or backend disagreement across retained points exits without publication.
  • Make every source prerequisite available. The subcommand covers the core stdin and filesystem ladder. Git, Docker, and web fixtures use the low-level scan --autoroute-calibrate probe on the exact source.

A build with only cpu-fallback reports health: direct and does not require calibration.

Calibrate core and source-specific workloads

Cargo installation does not benchmark your host. Calibrate the installed binary before its first automatic scan:

keyhog calibrate-autoroute

Run the command again after a binary, detector, configuration, driver, or hardware change. The command calibrates the core stdin and filesystem workload ladder. A routing diagnostic for an unproved Git, Docker, or web workload prints the exact low-level scan --autoroute-calibrate --autoroute-gpu command.

The default command calibrates the ordinary policy and every documented preset:

keyhog calibrate-autoroute                 # all policies
keyhog calibrate-autoroute --policy default
keyhog calibrate-autoroute --policy fast
keyhog calibrate-autoroute --policy deep
keyhog calibrate-autoroute --policy precision

The policy names correspond to no preset flag, --fast, --deep, and --precision. Each policy has its own resolved configuration digest and route decisions. A focused run keeps valid evidence for other configurations. It publishes only after every workload in the selected policy succeeds. Use the default all-policy sweep for a new installation. Use a focused policy to repair or refresh only the preset you run.

Which configuration the run measures

Route decisions are stored under the resolved scan configuration, and a .keyhog.toml found on the walk-up from the working directory is part of it. Run keyhog calibrate-autoroute from the repository whose scans it serves. Running it elsewhere primes the compiled-in defaults, and a scan inside a repository that carries a .keyhog.toml then reports none matching config digest and exits 2.

keyhog calibrate-autoroute                 # measures ./.keyhog.toml if present
keyhog calibrate-autoroute --no-config     # measures the compiled-in defaults

install.sh and install.ps1 pass --no-config. An install runs from an arbitrary directory, so it primes the host baseline; calibrate again inside a repository that overrides scan policy. The all-policy sweep measures in four isolated child processes, and each child inherits the mode: asking for the baseline measures the baseline in all four.

This drives the core stdin + filesystem workload ladder across every scan preset. Plain single-file probes cover every power-of-two size band from 1 byte through 32 MiB, with additional 4 MiB + 1, 8 MiB - 1, 8 MiB + 1, and 16 MiB - 1 probes retaining raw evidence on both sides of the required 8 MiB crossover. A coarse size class holds its points together when they agree, and also when they disagree without proving anything: the class keeps the lowest-complexity backend that is measured at every point and measurably slower at none. A disagreement that measurement does prove, where one point’s whole 95% interval for the selected backend sits above a peer’s, is a real crossover; it rejects calibration and requires the class to be split. Such a class reports confidence_separated: false, because the route is permitted by the evidence rather than proved by it. File-tree probes cover every chunk-count band through the default 32-chunk fused batch. Tar-member probes cover the same count ladder for payload-derived extracted filesystem chunks. Decode-heavy probes cover the decoder path. Empty input has no routing work and is not counted as a calibrated workload; daemon and watch paths return the exact empty result without consulting the cache. Each preset uses one compiled production scanner. Immutable detector, GPU literal, and GPU phase-two program artifacts are reused. Workload-shaped resident GPU state is reset before each representative. The measured shared literal and backend-shaped phase-two preparation costs are added to every matching one-shot GPU observation. Candidate order rotates across workload bands rather than giving one backend the same thermal position in every probe. The final count is the number of probes run, not the number of unique persisted route classes. Multiple representatives can share one logarithmic workload key. The summary separately reports unique route classes, exact measurement points retained by this sweep, and the total route decisions in the cache. The required readback checks every measured shape digest, not only the shared workload key. A missing representative prevents publication. The cache total can include valid decisions from prior calibration runs. The command also prints a cache route summary showing how many one-shot and daemon rows select a VYRE GPU route, plus the number of GPU candidate receipts measured. The command does not cover Git, Docker, or web source probes. Those workloads need a real external fixture such as a repository, running daemon, or served URL. The low-level scan --autoroute-calibrate probe measures one caller-supplied workload. It does not synthesize or sweep external fixtures. If one of these sources reports autoroute calibration required, run the reported repair_command. Decisions are written, parity-checked, to the autoroute cache ($XDG_CACHE_HOME/keyhog/autoroute.json by default; override with --autoroute-cache <path> or [system].autoroute_cache).

Canonical calibration admits every eligible execution class. The low-level scan --no-autoroute-gpu --autoroute-calibrate diagnostic measures a CPU-only candidate set, and that evidence cannot overwrite or be replayed by a normal all-candidate decision. The isolation lives in the persisted host generation, not in the config digest. A host generation records the eligible backend census plus the complete GPU device, runtime, driver, and batch-limit identity, and a scan replays a row only when that whole profile compares equal, so a CPU-only measurement is invisible to a scan that admits a GPU.

The resolved config digest deliberately does not record whether a calibration excluded a GPU. Recording it there was a guaranteed miss on every host and build with no GPU candidate, because the exclusion is vacuous but the digest still differed: calibration wrote decisions under a key no scan would ever request, and the immediately following identical scan reported a config mismatch and left the batch unscanned.

Startup reports every available GPU peer without creating execution devices or pipelines. Calibration acquires each peer when its candidate is measured and reports the exact acquisition failure. The autoroute cache stores separate CUDA, Metal, and WGPU cold and warm timing vectors, and keyhog backend --autoroute prints each eligible peer. A failed driver is ineligible until it is repaired and calibration is rerun.

Low-level calibration saves take an exclusive sibling-file lock across the complete read/merge/atomic-write cycle. The canonical calibrate-autoroute command adds a generation transaction around the full workload and preset sweep: every probe writes to an isolated cache, completed evidence is read back and validated there, and the live cache is replaced once only after the full sweep succeeds. A failed probe leaves the live cache byte-identical. Publication also compares both the live cache and its runtime-health artifact captured at sweep start while holding their canonical locks. If another process changed either one, KeyHog preserves the concurrent update and asks the operator to rerun instead of overwriting evidence or clearing a new route fault. A successful publication clears only the exact route faults remeasured by this sweep. The operating system releases a held lock if a writer exits or crashes.

Only identity-compatible, structurally valid rows are preserved. A storage or permission error while reading an existing cache aborts without replacing it. A readable cache with an incompatible schema, invalid JSON, invalid structure, or a different build/corpus identity emits an unconditional stderr warning with the cache path and replacement reason, then starts a fresh staged generation; unrelated rows from that invalid artifact are not merged.

One cache can be shared across hosts. Each route generation is keyed by the exact resolved config digest and host profile. Calibrating the same config on a second host preserves the first host’s evidence, and recalibrating either host merges only that host’s workload rows. A scan replays only the generation whose complete host identity matches the live machine. JSON inspection exposes the stable host_identity digest used to distinguish those generations.

Reading the cache hit rate

Every automatic scan prints one line on stderr saying what the cache did:

INFO autoroute cache: 100.0% hit (2 hit / 2 lookup(s))

One lookup is one batch asking the cache for its route. A hit means the batch ran on a persisted, measured-correct backend without benchmarking anything. A miss means no backend was selected, the batch remained unscanned, and the run records incomplete coverage.

The line prints in every output mode, including --format json -o <file>. That is the shape CI and calibration harnesses use, and it used to suppress the whole routing summary.

A scan with any miss names the cause and the repair:

WARN autoroute cache: 0.0% hit (0 hit / 2 lookup(s)); every byte was still
scanned, this costs speed not coverage; miss causes: cache-rejected=2;
2 distinct uncalibrated bucket(s); repair: the cache belongs to a different
build, host, detector corpus or scan config; recalibrate this exact
configuration (recalibrating one bucket will not help)

Read the miss cause before you recalibrate. The causes call for different actions:

causemeaning
no-cache-configuredno autoroute cache path resolved for this scan
cache-rejectedthe cache belongs to a different build, host, corpus, or config; recalibrating one bucket will not help
workload-unclassifiedthe batch could not be bucketed, so no calibration can cover it
bucket-absentthe cache is valid and does not cover this workload yet
runtime-class-unprovedthe bucket exists without a route proved for this runtime class
route-quarantineda persisted route faulted at runtime and was quarantined
route-health-unavailableroute-health state could not be read, so no persisted route is trusted
gpu-peer-identity-changedthe GPU peer changed since calibration

A miss costs speed, never coverage. Every byte is still scanned. This line is not a coverage gap, and a 0% hit rate does not mean the scan was incomplete; see Coverage truth for the signals that do mean that.

Run with -v to list every distinct uncalibrated bucket under the keyhog::routing target, most expensive first. One recalibration can then be planned to cover all of them, rather than learning about one bucket per run.

Under --profile, the same outcomes appear in the standard cache family as autoroute-decision (a scan reusing a persisted route) and autoroute-calibration (a calibration reusing evidence instead of measuring again), with hits, misses, and hit_rate_ppm in the --profile-out JSON.

Cache schema compatibility

The cache has one strict schema version. KeyHog reads the small version field before decoding any version-specific payload, so an older or newer cache cannot be mistaken for a partially valid one. There is no silent in-place migration: an unsupported version is reported as unsupported autoroute cache version with the version found, the version expected by the binary, and the command to regenerate it. The scan loader, calibration merge path, and backend --autoroute inspection use this same diagnostic. Re-run calibration after upgrading KeyHog or changing the cache format; a replacement save never merges rows from an incompatible schema.

Any decision containing a GPU one-shot route, persistent route, parity receipt, or measured candidate also binds the installer-owned GPU matcher manifest. KeyHog verifies every named .bin member against its SHA-256 digest before accepting the cache. Missing, malformed, duplicate, symlinked, oversized, or changed members reject the cache. Unrelated lazy runtime-cache files do not change this identity.

Each timing point stores a content-addressed measurement receipt: the canonical receipt generator, a digest of the complete payload multiset, and a digest of the exact source, offset, and decode shape. It stores no source text or paths. Same-sized representatives with different candidate density therefore remain distinct points, while the same chunks in a different producer order reuse one receipt. keyhog backend --autoroute --json exposes all three fields so a crossover can be tied to its exact probe.

What a decision covers

A decision is tied to its recorded build identity, host profile, detector corpus, and routing-relevant resolved scan configuration. Options that change that identity get their own calibration, even when they do not change which backend is fastest:

  • Build identity records the exact running executable SHA-256, package version, Git hash, and the CLI and dependency feature sets. GPU and SIMD support are read from the scanner library that actually owns and compiled those backends, not inferred from similarly named CLI features. Source capability identity separately records each compiled filesystem, archive, forge, cloud, container, and web source feature (including GitHub, GitLab, and Bitbucket), while verifier identity records whether live verification is compiled. A different artifact or recorded capability set cannot reuse the evidence, including dirty/profile/native-link builds that happen to share a package version and Git hash.
  • Host identity includes OS/architecture, CPU model and topology, memory, CPU instruction support, the live linked Hyperscan/Vectorscan runtime version when SIMD is eligible and, when the scanner can use a physical GPU, the GPU device, every available runtime backend and version, driver/runtime identity, resolved batch-input byte cap, and the exact sorted eligible-backend census for that resolved config. A missing or changed required field invalidates the evidence and requires recalibration.
  • SIMD is admitted when the scanner produced a nonempty backend-neutral plan and the linked Hyperscan/Vectorscan runtime has a reproducible identity. Scanner construction does not build its databases. Calibration or a selected SIMD route materializes the plan exactly once; failure aborts calibration or the selected scan with the initialization reason instead of removing SIMD from the census or substituting scalar CPU.
  • Backend identity covers the complete scan tail, not only phase one. The always-active phase-two Hyperscan prefilter executes only for the SIMD candidate. Scalar and GPU candidates use their own measured trigger path and the portable host residual, so their timing cannot borrow hidden SIMD work.
  • Each scan preset (default, --fast, --deep, --precision) is calibrated separately.
  • Flags hashed into the scan config (for example --threads, --min-confidence, --profile, or --perf-trace) fork the decision; instrumentation cannot reuse timings measured without its hot-path overhead. keyhog calibrate-autoroute sweeps the documented presets so the common combinations are covered.
  • Candidate-shape knobs (--min-secret-len, --entropy-threshold, decode depth, entropy/ML/keyword floors) fork the decision, because they change what reaches scan-phase output and can therefore change backend crossover.
  • Pipeline knobs (--threads, --reader-threads, --fused-batch, --fused-depth) and [tuning] settings fork the decision because they change work partitioning and backend warm-up behavior.
  • One calibration process may reuse a KeyHog-owned Rayon pool only at the same worker width. An external pool is rejected because its stack, naming, and ownership settings cannot be attested. An incompatible preset or live width fails before measurement, and the actual count is part of the resolved config identity.
  • Source policy (--limit-*, --max-file-size, --no-default-excludes) and detector floors fork the decision for real stdin/directory buckets that feed different cache/chunk geometry.
  • Workload shape matters: a single file, a directory, and a piped stdin stream are distinct buckets, and stdin is content-sensitive.

The host profile is deliberately checked, but it is not a complete performance- environment fingerprint: for example, CPU governor, system load, and every accelerator limit are not all identity fields. Inspection reports each decision’s persisted calibration timestamp and current age. Decisions do not expire by age. A timestamp later than the inspecting system clock is invalid evidence, so cache loading and inspection fail closed with clock and recalibration guidance. Recalibrate after driver, firmware, power-policy, or material workload changes even when the stored identity still parses as compatible.

keyhog config --effective prints the resolved scan settings. Pair it with keyhog backend --autoroute --json to verify that a routing-relevant setting change produced a new config_digest row.

A lookup first tries the complete workload key. Size, chunk-count, and maximum-file dimensions use one-power-of-two logarithmic ranges. A decision proves correctness and timing for the representative measured under that key. It does not prove that the same backend is fastest for every individual byte length inside the numeric range.

A size band nobody measured is served only by measured invariance. KeyHog collects every calibrated decision that shares this workload’s pattern band, decode state, and source-class set. Two such bands are the minimum: one band says nothing about whether the winner depends on size. Their measurements are then reconciled by the rule that reconciles the repeated points inside a single band. The served backend is the lowest-complexity backend measured at every one of those bands and proved slower at none. Bands that agree on the backend and disagree on the phase-2 localizer plan resolve to the compiled default plan, which each of them must have measured. A band whose own evidence resolves no route, a backend crossover where one band proves a peer faster and another proves the reverse, and any GPU route all withdraw the reuse. Nothing is benchmarked, guessed, or substituted at scan time; the served route is one calibration measured, repeatedly, for this exact class.

GPU routes are never reused for an unmeasured band. GPU correctness, not only GPU speed, varies with input size: batch input caps and per-slot capacities bind to the measured shape, and a parity receipt proves that shape and no other.

When neither an exact key nor an invariant family covers the batch, a normal scan selects no backend for it, leaves it unscanned, records incomplete coverage, and exits nonzero with recalibration guidance. Calibration and explicit backend contracts also fail when their requested evidence or route cannot be produced.

Large directory and multi-source scans run in process and produce multiple real batches. The core calibration command includes file-tree probes, while Git, Docker, and web fixtures require installer calibration.

One-shot scans and the daemon

Runtime lifetime changes accelerator cost, so it is part of routing semantics. Calibration records the scalar CPU distribution directly. For SIMD and each GPU peer it records the real first materialization/dispatch followed by warm trials:

Every candidate contributes exactly seven positive trial durations. Route comparisons pair the same rounds. Missing, extra, zero, or unpaired trials invalidate the decision instead of being trimmed or substituted.

  • An in-process one-shot scan includes cold Hyperscan or GPU cost when choosing a backend.
  • A ready daemon initializes accelerator state before accepting requests and chooses from warm accelerator trials. Startup derives its required peer set from the validated decision table. It does not warm unrelated eligible peers, and it refuses readiness if any selected peer cannot be warmed.
  • keyhog watch is also a compile-once persistent runtime. It warms every selected route before announcing readiness and uses warm evidence for later file events; it does not repeatedly price the same cold backend startup.

Decoded derived buffers are part of the measured route rather than a hidden runtime choice. Scalar and SIMD candidates keep their own backend for decoded rescans. GPU candidates explicitly compose with scalar for those small buffers, so neither scalar nor GPU timing can silently borrow Hyperscan work.

The current in-process router applies that cold-aware decision to each workload lookup. It does not infer request-wide GPU startup amortization across a large number of batches. This is why the cache and inspection output describe a measured workload key and runtime class rather than promising one universal crossover size.

Both routes consume the same parity-checked primary evidence; they derive the appropriate decision for their runtime instead of sharing one misleading “GPU time.” keyhog backend --autoroute prints both routes. CPU, Hyperscan/SIMD, and GPU remain peers in both cases. See Daemon and warm scans for request eligibility, in-process retry policy, socket, and timeout semantics.

Calibration never accepts a candidate that needs recovery. During an ordinary automatic scan, an accelerated-backend fault warns and replays the same stable snapshot through the fastest remaining measured-correct peer. GPU recovery replays only exact unprocessed ranges and retains completed GPU shards. Recovered work is counted separately, the affected workload route is quarantined, and the backend fault is written to a bounded <cache>.runtime-health.json artifact. Runtime health is separate from immutable timing evidence and survives restart. A successful calibration commit clears only the workload identities remeasured in that command. Missing health state means no runtime fault has been observed; malformed, oversized, unknown-backend, or calibration-inconsistent health state invalidates automatic routing. No backend is selected for an affected batch; it remains unscanned and receives repair guidance. An explicit GPU override or --require-gpu remains a hard backend contract and is not substituted. keyhog backend --autoroute reports quarantined readiness, aggregate and per-config fault counts, and the failed backend/reason on each affected workload; keyhog doctor reports the same repair state.

Diagnose invalid state and authenticated recovery receipts

Capture a metadata-bearing report, then inspect routing health:

keyhog scan . --format json-envelope --output keyhog.json
jq '{scan_status, backend_recoveries: (.metadata.backend_recoveries // [])}' keyhog.json
keyhog backend --autoroute
keyhog doctor

When automatic route state is unusable, the scan warning names the missing workload bucket and the dimensions that differ from the nearest measured class. No backend is selected, the affected batch remains unscanned, and the report uses scan_status: "partial" with a coverage gap. metadata.backend_recoveries contains only completed recovery from a faulting authenticated backend; invalid route state never creates a scalar recovery receipt.

Use the reported state to choose the repair:

  • For one uncovered core workload, rerun the same scan once with --autoroute-calibrate --autoroute-gpu. This measures its actual source, resolved configuration, and workload class.
  • For a standard preset ladder, run keyhog calibrate-autoroute. Add --policy default, fast, deep, or precision for a focused repair.
  • For Git, Docker, or web source classes, run the exact repair_command reported for that source.
  • For stale, recalibrate with the new binary after an upgrade.
  • For quarantined, repair the named SIMD or GPU runtime first. Use keyhog backend --self-test --require-gpu for a GPU path, then recalibrate so the exact route fault can clear.
  • For disabled or a storage error, fix the cache path or permissions and run the repair_command shown by JSON inspection.

An explicit diagnostic such as keyhog scan . --backend simd bypasses autoroute. It does not clear an invalid state. It is a hard contract and fails if that backend cannot initialize or execute. It is never substituted with another backend.

Inspect what is calibrated

keyhog backend --autoroute          # concise human-readable summary
keyhog backend --autoroute --verbose # every workload receipt
keyhog backend --autoroute --json    # machine-readable
keyhog backend --autoroute --autoroute-cache /absolute/custom/autoroute.json
keyhog doctor                       # reports the same readiness and repair action

The inspection command is also a health gate. A single-backend build reports health: direct and exits 0 even when its unused cache is absent or stale. For a multi-backend build, health: ready exits 0; quarantined, calibration_required, disabled, stale, and invalid exit 4 so automation cannot mistake an unusable autoroute state for a healthy host. JSON includes the same health value plus repair_command: null for direct or ready, the canonical calibration command for quarantined, absent, stale, or invalid evidence, and an explicit cache-path command when persistence is disabled. Scan reports expose recovered chunks, ranges, and bytes only after a fault in an authenticated selected backend. Invalid route state records an unscanned coverage gap; inspection remains unhealthy until calibration produces confidence-separated evidence.

Pass --autoroute-cache when the scan uses a non-default cache path through the matching scan flag or [system].autoroute_cache.

These show every persisted config and host generation, its workload buckets, representative route times, whether confidence was separated, the selection basis, and the resolved one-shot and daemon backends. The JSON view is lossless: each route includes its ordered nanosecond trials, cold observation, exact one-shot and warm projections, and 95 percent confidence bounds, so the result can be reproduced without parsing the private cache file. Each generation’s eligible_backends array defines the complete backend set. Every decision must contain all four localization plans for every eligible backend and every eligible resident depth for each GPU peer, and prove each route correct. Removing a candidate timing and its receipt together still invalidates the cache because validation compares the full Cartesian route set with this live config identity. The inspection shows exactly what is covered and how each existing decision was made. An invalid decision makes the inspection report the cache as unusable; inspection never omits a malformed row and presents the remainder as healthy.

Inspection validates build compatibility and the complete persisted cache structure. It does not have the live scan’s host, detector, rule, and resolved config inputs; those identities are checked when a real scan loads its decision. Therefore a readable, build-matched inspection is evidence that the cache can be examined, not a guarantee that the next workload has a usable row.

The top-level calibration_required field is true only when this build has multiple compiled scan backends. When false, direct_backend names the only possible route and a disabled or absent cache does not make automatic scans unhealthy. inspected_at_unix_ms is the clock value used for timestamp validation and age derivation. The per-decision JSON fields have these exact meanings:

FieldMeaning
calibrated_at_unix_msOldest persisted Unix timestamp among the decision’s measured points. A future value on any point invalidates the complete cache.
calibration_age_msAge of that oldest point, derived at inspection time from inspected_at_unix_ms; it is visible evidence, not an expiry policy.
backend, phase2_plain_localizer, phase2_keyword_localizer, gpu_pipeline_depthCold-aware backend, both phase-two localization choices, and resident GPU pipeline depth for an in-process one-shot scan. Host and synchronous GPU routes use depth one.
calibration_pointsNumber of exact content-and-source-shape representatives retained for this workload class. Equal byte/chunk counts can contribute more than one point.
sample_bytes_min, sample_bytes_max, sample_chunks_min, sample_chunks_maxExact measured envelope covered by the class.
measured_pointsComplete point-by-point projection: exact sample size, measurement_generator, payload_digest, measurement_shape_digest, timestamp, one-shot and daemon execution-plan winners, confidence status, every route timing, and every parity receipt. Use this array to distinguish same-sized probes and diagnose crossover behavior.
sample_bytes, sample_chunks, route_timingsConcise size projection plus the complete generic route-timing array for the first point after sorting by bytes, chunks, then measurement-shape digest. Each timing identifies the backend, both localization choices, GPU pipeline depth and capability, per-slot input and match capacities, one-shot time, and warm time when applicable. measured_points is authoritative.
confidence_separatedWhether one-shot evidence proves the route at every measured point, either as an exact paired-plan winner or as a statistically tied plan separated from every peer backend plan. false means the route is the dead-heat resolution of a measurement that separated nothing.
selection_basisexact-plan-paired-95pct-confidence, peer-separated-compiled-default-plan, peer-separated-statistically-tied-plan, or unseparated-dead-heat-lowest-complexity-backend. The last one names a decision the evidence permits rather than one it proves, and always pairs with confidence_separated: false.
selected_margin_nsSmallest one-shot representative-time margin to the next eligible route across all measured points; null when there is no peer route.
daemon_backend, daemon_phase2_plain_localizer, daemon_phase2_keyword_localizer, daemon_gpu_pipeline_depthBackend, both phase-two localization choices, and resident GPU pipeline depth derived for a ready persistent daemon from warm evidence.
daemon_confidence_separated, daemon_selection_basis, daemon_selected_margin_nsDaemon-route counterparts, also aggregated conservatively across every measured point.
source_mixtureStructured source-class components used by the workload identity: privacy-safe source_class for KeyHog-owned classes (null for unknown library-provided values), canonical execution-class digest, full-size versus payload provenance, reduced chunk/payload ratios, and maximum source-span bucket. The human-readable workload uses <source_class>@<digest> for known classes and custom@<digest> otherwise, so arbitrary source metadata is never echoed. JSON consumers should use these fields instead of parsing that string.
candidate_receiptsConcise summary of the first measured point’s receipts. Every receipt identifies the backend, both localization choices, GPU pipeline depth, dispatch capability, and per-slot input and match capacities. Every point carries the complete eligible route set; every result digest must equal its point’s scalar/both-off reference, and every evidence digest must recompute exactly or the cache is rejected.

Single-backend builds

A build that compiled only one backend has nothing to route. The portable build, for example, ships only the scalar CPU backend, so it skips autoroute entirely and never reports calibration required. Calibration applies only to builds that compiled a real backend choice (Hyperscan/SIMD and/or GPU).

How detection works

A KeyHog scan is a pipeline. Files come in one side, findings go out the other. In between, four stages:

files → [chunker] → [prefilter] → [detector match] → [post-process] → findings

Most chunks that fail the cheap prefilter stop there, which keeps full regex evaluation focused on plausible inputs. This is not an unconditional hard drop: a rejected chunk that looks encoded can enter a bounded decode-only recovery pass (recursively decoding up to a max_decode_depth, defaulting to 10), so an encoded secret is not lost merely because its plaintext anchor is absent from the original bytes.

Detection mechanisms

KeyHog does not use one universal test for “secret-like.” It composes several mechanisms, and their roles are deliberately different:

MechanismRoleCan create a candidate?
Service-anchored detector regexMatches a vendor or credential-specific shape from detector TOMLYes
Companion patternsFinds related fields near a primary match; required = true gates acceptance, while optional companions enrich confidence or verificationConfirms an existing candidate
Structured and multiline extractionReassembles assignments and strings that syntax splits across lines or nodesYes
Decode-through transformsScans supported encoded representations while preserving source attribution. Reverse and Caesar admission uses the active detector TOMLs’ decode_transforms prefixesYes
Bounded static program recoveryEvaluates recognized side-effect-free JavaScript XOR, explicit-key AES-256-CBC, and CryptoJS/OpenSSL passphrase expressions when every operand is embedded and immutableYes
Generic assignment bridgeExtracts values beside credential-role keys when no vendor shape existsYes
Shannon entropyMeasures byte-distribution uncertainty for opaque generic valuesYes, on the entropy-discovery path
BPE token efficiencyRejects language-like values that compress into common subword tokens; eligible candidates use it by default, and detector TOML can tune or disable itNo; precision gate
English bigram discriminatorDistinguishes random alphabetic tokens from pronounceable identifiers, dictionary placeholders, and low-diversity masks inside specific shape and context gatesNo; admits or rejects an extracted candidate within those gates
Shape, placeholder, path, and context policyRejects examples, references, prose, identifiers, and context-specific noise; entropy owners compile their isolated-token floors and lengths from their detector TOML plausibility tableNo; precision gates
Checksums and structural validatorsProves or rejects formats that carry intrinsic validity bits or grammarAdjusts acceptance/confidence
On-device MoE scoringScores ambiguous candidates using local features; never sends content awayAdjusts confidence
Live verificationOptionally asks the owning service whether a surviving credential is activeAdds a verdict after detection

Regex, generic extraction, entropy, and decode-through therefore find different candidate classes. Named regexes and generic assignment extraction create candidates; companions, validators, BPE, English bigram evidence, shape/context policy, and confidence then confirm, reject, or score them. Verification runs only after a candidate survives detection and reporting policy.

Structured extraction accepts balanced Helm actions as render-time syntax. It replaces each action with an inert YAML value while retaining every literal source byte. A Jupyter notebook truncated at end of file is repaired only by closing its open string and container delimiters. Any other syntax error remains a counted coverage gap.

ML participation is detector-owned through [detector.ml]. lift can raise a structural score but cannot veto a match, blend combines model and structural evidence, and authoritative lets the model decide an otherwise ambiguous channel. In the shipped corpus every regex-pattern channel is currently lift; 929 of 934 entropy channels disable ML. The five that do not are all generic owners: generic-api-key, generic-high-entropy-string, generic-keyword-secret, and generic-secret use authoritative, and generic-password uses lift. The model is therefore not a general regex false-positive veto. Its current feature record identifies detector and pattern-versus-entropy channel, but not the exact matched pattern. Pattern-local weak-anchor policy remains outside model conditioning until that provenance is carried end to end. The retrainer refuses mismatched detector/channel records and now requires positive and negative held-out support for every blend or authoritative channel before writing another model.

Before model inference, a cheap probabilistic gate may assign 0.1 confidence to an unaccompanied generic candidate that lacks secret-like randomness. It cannot short-circuit a service-regex match or a candidate backed by a matched companion, including a weakly anchored service pattern: those candidates continue into their detector-owned ML mode. This distinction prevents a corpus-wide randomness shortcut from overriding stronger detector-local evidence.

Static program recovery is a decode mechanism, not arbitrary code execution. KeyHog does not invoke Node.js or evaluate source. It recognizes a bounded grammar for cyclic byte-array XOR and Node-style AES-256-CBC decryption, resolves only literal numeric arrays, Base64-encoded JSON arrays, buffer literals, and empty-separator string joins, then checks binding consistency, UTF-8, AES block shape, and PKCS#7 padding before rescanning the recovered plaintext. Recovered XOR calls and Node AES ciphertext bindings are spliced back into bounded parent context, preserving assignment evidence and absolute source offsets. The CryptoJS dialect additionally requires an exact immutable require("crypto-js") alias, decrypt wrapper, literal passphrase and ciphertext bindings, an OpenSSL Salted__ envelope, and EVP_BytesToKey MD5 derivation. Dynamic values or unsupported syntax produce no derived candidate. The original source still follows the normal detector pipeline. The mechanism is disabled with decode recursion, including under --fast.

BPE is not a replacement name for entropy: it is an independent post-candidate signal. BetterLeaks calls the approach Token Efficiency; KeyHog uses the same broad BPE idea while keeping its own detector schema, thresholds, pipeline, and behavioral evidence.

Terminology matters here: BetterLeaks’ public documentation names the feature Token Efficiency and describes BPE tokenization as a natural-language false positive filter; it does not present “BPD” as a separate score. KeyHog names its related mechanism BPE token efficiency, uses cl100k_base, measures UTF-8 bytes per token, and resolves the ceiling per detector. If “BPD” is being used informally to mean a bits/byte or bytes/token density, do not treat it as a third byte-density score. KeyHog also uses a separate English letter-bigram discriminator, but it does not measure bits per byte or bytes per token.

The English bigram discriminator evaluates lowercase ASCII alphabetic runs against an embedded 26 by 26 log-probability model. Digits and symbols end a run. Fewer than six alphabetic characters produce no randomness verdict. A random-token verdict requires a mean score at or below -6.85 and at least three distinct letters. Depending on the calling gate, that evidence can keep an otherwise identifier-shaped random credential or reject a confidently English placeholder. The model does not model arbitrary bytes, numeric keys, hexadecimal or Base64 alphabets as such, or short values. Its current thresholds are scanner-wide constants, not detector TOML fields.

Detector-owned tuning: what each setting changes

Detection policy belongs in the detector TOML whenever the choice is specific to a credential type. Scan-wide CLI/TOML values are operational overrides for controlled comparisons or a corpus-wide policy; they are not a second hidden detector definition. keyhog explain <detector-id> shows the policy declared by that detector TOML and its provenance; keyhog config --effective shows the resolved scan-wide policy.

Practical ownership rule: any numeric value that changes one secret family’s recall, precision, shape admission, or scoring must be a named detector-TOML field. If the schema cannot express it, extend the typed schema and its explain/contract surfaces rather than adding a detector-specific literal in scanner code. Only true shared invariants, such as parser safety caps or a model’s fixed vocabulary, remain global.

Global structural non-secrets are typed Tier-B data in rules/entropy-universal-rejections.toml. Plain prefixes and explicit prefix-plus-length rules, including the Ag Sealed Secrets ciphertext boundary, apply uniformly before detector plausibility. They are corpus-global structural invariants, not a second source of per-detector tuning.

Detector TOML fieldIf increased / enabledIf decreased / disabled
entropy_lowRequires more Shannon entropy for keyword-anchored generic values; fewer low-randomness passwords/tokens surviveAdmits more values when the assignment key supplies evidence; shape, BPE, context, and confidence gates still apply
entropy_highTightens keyword-independent generic admission and raises the partial-confidence tier for entropy fallbacksAdmits more opaque candidates and grants the partial entropy score at a lower value
entropy_very_highTightens isolated, anchor-free admission and raises the full-confidence tier for entropy fallbacksExpands the no-keyword search and grants the full entropy score at a lower value
sensitive_path_entropy_very_highRaises the keyword-free bar even in sensitive filesLowers the explicit sensitive-path bar for that detector, improving recall in .env/secret manifests
plausibility.keyword_free_operator_marginRaises the detector-owned margin composed with the Tier-A entropy thresholdLowers that explicit margin for the keyword-free role owner; no other detector may declare it
[detector.entropy_fallback]Changes the emitted synthetic entropy finding identity and semantic class for that detectorOmitting it for an active entropy owner fails compilation; there is no scanner-global compatibility identity
entropy_rolesClaims one or more corpus entry paths: keyword-free, isolated-bare, or unclaimed-keywordOmitting a role disables that path in a focused custom corpus; no built-in owner or threshold is substituted
decode_transforms.reverse_prefixesAdmits character-reversed candidates that can recover one of the declared plaintext prefixesOmitting a prefix prevents reverse recovery for that prefix in a focused custom corpus
decode_transforms.caesar_prefixesAdmits only ROT-N shifts that can recover one of the declared plaintext prefixesOmitting a prefix prevents Caesar recovery for that prefix in a focused custom corpus
[[detector.entropy_shapes]]charset, optional grouping, diversity requirements, and a lower shape floor admit explicitly structured isolated credentialsStricter structural requirements or a higher floor narrow the exception; omission is invalid for an active entropy owner
entropy_floorA higher applicable length-bucket floor suppresses more low-entropy candidates for that detectorA lower floor preserves more human-chosen or structured credentials
plausibility.mixed_alnum_floorRejects more identifier-like alphanumeric runsPreserves more low-randomness mixed-alphanumeric values
plausibility.symbolic_entropy_floorRaises the minimum entropy for symbol-bearing credential assignments, including the bare auth= bridgePreserves more anchored symbolic passwords through the same compiled detector policy
plausibility.second_half_entropy_floorRejects candidates with a less-random tailPreserves more credentials whose entropy is front-loaded
plausibility.second_half_min_lenApplies the tail-entropy check to shorter valuesRestricts the tail check to longer values
plausibility.unique_chars_min_lenApplies distinct-character requirements to shorter valuesRestricts the diversity check to longer values
plausibility.min_unique_charsRequires more distinct characters once the diversity check appliesPreserves lower-diversity credentials
plausibility.unanchored_hex_max_lenAllows longer unanchored all-hex values before treating them as non-secret key materialRejects shorter unanchored all-hex values
plausibility.identical_char_max_lenAllows longer single-character repetitionsRejects shorter single-character repetitions
plausibility.structured_dotted_min_lenRequires a longer isolated structured dotted tokenAdmits shorter structured dotted tokens after the other gates pass
plausibility.mixed_alnum_min_lenRequires a longer mixed alpha-numeric credential before the carve-out appliesLets shorter anchored mixed tokens use the detector’s mixed floor
plausibility.isolated_mixed_entropy_floorRaises the floor for isolated contiguous or underscore-delimited mixed tokensPreserves more low-randomness isolated mixed tokens
plausibility.isolated_symbolic_min_lenRequires a longer isolated symbol-rich credential for the short-candidate exceptionAdmits shorter symbol-rich candidates; exact declared lower-dash layouts still use their own shape rules
plausibility.isolated_symbolic_min_symbolsRequires more symbol bytes in the isolated symbolic exceptionAdmits candidates with fewer symbol bytes after the other gates pass
plausibility.isolated_symbolic_requires_non_underscorePrevents underscore-only mixed tokens from bypassing their mixed entropy floor through the symbolic exceptionAllows underscore to satisfy the symbolic exception by itself
plausibility.isolated_colon_left_min_len / isolated_colon_right_min_lenRequires longer sides around an isolated opaque:opaque separatorAdmits shorter colon-separated opaque pairs
plausibility.leading_slash_base64_entropy_floorRaises the floor for unanchored slash-led base64Preserves more slash-led base64 candidates
plausibility.leading_slash_base64_min_lenRequires a longer unanchored slash-led base64 candidateAdmits shorter candidates after alphabet, padding, entropy, and shape checks pass
plausibility.reject_repeated_blocksRejects periodic mask values, including truncated repetitions in the bare auth= bridgeAllows that shape to continue through the remaining detector gates
plausibility.allow_alphabetic_credentialAdmits anchored all-letter passwords/tokens after other gatesRequires alphabetic-only values to clear the ordinary entropy path
plausibility.reject_program_identifiersRejects pure source-language identifier shapesAllows pure identifier-shaped values through the remaining gates
plausibility.reject_source_symbol_identifiersRejects digit-bearing mixed alphanumeric source-symbol shapes independently of the pure-identifier gateAllows those mixed values to follow the detector’s mixed_alnum_floor and mixed_alnum_min_len policy
plausibility.reject_dash_segmented_alnumRejects serial/product-key-like dash groupsAllows dash-segmented alphanumeric values through the remaining gates
entropy_policy_priorityWins more overlapping generic keyword-policy claimsYields shared keywords to a more specific detector; unique keywords are unchanged
bpe_max_bytes_per_tokenA higher ceiling is looser: fewer compressible/word-like candidates are rejectedA lower ceiling is stricter: more language-like values are rejected, with corresponding recall risk
bpe_enabled = falseNot applicableSkips token-efficiency rejection for detectors such as human-chosen passwords
decoded_hex_key_material_lengthsAdds only the declared pure-hex widths after transport decodingOmitted widths remain decoded-digest negatives
canonical_hex_key_materialGeneric detectors admit declared lengths only under exact keywords or vendor-prefixed suffixes; regex detectors use length-only entries because their matched pattern is the anchorOmitted policy, scope, or length remains a digest-shaped negative; there is no service-wide width fallback
min_len / keyword_free_min_lenLonger values are required; short false positives fall, but short real credentials can also fallShorter credential shapes become eligible
max_len (entropy-policy owner)Longer values remain eligible across generic assignment, entropy fallback, and explicit regex envelopes; increase only when the credential contract permits themOverlength values are rejected whole with value_too_long before entropy or BPE
allowlist_paths, allowlist_values, stopwordsAdds detector-specific path, value-regex, or literal exclusionsRemoving an exclusion makes that detector consider the matching path/value again; it does not affect other detectors
pattern required_literalsRoutes a prefixless regex only after at least one AST-proven necessary ASCII literal is present. The declaration is the sole owner of non-prefix literal routingOmitting it leaves the regex in its prefix, keyword-gated, or always-active route; an unsound declaration rejects the detector
public_identifier_assignment_markersClassifies detector-local assignment-key fragments as public identifiers instead of credentialsOmission disables this suppression for that detector; there is no scanner-global blockchain/network marker list
min_confidenceRaises this detector’s reporting floorLowers this detector’s reporting floor; an operator override can still replace it
detector/pattern weak_anchorKeeps generic shape/entropy gates active for a whole detector or an individual pattern; requires the owning detector’s entropy_high and entropy_floorTrusts unmarked patterns; use only when those patterns prove the credential shape
structural_password_slotApplies password-slot placeholder policy to a free-form value captured from a syntactic credential slotLeaves that detector outside the structural-password family
private_key_blockMakes the detector’s enclosing key block suppress less-specific findings nested inside itTreats the match as an ordinary, non-enclosing finding
[detector.credential_shape]Declares exact prefix/length/shape constraints that a captured credential must satisfyOmitting it leaves that detector without an additional credential-shape constraint

Resolution rules

These settings do not all use one generic “last value wins” rule:

  • Generic keyword ownership: the highest entropy_policy_priority among detectors claiming the normalized assignment keyword owns entropy and BPE policy. Equal priorities use stable detector identity, independent of corpus order. Custom detector policy keywords join entropy discovery directly; they do not need to be repeated in [scan].secret_keywords.
  • Final match resolution: the active compiled plan classifies named, phase-2 generic, entropy, and enclosing private-key findings. The reporting service string and detector-ID length do not change specificity. Unknown finding identities fail checked resolution instead of inheriting embedded or service-name behavior.
  • Entropy entry roles: entropy_roles selects the detector that owns each corpus-level entry path. A compiled corpus may have at most one owner for each role. Missing roles remain disabled, and duplicate owners fail scanner construction. Role selection never depends on a detector ID spelling.
  • Weak anchors: detector-level weak_anchor = true applies to every pattern, while the same field inside [[detector.patterns]] governs that exact regex. Each such detector owns entropy_high and length-bucketed entropy_floor. Scanner construction rejects an explicit weak anchor without that local policy. KeyHog never guesses this semantic choice from regex text, and min_confidence does not disable it. The compiled hot path uses a primitive detector-indexed floor program.
  • BPE ceiling: every active entropy owner declares either bpe_max_bytes_per_token or bpe_enabled = false; omission fails detector validation and scanner construction. An explicitly supplied [scan].entropy_bpe_max_bytes_per_token or --entropy-bpe-max-bytes-per-token replaces every BPE-enabled entropy/generic detector ceiling; the CLI wins over the config file. bpe_enabled = false still disables the gate for that detector.
  • Confidence floor: the scan floor defaults to 0.40. A detector TOML min_confidence replaces the scan floor for that detector, and an operator [detector.<id>].min_confidence replaces the detector-declared floor. Under --precision, the resolved global and per-detector floors are clamped to at least 0.85; neither source can weaken the precision preset.
  • Entropy policy: every active entropy owner must declare its high, low, very-high, sensitive-path, mixed-alphanumeric, symbolic, tail-entropy, length, isolated-shape, and BPE policy. Scanner construction compiles these into concrete detector-indexed values; a missing field is an error, not a runtime default. Schema defaults remain only for non-owning programmatic detector values that never supply entropy policy. The scan-wide entropy_threshold is deliberately not a blanket replacement for all four bands. On the phase-2 generic bridge it tightens only when it exceeds the owning detector’s high band. On the entropy scanner, a value above that high band tightens keyword and isolated candidates; a value below the keyword detector’s low band loosens that keyword path, while values between the low and high bands leave its low floor in place. The isolated path keeps its mixed-alphanumeric floor unless the scan threshold exceeds the high band. Named-detector heuristic confidence uses the resolved scan threshold as its partial entropy tier and the scoring margin above it as its full tier; changing the setting can therefore change a named finding’s confidence without changing whether its regex matched. These rules preserve the different evidence carried by an assignment key, an isolated opaque token, and an unanchored generic value. The owning entropy_high and entropy_very_high values also define the partial and full heuristic-confidence tiers for emitted entropy fallbacks. Detector ML policy then composes with that heuristic; an authoritative ML mode may replace it, while disabled, lift, and blend modes retain the documented heuristic semantics.
  • Sensitive paths: sensitive_path_entropy_very_high is a required detector-local threshold for active entropy owners. Equaling entropy_very_high means no sensitive-path relaxation; a lower declared value is an explicit detector-owned recall choice.
  • Credential plausibility: the required detector plausibility block owns its entropy floors, length carve-out, alphabetic admission, and repeated, identifier, and dash-segment rejection choices. There is no production-path fallback for an active entropy owner.
  • Synthetic entropy identity: every active entropy owner declares [detector.entropy_fallback] with a semantic class (generic, password, token, or api-key), an entropy-* id, display name, and service. The compiled scanner uses the complete metadata from the active detector corpus for entropy-only findings. Omitting the block is a visible compile error; no scanner-global keyword classifier or compatibility identity can relabel a custom candidate.
  • Isolated entropy shapes: generic entropy owners declare one data-driven shape with its character set, entropy floor, optional fixed-width grouping, diversity requirements, and special minimum length. For the shipped lower-alphanumeric app-password policy, candidate length is derived from four groups of four plus three separators; special_min_length controls the short-candidate revisit and must not exceed that derived length. The shape is used for anchorless synthetic entropy recovery; the anchored bluesky-app-password regex remains the source of the named Bluesky finding. A custom corpus without the shape has no isolated exception, rather than inheriting an embedded detector policy.
  • Isolated symbolic credentials: the detector’s plausibility.isolated_symbolic_min_len, plausibility.isolated_symbolic_min_symbols, and plausibility.isolated_symbolic_requires_non_underscore fields control the shorter symbol-rich exception. Contiguous and underscore-delimited mixed tokens stay under plausibility.isolated_mixed_entropy_floor when the owner requires a non-underscore symbol, and an exact declared lower-dash layout must satisfy its entropy_shapes policy instead of bypassing it as symbolic.

Token efficiency can carry more of the precision burden for a detector whose assignment key or regex already creates the candidate. That is the practical per-detector alternative to making Shannon entropy the decisive signal: use a permissive detector-owned entropy floor appropriate to the credential family, then let its BPE, shape, context, and confidence policy reject word-like noise. It is not equivalent to blindly replacing entropy with one global BPE number, and bpe_enabled alone never creates a candidate. Both configured gates still execute; the current pipeline has no entropy-or-BPE branch.

Detector-owned canonical_hex_key_material is the deliberate exception to the BPE and generic low-diversity/decode-as-data gates. Hexadecimal key bytes tokenize efficiently and use a small alphabet for the same mechanical reasons hexadecimal digests do, so the exact detector-owned contract supplies the discriminator: assignment scope plus length for generic detectors, or matched regex plus length for named detectors. Placeholder, degenerate-repeat, entropy, context, and reporting gates remain active. When ML is enabled, this exact TOML match is structural positive evidence and therefore preserves the detector heuristic floor; the model may raise its score but cannot erase a policy-proven key as if it were an unowned entropy candidate.

Scan-wide settings remain operational controls, but they do not all compose the same way. The operator-layer order and working TOML/CLI examples are in Configuration. Stable per-detector tuning belongs in the owning detector TOML and should be proved with that detector’s positive, negative, evasion, backend-parity, and corpus contracts.

Settings, active corpus, and exact identity

KeyHog keeps detector content, resolved scan policy, and corpus provenance separate:

  • The reported detector corpus digest binds the normalized corpus schema and the active detector specifications after composition and [detector.<id>] enabled = false removal. A matching disable therefore changes the digest. An unknown disabled ID warns and leaves this digest unchanged.
  • The autoroute rules identity also describes the active detector specifications. Operator confidence-floor overrides are composed later so different scan presets can coexist in one calibration cache. It is the canonical corpus identity an execution pack carries, so a scan that compiles the corpus and a scan that hydrates an installed generation of that same corpus read the same calibrated table. Self-test fixtures and declaration order are excluded.
  • The autoroute configuration identity binds the resolved scanner and operator policy. It includes the selected fast, deep, or precision preset, scan-wide and per-detector floors, the configured disabled-ID set, detector tuning inputs, worker and pipeline settings, backend/GPU policy, and profiling instrumentation.
  • The corpus path and embedded, replace, or overlay label are provenance. They are reported in versioned output, but the path spelling is not detector content. Copying the same normalized corpus to another directory does not create a different content digest. A mode change changes the digest only when it changes the resulting active specifications.

The preset definitions and their override rules are in Configuration. --profile is performance instrumentation, not a named scan-policy profile.

Hardware changes execution, not detection policy. CPU, SIMD/Hyperscan, and GPU routes consume the same resolved detector and configuration identities. Autoroute accepts a candidate only when its canonical detection identities match the reference: chunk membership, detector id/name/service/severity, exact credential, stored hash, companion identity, source, file, line, byte offset, commit, author, date, entropy, confidence, and multiplicity. Mismatch diagnostics name only the differing fields and occurrence counts. They never expose raw values or deterministic value fingerprints.

Built-in suppression, confidence, decode, and scanner postprocessing are already part of those backend results. CLI allowlists and rules, policy floors, cross-source deduplication, verification, and output formatting run after selection. Missing or stale exact evidence is an error. Calibration never relaxes a detector to make a backend look faster.

Two kinds of change exist, and keeping them apart is the whole point of the parity model. A policy change is allowed to change findings. An execution change is not. A finding-set difference across an execution change is a parity failure, which KeyHog treats as a defect rather than a result.

Policy changes: findings may change

ChangeFinding-set effectRouting and calibration effect
Change a preset (--fast, --deep, --precision)Intended. Each preset resolves a different confidence floor and decode policy.Configuration identity changes; calibration for the old identity is not reused
Change scan-wide policy, a per-detector floor, or the disabled-ID setIntended, according to the settingConfiguration identity changes
Change detector TOML, corpus schema, or replacement/overlay membershipCandidates, suppressions, confidence, or final findings may changeActive corpus and rules identity change; recalibration is required
Apply a matching [detector.<id>] enabled = falseThat detector stops reportingCorpus digest changes. An unknown disabled ID warns and leaves the digest unchanged
Change the inputThe input can change findingsThe route class changes only when the shape of the work changes: byte, chunk, maximum-file, or pattern band, decoder kinds, or the set of source classes

Execution changes: findings must not change

ChangeFinding-set effectRouting and calibration effect
Change CPU, GPU, driver, or accelerator availabilityNone for the same resolved identities and input. A parity mismatch rejects that route.Host, device, and runtime identity change; old host evidence is not reusable
Use --backend cpu, simd, gpu-cuda, gpu-metal, or gpu-wgpuNone. Parity-identical by contract.Diagnostic override. It bypasses autoroute and creates no reusable fastest-correct evidence
Switch between a one-shot process and a ready daemon or watch runtimeNone. Runtime lifetime must not change detector policy or canonical matches.Cold-aware and warm persistent-runtime routes may have different winners
Change --threads, worker, or pipeline settingsNoneConfiguration identity changes, so calibration is per worker shape
Copy the same normalized corpus to another pathNoneContent identity is unchanged; reported source provenance changes

A difference in the first table is a decision you made. A difference in the second table is a bug. Report it with the effective config, detector digest, input identity, backend, host identity, and the complete finding sets from both runs.

Strict Backend Parity

KeyHog exposes three search-backend classes: pure Rust CPU, SIMD/Hyperscan (simd-regex), and GPU/VYRE region presence. Autoroute measures five concrete runtime peers when eligible: scalar CPU, Hyperscan CPU, CUDA, native Metal, and WGPU. Portable builds retain the pure-Rust trigger path without Hyperscan. keyhog calibrate-autoroute rejects any peer whose canonical match identity differs from the reference. It records the first real GPU dispatch plus warm trials: an ordinary process resolves against the cold-aware GPU cost, while a daemon that initialized its engines before readiness resolves against the warm GPU evidence. A missing or invalid decision is not autoroute evidence: KeyHog warns, selects no backend for the affected batch, records incomplete coverage, and prints the exact repair command.

When comparing settings, record the effective config, detector digest, input identity, backend, host/accelerator identity, and complete findings, not only elapsed time or finding count. A faster run with a different result set is a detection change or parity failure, not a routing win.

Stage 1 - chunker

A file becomes one or more chunks. A chunk is {data: str, metadata: {source_type, path, line_offsets, …}}. The chunker:

  • Skips obvious binaries via magic-byte sniffing (PDF, PNG, zip, …).
  • Skips files matching is_default_excluded_path (node_modules, .min.js, build/, etc.).
  • Splits files larger than the 1 MiB window size into overlapping ~1 MiB windows so a single giant log file doesn’t blow scratch memory. Each window carries its absolute base byte offset and base line so findings report the real file offset/line, not the per-window one. Cross-window secrets are reassembled in stage 4.
  • Decodes UTF-16 BOM files into UTF-8 (PowerShell / .NET configs).

Specialized chunkers run too:

  • Git history → one chunk per (commit × file × diff line)
  • Docker images → one chunk per layer × file
  • Web URLs → one chunk per response body / sourcemap / WASM strings
  • S3 buckets → one chunk per object body
  • GCS buckets → one chunk per object body
  • Azure Blob containers → one chunk per blob body

Stage 2 - prefilter (the cheap pass)

Three gates, in order, each cheaper than the next:

  1. Alphabet screen. A 256-bit mask of which bytes the corpus’s detectors care about. A chunk with no relevant byte becomes a prefilter miss.

  2. Bigram bloom. A 4096-bit bloom filter of 2-byte sequences from detector keyword prefixes. A chunk with no overlapping bigram becomes a prefilter miss. This cheaply recognizes source that carries no relevant anchor vocabulary.

After these screens, ordinary misses stop. Decode-shaped misses instead take the bounded decode-only path described above; transformed plaintext is then attributed back to the original source.

  1. Backend trigger pass. The simd-regex backend compiles the detector corpus into Hyperscan databases when the simd feature is present; cpu-fallback uses the pure-Rust trigger path. One pass returns which detector IDs have a candidate match.

    GPU-capable builds add VYRE’s resident fused literal-evidence backend. Its single dispatch returns region presence plus detector-derived localization positions; the shared host regexes still decide every finding. There is no universal model-name or byte threshold at which KeyHog silently switches to it. --backend auto requires an exact persisted calibration decision for the current binary, detector/config digest, host/device/driver, workload class, and size bucket. Calibration keeps a GPU route only when its canonical match identities equal the reference and it is the fastest eligible backend for that key.

Stage 3 - detector match

For each pattern-backed detector that the prefilter flagged, the full regex evaluates. The regex is detector.patterns[].regex in that detector’s TOML, and its configured capture group becomes the candidate credential. Generic phase-2 detector TOMLs use keyword, length, entropy, token-efficiency, and shape policy for shapeless assignments or isolated opaque values. They may also carry explicit patterns for strongly structured envelopes such as JSON "secret", "token", or "apiKey" fields; both mechanisms remain owned by the same detector TOML instead of a central compatibility detector.

A detector’s .toml carries:

  • id, name, service, severity, keywords
  • zero or more patterns, each with regex + group + optional description (required for service-anchored detectors; optional structured-envelope anchors for phase2-generic)
  • optional typed companions; required entries gate acceptance, reinforcing entries add evidence, and forbidden entries suppress
  • optional bounded detector_relations with requires, conflicts, or subsumes semantics across findings in the same source, file, and revision
  • optional verify block: HTTP method, URL template, auth scheme, success status

Detectors fall into two camps:

  • Service-anchored. Regex requires a service-specific keyword (AWS_SECRET_ACCESS_KEY=, stripe.com/v1/, dn_ Deepnote prefix). These have HIGH precision: the keyword itself is positive evidence, not just a hint.

  • Generic / entropy discovery (generic-password, entropy-api-key, entropy-token). Triggered by entropy + assignment shape only - password = "...", secret: "...", JSON { "token": "..." }. Lower precision; suppression filters do most of the work.

    Surviving candidates also pass a BPE token-efficiency gate. Shannon entropy asks how evenly bytes are distributed; token efficiency asks how readily a fixed subword vocabulary compresses the value. Dotted API names and prose can have high Shannon entropy but tokenize into a few common pieces, while opaque secrets usually require many short tokens. The mechanisms are complementary, and generic detector TOMLs may own their token-efficiency ceiling through bpe_max_bytes_per_token. Opaque API-key/secret policies use their detector-owned ceiling, falling back to the scan-wide default of 2.2 UTF-8 bytes per token when they do not declare one; password/passphrase policies set bpe_enabled = false because human-chosen credentials may intentionally be word-like. Disabled policies skip tokenizer work entirely rather than using a magic oversized ceiling.

The entropy-generic, entropy-password, entropy-token, and entropy-api-key IDs are output classifications for entropy-discovered findings, not four additional detector TOML files. Their candidate policy is owned by the corresponding phase-2 TOMLs selected from the assignment context: generic-secret, generic-password, generic-keyword-secret, or generic-api-key. Use keyhog explain on those owning detector IDs when tuning entropy, BPE, length, or canonical-key policy.

The split matters for the post-process stage.

Stage 4 - post-process

Even a regex match isn’t always a credential. Stage 4 filters:

  • Known example fixtures (Stripe docs key, AWS docs key, RFC 7519 specimen JWT).
  • Placeholder language - credentials containing YOUR_, INSERT, EXAMPLE, PLACEHOLDER, TODO, FIXME, etc.
  • Shape gates.
    • Universal: punctuation_decorated_identifier - credentials starting with --, &, @, !, /, $ (CLI flags, pointers, SQL vars, shell vars, GraphQL refs) or ending in : / ! (UI labels, TypeScript non-null assertions).
    • Generic / entropy only: pure_identifier, word_separated_identifier, scheme_prefixed_uri, url_or_path_segment, contains_uuid_v4_substring. These shapes can be real credentials when paired with a service or protocol anchor, so named detector TOMLs and structural authorization detectors own those cases. A generic token=<uuid> remains an identifier; an Authorization: Bearer <uuid> value is a credential because the Bearer envelope supplies the missing evidence. Public salts and nonces are not generic secrets. A detector for a product whose field is genuinely secret despite that name must own the product syntax explicitly.
  • Path-based suppressions - vendored bundles (node_modules/, wp-includes/, bower_components/), CI workflow files (where ${{ secrets.NAME }} references are syntactic, not credentials), i18n translation files, secret-scanner source files (the file IS a scanner; its regex literals shouldn’t fire on itself).
  • Cross-chunk reassembly. A secret split across window boundaries gets reassembled from the tail of chunk N + the head of chunk N+1.

A finding that survives stage 4 makes it to output.

Semantic source roles and structured parsing

KeyHog analyzes the syntactic role of matched text to distinguish genuine credential assignments from comments, documentation, and mock data.

Each candidate is classified into a SemanticSourceRole:

Semantic source roleSource contextPrecision effect
environment-assignment-value.env files, shell KEY=value linesHighest confidence for credential assignments
structured-header-valueHTTP request/response headers, YAML/JSON auth blocksHigh confidence for credential headers
code-literalString literals in source code ASTs (.js, .py, .rs, .go, …)Standard confidence; subject to identifier and placeholder screens
standalone-tokenBare tokens without key-value assignment anchorsEvaluated through entropy, shape, and BPE token efficiency gates
commentSingle-line and block comments in source filesDowngraded by default unless --scan-comments is enabled
test-fixtureUnit test fixtures, mock data, and test filesSuppressed by default unless --no-suppress-test-fixtures is enabled
documentationMarkdown fenced blocks, docstrings, README filesSuppressed or downgraded according to detector documentation policy
binary-stringExtracted printable strings from compiled binariesEvaluated under binary strings length and entropy bounds
unattributedSynthetic findings or callers without semantic indexingDefault neutral baseline
unknownFiles with unrecognized or non-matching structured extensionsAbstained role; candidate evaluates on standard structural evidence

Structured parser scoping and abstention

Structured configuration parsers (dotenv, JSON, YAML, TOML) enforce strict file-extension scoping:

  1. Extension-matching paths: Files with recognized extensions (.env, .json, .yaml, .yml, .toml) parse according to their format grammar.
  2. Non-matching extensions: Files with non-matching extensions (for example config.unknown or data.txt) abstain to SemanticSourceRole::Unknown. They do not guess format syntax from arbitrary file extensions.
  3. Unnamed memory buffers: Unnamed streams (path: None), such as standard input or in-memory chunk buffers, use content-based structural sniffing to identify dotenv or JSON payloads.

Decoded sub-chunk semantic scoping

When an input contains encoded strings (such as Base64, Hexadecimal, or URL-encoded payloads), KeyHog’s decode-through engine extracts the decoded bytes into a sub-chunk.

A decoded sub-chunk clears its inherited file path to None during semantic indexing. This ensures that the decoded payload is parsed based on its own syntactic structure rather than inheriting the outer file’s extension. For example, a Base64-encoded JSON object inside a .txt file is parsed as structured JSON rather than plain text.

A credential found in both the container bytes and the decoded payload is reported once, at the coordinate in the file you can open. Its evidence is the stronger of the two. A Kubernetes Secret whose base64 data: value decodes to AWS_ACCESS_KEY_ID=... therefore reports likely with the assignment role the decoded text proves, at the offset of the encoded value.

Pattern provenance and secret-safe evidence

Every finding emitted by KeyHog carries a structured provenance record inside its evidence block. This metadata identifies the exact pattern and context that produced the match without disclosing secret material:

{
  "schema_version": 1,
  "detector_digest": "0123456789abcdef",
  "pattern_index": 0,
  "candidate_channel": "pattern",
  "source_role": "environment-assignment-value",
  "context_class": "vendor-pattern"
}

Provenance fields

  • schema_version: Version of the provenance schema (currently 1).
  • detector_digest: 16-character lowercase hexadecimal hash of the active compiled detector specification.
  • pattern_index: 0-indexed ordinal of the matched regex pattern in the detector TOML, or null for entropy-discovered candidates.
  • candidate_channel: Pipeline stage that generated the candidate: pattern (regex match), entropy (entropy discovery), companion (companion match), static-recovery (bounded JavaScript XOR/AES evaluation), or unattributed.
  • source_role: The SemanticSourceRole where the match was located.
  • context_class: Surrounding context category (vendor-pattern, weak-anchor, generic-assignment, standalone-token, or unsupported-context).

Provenance records are deterministic, portable, and safe to share in public CI logs and triage envelopes.

Where the speed comes from

The alphabet screen and bigram bloom reject irrelevant chunks before regex confirmation. Literal triggers narrow the active detector set, and the scanner shares confirmation, suppression, and reporting tails across CPU and GPU backends. Windowing bounds scratch space for large inputs; caches avoid repeated compiler and index work.

End-to-end throughput depends on the detector/config digest, source shape, candidate density, decoding and verification policy, cache state, CPU, GPU, driver, and storage. Use keyhog calibrate-autoroute for routing evidence on the installed host and the repository benchmark harness for reproducible cross-version measurements; do not treat a throughput number from another machine or detector corpus as a routing threshold.

Where the precision comes from

FilterWhat it catches
Known example fixturesStripe docs key, AWS docs key, RFC 7519 JWT
pure_identifiergetParameter, Benutzername, auth_decoders
word_separated_identifiers3_secret_access_key (function name)
scheme_prefixed_uriurn:foo:bar (URI literal, not creds)
url_or_path_segment/api/v1/users/123 (REST path)
contains_uuid_v4_substringTOKEN_LIST=636765a9-… (UUID identifier)
punctuation_decorated_identifier--api-secret, &password, Password:
Vendored-minified-pathnode_modules/jquery-3.6.0.min.js
CI workflow path.github/workflows/ci.yml - ${{ secrets.X }}
i18n translation pathlocale/de.po - translated password word

Each filter has a known-FP-cluster it was built to defuse. The Suppressions page enumerates them with examples.

What this looks like for one finding

file.env contains: AWS_SECRET_ACCESS_KEY=ev0BsFtSD7S/4VWYObxiEhME3hJBXeYzR43jgiB1

stage 1 - chunker:        emit chunk{ path: "file.env", data: "AWS_SECRET..." }
stage 2 - alphabet:       PASS (chunk has `=`, alphanumerics from the corpus)
stage 2 - bigram bloom:   PASS (`AW`, `WS`, `_S` are in the bloom)
stage 2 - simd-regex:     MATCH → triggers `aws-secret-access-key` + `generic-password`
stage 3 - regex eval:
  `aws-secret-access-key` detector pattern captures the 40-byte value
    captures `ev0BsFtSD7S/4VWYObxiEhME3hJBXeYzR43jgiB1`
  `generic-password` regex doesn't match (no `_password`/`_pwd` substring)
stage 4 - post-process:
  known-example check: no
  `looks_like_pure_identifier`: false (has digits + /)
  `looks_like_punctuation_decorated_identifier`: false
  → EMIT

That’s one finding’s life. Multiply by 10⁶ files and the throughput math is why each stage matters.

Detectors

A detector is a single TOML file that teaches KeyHog one shape of credential. The embedded corpus is generated from detectors/*.toml; query the running binary for its exact corpus size rather than relying on a number copied into documentation.

Pattern counts

KeyHog counts detectors and patterns separately. A detector is one TOML file; each file may define one or more [[detector.patterns]] rows. The startup banner’s parenthesized pattern total is the compiled scanner count after the engine expands those rows (and related trigger keywords) into the literal and regex slots it actually runs, so it is always larger than the raw TOML row count. Use keyhog detectors --format json | jq length for the embedded detector count; the banner line shows the live compiled total for your binary.

Anatomy of a detector

# detectors/stripe-secret-key.toml

[detector]
id = "stripe-secret-key"
name = "Stripe Secret Key"
service = "stripe"
severity = "critical"
ml = { match_mode = "lift", entropy_mode = "disabled", weight = 1.0, context_radius_lines = 5 }
match_confidence = { literal_prefix_weight = 0.35, context_anchor_weight = 0.20, entropy_weight = 0.20, high_entropy_partial_weight = 0.12, moderate_entropy_threshold = 3.0, moderate_entropy_weight = 0.05, low_entropy_penalty_floor = 2.0, low_entropy_min_match_length = 10, low_entropy_penalty_multiplier = 0.60, keyword_nearby_weight = 0.10, sensitive_file_weight = 0.10, companion_weight = 0.05, very_high_entropy_margin = 1.2999999999999998, named_anchor_floor = 0.55, assignment_context_multiplier = 1.0, string_literal_context_multiplier = 0.9, unknown_context_multiplier = 0.8, documentation_context_multiplier = 0.3, comment_context_multiplier = 0.4, test_context_multiplier = 0.3, encrypted_context_multiplier = 0.05, soft_context_suppression_threshold = 0.5, encrypted_context_suppression_threshold = 0.8, post_match = { placeholder_multiplier = 0.05, minimum_byte_diversity = 0.1, low_diversity_multiplier = 0.1, maximum_repeat_ratio = 0.8, degenerate_run_min_length = 10, degenerate_repeat_multiplier = 0.1, fixture_path_multiplier = 0.5, ml_context_reapply_below = 0.95 } }
validators = [{ type = "pattern-shape", prefixes = ["sk_live_", "sk_test_", "rk_live_", "rk_test_"], allow_overlong = false }]
keywords = ["sk_live_", "sk_test_", "rk_live_", "rk_test_", "stripe"]
simdsieve_prefixes = ["sk_live_", "sk_test_", "rk_live_", "rk_test_"]

[[detector.patterns]]
regex = 'sk_live_[a-zA-Z0-9]{24,}'
description = "Stripe live secret key"

[[detector.patterns]]
regex = 'sk_test_[a-zA-Z0-9]{24,}'
description = "Stripe test secret key"


[detector.verify]
method = "GET"
url = "https://api.stripe.com/v1/charges?limit=1"
allowed_domains = ["api.stripe.com"]

[detector.verify.auth]
type = "basic"
username = "match"
password = ""

[detector.verify.success]
status = 200
policy = "status_with_error_backstop"

That’s the whole contract for one service. Every other detector follows the same shape.

Verification success and metadata json_path fields use the single rooted response-selector grammar documented in Verification.

Each shipped detector owns at least one canonical positive/negative truth pair:

[[detector.tests]]
pattern_index = 0
test_positive = "SERVICE_PUBLIC_ID=example-production-fixture"
test_negative = "SERVICE_PUBLIC_ID=short"
negative_class = "boundary"

[[detector.tests]]
pattern_index = 1
test_positive = "SERVICE_LEGACY_ID=example-legacy-fixture"
test_negative = "SERVICE_LEGACY_ID=short"
negative_class = "boundary"

Detector test records are executable production-path fixtures, not prose examples. pattern_index is the zero-based pattern ordinal. negative_class is one of boundary, identifier, prose, regex-literal, or sibling-prefix. The positive must surface that exact detector id and the negative must leave that detector silent. An enforcement-capable policy requires one indexed positive and named negative per pattern. Compatibility-mode detectors retain their detector-level pairs; the deterministic corpus gate supplies an exact regex witness for every pattern. Larger adversarial, evasion, performance, and scale corpora remain separate because one compact pair cannot prove those contracts.

Fields

detector.id - kebab-case, globally unique. Shows up in JSON output as detector_id and in CLI output as the third column.

detector.kind - optional execution class. Omission or "regex" selects the normal anchored-regex contract. "phase2-generic" selects the shared generic discovery engine with every detector-specific decision owned by this TOML; it may have no patterns, but must declare the mechanism-specific fields validation requires. service is report taxonomy and never selects either execution class.

detector.ml - model policy compiled with the detector. match_mode controls regex and generic-assignment candidates; entropy_mode controls synthetic entropy candidates owned by this detector. Each mode is disabled, lift, blend, or authoritative. lift applies the declared fraction of positive model evidence without letting an uncalibrated model veto structural evidence; blend is the arithmetic mixture and can raise or lower confidence; authoritative uses the model score directly. weight controls lift and blend, and context_radius_lines is the bounded source window supplied to feature extraction. Normal scans use these detector values. An explicitly supplied --ml-weight is a scan-wide diagnostic or benchmark override. Detectors that do not own entropy policy set entropy_mode = "disabled". The model input also carries detector facts from this same TOML: exact service context, entropy-policy/phase-2 ownership, weak-anchor and structural-password-slot classification, verification, required companions, and the pattern or entropy candidate channel. Entropy candidates additionally carry a one-hot family read from this detector’s entropy_fallback.class, so API keys, passwords, tokens, and generic entropy do not collapse into one model input. The shared scorer does not infer a detector family from its id or apply one unconditioned probability to every secret type. Every detector TOML must declare detector.ml; omission fails parsing instead of silently applying an embedded or scanner-side model policy. Programmatic DetectorSpec::default() disables both model paths until the caller opts in.

detector.match_confidence is the complete pre-model scoring policy for regex matches. The six signal weights define the maximum normalized evidence. high_entropy_partial_weight, moderate_entropy_threshold, and moderate_entropy_weight define the lower entropy tiers. very_high_entropy_margin is added to the resolved operational entropy threshold for the full entropy weight. The shipped value is the exact binary64 difference between the historical 5.8 and 4.5 tiers. The low-entropy fields define the long-value penalty. The seven context multipliers define how assignment, string-literal, unknown, documentation, comment, test, and encrypted source contexts affect this detector before and after model scoring. The two context-suppression thresholds decide when a comment, test, documentation, or encrypted candidate is too weak to report. The nested post_match policy owns the placeholder, byte-diversity, repeated-run, decoded-envelope, fixture-path, and post-model context adjustments. data_envelope_multiplier is present only when decoded payload evidence applies to that detector. A named detector declares named_anchor_floor and omits low_promise_confidence. A phase-two generic owner does the reverse. This lets the cheap promise gate reject only unaccompanied generic candidates. Missing, misplaced, non-finite, or non-monotonic policy fails detector validation. KeyHog precomputes the normalization reciprocal once in the detector execution plan. DetectorSpec::default() leaves this policy unset, so a programmatic detector must declare it before scanner compilation.

detector.validators - optional typed offline validation programs compiled with this detector. crc32-base62 declares prefixes, entropy_len, checksum_len, reject_overlong, and the confidence floor earned by a valid checksum. github-fine-grained-crc32 declares both segment lengths and checksum width. base64-payload declares the exact base64 alphabet (standard, standard-no-pad, url-safe, or url-safe-no-pad) plus encoded and decoded length bounds, so the hot path performs one direct decode without guessing a dialect from candidate bytes. pattern-shape reuses this detector’s patterns as its structural contract and does not claim checksum proof or raise confidence. Prefixes, widths, bounds, and floors belong here, never in a scanner-side service table. Named matches dispatch directly through their compiled detector plan; generic candidates use a compiled first-byte prefix index. One verdict follows the candidate through suppression, ML batching, and final confidence, so validation is not repeated after inference.

detector.decode_transforms declares admission for asymmetric evasion recovery. Use plaintext prefixes. KeyHog compiles the reversed and rotated spellings once from the active corpus:

decode_transforms = { reverse_prefixes = ["dapi"], caesar_prefixes = ["dapi"] }

An empty list disables that transform for this detector. A custom corpus does not inherit prefixes from the embedded corpus. Reverse prefixes must contain at least three ASCII bytes. Caesar prefixes must contain an ASCII letter. These fields control only reverse and Caesar admission. Base64, hex, URL, JSON, Unicode, MIME, quoted-printable, and bounded static-program recovery use shared representation grammars because their eligibility is not specific to a secret type.

detector.name - human-readable name. Shows up in keyhog detectors listing and IDE plugins.

detector.service - the upstream service slug. Used for grouping findings (e.g. “you leaked 3 stripe credentials”); a single service can have multiple detectors (stripe-secret-key, stripe-restricted-key, stripe-publishable-key).

detector.simdsieve_prefixes - optional literal prefixes for the first-pass AVX-512/AVX2/NEON accelerator. This is detector-owned Tier-B policy: each value must be non-empty ASCII, unique in the loaded corpus, and must be an actual literal prefix of one of the same detector’s regex patterns. The loaded corpus may declare at most 16 total (the backend ABI limit); duplicate ownership, unbacked prefixes, and over-capacity corpora fail scanner construction instead of silently disabling acceleration. Most detectors leave this empty.

detector.severity - one of critical | high | medium | low | client-safe | info. The CLI exits non-zero when any finding clears the active gate; under --verify, confirmed live credentials escalate that outcome to exit 10. SARIF / GitHub Code Scanning surface severity prominently.

client-safe is the bug-bounty tier for keys public by design (Sentry DSN, Stripe pk_*, Mapbox pk., PostHog phc_, Firebase Web API key, Google Maps browser key, Mixpanel project token, Algolia search-only, Datadog browser RUM, Bugsnag, Segment write key). The detector still fires (a token grep is a token grep), but the finding renders below low and --hide-client-safe filters it out entirely. Set per-pattern via the client_safe = true field on a [[detector.patterns]] block - detectors that fire on both the public and the secret prefix (Stripe pk_* vs sk_*, Mapbox pk. vs sk.) tag only the public pattern so a misused secret key still surfaces at its nominal severity.

detector.keywords - optional prefilter and context signals. Regexes with an extractable leading literal use that prefix automatically. A prefixless regex uses only its declared required_literals; without either route it uses the keyword-gated or always-active phase-2 path. kind = "phase2-generic" detectors require keywords because their assignment/context bridge is the candidate source.

detector.capture_role declares what the pattern captures. Values are unknown, assignment-value, token, credential-envelope, private-key-block, connection-string, url-userinfo, header-value, and command-argument-value.

detector.anchor_role declares the surrounding anchor strength. Values are unknown, exact-key, distinctive-prefix, structured-envelope, companion-bound, weak-context, and unanchored.

detector.allowed_source_roles is a list of candidate-bounded source roles. Values are structured-assignment-value, environment-assignment-value, string-literal, command-argument-value, command-option-declaration, header-value, url-authority-userinfo, connection-string, standalone-token, pem-block, regex-rule-definition, identifier-type-member-name, prose-documentation, test-fixture, and generated-vendor-material. The observed source-role enum also contains unknown, but it is invalid in this declaration. Omit the field when no source-role restriction is declared.

Structured source-role extraction runs only for emitted candidates. JSON, JSONL, TOML, YAML, dotenv, and INI inputs build at most one 64 KiB source index per bounded chunk. Every candidate uses exact candidate/value span lookup against that reused index and retains bounded key-path spans. Malformed or truncated syntax and unsupported, over-nested, or over-budget input yields unknown with abstaining parser confidence. Parser failure never suppresses a finding. These roles are retained as adjudication evidence without changing public RawMatch output.

Rust, JavaScript/TypeScript, and Python source-role extraction runs only for emitted candidates and accepts at most 64 KiB of source. Exact lexical spans distinguish string literals, identifiers, regex definitions, test fixtures, command arguments, and command-option declarations. Inline test scopes use balanced syntax; test-file ownership uses the scanner’s Tier-B path rules. Malformed, truncated, unsupported, or over-budget code yields unknown with abstaining confidence and never suppresses a finding.

Markdown, roff/man, shell script, Dockerfile, and Containerfile extraction is candidate-triggered and accepts at most 64 KiB of source. Markdown prose and inline code receive prose-documentation; shell-language fences use shell token roles. Roff option declarations remain distinct from prose. Shell tokenization distinguishes environment assignments from command argument values. Structured files under detector and rule paths derive regex-definition, test-fixture, and prose roles from rules/structured-source-role-markers.toml. Malformed, truncated, unsupported, or over-budget input yields unknown with abstaining confidence and never suppresses a finding.

detector.required_evidence is a list containing checksum, required-companion, private-key-companion, structural-grammar, or live-verification. Omitted semantic fields carry no proof and preserve the schema-3 finding policy. Unknown enum spellings, unknown source-role entries, and duplicate list entries fail corpus validation.

detector.patterns[] - one or more regexes. Each carries:

  • regex - the pattern. Every regex is compiled case_insensitive, so it matches both cases without explicit alternation. To make a single pattern case-SENSITIVE (AWS AKIA is uppercase; some GCP/Snowflake ids are lowercase), prefix its regex with the inline flag (?-i) in the TOML - no schema field needed. The loaded expression is byte-for-byte the authored TOML value: KeyHog never widens separator classes or quantifiers at load. When an anchor intentionally accepts joined, spaced, underscored, and hyphenated words, write [_\-\s]* explicitly in that detector. A narrower class remains narrow and changes only that detector’s digest and behavior.

    Anchor command-line options before the leading dash so a detector cannot restart inside a longer option such as --add-password. When providers share a token prefix, separate their issued length grammars and use provider-owned context for the overlapping shape. Record each decision as a sibling-prefix or regex-literal hard negative.

  • group - which capture group is the credential. 0 = whole match, 1 = first captured group, etc.

  • description - what shape this captures (env var, header, URL, …).

  • required_literals - optional detector-owned routing literals. Every regex match must contain at least one listed ASCII literal. Corpus loading proves that OR-condition from the regex AST, then scalar, Hyperscan, CUDA, Metal, and WGPU compile the same literals into their candidate plan. Invalid, optional, or branch-incomplete declarations reject the detector instead of risking recall. KeyHog never selects a non-prefix literal from the regex implicitly.

  • client_safe - optional bool, default false. When true, any match against this pattern collapses to Severity::ClientSafe regardless of the detector’s nominal severity. Use for patterns that capture keys the vendor expects to ship in client bundles (Sentry DSN, Stripe pk_*, etc.). Per-pattern (not per-detector) so a detector that covers both the public and the secret prefix can tag only the public one.

Multiple patterns means “any of these shapes”. A typical detector has 1-3 patterns covering env-var, JSON, and inline forms.

detector.companions[] defines typed evidence near a primary match. Each entry sets name, regex, within_lines, optional within_bytes, direction, scope, requirement, optional capture_group, and value_relation. requirement = "required" gates the primary. reinforcing evidence enriches confidence or verification, while forbidden evidence suppresses the primary. The scopes are window, same-line, same-record, and same-object.

detector.detector_relations[] resolves findings produced by different detectors in the same source, file, and revision. Each relation names a detector_id, a kind (requires, conflicts, or subsumes), bounded line and byte distances, and the target direction. requires removes the owner when the target is absent. conflicts removes the owner when the target is present. subsumes keeps the owner and removes the bounded target. Unknown targets, self-relations, contradictory pairs, and dependency cycles reject the compiled corpus.

[[detector.detector_relations]]
detector_id = "notion-integration-token"
kind = "subsumes"
within_lines = 0
within_bytes = 0
direction = "either"

detector.source_admission restricts a detector to positively selected sources. path_patterns contains file-path regexes, source_types contains exact source labels, and file_extensions contains lowercase extensions without a dot. Lists are alternatives within one field. Every non-empty field must match. Missing path or source metadata fails closed when that selector is declared.

detector.verify - optional. If present, keyhog scan --verify makes the documented API call with the captured credential and:

  • live + valid -> keep severity, mark verification: "live"
  • live + invalid -> downgrade severity one tier, mark verification: "dead"

Every shipped HTTP verifier declares allowed_domains beside its URL. Literal hosts must match that list when the TOML loads, and the resolved host must match again before a request is sent. Use the narrowest service-owned host. Do not add localhost, a generic decoder site, or an unresolved tenant placeholder to make a non-verifiable detector appear live-capable.

detector.verify.metadata[] maps provider responses to report evidence. Each entry owns three fields in this detector TOML:

  • name: a reviewed provider-neutral semantic role. Unknown and duplicate canonical roles fail detector validation.
  • json_path: the rooted response selector.
  • sensitivity: public emits a scalar value up to 256 bytes, hashed emits only its SHA-256 digest, and secret never enters findings. Omission defaults to hashed for compatibility with older custom detectors.

Multi-step extract entries use arbitrary flow-local names because later request templates consume them. They are transport state and never become report metadata.

Per-detector recall/precision knobs

Credential-family policy belongs in the individual detector TOML whenever the schema provides a detector field. This is where stable entropy bands, length bounds, BPE behavior, confidence floors, allowlists, and shape classifications are tuned for one secret type. Scan-wide CLI and [scan] settings remain explicit operational overrides for corpus-wide policy and controlled comparisons; they are not hidden detector definitions.

This follows the design precedent established by min_confidence (the per-detector confidence floor) and entropy_floor (the low-entropy suppression floor).

Active entropy owners must declare their complete policy. Missing tuning data fails corpus validation or scanner construction instead of inheriting scanner-side detector policy. An explicitly supplied scan-wide override has final authority only where the field documents that precedence, such as BPE or the ML weight diagnostic override.

The available per-detector tuning fields are:

Entropy Thresholds

For example, an entropy owner can declare its complete confidence mapping:

entropy_fallback_confidence = { low_entropy_max = 0.55, high_entropy = 0.65, very_high_entropy = 0.75, keyword_lift = 0.1, max_confidence = 0.9 }

KeyHog compiles this mapping with the detector. A custom corpus can tune one secret family without changing another family or inheriting scanner literals.

The same owner declares how a generic assignment becomes a confidence score:

[detector.generic_assignment_confidence]
ordinary_base = 0.60
test_base = 0.25
documentation_base = 0.30
comment_base = 0.30
scanned_comment_base = 0.60
entropy_reference = 3.5
entropy_gain_per_bit = 0.10
entropy_lift_max = 0.25
length_reference = 16
length_gain_per_byte = 0.005
length_lift_max = 0.15
max_confidence = 0.95

For example, a 20-byte value gains four times length_gain_per_byte. Entropy above entropy_reference gains entropy_gain_per_bit for each additional bit. Each lift stops at its declared maximum, and the final score stops at max_confidence.

  • entropy_high (float, required for active entropy owners): Per-detector high-entropy threshold (bits/byte) for keyword-independent detection and the partial entropy-fallback heuristic-confidence tier.
  • entropy_low (float, required for active entropy owners): Per-detector keyword-context entropy threshold.
  • entropy_very_high (float, required for active entropy owners): Per-detector very-high threshold for keyword-free or isolated tokens and the full entropy-fallback heuristic-confidence tier. Compiled policy requires entropy_low <= entropy_high <= entropy_very_high.
  • sensitive_path_entropy_very_high (float, required for active entropy owners): Per-detector keyword-free threshold for clearly sensitive paths. It must not exceed entropy_very_high; omission is invalid rather than an undocumented scanner-wide discount.
  • plausibility.keyword_free_operator_margin (float, required only for the keyword-free role owner): Margin added to the resolved Tier-A entropy_threshold before keyword-free admission. The effective floor is max(path-specific entropy_very_high, entropy_threshold + keyword_free_operator_margin); no scanner-owned margin is applied.
  • entropy_fallback (table, required for active entropy owners): Identity metadata for synthetic entropy findings owned by this detector. class is one of generic, password, token, or api-key; id must use the entropy- namespace; and name/service must be non-empty. Both regex-kind detectors that set entropy_policy_priority and phase-2 generic owners must declare this block. The primary detector’s reporting service may name any taxonomy and does not grant or deny entropy ownership. Omitting the block is a compile error, never a compatibility identity.
  • entropy_fallback_confidence (inline table, required for active entropy owners): Maps this detector’s Shannon evidence to report confidence. low_entropy_max caps candidates below entropy_high; high_entropy and very_high_entropy are the base scores for those detector thresholds; keyword_lift applies only when a configured keyword owns the candidate; and max_confidence caps the result. Every value must be a finite probability, and the three base tiers must be monotonic. The scanner does not supply confidence tiers for an omitted policy.
  • generic_assignment_confidence (table, required for active entropy owners): Maps generic assignment evidence to confidence for the detector that owns the assignment keyword. The five context fields choose the base score. entropy_reference, entropy_gain_per_bit, and entropy_lift_max define the entropy lift. length_reference, length_gain_per_byte, and length_lift_max define the byte-length lift. Every base, gain, lift cap, and maximum must be a finite value from 0 to 1. entropy_reference must be from 0 to 8. The scanner does not supply a hidden assignment-confidence policy.
  • entropy_roles (string array): Corpus-level entry roles owned by this detector. keyword-free owns anchor-free high-entropy candidates, isolated-bare owns detector-shaped bare candidates, and unclaimed-keyword owns configured credential keywords not claimed by another detector. A role may have only one owner in a compiled corpus. Omission disables that entry path for a focused custom corpus; the scanner never substitutes a built-in detector ID or global policy.
  • entropy_shapes (one array-table entry required for active entropy owners): Declarative isolated-shape policy owned by this detector. The entry declares a charset, entropy_floor, special_min_length, optional fixed-width grouping, and explicit diversity requirements such as require_group_alpha_digit or require_non_hex_alpha. A grouped candidate’s exact length is derived from its group count, group width, and separator, so adding a shape family does not require a scanner enum variant. Multiple entries are rejected rather than silently ignored or ambiguously combined.
  • plausibility (inline table, required for active entropy owners): Complete strict candidate-shape policy. Its entropy floors, length boundaries, diversity requirements, isolated-token shapes, and rejection switches are all required detector data. second_half_min_len, unique_chars_min_len, min_unique_chars, unanchored_hex_max_len, identical_char_max_len, structured_dotted_min_len, and leading_slash_base64_min_len own boundaries that were formerly scanner literals. isolated_mixed_entropy_floor governs contiguous or underscore-delimited mixed tokens. The three isolated-symbolic fields govern the shorter symbol-rich exception’s byte length, minimum symbol count, and whether one symbol must differ from underscore. reject_program_identifiers covers pure source-language names, while reject_source_symbol_identifiers independently controls digit-bearing mixed alphanumeric names. An exact lower-dash layout declared by entropy_shapes cannot bypass its shape-specific rules. The keyword-free role owner additionally declares keyword_free_operator_margin; no detector inherits an invisible family decision from scanner code.
  • entropy_floor (array of tables, required for active entropy owners and detectors or patterns using weak_anchor): Length-bucketed low-entropy suppression floor mapping maximum lengths to minimum entropy scores. Weak-anchor regex findings read their own table; they never borrow another detector’s calibration.
    • max_len (integer, optional): Inclusive maximum length for this bucket.
    • floor (float): Shannon entropy floor.
  • entropy_policy_priority (integer, optional): Resolves overlapping generic keyword claims. Higher values own entropy, length, canonical-shape, and BPE policy for the shared keyword. Phase-2 generic detectors participate at priority zero when omitted. Regex detectors do not participate unless they set this field. This makes the primary precedence explicit in detector TOML without overloading reporting service. Equal priorities use stable detector identity, so corpus order cannot change the owner.

BPE token efficiency

  • bpe_enabled (bool, optional): Detector-local token-efficiency switch. Omission inherits the enabled default. Set false for families such as human-chosen passwords where word-like values are legitimate; the scanner then skips BPE tokenization for that detector. Do not combine false with a bpe_max_bytes_per_token ceiling; detector validation rejects the conflict.
  • bpe_max_bytes_per_token (float, optional): Per-detector cl100k_base UTF-8-bytes-per-token ceiling. Values above the ceiling use fewer common subword tokens per byte, which makes them more likely to be word-like; they are suppressed after the cheaper shape and entropy gates. The detector field takes precedence over the compiled scan fallback. An explicitly configured [scan].entropy_bpe_max_bytes_per_token or CLI flag is the final Tier-A override for all eligible detectors. Lower ceilings favor precision and higher ceilings favor recall. This field tunes the precision gate after a detector or phase-2 discovery path has produced a candidate; it never creates one. The runtime relationship between BPE, Shannon entropy, and BetterLeaks’ Token Efficiency terminology is defined in How detection works.

Decoded key material

  • decoded_hex_key_material_lengths (integer array, optional; kind = "phase2-generic" only): Exact printable-hex character counts this detector may retain after transport decoding. Each width must be even and at least 16, with no duplicates. generic-api-key.toml declares [32, 48]; broad token/secret detectors declare none, so decoded 40-hex SHA-1 and 64-hex SHA-256 shapes remain digest-suppressed. Structured decoders preserve transport provenance, so direct secret_key=<64hex> policy cannot silently reclassify a base64-wrapped digest.
  • canonical_hex_key_material (array of tables, optional): Declares exact pure-hex character counts this detector may treat as key material instead of a digest. A kind = "phase2-generic" table must include keywords or suffixes; exact keywords must also appear in the detector’s top-level keywords, suffixes admit only vendor-prefixed names, and excluded_keywords removes ambiguous names such as license_key. A regex detector instead declares a length-only table. Its matched pattern supplies the scope, so assignment scopes are rejected on that path rather than silently ignored. Matching assignment keys ignores case and _, -, or . separators. Direct assignments and structured assignment extraction (including XML) resolve the same policy; there is no format-specific override. For example, generic-api-key.toml admits 64-hex only for its explicit cryptographic roles such as signing_key, encryption_key, and hmac_secret, while generic-secret.toml owns private_key, signing_secret, and its declared vendor suffixes. Neither turns a broad api_key=<sha256> assignment into a finding. Canonical hex admitted by this policy skips BPE token efficiency and the generic low-diversity/decode-as-data confidence penalties because those mechanisms inherently classify pure hexadecimal as non-secret. The entropy, placeholder, degenerate-repeat, context, and reporting gates still apply. The owning detector’s ml.match_mode governs structurally proven key material, while ml.entropy_mode governs its weaker entropy fallback.

The fields live beside the detector’s other top-level policy, not in a scan-wide suppression table. A phase-2 example is:

decoded_hex_key_material_lengths = [32, 48]
canonical_hex_key_material = [
  { lengths = [32, 48], keywords = ["api_key"], suffixes = ["key", "secret"], excluded_keywords = ["license_key"] },
  { lengths = [64], keywords = ["encryption_key"] },
]

A weak-anchor named detector declares only the widths its own regex captures:

canonical_hex_key_material = [{ lengths = [32] }]

Omitting that declaration leaves a pure-hex capture digest-suppressed. KeyHog does not infer widths from the service name, confidence floor, or a global list.

keyhog explain <detector-id> prints both declarations in the human-readable policy view. keyhog detectors --format json exposes them under each detector’s policy object, so automation can inspect the same loaded TOML contract the scanner uses.

Candidate Lengths

  • keyword_free_min_len (integer): Per-detector minimum length for an anchor-free (keyword-free or isolated) candidate. Active entropy owners must declare it; omission fails compilation instead of selecting a scanner constant. The backend-neutral no-hit router combines the active role owner’s value with a conservative necessary length derived from that owner’s effective Shannon floor, so replacement detector corpora keep their own boundary without widening the shipped hot path to candidates that cannot reach its entropy threshold.
  • min_len (integer, optional): Per-detector minimum candidate length in UTF-8 bytes for any candidate this detector emits. Falls back to no detector-specific floor beyond the path-wide default if unset.
  • max_len (integer, required for every entropy-policy owner): Inclusive maximum byte length for every candidate owned by the detector. Generic assignment, entropy fallback, and explicit regex envelopes use one compiled bound before entropy or BPE. An overlength value is rejected whole with value_too_long; it is never reported as a truncated prefix. The generic candidate generator uses the largest ceiling in the loaded corpus so the resolved owner can apply its exact value. max_len must be at least 8 and no smaller than min_len. Omission fails scanner construction. Regex patterns can use narrower repetition bounds.

The generic assignment bridge exists only when the loaded corpus contains at least one phase2-generic detector. A focused custom corpus without one compiles without that bridge; KeyHog does not silently inject the bundled generic rules.

Allowlists & Exclusions

  • allowlist_paths (array of strings, optional): Per-detector path-exclusion regexes (BetterLeaks-style allowlist). Any candidate match whose file path matches any of these regexes is suppressed.
  • allowlist_values (array of strings, optional): Per-detector value-exclusion regexes. Any candidate secret value matching any of these regexes is suppressed (useful for filtering out test, example, or placeholder values).
  • stopwords (array of strings, optional): Per-detector literal stopwords. A matched value equal to or containing any of these strings (case-insensitive) is suppressed.
  • public_identifier_assignment_markers (array of strings, optional): Detector-local canonical-uppercase assignment-key fragments for public IDs such as wallet, contract, address, or peer identifiers. Boundary bytes are significant; KeyHog performs allocation-free ASCII-insensitive matching against the source line. An empty list disables this suppression for that detector.

Classification and shape policy

These fields are detector facts, not operator preferences. They therefore live only in the individual detector TOML and have no CLI or global-config override:

  • structural_password_slot (bool, default false): The pattern proves a syntactic password slot, such as URL userinfo, IDENTIFIED BY, a password CLI flag, or an authorization scheme. The scanner keeps the dedicated placeholder checks but does not reject a legitimate free-form password with the generic randomness floor.
  • weak_anchor (bool, default false): At detector level it applies to every pattern. Inside one [[detector.patterns]] table it applies only to that regex, so a strong sibling does not inherit its gates. Use it when the service context is useful but the captured value still collides with broad hex/base64/identifier shapes. Generic shape and randomness safeguards remain active, and the detector must declare entropy_high and entropy_floor. KeyHog does not infer this field from regex syntax.
  • private_key_block (bool, default false): The match spans an enclosing PEM/OpenSSH private-key block. Resolution suppresses lower-specificity child findings inside that span instead of reporting the key body repeatedly.
  • generic_vendor_suffixes (string array, default empty): A phase-two generic detector can own structural <vendor>_<suffix> assignments that no exact keyword claims. Only one detector may declare this list. Entries are lowercase ASCII alphanumeric tokens.
  • generic_assignment_tail_suffixes (string array, default empty): The same owner can admit suffix segments after an exact keyword, such as secret_key_base. KeyHog compiles this list into the assignment matcher; omission disables the extra tail grammar.
  • resolution_priority (integer, default 0): When two detectors claim overlapping credentials, the higher value wins before generic class and confidence tie-breakers. Use it only for demonstrably more specific attribution, such as a GitHub App key over a generic PEM block. Equal values keep the normal deterministic resolution order.
  • [detector.credential_shape] (table, optional): A fail-closed byte-shape contract. It can declare exact_length, prefix, body_min_length, and body_max_length; candidates outside the declared shape are suppressed.

Because these values are loaded from the active detector corpus, custom corpora carry their classifications with them. There is no separate detector-id list or hidden Rust-side family table to keep synchronized.

Confidence Floors

  • min_confidence (float, optional): Per-detector minimum confidence floor. Overrides the global scan confidence floor.

Listing the active corpus

With no --detectors flag, KeyHog first uses ./detectors when that directory exists in the caller’s working directory. Otherwise it checks the platform user and system data directories and the executable directory for an installed detector corpus. The first directory found is the complete active corpus. If none exists, KeyHog uses the embedded corpus.

keyhog detectors
keyhog detectors --format json
keyhog detectors --format json | jq length
keyhog detectors --detectors "$PWD/.keyhog/detectors" --format json

The last command names one corpus explicitly. KeyHog does not search another location or fall back to the embedded corpus when that path is explicit.

Structured listings include a policy object for every detector. It carries the loaded detector-local kind, entropy/BPE/length thresholds, stopwords, allowlists, classifications, and credential shape; absent optional fields are null, not silently filled with an undocumented value.

Filter by service:

keyhog detectors --format json \
  | jq '.[] | select(.service == "stripe")'

Which mechanisms a detector actually uses

KeyHog matches patterns, validates structure, scores entropy, gates on BPE token efficiency, recovers encoded values, confirms with companions, verifies live, and suppresses with detector-owned allowlists. A given detector uses some of those and not others. Ask which:

keyhog detectors --mechanisms
Mechanism manifest: 934 detectors from detectors

  regex                    932  phase-1 pattern anchors
  keywords                 934  phase-2 keyword triggers for shapeless candidates
  structure                 47  offline structural proof: checksum, payload decode, or declared shape
  entropy                   58  detector-owned Shannon entropy floors
  bpe                        5  BPE token-efficiency precision gate
  byte_pair_likelihood     n/a  fixed-point byte-pair log-likelihood scoring [UNAVAILABLE: see --format json for the reason]
  decode                    80  detector-declared evasion and transport decode recovery
  companions               174  secondary patterns that confirm a match
  detector_relations         5  relations to findings from other detectors
  verification             347  live verification against the provider
  suppression               10  detector-owned allowlists, stopwords, and public-identifier markers
  source_admission           1  positive source selectors gating where this detector fires

Every detector declares at least one mechanism.

This does not scan. It reads the same corpus a scan would load.

Scope it with --search, and take the machine-readable document with --format json:

keyhog detectors --mechanisms --search aws --format json \
  | jq '.detectors[] | select(.id == "aws-access-key")'
{
  "id": "aws-access-key",
  "service": "aws",
  "kind": "regex",
  "mechanisms": [
    { "id": "regex", "evidence": ["patterns"] },
    { "id": "keywords", "evidence": ["keywords"] },
    { "id": "structure", "evidence": ["credential_shape"] },
    { "id": "decode", "evidence": ["decode_transforms.reverse_prefixes", "decode_transforms.caesar_prefixes"] },
    { "id": "companions", "evidence": ["companions"] },
    { "id": "verification", "evidence": ["verify"] }
  ]
}

evidence names the detector TOML field that made each mechanism active, so the claim is checkable against the data file rather than taken on trust. There is no per-detector table in the binary: every answer is derived from the corpus, so scoping the manifest changes every count in it, and a TOML edit changes the manifest with no release.

A mechanism KeyHog cannot express yet is reported rather than omitted, because a missing row cannot be told apart from “no detector uses this”:

keyhog detectors --mechanisms --format json \
  | jq '.summary[] | select(.available == false)'
{
  "id": "byte_pair_likelihood",
  "description": "fixed-point byte-pair log-likelihood scoring",
  "available": false,
  "unavailable_reason": "no detector field expresses this yet; the fixed-point byte-pair model is unbuilt (BACKLOG KH-850), so no detector can declare it and this row is structurally empty rather than measured",
  "detectors": 0
}

Two questions the manifest answers that nothing else does. Which detectors can verify a credential against the provider:

keyhog detectors --mechanisms --format json \
  | jq -r '.detectors[] | select([.mechanisms[].id] | index("verification")) | .id'

And which detector has no regex anchor at all, and therefore reaches candidates only through phase 2:

keyhog detectors --mechanisms --format json \
  | jq -r '.detectors[] | select([.mechanisms[].id] | index("regex") | not) | .id'

On the shipped corpus that second query returns exactly one line, generic-secret.

Explaining one detector

keyhog explain stripe-secret-key

Prints the loaded detector’s keywords, patterns, companions, verification endpoint, and detector-local admission policy. For generic detectors that policy includes Shannon-entropy floors, BPE UTF-8 bytes/token ceilings, length bounds, stopwords, and allowlists exactly as declared by the detector TOML:

keyhog explain generic-secret

Add --compiled-plan to inspect the evidence rules the scanner executes. The output includes resolved companion capture groups, direction, structural scope, requirements, value relations, and cross-detector operations:

keyhog explain notion-oauth-secret --compiled-plan

This is the first place to look when debugging why a detector did or did not fire. It makes detector-owned tuning visible without searching for a Rust-side override table.

Custom detector corpora

Start from a detector that already satisfies the current schema. This example creates a focused corpus from the repository’s shipped Stripe detector:

mkdir -p "$PWD/.keyhog/detectors"
cp detectors/stripe-secret-key.toml "$PWD/.keyhog/detectors/"
cat > "$PWD/.keyhog/detectors/corpus.toml" <<'EOF'
schema_version = 5
EOF
keyhog detectors --detectors "$PWD/.keyhog/detectors" --audit

Edit the copied TOML only after the audit succeeds. A new detector must declare all required policy blocks. Copying only the short [detector] and [[detector.patterns]] example from this chapter does not create a valid schema-5 detector.

Declare the current corpus schema beside the detector files:

# my-detectors/corpus.toml
schema_version = 5

Schema 5 adds zero-based pattern_index ownership and typed negative_class values to [[detector.tests]]. Declaring either field under a schema-1 through schema-4 manifest fails the complete corpus load. The complete per-pattern evidence gate applies to schema-5 corpora. Schema-4 semantic policies continue to load without ownership fields that their manifest cannot declare.

Schema 4 adds typed capture_role, anchor_role, allowed_source_roles, and required_evidence declarations. Omission preserves the schema-3 finding policy and carries no semantic proof. Declaring any of these fields under a schema-1, schema-2, or schema-3 manifest fails the complete corpus load.

Schema 3 adds typed companion semantics and cross-detector relations. It also keeps the schema-2 requirement that every [detector.verify.success] and per-step success table classify its evidence explicitly with policy = "body_positive", "status_with_error_backstop", or "status_authoritative". An omitted policy is a validation error that names the affected success table.

For compatibility, a directory without corpus.toml is schema 1, as is a manifest that explicitly declares schema_version = 1. Schema-1 success tables written before policy classification are normalized to status_with_error_backstop: an accepted status is necessary, but a known error-shaped response still prevents a live verdict. This is deliberately not the more permissive status_authoritative policy. New corpora should declare schema 5 and serialize every policy rather than relying on legacy normalization.

Manifest typos, unsupported schema versions, and schema-2 through schema-5 success tables with missing policies fail closed. A bounded newer schema declaration may be parsed only to produce compatibility diagnostics; a gated load refuses the complete corpus rather than skipping fields or detector files it cannot interpret. The effective corpus digest binds the normalized schema and manifest identity, so legacy and schema-2 through schema-5 corpora cannot share an identity merely because their detector fields otherwise match.

Audit a custom corpus directly before scanning with it:

keyhog detectors --detectors "$PWD/.keyhog/detectors" --audit

Then select exactly one composition mode:

Effective modeCommandActive detectors
embeddedkeyhog scan .The installed execution pack prepared from the embedded corpus (via keyhog install).
replacekeyhog scan . --detectors "$PWD/.keyhog/detectors" --detectors-mode replaceOnly the named directory. This is also the default when a directory is selected and no mode is configured.
overlaykeyhog scan . --detectors "$PWD/.keyhog/detectors" --detectors-mode overlayThe embedded corpus plus the named directory. A custom ID may not equal an embedded ID.

Overlay never shadows a shipped detector. An ID collision fails before scanning. Replace never fills missing detector families from the embedded corpus.

The path must name an existing, non-empty directory. A missing path, a regular file, an empty directory, invalid TOML, an unsupported manifest, or an invalid detector fails before findings are written. Supplying --detectors-mode without a detector path from either the CLI or .keyhog.toml also fails. KeyHog does not turn any of these errors into an embedded scan.

The CLI and configuration-file precedence for the path and mode is documented in Configuration. Versioned JSON envelopes report embedded, replace, or overlay, the source and detector counts, and the effective corpus digest under metadata.resolved_scan.effective.

Disabling specific detectors

Disable an exact detector ID after corpus composition:

# .keyhog.toml
[detector.aws-access-key]
enabled = false

[detector.generic-secret]
enabled = false

Use the detector_id field from JSON output or the ID shown by keyhog detectors. The override applies to an embedded, replacement, or overlaid detector with that ID. It cannot add a detector that the selected corpus does not contain.

Disabled detectors are removed before scanner compilation, so they have no scan cost. A detector that requires a disabled detector is also removed, including transitive dependents. conflicts and subsumes relations to the disabled detector are removed while their owners remain active. Accelerated literal slots use the same canonical TOML ID. There is no separate hot-* ID to disable. Retired hot-* IDs are rejected by keyhog explain rather than accepted as aliases.

An unmatched disabled ID produces a warning. If the overrides remove every loaded detector, KeyHog fails before scanning instead of reporting a clean scan from an empty engine. Removing a detector changes the active corpus digest and the autoroute configuration identity. Recalibrate autoroute before relying on automatic routing for that policy.

Running only a chosen subset

Use a replacement corpus when you want an allowlist of detector files:

mkdir -p "$PWD/my-detectors"
cp detectors/stripe-secret-key.toml detectors/aws-*.toml "$PWD/my-detectors/"
printf 'schema_version = 5\n' > "$PWD/my-detectors/corpus.toml"
keyhog detectors --detectors "$PWD/my-detectors" --audit
keyhog scan . --detectors "$PWD/my-detectors" --detectors-mode replace

Quieting a noisy detector

When a detector produces persistent false positives in your repo, down-weight it instead of dropping it entirely so a real hit still surfaces:

CACHE="$XDG_CACHE_HOME/keyhog/calibration.json"
keyhog calibrate --cache "$CACHE" --fp generic-api-key
keyhog scan . --calibration-cache "$CACHE" --min-confidence 0.7

Each --fp lowers that detector’s Bayesian confidence multiplier (persisted under the platform cache directory, normally $XDG_CACHE_HOME/keyhog/calibration.json). Scans use those counters only when you pass --calibration-cache <PATH> or set [system].calibration_cache, so repeated FPs steadily push that detector below your --min-confidence floor without hidden host-state drift. To suppress specific findings rather than a whole detector, use a .keyhogignore, the [allowlist] config, or a --baseline.

Severity bumps and downgrades

Severity is a property of the detector, but can shift per-finding:

  • Git history → severity one tier lower. A credential present only in non-HEAD git history (the developer already removed it from main) is still a leak - anyone can fetch it - but strictly less urgent than one live in HEAD. Reported in the chunk.metadata.commit field of the finding.

  • Verification: dead → severity one tier lower. The credential was format-valid but the API rejected it. Could be a rotated key, a fake in a test file, or a typo.

  • Verification: live → severity unchanged. The credential authenticates successfully. As bad as it can get.

Writing your own

Write a detector is the full workflow: how to choose the three example shapes you need, the rules that reject a file at corpus load, how to write the contract file every shipped detector has, and the mistakes that show up in most first drafts.

The short version:

  1. Find a real example of the credential format, a lookalike that is not one, and the form your provider actually deploys.
  2. Write the regex with a keyword anchor. Set group to the capture holding the credential, not 0.
  3. Add detectors/<service>-<thing>.toml with id, keywords, patterns, and optionally verify.
  4. Add a contract at crates/scanner/tests/contracts/<id>.toml with at least two positives, two negatives, and two evasions.
  5. Run cargo test -p keyhog-scanner --test contracts_runner. It must pass for your detector to ship.

The contracts gate enforces that every shipped detector catches what it claims to catch.

Write a detector

A detector is one TOML file that tells KeyHog what a credential looks like, how confident to be about a match, and how to check whether the credential is live.

This page takes you from an example credential to a detector that passes the gates. Read Detectors and custom corpora first for the field reference; this page is the workflow and the rules that reject a file.

Start from a real example

Find one real instance of the format. Vendor documentation, a public leaked sample, or the provider’s own SDK are all fine. You need three shapes before you write a line of TOML:

  • The credential itself.
  • Something that looks like it but is not one. A request ID with the same alphabet, a hash of the same length.
  • The form your provider actually emits in production, which is often not the form in the docs.

If you cannot produce all three, you do not yet know the format well enough to write a detector that will not generate false positives.

Put the file in place

Work in your own directory first. You do not need to touch the shipped corpus to write and test a detector:

mkdir -p /tmp/acme-detectors
$EDITOR /tmp/acme-detectors/acme-api-key.toml

Four things are required: an identity block, an ml policy, a match_confidence table, and at least one pattern.

[detector]
id = "acme-api-key"
name = "Acme API Key"
service = "acme"
severity = "high"
keywords = ["acme", "ACME_API_KEY"]
ml = { match_mode = "lift", entropy_mode = "disabled", weight = 1.0, context_radius_lines = 5 }
# Copy this line unchanged from an existing detector, then tune it.
match_confidence = { literal_prefix_weight = 0.35, ... }

[[detector.patterns]]
regex = '''ACME_API_KEY[\s"'=:]+(acme_[a-zA-Z0-9]{32})'''
description = "Acme API key with context anchor"
group = 1

group = 1 is the capture group holding the credential. Group 0 is the whole match, which would report the keyword as part of the secret.

match_confidence is a single inline table with about thirty required weights. There is no partial form: omitting one field fails the load with missing field <name>. Copy the whole line from a detector whose shape is closest to yours and change the weights you care about:

grep '^match_confidence' detectors/1password-secret-key.toml

The same applies to ml. Omitting it fails with missing field ml, and setting entropy_mode to anything other than "disabled" makes the detector an entropy owner, which then requires the whole entropy policy.

Check it before you scan

Validation runs at corpus load. An invalid detector fails the load with an exact message rather than being skipped:

keyhog detectors --detectors /tmp/acme-detectors

A valid corpus prints what loaded:

Loaded 1 detectors (/tmp/acme-detectors):
  - acme (1 detectors)
    - acme-api-key

An invalid one refuses the whole corpus rather than scanning with a hole in it:

error: loading detectors from directory: 1 of 1 detector file(s) from
/tmp/acme-detectors failed to load, pass the quality gate, or exist at all,
that is a partial detector corpus, so keyhog is refusing to scan without a
complete detector corpus (a partial corpus silently drops recall).

That refusal is the point. A corpus that quietly loses a detector is a silent clean.

Read the resolved policy for one detector:

keyhog explain acme-api-key --detectors /tmp/acme-detectors

That prints the compiled spec, the patterns with their capture groups, the keywords, the severity, and the declared detector policy, so you can confirm the file you wrote is the policy that loaded.

Then prove it fires:

mkdir -p /tmp/acme-fixture
printf 'ACME_API_KEY=acme_%s\n' \
  "$(head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32)" \
  > /tmp/acme-fixture/app.env
keyhog scan /tmp/acme-fixture --detectors /tmp/acme-detectors \
  --format json-envelope | jq '[.findings[].detector_id]'

Expect ["acme-api-key"].

Rules that reject a file

The quality gate in crates/core/src/spec/validate.rs rejects a detector rather than accepting a weaker one. These are the rules that catch most first drafts.

Identity

detector.id must be non-empty and free of leading or trailing whitespace. Ids are the stable handle used by suppressions, baselines, and reports, so a padded id is an error, not a trim.

Patterns

At least one pattern is required. Each regex must compile, must be at most 4096 characters, and must stay inside the complexity bounds on AST node count, alternation branches, and repetition. A pattern that names group = N must expose group N.

A pattern that is only a character class is rejected when its context radius is wide. [a-f0-9]{32} with no anchor matches every MD5 in the tree. Give it a keyword anchor in the regex, or bound it with a tight companion.

Keywords

keywords are the literal strings that admit a chunk to the detector’s phase-2 work. Without them the detector runs against every chunk. Include the environment-variable spelling, the vendor’s own spelling, and any prefix that appears in the credential itself.

Companions

A companion is a second value that must appear near the first, such as an AWS secret key beside an access key. Companions are bounded:

  • within_lines has a search-window limit, and scope = "same-line" requires within_lines = 0.
  • within_bytes must be between 1 and 1048576.
  • A pure character-class companion regex is rejected unless within_lines is at most 5, because a wide radius plus a loose class is a false-positive machine.
  • Schema-v2 required = true cannot be mixed with a typed requirement. Use one.

Entropy policy

A detector that owns an entropy policy must declare all of it. There is no runtime fallback to a scanner constant. An active entropy owner must declare entropy_floor, detector.entropy_shapes with exactly one entry, bpe_enabled, entropy_fallback, entropy_fallback_confidence, generic_assignment_confidence, and a non-disabled ml.entropy_mode. Omitting any of them fails the load.

Most detectors are not entropy owners. In the shipped corpus, 929 of 934 detectors set entropy_mode = "disabled". Write a regex detector unless you are deliberately adding a generic channel.

Lengths

max_len is required for every entropy-policy owner. It must be at least 8 and no smaller than min_len. An overlength value is rejected whole and reported as value_too_long; KeyHog never reports a truncated prefix as a finding.

keyword_free_min_len is required for active entropy owners. Omission fails compilation rather than picking a scanner constant.

Confidence

min_confidence is a probability in the closed range [0.0, 1.0]. A value below 0.0 clears the floor for every candidate, a value above 1.0 means the detector never fires, and NaN makes every comparison false. All three are rejected. detector.match_confidence is required; scanner-wide match scoring defaults are not permitted.

Verification

A [detector.verify] block must name an HTTP method, a URL, and the domains it is allowed to reach. Success status codes must be in the range 100 to 599. Verification sends credential-derived requests to a live provider, so allowed_domains is the boundary that keeps a detector from being turned into an outbound request primitive.

Write the contract

Every shipped detector has a contract file. The contract is the behavioral test, and it lives beside the corpus rather than in Rust:

$EDITOR crates/scanner/tests/contracts/acme-api-key.toml

A contract declares its identity, then positives, negatives, evasions, and a performance budget:

schema_version = 1
detector_id = "acme-api-key"
service = "acme"
severity = "high"
readme_claim = "Acme"

[[positive]]
text = "ACME_API_KEY=acme_<32 generated characters>"
credential = "acme_<32 generated characters>"
reason = "env-var-style assignment, common CI shape."

[[negative]]
text = "ACME_API_KEY=acme_REPLACE_ME_WITH_YOUR_KEY_00000000"
reason = "Placeholder value, not a credential."

[[evasion]]
text = "<config><acme>acme_xMlEnCoDeDxMlEnCoDeDxMlEnCoDeD</acme></config>"
credential = "acme_xMlEnCoDeDxMlEnCoDeDxMlEnCoDeD"
reason = "Inside an XML element body."

[perf]
fixture_bytes = 4096
max_microseconds = 15000

[scale]
fixture_bytes = 1048576
min_findings = 1
max_seconds = 1.0

Write at least two of each:

  • Two positives. The environment-variable form and the quoted form. Add the Authorization: header form when the provider uses bearer tokens.
  • Two negatives. A placeholder and a same-alphabet non-credential. These are the cases that decide whether your detector is usable in a real repository.
  • Two evasions. The shape your provider actually deploys, and the credential nested in a structured format such as XML, YAML, or JSON.

Do not use the vendor’s documentation sample as a positive. KeyHog suppresses the well-known samples on purpose, so a contract built on sk_live_4eC39HqLyjWDarjtT1zdp7dc tests the suppression list rather than your detector. Generate your own value with the same shape.

Run the contract:

cargo test -p keyhog-scanner --test contracts_runner

Every positive must be found with the exact credential span. Every negative must not be found. Every evasion must be found. A detector that fails its own contract does not ship.

Common first-draft mistakes

The regex captures the keyword. Set group to the capture group holding the credential, not 0.

The pattern has no anchor. A bare character class with a wide radius is rejected by the gate, and if it were not, it would report every hash in every lockfile. Anchor on the vendor prefix or on the assignment keyword.

The negatives are too easy. A negative that is obviously not a credential proves nothing. Use the value your users will actually have in the repository: a placeholder, a truncated key, a request ID with the same alphabet.

The detector claims entropy ownership by accident. Setting one entropy field makes the detector an active entropy owner and requires the whole policy. Leave ml.entropy_mode = "disabled" unless you mean it.

The positives are all from the docs. See above. Generate values.

Ship it in your own corpus

You do not have to modify the shipped corpus. Point KeyHog at a directory:

keyhog scan . --detectors /tmp/acme-detectors

An explicitly named directory replaces the embedded corpus by default. Compose instead of replacing with:

keyhog scan . --detectors /tmp/acme-detectors --detectors-mode overlay

Overlay rejects an id that collides with an embedded detector, so a custom corpus cannot silently shadow a shipped one. A named directory that does not exist is an error, never a quiet fallback to the embedded corpus.

Confirm the corpus that actually loaded:

keyhog detectors --detectors /tmp/acme-detectors

Then confirm the count in a report. metadata.resolved_scan.effective carries detector_corpus_source, detector_corpus_mode, detector_corpus_digest, and the custom and embedded counts, so a report proves which corpus produced it.

Suppressions

A suppression removes a match that you have reviewed and accepted. Use the narrowest rule that describes the exception. A path-wide or detector-wide rule can hide a different credential later.

KeyHog has three layers, and only the last two are suppression:

  • The directory walker, which decides whether a file is read at all. See Files the walker never reads below. This layer is the one that surprises people, because a file it drops produces no finding and no detector ever sees it.
  • Operator surfaces that you configure, such as allowlists, inline directives, per-detector floors, and baselines.
  • Always-on shape and path heuristics. These remove shapes that are not credentials. You cannot disable them. See How detection works.

There is no .keyhog.toml [suppress] table. Older docs showed a [suppress] hashes = […] / paths = […] / detectors = […] block. It never existed. Current .keyhog.toml parsing rejects unknown tables and keys before scanning, so [suppress] fails loudly instead of creating a silent no-op. Use the surfaces below instead. Per-detector control lives under [detector.<id>]; hash/path/detector allowlisting lives in .keyhogignore.

Files the walker never reads

A directory scan skips some files before detection starts. Put a credential in vendor/lib/conf.env and keyhog scan . exits 0:

  No secrets detected in the scanned files.
WARN 1 path(s) skipped by the exclusion policy (default excludes such as lock files, minified/bundled assets, vendored and build-output trees). Default-excluded directories are pruned during discovery and counted once each; nested files under them are not enumerated. Pass `--no-default-excludes` to scan them. A `--git-staged` scan also counts staged paths removed by the repository's `.keyhogignore` here.

The warning goes to stderr and the exit code stays 0, so a CI job that reads only stdout and the exit status sees a clean scan.

Discovery prunes a default-excluded directory as soon as its name matches, so an 80,000-file node_modules tree contributes one Excluded path rather than eighty thousand. File-level default excludes (lock files, .min. / .bundle. names) are still counted one per file. A path is skipped when any segment of it, at any depth, is one of these names:

.git  node_modules  target  .cache  __pycache__  .venv  venv  .tox
dist  build  out  .next  .nuxt  vendor  swagger  swagger-ui

services/out/conf.env and app/dist/conf.env are both skipped. The list also covers lock files, editor backups, and filenames containing .min. or .bundle.. The shipped list is crates/sources/rules/default_excludes.toml.

Decide whether that list matches your repository. Go and PHP projects keep dependencies in vendor/. Java and many JavaScript projects build into build/, dist/, or out/, and some teams keep hand-written code in a directory that happens to carry one of those names. Scan everything with:

keyhog scan . --no-default-excludes

To scan one excluded tree without disabling the list, name it directly:

keyhog scan vendor/

Minified and vendored paths get a second rule that runs after matching, not just at the walker. A finding in .min.js, .bundle.js, .min.css, or under a vendored tree is dropped by default, because random bytes in a third-party bundle collide with credential shapes often. The drop is counted and reported as its own coverage-gap row naming how many matches were dropped, and --no-default-excludes turns off this rule as well as the walker skip:

keyhog scan dist/ --no-default-excludes

Build tooling inlines API keys into frontend bundles, so treat a nonzero count on that row as worth one rerun rather than as noise.

Where each surface fires

Suppression runs at one chokepoint, in this order. Earlier surfaces act on raw matches (before dedup/verify); later ones act on resolved findings.

#SurfaceKeyed onStageOpt-out / scope
1[detector.<id>] enabled = false (Tier-A compiled + Tier-B .keyhog.toml)detector idraw matchper-detector
2Bundled test-fixtures.tomlexact / substring of the credential valueraw match--no-suppress-test-fixtures
3Self-scan test-data paths (keyhog repo only)detectors/ tests/ fixtures/ benches/ segmentraw match--no-suppress-test-fixtures; only inside keyhog’s own tree
4.keyhogignore: path:path globraw matchfile
5.keyhogignore: hash: / bare hashSHA-256 of valueraw matchfile
6.keyhogignore: detector:detector idraw matchfile
7[detector.<id>] min_confidence / --min-confidenceconfidence scoreraw matchfloor
8--severityseverity rankraw matchfloor
9Inline keyhog:ignore (and aliases)the line itselfraw matchin-source
10.keyhogignore.toml [[suppress]] rulescomposable predicateresolved findingfile
11--hide-client-safeclient-safe tierresolved findingflag
12Baseline (--baseline / --update-baseline)detector id + credential hash, never the pathresolved findingflag

Everything is wired through filter_and_resolve (raw stage) and the run loop (resolved stage), so the --daemon route and every output format apply the exact same set; there is no path that scans under a weaker suppression policy.

Precedence and discovery

Suppression is additive. A match removed by any surface stays removed. There is no negation rule that restores a match removed by an earlier surface.

For a directory scan, KeyHog loads .keyhogignore and .keyhogignore.toml from the scan root. For a single-file scan, it uses the file’s parent directory. Source modes without a filesystem scan path use the current directory. [allowlist].file in .keyhog.toml replaces the discovered line-based .keyhogignore; it does not replace .keyhogignore.toml.

That last rule has a sharp edge. keyhog scan --stdin has no scan path, so it picks up whatever .keyhogignore sits in the directory you happen to be standing in, and applies it to input that has nothing to do with that repository:

cd ~/work/some-repo
kubectl get secret app -o yaml | keyhog scan --stdin

A credential whose hash that repository has allowlisted is reported when you run the same pipe from /tmp, and silently dropped when you run it from the checkout. Exit 0, empty report, and nothing saying an allowlist was consulted.

Only hash: entries can do this. A path: rule needs a path to match, and a pipe has none. So the size of the risk is the number of live hash entries in that file, not its total length, and the values most likely to be listed are vendor-docs examples, fixture shapes, and documentation credentials, which is exactly the class that also turns up in somebody else’s real configuration.

Run piped scans from a directory with no .keyhogignore, or pass --config with a .keyhog.toml whose [allowlist].file names the policy you intend.

Within .keyhogignore, every active line is an alternative. Within one .keyhogignore.toml table, predicates use AND. Separate tables use OR. The two files also use OR, so a broad line-based rule cannot be narrowed by adding a declarative rule.


Operator surfaces

Choose a surface by scope:

ExceptionPreferAvoid
One value in one fixture.keyhogignore.toml with detector, path, and hashDisabling the detector
One value wherever it appearshash: in .keyhogignoreStoring the plaintext credential
One finding in a local source fileDetector-scoped inline directiveUnscoped directive on a line with several values
Findings that predate adoptionBaselinePath rules for the whole legacy tree
A detector that is not applicable to the repository[detector.<id>] enabled = falseA large list of per-file rules

Triage artifacts

keyhog triage imports a redacted finding envelope and creates two different artifacts. --suppressions contains dismissed decisions for the exact, path, and repository scopes. --pattern-feedback contains validated training observations. A pattern-feedback-only decision appears only in training feedback and can never become runtime suppression.

The envelope and both outputs have independent version fields. Each record carries a finding hash, a stable detector ID, the exact public evidence.provenance object from the scanner, a bounded context digest, a typed reason, and one scope. Provenance binds the 16-hex active detector digest, nullable pattern index, candidate channel, source role, context class, and the channel-specific detector owner. A reported :reassembled suffix resolves to the same embedded detector; every other synthetic suffix fails closed. Path and repository scopes carry BLAKE3 identities. None of these files accepts a credential value, context text, filesystem path, repository URL, or free-form reason.

{
  "version": 1,
  "detector_digest": "0123456789abcdef",
  "records": [{
    "finding_hash": "blake3:<64-lowercase-hex>",
    "detector_id": "<stable-detector-id>",
    "provenance": {
      "schema_version": 1,
      "detector_digest": "0123456789abcdef",
      "pattern_index": 0,
      "candidate_channel": "pattern",
      "source_role": "standalone-token",
      "context_class": "unsupported-context"
    },
    "context_digest": "blake3:<64-lowercase-hex>",
    "disposition": "dismissed",
    "reason": "false-positive",
    "scope": {
      "path": {
        "path_hash": "blake3:<64-lowercase-hex>"
      }
    }
  }]
}

The command accepts only the detector corpus built into the running binary. On Unix, every input read, output create, and failed-output cleanup resolves relative to held no-follow directory descriptors. Windows builds fail before reading the envelope because equivalent reparse-point-safe held-handle I/O is not available. Stale detector or pattern identities, unknown fields, malformed digests, version mismatches, excessive input, symbolic links, special input files, and existing output files fail without publishing either output. See Triage and feedback interchange and keyhog triage.

.keyhogignore: one condition per line

Create .keyhogignore at the scan root. Each non-comment line suppresses by credential hash, detector ID, or path glob:

# One reviewed credential value, stored only as its SHA-256 digest.
hash:5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8

# Every finding from one detector.
detector:generic-password

# Files below fixtures/ at the scan root.
path:fixtures/**

# A bare non-hash entry is also a path glob.
**/*.min.js

A bare 64-character hexadecimal line is a credential hash. Prefer the hash: prefix because a 64-character path would otherwise be ambiguous. * matches one path segment. ** matches zero or more segments. A trailing slash matches the directory and its descendants. Patterns without a leading ** are rooted, so fixtures/** does not match packages/app/fixtures/demo.env.

Generate a candidate hash rule from the exact finding you reviewed:

keyhog scan fixtures/oauth.env --format json \
  | jq -r '.[] | select(.detector_id == "generic-api-key" and .location.line == 3) |
      "hash:" + .credential_hash'

Review the printed line before you add it. This command prints nothing if the detector ID or line does not match. Do not substitute the redacted display value or add a sha256: prefix.

Optional governance metadata follows an entry after ;:

hash:5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 ; reason="published OAuth client_id" ; expires=2026-12-31 ; approved_by="secops"

require_reason, require_approved_by, and max_expires_days under [allowlist] in .keyhog.toml can require this metadata. These governance rules are enforced before any suppression is active. An expired entry, invalid hash, unknown metadata key, missing required field, or overlong expiry stops the scan with exit 2. No line from that file becomes active.

.keyhogignore.toml: combine conditions

Use .keyhogignore.toml when one condition is too broad. The following rule suppresses only one reviewed AWS fixture value:

[[suppress]]
detector = "aws-access-key"
path_eq = "fixtures/aws.env"
credential_hash = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"

All three predicates must match. A finding with the same hash in another file, or a different credential in this file, still reports. See the .keyhogignore.toml reference for every predicate and more examples.

An empty [[suppress]] table and literal_true = false by itself are rejected. Write literal_true = true only for an intentional match-everything policy. Unreadable TOML, invalid TOML, an unknown predicate, or an invalid severity stops the scan with exit 2. KeyHog does not continue with an empty declarative policy.

Inline directives: suppress one local source line

Put a directive in a comment on the finding’s line or the line immediately above it. Scope the directive to a detector whenever the line can contain more than one value:

const token = process.env.STRIPE_TEST_TOKEN; // keyhog:ignore detector=stripe-secret-key

Recognized directives are keyhog:ignore, keyhog:allow, gitleaks:allow, and betterleaks:allow. Recognized comment markers are //, #, --, /*, and <!--.

Without detector=, the directive suppresses every finding on that line. A different detector ID does not suppress the finding. Inline directives are read from local filesystem source text. They do not act as an allowlist for archive members, Git history, or remote sources. Use an allowlist file for those modes.

Per-detector control: .keyhog.toml [detector.<id>]

# Turn a noisy detector off entirely.
[detector.generic-password]
enabled = false

# Or keep it but raise its confidence floor (precedence over --min-confidence).
[detector.slack-webhook-url]
min_confidence = 0.85

Shipped floors and availability live in each detector’s own TOML, which is embedded into the binary and used by benches and default scans. Repository .keyhog.toml entries are validated operator overrides composed into that active corpus before scanning; there is no hidden Rust floor or disable list.

Bundled test fixtures (always on, opt-out)

crates/cli/data/suppressions/test-fixtures.toml, baked into the binary, lists publicly documented credentials that vendor docs ship as examples. It is matched on the exact captured value (plus a tiny substring list for tokens like EXAMPLE / PLACEHOLDER). Schema:

schema_version = 1

[[exact]]
credential = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"
service = "stripe"
source = "https://docs.stripe.com/api/authentication"

[[substring]]
needle = "EXAMPLE"

Pass --no-suppress-test-fixtures to see them fire (useful when validating that a detector still matches the canonical shape). The same flag also disables the self-scan test-data path filter (#3), which only ever applies inside keyhog’s own source tree.

Surface #3 leaves no coverage-gap row. Its drops are recorded only as dogfood telemetry, so a credential planted under detectors/, tests/, fixtures/, or benches/ inside a keyhog checkout comes back clean with no gap and no warning. Pass --no-suppress-test-fixtures for any scan of keyhog’s own tree that has to be trustworthy.

Confidence and severity floors

  • --min-confidence <f> (or [scan].min_confidence) drops findings below a score. A per-detector [detector.<id>].min_confidence takes precedence for that detector.
  • --severity <level> drops findings below a severity rank.
  • --hide-client-safe drops the client-safe tier (public-by-design keys).

Baselines: suppress what already existed

A baseline is a JSON file listing findings you have already reviewed. Later scans report only the findings that are not in it. Adopt one in two commands:

keyhog scan . --create-baseline .keyhog-baseline.json
keyhog scan . --baseline .keyhog-baseline.json

--create-baseline writes the file, prints no findings, and exits 0. The second command reports only new findings, so an unchanged repository exits 0 and a newly committed credential exits 1. Commit the file and review changes to it like code.

What counts as a new finding

A baseline entry is keyed on one pair: the detector ID and the SHA-256 of the credential value. file_path and line are written for human review. Neither one takes part in matching.

{
  "version": 2,
  "created": "2026-08-04T09:12:33.104882731+00:00",
  "entries": [
    {
      "detector_id": "github-classic-pat",
      "credential_hash": "sha256:94b9b7f8b35f61bbec1125726f7a794010497975d7f69ce6d0dcb43b7a5913db",
      "file_path": "/home/dev/service/app.env",
      "line": 1,
      "evidence": {
        "tier": "likely",
        "reason_code": "vendor-pattern",
        "provenance": {
          "schema_version": 1,
          "detector_digest": "0123456789abcdef",
          "pattern_index": 0,
          "candidate_channel": "pattern",
          "source_role": "environment-assignment-value",
          "context_class": "vendor-pattern"
        }
      }
    }
  ]
}

Baseline schema 2 records the finding’s required evidence verdict and schema-1 candidate provenance. Schema 1 baselines and entries without exact evidence provenance are rejected; regenerate them before scanning.

That key decides every outcome:

Change in the repositoryResult
The credential moves to another line or another fileStill suppressed
The tree is checked out at a different path, or the file is renamedStill suppressed
The same credential is copied into a second fileStill suppressed, in both places
The credential is rotated to a new valueReported as new
A different credential appears in a baselined fileReported as new
A second detector matches the same valueReported as new, under that detector ID

Plan for the third row. A baseline accepts a credential value, not a location. If someone copies a baselined key into a new service, the gate stays silent. Rotate anything you are not willing to accept everywhere, and keep the baseline small enough that a reviewer can read it.

file_path is an absolute path from the machine that wrote the file. Generate the baseline from one place so a committed baseline does not churn with each developer’s checkout directory.

Accept new findings after review

--update-baseline folds new findings into the file and still reports them:

keyhog scan . --update-baseline .keyhog-baseline.json

The scan prints each new finding and applies the active evidence policy, exactly as --baseline does. Updating the file does not change the exit code. Run this locally once you have reviewed the findings and decided to accept them, then commit the result. Never run it in CI: a job that rewrites its own baseline accepts every secret it finds.

KeyHog never removes an entry. After you rotate a credential, delete its entry by hand or regenerate the file with --create-baseline. A stale entry keeps suppressing the old value indefinitely.

Compare two baselines

keyhog diff reports what changed between two baseline files, which is how you review a proposed baseline update:

keyhog scan . --create-baseline proposed.json
keyhog diff .keyhog-baseline.json proposed.json
keyhog diff

  PASS 0 new   PASS 0 removed   = 2 unchanged

UNCHANGED entropy-api-key @ /home/dev/service/sub/other.env:2
UNCHANGED github-classic-pat @ /home/dev/service/app.env:1
PASS no new or unverified live-risk findings

Add --json to gate on the result in CI, or --hide-unchanged when only the changes matter. Run keyhog diff --help for every option.

What a baseline does not do

  • It does not exclude bytes from scanning. Use a path: rule in .keyhogignore when a tree should not be read at all.
  • It does not suppress a coverage gap. A scan with unreadable or truncated input still reports the gap, and a gap with no blocking finding still exits 13.
  • It is not a shared allowlist. Matching ignores the path, so one file would work across several repositories, and that is exactly the problem: one team’s accepted credential would silently pass another team’s gate. Keep one reviewed set per repository.

For a complete CI gate built on a baseline, see Fail only on new secrets.


Always-on heuristics (cannot opt out)

Shape-based

List-independent heuristics about credential shape that are universally true.

FilterDrops shapes like
punctuation_decorated_identifier--api-secret, &password, $API_KEY, Password:, apiKey!

For generic-only / entropy-only / weakly-anchored detectors, additional shape gates apply (pure-identifier, scheme-URI, UUID, base64-blob, …). See How detection works for the full list and rationale.

Printable base64 is decoded once for the same structural checks. Encoded UUIDs, IAM ARNs, labelled and canonical digests, license serials, prose, and placeholder text remain non-secrets after transport encoding. The generic API-key detector’s decoded_hex_key_material_lengths = [32, 48] policy keeps those two encoded key widths; 40-character SHA-1 and 64-character SHA-256 shapes remain digest-suppressed. Structured decoding preserves transport provenance, so a direct-assignment allowance cannot leak into a decoded value. Service-specific detector TOMLs can supply stronger syntax and bypass only the shape gates their anchor proves safe.

For direct pure-hex assignments, a phase-2 detector can declare exact canonical_hex_key_material keyword/length pairs plus detector-owned suffixes for vendor-prefixed names. A named regex detector declares a length-only rule; the matched pattern is its scope. The shipped generic API-key detector admits 32/48-hex for strong key roles and vendor-prefixed *_key/*_secret names, and 64-hex only for its explicit cryptographic roles such as encryption_key, signing_key, and hmac_secret; the generic-secret detector separately owns private_key, signing_secret, secret, and its vendor-prefixed forms. Bare key and license_key remain suppressed. There is no scanner-global service-key width fallback: omitting the detector policy leaves the value digest-suppressed. Generic UUID assignments, public salts, and nonces stay suppressed; a named detector or structural authorization envelope must provide stronger evidence. Canonical policy does not bypass placeholder or degenerate-value checks. Short repeated runs remain valid because they occur naturally in random material over the 16-symbol hex alphabet; a run of ten identical bytes is treated as filler.

Path-based

Path policy is mechanism-specific. CI and localization paths do not disable named detectors. A valid GitHub, AWS, or other service credential still reports there. Those paths suppress only broad entropy candidates whose positive evidence is the file’s prose or syntax.

Path classNamed detectorGeneric assignmentEntropy fallback
Recognized vendored or minified trees and bundlesSuppressed after matchingSuppressedSuppressed
CI workflows and pipeline filesScannedScannedSuppressed
Localization and translation filesScannedScannedSuppressed
Secret-scanner implementation pathsSuppressed after matchingNormal generic policyNormal entropy policy

Decoded and recovered candidates keep their source path and return through the ordinary detector and suppression pipeline. A transform does not erase path policy or turn a CI or localization path into a blanket exclusion.

Vendored and minified classes include node_modules/, specific static or vendored asset pairs, WordPress trees, recognized Rails legacy vendored assets, *.min.js, *.bundle.js, and *.min.css. A bare vendor/ directory is not a blanket exclusion at this layer, but the directory walker drops it earlier, so a vendor/ credential is still absent from a plain keyhog scan .. See Files the walker never reads. CI classes include GitHub Actions, GitLab CI, CircleCI, Jenkinsfile, Travis, Azure Pipelines, and Bitbucket Pipelines. Localization classes include locale, i18n, l10n, translation, and language directories plus gettext files. Secret-scanner paths match shipped scanner-name markers.

These built-in predicates are not configurable. “Normal policy” means the mechanism evaluates its ordinary value, context, and path gates. It is not a blanket suppression for secret-scanner paths. If a predicate suppresses a real credential under a mechanism shown as scanned, report it as a recall bug.

Not a suppression surface: [lockdown] require = true in .keyhog.toml (and --lockdown) is a fail-closed hardening control: it refuses to run, mlocks memory, and forbids disk cache / --verify / --show-secrets. It never hides a finding. Likewise audit.toml is cargo-audit’s RustSec advisory ignore-list for keyhog’s own dependencies (a supply-chain CI gate), unrelated to scan findings.

Telemetry: what got suppressed

--dogfood prints one JSON object to stderr, separate from the findings report on stdout. It includes exact example and static-recovery aggregates, a bounded detail list, and detail_events_dropped when that list fills:

{"dogfood":{"example_suppressions_total":0,"static_recovery_rejections":{},"detail_events_dropped":0,"events":[]}}

Capture stderr to inspect it:

keyhog scan . --dogfood --quiet 2>&1 >/dev/null | jq '.dogfood.events[]'

2>&1 >/dev/null sends the dogfood object (stderr) to jq while discarding the normal report (stdout). --quiet is required for a parseable stream: without it, materialization, cache, and autoroute status lines share stderr and precede the object. --dogfood is independent of --format, so the report format does not matter here.

Suppression events carry the path, redacted credential, and rule that fired. static_recovery_rejected events carry the decoder, reason, path, and absolute expression byte offset. Internal detail deduplication also uses source type and optional commit, so equal paths from separate history revisions remain separate events. Aggregate counts measure every rejected evaluation attempt. Repeated evaluation of one expression can therefore increment an aggregate without duplicating its retained detail. The events never contain source or recovered bytes. Detail retention is capped at 1,024 events per scan. Aggregate rejection counts remain exact after the cap. detail_events_dropped reports retention-bound drops and recording attempts rejected because the detail buffer was unavailable.

Adding a suppression for an FP cluster

If you find a cluster of 5+ FPs that share a shape, file an issue with:

  1. The detector that fired.
  2. A sanitized example (replace the captured value with [REDACTED]).
  3. Why it is not a credential (regex shouldn’t have matched, or a shape gate should have caught it).

The right fix is a tightened regex, a new shape filter, or a path exclusion. Adding the literal credential to the test-fixtures list is the LAST resort: it hides one specific value, not the underlying shape.

Triage and feedback interchange

keyhog triage imports a versioned redacted finding envelope and emits two distinct, secret-safe artifacts: runtime suppressions for the scanner and feedback observations for pattern training.

keyhog triage \
  --input envelope.json \
  --suppressions suppressions.json \
  --pattern-feedback pattern-feedback.json

Purpose and separation of concerns

Scanning at scale produces findings that need operator review. Once reviewed, decisions diverge into two operational tracks:

  1. Runtime suppressions (--suppressions): Scoped rules that prevent the scanner from reporting a reviewed finding in future runs.
  2. Pattern feedback (--pattern-feedback): Validated true-positive and false-positive observations consumed by model retraining and detector tuning loops.

The two outputs have different lifecycles and scopes. A runtime suppression applies to a specific finding, path, or repository. A pattern feedback record informs detector and model weights across the entire detector corpus. Keeping the outputs in separate files ensures that model training data cannot accidentally act as an unreviewed runtime bypass.

Input envelope format

The input file is a versioned JSON envelope containing reviewed finding records. Every record carries cryptographic digests and provenance metadata; no plaintext credentials, context snippets, or raw file paths are accepted.

{
  "version": 1,
  "detector_digest": "0123456789abcdef",
  "records": [
    {
      "finding_hash": "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
      "detector_id": "stripe-secret-key",
      "provenance": {
        "schema_version": 1,
        "detector_digest": "0123456789abcdef",
        "pattern_index": 0,
        "candidate_channel": "pattern",
        "source_role": "environment-assignment-value",
        "context_class": "vendor-pattern"
      },
      "context_digest": "blake3:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
      "disposition": "dismissed",
      "reason": "false-positive",
      "scope": {
        "path": {
          "path_hash": "blake3:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
        }
      }
    }
  ]
}

Required envelope fields

FieldTypeDescription
versionu32Envelope format version (must be 1).
detector_digeststring16-character lowercase hexadecimal digest of the active detector corpus. Must match the running binary’s embedded corpus.
recordsarrayList of reviewed finding records.

Record fields

FieldTypeDescription
finding_hashstringPrefixed blake3:<64-hex> hash of the finding.
detector_idstringStable identifier of the detector that fired.
provenanceobjectSecret-safe pattern and source-role provenance from the scan report.
provenance.schema_versionu32Provenance schema version (must be 1).
provenance.detector_digeststring16-hex detector digest matching the envelope root.
provenance.pattern_indexu32 | null0-indexed pattern ordinal within the detector TOML, or null for entropy/generic channels.
provenance.candidate_channelstringChannel that created the candidate: pattern, entropy, companion, static-recovery, or unattributed.
provenance.source_rolestringSemantic source role where the secret was matched (e.g. environment-assignment-value, code-literal, standalone-token).
provenance.context_classstringContext classification (e.g. vendor-pattern, weak-anchor, generic-assignment, standalone-token, unsupported-context).
context_digeststringPrefixed blake3:<64-hex> digest of the surrounding context window.
dispositionstringReview disposition: dismissed.
reasonstringTyped reason: false-positive, test-fixture, accepted-risk, mitigated.
scopeobjectScoping rule for the suppression.

Scopes

Each record specifies exactly one scope variant:

ScopeJSON structureBehavior
exact{"exact": {}}Suppresses only this exact finding hash.
path{"path": {"path_hash": "blake3:<64-hex>"}}Suppresses findings with this detector and pattern at the specified path hash.
repository{"repository": {"repository_hash": "blake3:<64-hex>"}}Suppresses findings with this detector and pattern across the specified repository hash.
pattern-feedback-only{"pattern_feedback_only": {}}Emits a training feedback record only. Never creates a runtime suppression.

Generated outputs

Runtime suppressions (--suppressions)

Runtime suppressions contain only reviewed dismissal records for exact, path, and repository scopes. Records with pattern-feedback-only scope are excluded.

{
  "version": 1,
  "detector_digest": "0123456789abcdef",
  "suppressions": [
    {
      "finding_hash": "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
      "detector_id": "stripe-secret-key",
      "provenance": {
        "schema_version": 1,
        "detector_digest": "0123456789abcdef",
        "pattern_index": 0,
        "candidate_channel": "pattern",
        "source_role": "environment-assignment-value",
        "context_class": "vendor-pattern"
      },
      "scope": {
        "path": {
          "path_hash": "blake3:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
        }
      },
      "reason": "false-positive"
    }
  ]
}

Pattern feedback (--pattern-feedback)

Pattern feedback contains training observations from all valid records, including those scoped as pattern-feedback-only.

{
  "version": 1,
  "detector_digest": "0123456789abcdef",
  "feedback": [
    {
      "detector_id": "stripe-secret-key",
      "provenance": {
        "schema_version": 1,
        "detector_digest": "0123456789abcdef",
        "pattern_index": 0,
        "candidate_channel": "pattern",
        "source_role": "environment-assignment-value",
        "context_class": "vendor-pattern"
      },
      "context_digest": "blake3:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
      "reason": "false-positive",
      "source_role": "environment-assignment-value"
    }
  ]
}

Security and isolation guarantees

The triage subsystem is built to operate safely on untrusted feedback artifacts:

  • No secret leakage: Plaintext credentials, unredacted tokens, and raw context strings are never accepted, stored, or emitted. All finding and context identities are cryptographic digests.
  • No path leakage: File paths and repository URLs are replaced with BLAKE3 hashes before interchange.
  • Corpus digest binding: The envelope’s detector_digest must match the running binary’s compiled detector corpus. Stale or cross-version envelopes are rejected before processing.
  • Descriptor-relative I/O (Unix): All file operations use descriptor-relative system calls (openat, unlinkat) with O_NOFOLLOW and O_CREAT | O_EXCL flags. This prevents symbolic link traversal and time-of-check to time-of-use (TOCTOU) race conditions.
  • Destination collision prevention: --input, --suppressions, and --pattern-feedback paths must all be distinct. Existing output files are refused to prevent accidental overwrites.
  • Bounded resource consumption: Input size is capped at 16 MiB (MAX_TRIAGE_INPUT_BYTES); output files are capped at 32 MiB (MAX_TRIAGE_OUTPUT_BYTES).
  • Platform fail-closed: On Windows or platforms lacking safe descriptor-relative directory I/O, keyhog triage exits with an explicit error without reading or writing files.

Verification

keyhog scan --verify makes HTTP requests for eligible findings whose detector declares a verification endpoint. The provider response becomes a structured verification outcome. Detectors without a verifier are unverifiable, and low-confidence findings that do not meet the verifier floor are skipped.

Data-egress boundary: verification sends credential material outside the scanner process. Depending on the detector declaration, the captured credential or a companion value is placed in an HTTPS request URL, query, authorization/header field, or body and sent to the detector-declared provider. Only eligible findings with a verifier are sent; this is not every finding. Review custom detector TOML and the outbound network boundary before enabling verification. --verify is refused under lockdown.

--no-verify explicitly disables credential verification and overrides verify = true discovered in .keyhog.toml. The Action’s default verify: 'false' maps to this flag, so committed configuration cannot silently enable network egress in an Action run.

--timeout <SECONDS> (or .keyhog.toml timeout) sets the HTTP timeout for each verification request; the default is five seconds. It is not a whole-scan deadline. --per-chunk-timeout-ms is the separate optional scanner deadline, and --oob-timeout controls callback observation waits. On the command line, timeout, concurrency, and request-rate controls require --verify; TOML may store their defaults for runs that explicitly enable verification.

The text reporter renders each finding as a bordered box. With --verify, the verification verdict is appended to the Evidence: line in parentheses: (LIVE) for an active credential, (dead) for one the provider rejected, (revoked), (limited) (rate-limited), or (error). Live verification also upgrades the evidence verdict to confirmed/live-verification. Dead, revoked, and other non-live outcomes retain the scanner’s evidence verdict. A dead or revoked credential is downgraded one severity tier (see the table below), so its box header drops accordingly (CRITICALHIGH).

  ┌    CRITICAL ─── Stripe Secret Key
  │ Secret:     sk_l...p7dc
  │ Location:   src/config/staging.env:14
  │ Evidence:   confirmed/live-verification  ■■■■■■ 100%  (LIVE)
  │ Action:     Roll the exposed Stripe secret key in the Dashboard, update production consumers, then delete the old key.
  │ Docs:       https://docs.stripe.com/keys#roll-api-key
  └─────────────────────────────────────────────

  ┌        HIGH ─── Stripe Secret Key
  │ Secret:     sk_l...ab12
  │ Location:   src/old/legacy.env:8
  │ Evidence:   likely/vendor-pattern  ■■■■■■ 100%  (dead)
  │ Action:     Roll the exposed Stripe secret key in the Dashboard, update production consumers, then delete the old key.
  │ Docs:       https://docs.stripe.com/keys#roll-api-key
  └─────────────────────────────────────────────

The second finding’s header reads HIGH, not its declared CRITICAL: a dead credential is downgraded one tier (see “Severity shift on verification” below). The verdict words shown here (LIVE, dead, revoked, limited, error) are the text-reporter labels. The machine-readable --format json value is the lowercase VerificationResult variant instead: "live", "dead", "revoked", "rate_limited", "unverifiable", "skipped", or an {"error": "..."} object; never the verified-live/verified-dead strings. See Output formats.

The JSON form is exact and preserves errors as data:

{"verification":"live"}
{"verification":"rate_limited"}
{"verification":{"error":"connection failed: could not open a connection to the endpoint. Fix: check DNS resolution, firewall/egress rules, and proxy settings for the credential's host"}}

These are fragments from finding objects, not three complete findings. Error text never contains the credential. The default reporters emit only credential_redacted. Do not use --show-secrets in CI, retained logs, or machine-readable artifacts.

What “live” means

Each detector’s verify block in its TOML defines:

  • method (GET / POST)
  • url (with {{match}} placeholder for the captured credential)
  • auth.type (bearer, basic, header, query, none)
  • auth.field (match, companion-name, …)
  • success.status (HTTP status code, default 200)
  • success.policy, an explicit evidence classification in the current corpus schema:
    • body_positive requires stable positive body evidence from body_contains or json_path
    • status_with_error_backstop accepts the declared status only when the response is not a recognized error shape
    • status_authoritative treats the declared status as sufficient even when the provider’s body resembles an error
  • optional success.body_contains (substring the response body must contain)
  • optional success.json_path and success.equals for structured JSON responses
  • optional metadata selectors for reviewed response evidence attached to live findings

Schema-2 and schema-3 corpora must write this policy; omission fails validation. A manifest-free or explicitly schema-1 custom corpus retains its historical status behavior by normalizing an omitted policy to status_with_error_backstop, never status_authoritative. See Custom detector corpora for the version boundary and corpus identity rules.

Response selectors use one $-rooted grammar:

  • $ selects the full response value.
  • $.account.email selects exact, case-sensitive object keys.
  • $.orgs[0].name selects a zero-based array item.
  • $["account.name"] selects a key that contains a dot.

Wildcards, filters, recursive descent, RFC 6901 /path forms, whitespace outside quoted keys, and implicit roots are rejected when the detector loads. Selectors are limited to 1,024 bytes, 64 segments, and array indexes up to 1,000,000. A success selector that is absent or resolves to null does not satisfy the success contract. equals compares strings, numbers, and booleans exactly and requires a json_path. Metadata fields are optional enrichment, so a valid selector miss omits that field. Every direct metadata entry declares sensitivity = "public" | "hashed" | "secret" in its detector TOML. Public evidence must be one string, number, or boolean no larger than 256 bytes. Hashed evidence emits only a sha256: digest and may summarize a structured value. Secret evidence never enters finding metadata. Omission retains the fail-closed hashed behavior for older custom detectors.

Metadata names resolve to a reviewed provider-neutral role such as account_id, email, scope, status, team_id, or user_id. Unknown names and duplicate canonical roles reject the detector instead of creating provider-controlled report keys. Invalid selector syntax is also a detector configuration error. A malformed successful JSON response is a verification error rather than a dead credential. Direct verification metadata and multi-step extract fields use the same selector grammar. Multi-step extracts are request transport state for later templates and never enter reports.

The verifier:

  1. Renders the URL with the credential substituted in
  2. Builds the auth header / query param as specified
  3. Sends the request
  4. Compares the response status (and optionally body) to the success criteria

If the success contract matches, the result is live. A normal rejection is dead. A provider-specific disabled state can be revoked. HTTP 429 and retryable 5xx responses are rate_limited. KeyHog tries transient outcomes at most three times. A timeout, DNS failure, blocked destination, redirect, TLS failure, or other transport failure is an {"error":"..."} machine value. Errors and rate limits are inconclusive. They leave severity unchanged.

Verification outcome and process exit

Verification failures do not turn the scan into a system error. They remain finding outcomes:

Reported findingsExit
No findings0
At least one finding, none live1
At least one live finding10

One live finding makes the exit 10 even when other findings are dead, rate-limited, or errors. A source-coverage failure can still make the artifact partial; findings take precedence in process-exit selection. Read scan_status from json-envelope or SARIF rather than treating exit 1 or 10 as proof that all requested input was scanned.

Permissions and blast radius

live proves only that the detector’s declared request was accepted. It does not mean KeyHog enumerated everything the credential can read, write, delete, administer, or bill. Provider evidence is included only when the detector TOML declares a reviewed role and sensitivity for a response selector and the endpoint returns it. The JSON metadata object contains only an allowed public value or hashed digest. An absent field is unknown, not empty or denied.

KeyHog does not currently compute effective IAM policy, inherited group roles, resource-level grants, organization policy, network restrictions, or reachable resource inventories. It also cannot infer whether a successful low-impact request represents the credential’s maximum privilege. Treat every live result as exposed authority whose full blast radius must be reviewed in the provider’s own audit and access-control tools.

Severity shift on verification

The verification result is the lowercase VerificationResult variant in JSON; the text reporter prints the corresponding label in the Evidence: line’s (...) suffix.

Verification resultSeverity action
liveUnchanged (it really is what it claims to be)
deadDowngrade one tier (critical -> high, high -> medium, …)
revokedDowngrade one tier (same as dead)
rate_limitedUnchanged, treated as unverified
errorUnchanged, treated as unverified
unverifiable (detector has no verify block)Unchanged
skipped (no --verify flag)Unchanged

The one-tier downgrade is the canonical Severity::downgrade_one step (critical -> high -> medium -> low -> client-safe -> info); it never collapses to a fixed level. A dead or revoked credential is still a leak (developer typed it into a file once), so KeyHog doesn’t drop it entirely. The downgrade just means “this is less urgent than a credential someone could authenticate with right now.” A credential found only in non-HEAD git history is downgraded once on that axis too, so a dead credential in git history drops two tiers.

Network behavior

--verify makes network calls. Two flags shape what the verifier talks to:

  • --proxy <url> routes verification through an explicit HTTP, HTTPS, or SOCKS5 proxy. The same scan-wide flag also routes remote-source HTTP clients. When unset, no proxy is used. Ambient HTTPS_PROXY, HTTP_PROXY, ALL_PROXY, and NO_PROXY variables are ignored. Use --proxy off to force a direct connection when TOML configured a proxy.
  • --insecure accepts invalid or self-signed certificates in verification and remote-source HTTP clients. Use it only for endpoints you control. Strict TLS is the default, and no environment variable can disable certificate verification.

An invalid proxy URL prevents the verification engine from starting. A proxy that cannot connect produces an error result for the affected finding after the bounded retries. It does not silently retry direct. --proxy off also prevents ambient proxy discovery.

The verifier never follows redirects. A redirect produces an error beginning too many redirects: the endpoint issued a redirect, but redirects are disabled for SSRF safety. This prevents a provider endpoint from redirecting a secret-bearing request to a private address. If a legitimate endpoint redirects, update the detector to use its canonical API URL.

Outbound destinations are filtered at the client level:

  • Shipped HTTP verifier blocks declare allowed_domains in their owning detector TOML. Literal endpoint hosts are checked when the detector loads. Interpolated hosts are checked again before every request with the same exact-or-subdomain matcher.
  • Public multi-tenant suffixes are exact-only. An allowlist entry for a shared suffix never licenses arbitrary tenant subdomains.
  • No localhost, 127.0.0.0/8, 169.254.0.0/16, or other RFC 1918 private ranges.
  • No IPv4-mapped IPv6 of the above.
  • No cloud-metadata IPs (169.254.169.254 AWS/Azure/GCP).

These rules are enforced for every detector even if its TOML specifies a localhost URL by mistake. If a project configures a proxy but a particular run must be direct, pass --proxy off; shell proxy variables are ignored by design.

verify.service inherits the detector’s service when omitted. It owns rate limiting and remains a compatibility source for custom detectors that use the built-in provider map. The shipped corpus uses detector-local allowed_domains, so the endpoint and its network authority are reviewed in one file.

Out-of-band callbacks

--verify-oob requires --verify and starts one interactsh collector session. Only detectors with a validated [detector.verify.oob] block use that session. The v0.5.81 shipped detector corpus contains no OOB-enabled detector, so the flag has no effect on shipped findings. It is for reviewed custom corpora.

If the collector handshake fails, KeyHog prints a stderr warning naming the configured server and a redacted handshake error. Ordinary HTTP verifiers keep running. OOB-required findings fail closed as verification errors with an error result before their HTTP probe is sent. See the OOB verification reference for detector, collector, DNS, egress, and output prerequisites.

Rate limits

Verification is rate-limited per-service within a single keyhog scan invocation. The default is 5 requests/second per service (a 200 ms gap between calls to the same service), tunable with --verify-rate <RPS>. That’s slow enough to avoid tripping vendor rate limits for typical scans (dozens of findings) and fast enough to feel interactive. Pass --verify-batch to additionally serialise calls per service (one in-flight at a time) on top of the rate cap.

Concurrency is a separate bound: --verify-concurrency <N> (or .keyhog.toml verify_concurrency) sets the maximum in-flight verification requests per service, default 5. --verify-rate owns the requests/second dimension. Zero is invalid rather than silently becoming one.

If you have hundreds of candidates and want parallelism, the right approach is to scan first WITHOUT --verify to get the candidate list, then verify in batches with a script that respects each service’s documented rate limit.

Low-confidence candidates

--verify sends only findings that meet the verifier confidence floor. Findings below that floor still appear in every output format with "verification":"skipped". The verification field stays skipped, and KeyHog prints a stderr warning with the count and floor. skipped is not evidence that the credential is dead. A machine consumer that requires complete verification must reject both skipped and unverifiable, as well as rate_limited and error.

Detectors without verification

Not every detector has a verify block. Query the installed corpus instead of relying on a copied count:

keyhog detectors --format json | jq '[.[] | select(.verify)] | length'

Detectors counted there ship a live verification endpoint. The rest include:

  • Format-only detectors, such as private keys and certificates, for which there is no service endpoint to call.
  • Services without a known low-impact verification endpoint.

With --verify, these findings are reported as "verification":"unverifiable". Without --verify, every finding is "verification":"skipped".

What you can’t do

  • --verify is not guaranteed to use GET. The owning detector TOML declares the method, URL, headers, query, and optional body, and some shipped providers require POST to perform an authentication or low-impact probe. Verification can create provider audit events, consume rate limits, or incur provider-side effects. Inspect keyhog explain <detector-id> before enabling it in a sensitive account.
  • The verifier does NOT cache results across runs. Each keyhog scan --verify makes fresh calls. Caching would risk reporting a rotated credential as “live” hours after it was revoked.
  • You can’t call verification on a credential that wasn’t captured by a scan. There’s no keyhog verify <credential> subcommand, because verification depends on knowing which detector it came from.

Access targets: what the credential opens

A finding tells you where a credential is. It does not tell you what that credential reaches, which is the first thing you need in order to decide whether to page someone.

--access-targets answers the second question. It runs after the scan, over the findings the report is about to publish, and attaches typed targets to them.

keyhog scan <path> --access-targets --format json-envelope -o keyhog.json

The flag is off by default. A report produced without it has no access_targets key at all, and its findings are byte-identical.

Read one target

jq '.access_targets.targets[0]' keyhog.json
{
  "credential_hash": "bfca93e20109530d8937af3f30a33b0441c4372d08d145924a35cc5edd57551b",
  "detector_id": "url-credentials",
  "service": "generic",
  "location": {
    "source": "filesystem",
    "file_path": "corpus/02/mirror-pos-0001794.js",
    "line": 1
  },
  "targets": [
    {
      "kind": "endpoint",
      "value": "zajgrjseiiwa.example.org:3306",
      "redaction": "none",
      "label": "database host",
      "confidence": 0.95,
      "evidence": {
        "relation": "same_line",
        "rule_id": "database-uri-endpoint",
        "file_path": "corpus/02/mirror-pos-0001794.js",
        "line": 1,
        "column": 65,
        "span_bytes": 29,
        "line_distance": 0,
        "provenance": {
          "source": "tier_b_rule",
          "base": 0.95,
          "decay_steps": 0,
          "decay_factor": 0.85
        }
      }
    },
    {
      "kind": "database",
      "value": "tigiwuns",
      "redaction": "none",
      "label": "database name",
      "confidence": 0.9,
      "evidence": {
        "relation": "same_line",
        "rule_id": "database-uri-name",
        "line": 1,
        "column": 95,
        "span_bytes": 8,
        "line_distance": 0,
        "provenance": {
          "source": "tier_b_rule",
          "base": 0.9,
          "decay_steps": 0,
          "decay_factor": 0.85
        }
      }
    }
  ]
}

credential_hash is the join key back into .findings[].

The five kinds

Ordered by blast radius, widest first.

KindBoundaryExample value
accountbilling or ownershipan AWS account id, an Azure storage account
tenantidentity or organization inside a providera Slack workspace, an Okta org, a GCP project
endpointa network address it authenticates todb.example.org:5432, an API base URL
databasea named logical database inside an endpointcustomers
resourceone addressable objectan S3 bucket, an ARN, a git repository

Rank by the widest kind present:

jq -r '.access_targets.targets[]
       | select([.targets[].kind] | index("account"))
       | "\(.location.file_path)\t\(.targets[0].value)"' keyhog.json

Three ways a target is tied to a credential

evidence.relation says which, and it is the primary sort key.

  • decoded. Recovered from the credential itself, offline, with no file read. An AKIA key carries its AWS account id. This cannot be a coincidence of proximity, so it scores highest.
  • same_line. Found on the finding’s own line. The usual case for a connection string, where the password and the host are the same token.
  • same_file. Found elsewhere in the indexed part of the file. Confidence decays by a factor of 0.85 for every 25 lines of distance, up to four applications. evidence.line_distance and provenance.decay_steps show exactly what was charged.

Which providers are understood

Every rule lives in crates/core/data/access-targets.toml. There is no provider match arm in the Rust. Adding a provider is a data edit reviewable in one diff, the same as the detector corpus.

Measured on benchmarks/corpora/homefield, 2,251 findings produced 1,213 rows. Sixteen distinct rule ids appear: 15 of the 21 [[rule]] entries, plus the [[metadata]] account mapping.

3559 endpoint  database-uri-endpoint
1366 tenant    atlassian-site
 910 account   azure-storage-account
 746 tenant    supabase-project
 708 tenant    slack-workspace
 686 database  database-uri-name
 315 resource  git-repository
 305 endpoint  declared-api-endpoint
 247 endpoint  jdbc-endpoint
 187 endpoint  aws-service-endpoint
 142 endpoint  azure-sql-server
 140 resource  firebase-instance
  79 tenant    shopify-store
  75 resource  slack-webhook-channel
   3 tenant    gcp-project-id
   2 account   metadata:account_id

No document text is ever emitted

A target value is an address. Three rules make that true rather than hoped for.

  1. A connection-string rule skips userinfo with a non-capturing group. Given a URI of the form scheme:// then userinfo, then host, then :port, then /database, only the host, the port, and the database name can reach a capture. The credential between // and @ has no capturing group to land in.
  2. A rule must capture a numbered group. Group 0, the whole match, is rejected at policy load, because the whole match includes surrounding text.
  3. Any candidate whose SHA-256 equals a credential digest in the same report is dropped, whatever the rule intended.

Evidence is structural: a rule id, a line, a column, a span length, a distance. There is no excerpt field, and there will not be one, because the line that holds a credential holds the credential.

Verify it on your own report:

jq -c '.access_targets' keyhog.json | grep -c -F -f your-known-secrets.txt

On the 3,000-secret mirror corpus that count is 0.

Telling “no door” from “never looked”

An empty target list means one of two very different things, so the report says which.

jq '.access_targets.coverage' keyhog.json
{
  "findings_total": 1,
  "findings_with_file_context": 0,
  "files_indexed": 0,
  "bytes_indexed": 0,
  "complete": false,
  "gaps": [
    {
      "reason": "historical_content",
      "explanation": "the finding is historical content at a commit; the working-tree file was not indexed because its neighbours may postdate the credential",
      "findings": 1,
      "examples": [".env"]
    }
  ]
}

complete: true with no targets means the pass read the file and found no door. complete: false means some findings were never inspected, and gaps names why.

The reasons:

ReasonWhat happened
historical_contentthe finding is at a commit; the working-tree file may have changed since
source_not_readablethe backend exposes no re-readable local file (container layer, cloud object, stdin)
no_file_paththe finding carries no path
transient_read_failedthe file was removed, replaced, or locked between the scan and this pass, so a rerun may cover it
permanent_read_failedthe file cannot be read and a rerun will not change that: permissions, or not a regular file
not_utf8the prefix is not valid UTF-8, so byte offsets cannot become lines
file_truncatedthe file is larger than 1 MiB, so only its prefix was indexed
derived_view_anchorlessthe credential came from a decode view or a windowed read, so its line does not index the file
byte_budget_exhaustedthe pass reached its 256 MiB whole-run ceiling first

derived_view_anchorless is the subtle one. A finding’s source is sometimes filesystem/<view> rather than filesystem: filesystem/base64, filesystem/hex, filesystem/reverse and filesystem/quoted-printable mean the credential was recovered from a decoded view, and filesystem/windowed means the file was large enough to be read in windows and the line number is relative to the window. In every one of those the line does not index the file.

The file is still indexed and its doors are still reported. What is dropped is the proximity claim: those targets are same_file with no line_distance and the maximum decay applied, so a door on the adjacent line scores the same as one four hundred lines away. That under-claims on purpose. The alternative is asserting a distance the line number cannot support.

Worked example, one 1.3 MB file scanned with --access-targets, credential on line 1 and the door on line 2:

source                 filesystem/windowed
targets                endpoint prod-db.example.org:5432   confidence 0.496
                       database customers                  confidence 0.470
relation               same_file, line_distance null
coverage.complete      false
coverage.gaps          derived_view_anchorless (1), file_truncated (1)

The same door on line 2 of a small file scores 0.95 and 0.90 as same_line.

Cost

The pass reads each distinct file at most once, over at most 1 MiB of it, under a 256 MiB ceiling for the whole run. Cost is linear in indexed bytes plus one sort per finding, never quadratic in findings.

Two runs on this host, both with --access-targets:

CorpusFindingsFiles indexedBytes indexedRows
benchmarks/corpora/mirror/corpus2,8622,841708,174198
benchmarks/corpora/homefield2,2511,057772,9741,213

Files indexed is below findings on both, because several findings share a file and the index is built once.

What this is not

--access-targets is intra-file. It answers “what does this one credential open”. For “where else does this same credential appear”, use --correlate, which joins findings across files. The two are independent and can be combined.

Confidence calibration

Not the same as autoroute calibration. This page is about scoring: teaching KeyHog how much to trust each detector from your own confirmed true/false positives. For backend selection (which engine is fastest and proven-correct), see Autoroute calibration. The two subsystems share only the word “calibration”: different command, different cache file, different purpose.

KeyHog keeps a per-detector Bayesian Beta(α, β) posterior over P(true positive | this detector fired). Each confirmed true positive increments α; each confirmed false positive increments β. Both start from a uniform Beta(1, 1) prior, so a detector with no recorded history has a posterior mean of 0.5 and is treated neutrally.

At scan time, once a detector has accumulated real observations, its posterior mean multiplies that detector’s confidence score: detectors with a clean history are amplified, chronic false-positive emitters are muted.

Record outcomes

keyhog calibrate --tp stripe-secret-key   # record one true positive
keyhog calibrate --fp generic-api-key     # record one false positive
keyhog calibrate --show                   # print current counters

Counters persist to $XDG_CACHE_HOME/keyhog/calibration.json by default. Pass --cache <PATH> to use a different file. A corrupted or schema-incompatible cache fails closed and is never overwritten, so you never silently lose recorded history.

How it affects scans (opt-in and deterministic)

Calibration is opt-in. A default scan does not read the counter file, so two machines produce byte-identical findings for the same input regardless of what history happens to sit in a local cache. To apply calibration during a scan, point at the file explicitly:

keyhog scan . --calibration-cache ~/.cache/keyhog/calibration.json

or in configuration:

[system]
calibration_cache = "/absolute/path/to/calibration.json"

An explicitly supplied cache must already exist and parse cleanly. A missing or damaged explicit cache fails before scanning rather than silently continuing without calibration, so a run that asked for calibration never quietly produces uncalibrated scores.

When enabled, the multiplier is applied only to detectors that have observations beyond the prior. A fresh, never-calibrated detector is left untouched rather than uniformly halved, so a brand-new install behaves exactly as it did before you enabled calibration until real history accumulates.

Cache integrity

The cache carries a schema version. A version this binary does not understand, a truncated/corrupt file, an out-of-range counter, or an empty detector id is rejected on load (the scan fails closed rather than silently scoring against a damaged cache). Counters are keyed by detector id; if you rename or retire a detector, its old counters simply stop being consulted; re-record outcomes for the new id.

Hardening and data handling

A secret scanner reads credentials into memory. This chapter states which process protections KeyHog applies, which commands can use the network, and where the guarantees stop. For vulnerability reporting, see the security policy.

Start with the boundary

A normal local scan does not send findings, filenames, or telemetry away from the host:

keyhog scan . --daemon=off

Filesystem, local Git, archive extraction, decoding, detection, suppression, and reporting remain local. Network access begins only when you select an operation that needs it.

OperationWhy it can use the network
scan --url, --github-org, --github-collaboration, --gitlab-group, --bitbucket-workspace, --s3-bucket, --gcs-bucket, or --azure-container-urlReads the remote source you named.
scan --verifySends credential-derived requests for detectors that have a live verification plan. Out-of-band verification can also wait for callbacks.

The detector corpus and detector-owned validators are local. Offline validators such as checksum or payload-shape checks do not contact the service.

In-process scan hardening

The one-shot scan orchestrator and scan-system apply process protections before reading scan input:

  • On Linux, prctl(PR_SET_DUMPABLE, 0) disables ordinary core dumps, same-user ptrace attachment, and same-user /proc/<pid>/mem access.
  • On macOS, ptrace(PT_DENY_ATTACH, ...) denies debugger attachment.
  • On Windows, KeyHog does not currently wire WER dump suppression or an equivalent debugger-denial policy. The scan records that hardening gap.

These protections are best effort during a normal scan. A failure is logged and the scan continues. They are not applied merely because another subcommand was invoked. In particular, watch and the long-lived daemon do not pass through the one-shot scan hardening call.

Do not treat these controls as protection from a privileged host administrator. Run KeyHog inside the same host trust boundary as the data it scans.

Lockdown mode

--lockdown is a Linux-only, fail-closed mode for scan and scan-system. Run it only after granting enough locked-memory allowance for the process:

keyhog scan . --daemon=off --lockdown

For scan, lockdown:

  • calls mlockall(MCL_CURRENT | MCL_FUTURE) so current and future process pages stay resident;
  • sets RLIMIT_CORE to zero and checks /proc/self/coredump_filter; it fails only if the dump limit cannot be set and the filter still permits a dump;
  • checks the default KeyHog cache root and any selected incremental-cache path for persistence artifacts that could contain past scan state;
  • allows validated KHHS Hyperscan pattern-database shards because they contain compiled detector automata, not findings or credentials;
  • disables incremental cache use with a warning;
  • refuses --verify and --show-secrets;
  • refuses --fast, --no-default-excludes, --no-unicode-norm, --no-decode, --no-entropy, and --no-ml.

The daemon route is not eligible for a lockdown scan. scan-system --lockdown also refuses --include-network. On macOS and Windows, memory locking for this mode is not implemented, so lockdown fails rather than claiming a no-swap guarantee.

Credentials in memory and reports

Reported credential bytes are stored in the Credential type and zeroized when that buffer is dropped. This protects that owned buffer. It is not a claim that every temporary source or decoder allocation has never held the same bytes.

Reports redact credentials by default. --show-secrets explicitly requests plaintext output and is rejected by lockdown. Treat any plaintext terminal, pipe, or report file as secret-bearing data.

Binary replacement

KeyHog has no self-update path. There is no signed binary-asset release channel: automatic releases publish crates.io packages only, and no workflow builds, signs, or uploads release binaries.

Update with cargo install --locked --force keyhog, then run keyhog doctor to verify the replacement before you rely on it; see Install. install.sh and install.ps1 install a bundle you already hold, with --from-file, and never fetch one.

  • Environment variables lists every direct production environment read and the platform directory variables that affect paths.
  • Exit codes explains how hardening and other setup failures reach automation.
  • Daemon and warm scans documents the separate daemon process and its eligibility boundary.

Security Policy

Reporting a Vulnerability

Please report security vulnerabilities through GitHub’s built-in Private Vulnerability Reporting first:

  1. Open Report a vulnerability.
  2. Fill out and submit the private advisory form.

If GitHub private reporting is unavailable or the form cannot be submitted, email security@santh.dev with:

  • Affected version / commit SHA
  • Reproduction steps and proof-of-concept (where safe to share)
  • Impact assessment

PGP encryption is not required for email reports.

Do not open a public issue or include live credentials in a report. We will coordinate remediation and disclosure through the private advisory or email thread. Timing depends on the impact, affected releases, and fix validation.

Supported Versions

Only the main branch (and the latest published crate / package release) receives security fixes. Vendored snapshots and forks are responsible for backporting.

Out of Scope

  • Findings against archived branches or deprecated tags.
  • Self-XSS or social-engineering attacks against maintainers.
  • Reports that depend on a compromised upstream package without a reproducible downstream impact.

Coordinated Disclosure

GHSA advisories are filed under the appropriate Santh GitHub organization. We coordinate CVE assignment via GitHub’s CNA when a fix ships.

RustSec Advisory Assessment (v0.5.42)

A cargo audit of Cargo.lock surfaces four accepted advisories total (one vulnerability and three informational warnings across the workspace and VYRE). Each was reviewed against KeyHog’s actual usage of the affected crate and given an explicit accept-with-rationale decision or a fix path. The accepts are reflected in the [advisories] ignore list at the workspace-root audit.toml; cargo audit exits clean with that file in place.

Accepted (rationale-documented)

RUSTSEC-2023-0071 - rsa 0.9.7 Marvin attack

Risk: the crate’s RSA private-key operations are not fully constant-time; an attacker who can submit chosen ciphertexts and remotely observe decryption timing may recover private-key material.

Why not applicable: the OOB verifier is a client, not a decryption service. It generates an ephemeral keypair, shares the public half with the configured Interactsh server, and decrypts one server-pushed OAEP-wrapped session key locally. KeyHog returns neither a validity verdict nor decryption timing to a caller, and transport is HTTPS through the verifier’s screened/pinned client. The Interactsh server already generated and therefore knows the wrapped AES session key. There is no remote RSA decryption oracle exposed by KeyHog.

RUSTSEC-2026-0002 - lru 0.12.5 IterMut Stacked Borrows violation

Risk: LruCache::iter_mut() invalidates an internal pointer (detectable by Miri’s Stacked Borrows checker).

Why not applicable: the unsound API is LruCache::iter_mut(), and no KeyHog crate calls it. Every lru consumer in the tree is enumerated here so this rationale can be rechecked mechanically:

  • crates/scanner/src/fragment_cache.rs - sharded Mutex<LruCache<String, Vec<SecretFragment>>>, reached through get_or_insert_mut_ref; the iter_mut() calls in that file are on its own Vec<SecretFragment> cluster, not on the cache.
  • crates/scanner/src/compiler/compiler_compile.rs - sharded Mutex<LruCache<String, Arc<Regex>>> compiled-regex cache.
  • crates/scanner/src/entropy/bpe.rs - thread-local LruCache<u64, TokenCountCacheEntry> token-count cache.

RUSTSEC-2024-0436 - paste 1.0.15 unmaintained

Risk: crate is unmaintained; future advisories will not get fixes.

Why accepted: paste is a build-time proc-macro pulled through the Metal backend. It is absent from the runtime dependency graph as executable library code, and the release build pins and audits its exact source version.

RUSTSEC-2025-0141 - bincode 2.0.1 unmaintained

Risk: bincode is unmaintained upstream; security defects against it will not be patched.

Why not applicable now: KeyHog itself does not depend on bincode directly. It is only pulled in transitively through the published VYRE GPU stack, which uses bincode for serializing compiled GPU pattern databases. The serialization surface is local disk caches keyed under $KEYHOG_CACHE_DIR; there is no untrusted network input deserialized through bincode. KeyHog pins the exact VYRE versions, records the resolved bincode version in Cargo.lock, and treats the cache as local state rather than a network interchange format.

Resolved in v0.5.3

RUSTSEC-2025-0140 - gix-date 0.9.4 non-utf8 String construction

Risk: A malicious commit with a non-UTF-8 timestamp string could have triggered UB through TimeBuf::as_str.

Resolution: Bumped gix from =0.70.0 to 0.77.0 (which pulls gix-date 0.12.0+). The bump is API-clean - all five git-using sources tests pass without source changes. See commits under “security: bump gix”.

RUSTSEC-2025-0021 - gix-features 0.40.0 SHA-1 collision attacks

Risk: gix-features 0.40.0 did not detect SHA-1 collisions in git objects (Severity 6.8 / medium).

Resolution: Same gix bump pulls gix-features 0.42.0+, which adds collision detection. No source changes needed in keyhog’s git source layer.

The gix bump also coordinated with two transitive dependency updates required by its newer versions: smallvec 1.14.0 → 1.15.1 and memmap2 0.9.9 → 0.9.10.

CLI reference

The generated tables on this page are rebuilt from KeyHog’s live clap command tree in CI. They cover the root options, every visible top-level command, nested subcommands, aliases, hidden flags, value arities, defaults, and possible values. The surrounding workflow guidance remains curated so it can explain semantics, precedence, and failure modes that --help cannot.

keyhog scan [PATH]...

The main subcommand. Scans one or more PATH roots (default: current directory) and emits findings. Pass several roots in a single run (keyhog scan src/ tests/ config/) and each is walked as its own source; a root nested inside another is folded into its covering parent (announced on stderr) so no subtree is scanned twice. Exit code: 0 means no finding blocks the active evidence policy, 1 means at least one finding blocks, 2 user error, 3 system error, 10 live credential, 11 scanner panic, 12 selected or required GPU unavailable, and 13 requested source failure or incomplete coverage.

ArgumentValueDefaultDescription
<PATH>PATH...Path(s) to scan. Pass several to scan multiple roots in one run (keyhog scan a/ b/ c/); nested or duplicate roots fold into their covering parent. Positional shorthand for --path (single root only)
--access-targetsReport the resource each credential opens (its “door”). A finding says where a credential is. It does not say which database, bucket, tenant, or account that credential reaches, which is the first thing a responder needs in order to rank it. The address almost always sits next to the credential (in the same connection string, the same .env, the same variable block) and no detector can see it: a companion regex is bounded to a few lines and is written to capture the other half of the CREDENTIAL, not the resource. This pass runs after the scan, over the findings the report is about to publish, and attaches typed targets: account, tenant, endpoint, database, resource. Which providers are understood is Tier-B data (crates/core/data/access-targets.toml), not a hardcoded list. Redaction-safe by construction. Connection-string rules skip userinfo with a non-capturing group, any candidate whose digest matches a credential in the same report is dropped, and evidence carries only the rule id, line, column, span length, and line distance. No document text is ever emitted. Bounded: file context is indexed at most once per file, over at most 1 MiB of it, under a 256 MiB whole-pass ceiling. Findings the pass could not inspect (git history, container layers, stdin, unreadable paths) are reported as coverage gaps, so an empty target list never reads as “this credential opens nothing”. Purely additive: findings are never added, dropped, reordered, or edited. --format json-envelope gains an access_targets object; every other format is untouched. Default off, so a report produced without this flag is byte-identical.
--action-receipt (hidden)PATHWrite an internal composite-Action receipt bound to the completed report
--allow-gcs-token-forwardForward the ambient GCS bearer token to a custom GCS endpoint you trust. Off by default; googleapis.com endpoints do not need this. This flag is intentionally explicit because it can send a bearer token to a third-party host
--allow-private-cloud-endpointAllow web, hosted-git, and cloud sources to reach an endpoint whose host, literal or DNS-resolved, is private, loopback, link-local, or cloud-metadata. OFF by default: the shared SSRF screen refuses every such endpoint. Enable ONLY for a trusted private-network deployment, such as an on-premises web application or self-hosted object store. This flag (or its [http].allow_private_endpoint TOML equivalent) is the ONLY way to relax the screen. No environment variable can silently turn KeyHog into an SSRF proxy for internal services
--allow-s3-credential-forwardForward ambient AWS credentials to a custom S3 endpoint you trust. Off by default; AWS-owned endpoints do not need this. This flag is intentionally explicit because it can send AWS identity material to a third-party host
--allow-script-verifyPermit detector script: verification for trusted detector corpora. Off by default because scripts execute verifier-supplied code with credential-adjacent context. Prints an explicit warning when active
--autoroute-cachePATH|offOverride the persistent autoroute calibration cache file. Use an absolute path, or off to disable persistence. Config: [system].autoroute_cache in .keyhog.toml; this flag overrides it.
--autoroute-calibrateRun this scan as an explicit autoroute calibration probe: benchmark parity-checked backend candidates and persist the fastest-correct decision for each workload bucket. Normal scans never benchmark on cache miss; they use persisted evidence or fail closed without scanning. An explicit --backend is diagnostic only
--autoroute-gpuAllow autoroute calibration to include GPU candidates for eligible workload buckets. Normal scans still use persisted calibration only
--azure-container-urlURLScan an Azure Blob Storage container URL. Include a SAS query string for private containers
--azure-prefixPREFIXOptional Azure Blob prefix to limit the scan
--backendBACKENDSelect persisted autoroute or explicitly force one diagnostic backend. Accepted values are listed below Possible values: auto, gpu-cuda, gpu-cuda-region-presence, gpu-metal, gpu-metal-region-presence, gpu-wgpu, gpu-wgpu-region-presence, simd, simd-regex, cpu, cpu-fallback.
--baselinePATHSuppress findings that match an existing baseline file
--batch-pipelineForce the coalesced batch scan pipeline instead of the fused filesystem pipeline. This is an explicit calibration/diagnostic control, not an ambient environment switch. Config: [system].batch_pipeline; this flag overrides it
--benchmarkRun the built-in backend benchmark corpus and exit. This measures backend throughput over KeyHog’s own corpus; it never scans an operator-supplied target and never writes a report. Passing a scan target (PATH, --path, --stdin) or a report destination (--output) alongside it used to exit 0 having silently ignored both, so an operator could read “benchmark winner: …” as a completed scan of their tree. Those combinations now fail closed with the conflict named.
--binaryScan binary files for hardcoded strings
--bitbucket-endpointBITBUCKET_ENDPOINThttps://api.bitbucket.org/2.0Bitbucket Cloud API endpoint root
--bitbucket-tokenAPP_PASSWORDBitbucket app password for –bitbucket-workspace. Prefer KEYHOG_BITBUCKET_TOKEN so the token is not exposed in the process list
--bitbucket-usernameUSERNAMEBitbucket username for –bitbucket-workspace. May be supplied through KEYHOG_BITBUCKET_USERNAME
--bitbucket-workspaceWORKSPACEScan all repositories in a Bitbucket Cloud workspace
--cache-dirDIROverride the Hyperscan compiled-database cache directory. This is explicit CLI/TOML configuration, not an environment variable: pass an absolute path under your home directory or the per-user keyhog temp cache root. Config: [system].cache_dir in .keyhog.toml; this flag overrides it.
--calibration-cachePATHExplicit per-detector Bayesian calibration cache for confidence scoring. Normal scans are hermetic and ignore any default keyhog calibrate cache unless this flag or [system].calibration_cache supplies a path. The file must already exist and parse cleanly; damaged or missing explicit caches fail before scanning so score changes are reproducible.
--configPATHLoad configuration from a specific file path
--correlateReport cross-file credential correlations alongside the findings. Joins one credential value seen at several file paths, across the detector boundary that per-detector dedup never crosses, and provider credentials whose halves are separate detectors split across files of one directory (an AWS access key in main.tf, its secret in .env). Which providers have halves is Tier-B data, not a hardcoded list, and an ambiguous directory reports nothing rather than a guess. Additive only: --format json-envelope gains a correlations array and --format text a summary block. Findings and every other format are unchanged, so a default scan is byte-identical without this flag.
--create-baselinePATHCreate a new baseline file from current findings and exit
--daemon[auto|on|mass|off]Daemon routing: auto (default, use a live daemon for eligible warm requests), on (require the warm stdin/single-file route), mass (stream bounded directory, Git, archive, binary, remote, or cloud source batches to a daemon started with daemon start --mass), or off (force in-process). Bare --daemon means on. Startup and request latency depend on the corpus, backend, cache state, host, and input. See keyhog daemon start --help. Socket: the daemon route connects to the shared default resolution ($XDG_RUNTIME_DIR, then the OS cache directory, then the OS temporary directory) unless --daemon-socket <path> points it at a daemon bound elsewhere (daemon start --socket <path>). Unix only: Windows rejects explicit auto and on; explicit off is accepted as a portable declaration of in-process execution. Optional value. Possible values: auto, on, mass, off.
--daemon-socketPATHConnect the daemon route to a daemon bound on a non-default socket. By default scan --daemon uses $XDG_RUNTIME_DIR/keyhog.sock, then the OS user-cache directory, then the OS temporary directory. Pass the same path a daemon was started on (keyhog daemon start --socket <path>) to reach a fixed-location daemon (e.g. a shared/system or systemd-managed instance). Combining it with --daemon=off is rejected as contradictory.
--decode-depthDEPTHMaximum depth for recursive decoding (1-10, default: 10)
--decode-size-limitSIZEMaximum prepared chunk size admitted to decode-through (default: 512KB)
--dedupDEDUPcredentialDeduplication scope for findings Possible values: credential, file, none.
--deepDeep recovery mode: scans entropy candidates in source files, removes comment confidence penalties, keeps heuristic evidence alongside ML for entropy candidates, sets decode depth 10, and admits one 1 MiB chunk into decode-through. Compatible explicit knobs override this BASE
-d, --detectorsDETECTORSdetectorsDetector TOML directory
--detectors-modeMODEHow an explicitly selected custom corpus participates in the embedded corpus. Omitted preserves the established replace behavior Possible values: replace, overlay.
--developer-compile-embedded-detectors (hidden)Developer-only escape hatch: allow in-process compilation of embedded or custom detectors
--docker-imageIMAGEScan a Docker image by unpacking docker image save
--dogfoodEmit a structured --dogfood JSON trace to stderr after the scan: every credential that was matched but suppressed, with the reason, both example/test/placeholder markers (kind: example_suppressed) AND shape/heuristic gates such as UUID-v4, bare-hex digest, base64 blob, dashed serial, or repetitive run (kind: shape_suppressed, reason names the gate), plus bounded static-recovery expressions rejected as malformed (kind: static_recovery_rejected). Detail events are bounded; exact aggregate rejection counts and detail_events_dropped remain visible after the bound is reached. Credentials are redacted (prefix and suffix shown, middle elided), and recovery rejections contain no source bytes. Useful when keyhog reports zero findings and you want to know whether a match was made and silenced, recovery rejected an expression, or the candidate never reached the engine
--entropy-bpe-max-bytes-per-tokenRATIOBPE “rare-not-random” suppression bound in bytes-per-token (default: 2.2). A surviving entropy/generic candidate whose cl100k_base bytes-per-token is above this is treated as word-like (dotted API paths, prose) and dropped. Lower = more aggressive suppression (higher precision, lower recall); a large value effectively disables the gate
--entropy-source-filesEnable entropy scanning in source code files
--entropy-thresholdBITSEntropy threshold in bits per byte (default: 4.5)
--evidence-policyPOLICYFinding evidence tiers that produce a non-zero CI exit. default blocks likely and confirmed; paranoid also blocks review. Findings remain visible under either policy Possible values: default, paranoid.
--exclude-pathsPATH...Explicit paths or glob patterns to exclude from scanning
--fastFast mode: pattern matching only. No decode, no entropy, no ML scoring. Maximum speed. A preset is a BASE: it seeds defaults, then compatible explicit knobs override it (e.g. --fast --decode-depth 2 re-enables shallow decode on top of the fast base). Entropy-only knobs conflict because fast mode disables entropy, so accepting them would create a no-op flag
--formatFORMATtextOutput format. json is a bare findings array for pipelines; prefer json-envelope for scan status, coverage gaps, and backend recoveries in one document (KH-1435 / KH-1474) Possible values: text, json, json-envelope, jsonl, jsonl-envelope, sarif, csv, github-annotations, gitlab-sast, html, junit.
--fused-batchNFused filesystem pipeline chunk batch size
--fused-depthNFused filesystem pipeline channel depth
--gcs-bucketBUCKETScan a Google Cloud Storage bucket via the JSON API
--gcs-endpointURLOptional GCS endpoint override for compatible APIs or tests
--gcs-prefixPREFIXOptional GCS object prefix to limit the scan
--git-blobsGIT_BLOBSScan repository blobs from refs, reflogs, stashes, and unreachable objects. Commit blobs are collected by parent-tree diff (added, changed, and deleted sides); every ref tip under refs/ plus HEAD, root commits, and unreadable parents fall back to a full tree walk
--git-diffBASE_REFScan only changed lines between two git refs (e.g., –git-diff main)
--git-diff-pathGIT_DIFF_PATHPath to git repository for –git-diff (defaults to current directory)
--git-historyPATHScan reachable commits using added lines from each commit patch
--git-stagedScan exact staged index blobs, never substituted working-tree bytes
--github-allInclude every supported collaboration surface for –github-collaboration. This is the concise equivalent of passing all six –github-* surface flags
--github-api-endpointURLGitHub-compatible API endpoint for –github-collaboration
--github-collaborationOWNER/REPOGitHub repository whose explicitly selected collaboration surfaces are scanned
--github-discussionsInclude discussion text and comments from –github-collaboration
--github-gistsInclude public gist revisions and comments for the repository owner
--github-issuesInclude issue text and comments from –github-collaboration
--github-orgORGScan all repositories in a GitHub organization
--github-pull-requestsInclude pull request text, issue comments, and review comments
--github-releasesInclude release notes, including drafts and prereleases, plus every release asset name and label, from –github-collaboration
--github-tokenPATGitHub personal access token for –github-org or –github-collaboration. Prefer KEYHOG_GITHUB_TOKEN so the token is not exposed in the process list
--github-wikiInclude every readable wiki revision from –github-collaboration
--github-wiki-urlURLExplicit clone URL for the wiki selected by –github-wiki
--gitlab-endpointGITLAB_ENDPOINThttps://gitlab.comGitLab API endpoint root, for example https://gitlab.example.com
--gitlab-groupGROUPScan all projects in a GitLab group, including subgroups
--gitlab-tokenPATGitLab personal access token for –gitlab-group. Prefer KEYHOG_GITLAB_TOKEN so the token is not exposed in the process list
--gpu-batch-input-limitSIZEGPU batch-input buffer byte budget, e.g. “256MB” or “1GB”. Overrides the VRAM-adaptive default (128 MiB–1 GiB by detected VRAM); the value is clamped into that range. Larger buffers scan more bytes per GPU dispatch on big inputs at higher VRAM cost. Config: gpu_batch_input_limit in .keyhog.toml; this flag overrides it
--hide-client-safeDrop every client-safe finding before reporting. Use this for bug-bounty / exfiltration-impact workflows where keys that are public by design (Sentry DSN, Stripe pk_*, Firebase web, Mapbox pk., PostHog project, Google Maps browser, Mixpanel project, Algolia search, Datadog browser RUM) are noise: the vendor expects them to ship in client bundles and no attacker gains server-side access from finding one. Default off: client-safe findings still appear in scan output at the CLIENT-SAFE tier (below LOW) so a misconfigured “publishable” key wired into a server-only detector still surfaces. --hide-client-safe is the explicit opt-in to silence them.
--incrementalIncremental scan: skip files whose metadata and content match the spec-bound Merkle index. The index is updated after successful scanning. This works in process and with --daemon=mass for daemon-local filesystem roots. If acquisition yields only unchanged files, backend routing and scanner dispatch do not start. Pass --incremental-cache <path> to override the default location
--incremental-cachePATHOverride the merkle-index cache file location
--insecureSkip TLS certificate verification for every outbound HTTP request. Needed when scanning through Burp / mitmproxy / corporate-MITM CAs that present self-signed certificates. Off by default. This flag (or its TOML equivalent) is the ONLY way to disable verification: no environment variable can turn it off, so an ambient toggle can’t silently expose secrets to a MITM
--limit-azure-blob-bytesSIZEMaximum bytes downloaded for one Azure blob
--limit-binary-decompiled-bytesSIZEMaximum Ghidra decompiled-output bytes accepted for parsing
--limit-binary-read-bytesSIZEMaximum bytes read for binary strings extraction
--limit-cloud-max-objectsNMaximum objects listed from one S3/GCS/Azure container before truncating
--limit-docker-image-config-bytesSIZEMaximum bytes accepted for Docker/OCI image config and manifest JSON
--limit-docker-tar-entry-bytesSIZEMaximum bytes allowed for one Docker tar entry
--limit-docker-tar-total-bytesSIZEMaximum cumulative bytes unpacked for one Docker/OCI image, summed across the image tar and every layer tar
--limit-gcs-object-bytesSIZEMaximum bytes downloaded for one GCS object
--limit-git-blob-bytesSIZEMaximum bytes read from one git blob
--limit-git-chunksNMaximum chunk count emitted by a git blob-history scan
--limit-git-line-bytesSIZEMaximum bytes buffered for one line of git stdout
--limit-git-total-bytesSIZEMaximum aggregate bytes emitted by a git blob-history scan
--limit-hosted-git-pagesNMaximum hosted-git listing pages or GitHub collaboration API requests
--limit-s3-object-bytesSIZEMaximum bytes downloaded for one S3 object
--limit-stdin-bytesSIZEMaximum bytes accepted from –stdin before failing closed
--limit-web-response-bytesSIZEMaximum HTTP response bytes scanned by –url
--lockdownLockdown mode: maximum security at the cost of throughput. Enables every protection in keyhog_core::apply_protections(true) (mlock, refuse-on-coredump-leak, refuse-on-disk-cache), forces HTTPS-only verifier, refuses to write any cache to disk, and hard-aborts if any protection fails to take. Use this when keyhog is running inside EnvSeal or otherwise in a security-critical embedding
--matcher-cacheDIR|offOverride the MatcherArtifact cache directory. Persists the eager compiled matcher graph across process invocations. This is distinct from --cache-dir, which only stores Hyperscan .db shards. Use an absolute directory, or off to disable. Config: [system].matcher_cache in .keyhog.toml; this flag overrides it.
--max-commitsMAX_COMMITSMax git commits to traverse
--max-file-sizeSIZEMaximum file size to scan. Files larger than this are listed in the end-of-scan “files skipped: exceeded –max-file-size” summary. Default is 100 MiB, the FilesystemSource ceiling. Files above the 1 MiB window size are read in overlapping ~1 MiB windows (so memory stays bounded regardless of file size), up to this cap
--min-confidenceFLOATMinimum confidence score (0.0 - 1.0) to report findings (default: 0.40)
--min-secret-lenNMinimum credential length for entropy-discovery candidates (default: 16). Named detectors keep their own shape-specific length gates
--ml-thresholdTHRESHOLDRaise the global confidence floor (0.0 to 1.0). Takes effect as max(min_confidence, ml_threshold), so it tightens but never loosens the floor set by --min-confidence. Despite the name, this raises the floor for ALL findings, not only ML-scored ones, and still applies when --no-ml disables ML scoring. A detector’s explicit min_confidence in its TOML remains that detector’s effective floor. Absence leaves the canonical floor untouched
--ml-weightWEIGHTOverride every detector’s ML scoring weight for diagnostics/benchmarks
--no-autoroute-gpuKeep GPU candidates out of autoroute calibration even when TOML enables them
--no-batch-pipelineKeep the fused filesystem pipeline even when [system].batch_pipeline is true
--no-colorDisable ANSI color in the report and the stderr summary, regardless of whether the output is a TTY (the NO_COLOR convention is also honored)
--no-configIgnore any ambient .keyhog.toml: skip the walk-up discovery from the scan root and reject an explicit --config. The scan then runs on the compiled-in shipped defaults (the Tier-A SHIPPED_* floors/disables) and nothing else. This is the hermetic, reproducible config used by CI gates and the benchmark harness, so the measured behavior is the shipped default BY DESIGN and cannot silently drift when a stray .keyhog.toml appears on an ancestor path; the hermetic-config tests pin that contract
--no-decodeSkip decoding base64/hex encoded content
--no-default-excludesDisable every default exclusion for this scan. Two separate defaults are turned off. The walker stops skipping lock files, minified and bundled assets, build outputs, and vendored trees, so their bytes are read. The scanner also stops dropping findings whose path is a minified or vendored bundle (.min.js, .bundle.js, .min.css, node_modules/, site-packages/, wp-includes/, and similar), so a credential a build pipeline inlined into app.min.js is reported instead of silently discarded. Expect more noise: random byte sequences in third-party bundles do collide with credential shapes. Without this flag, findings dropped by the second rule are counted and reported as a coverage gap, so you can see how many there were before deciding to rerun.
--no-entropyDisable entropy-based detection
--no-entropy-ml-scoringScore entropy-discovery candidates with the bare entropy heuristic instead of routing them through the MoE (the model is authoritative by default). The default ML path is a recall-safe precision win on the detector-owned model mode; this opt-out selects bare entropy-only scoring. It does not change detector policy and has no effect when --no-entropy or --no-ml is set
--no-gpuDisable GPU probing and GPU backend acquisition for this scan
--no-keyword-low-entropyDisable the lower-floor generic-keyword-secret bridge for anchored values (PASSWORD=, *_PASS=, secret:, api_key= …). Anchored candidates must then satisfy the stricter generic-secret policy. No effect unless the generic keyword bridge would otherwise fire
--no-mlDisable ML-based confidence scoring
--no-suppress-test-fixturesOpt out of the bundled test-fixture suppression list. By default keyhog suppresses well-known public demo credentials (Stripe’s docs example sk_live_4eC39..., GitHub’s docs example ghp_aBcD..., the keyhog test fixtures, etc.) so the report stays focused on real leaks rather than tutorial copies. Pass this flag when you intentionally want those surfaced. Useful for differential benchmarking against gitleaks / trufflehog (which do NOT suppress these), or for auditing the suppression list itself
--no-unicode-normDisable Unicode normalization (not recommended)
--no-verifyDisable credential verification, overriding verify = true in .keyhog.toml
--oob-serverHOSToast.funInteractsh server for OOB verification. Defaults to projectdiscovery’s public collector at oast.fun. Use a self-hosted server for sensitive scans; the collector sees correlation IDs and the IPs of services that call back, never the credential itself. Only meaningful with --verify-oob; clap rejects the flag without it instead of silently ignoring it (the prior behavior gave false confidence that an override had been applied)
--oob-timeoutSECS30Per-finding OOB wait timeout in seconds. Detector specs may set their own timeout_secs; this value is the global default. The upper bound is max(this value, 120s), so a detector can always wait at least 120s for a delayed webhook even when this default is lower. Lower = faster scans, higher = catches services with delayed webhooks (e.g., queued mail delivery). Requires --verify-oob
-o, --outputOUTPUTWrite findings to file
-p, --pathPATHScan a directory or file
--per-chunk-timeout-msMSHard deadline per chunk scan in milliseconds. Default unset = no operator deadline; decode still has its internal bomb guard
--perf-traceRaise --profile to its diagnostic level: add higher-overhead per-pattern, per-decoder, and backend timing traces on stderr
--precisionHigh-precision mode for mass scanning: minimise false positives at the cost of some recall. Disables entropy discovery and the relaxed keyword bridge, retains ML scoring for remaining candidates, raises the minimum confidence floor to 0.85, and uses decode depth 1. Explicit confidence flags may tighten but cannot lower that floor. Entropy-only knobs conflict because precision mode disables entropy
--profileEmit low-overhead stage, resource, build, policy, source, and measured workload identity evidence to stderr at scan end
--profile-outPATHWrite the complete causal scan profile as JSON to PATH at scan end. Implies --profile; the artifact is written atomically
--progressShow progress bar
--proxyURLRoute outbound HTTP through a proxy (http://burp:8080, socks5://127.0.0.1:9050, etc.). This flag (or its TOML equivalent) is the ONLY way to set a proxy: no environment variable is consulted, and ambient HTTPS_PROXY / HTTP_PROXY / ALL_PROXY is ignored, so a stray env proxy can never silently reroute secret-bearing traffic. When unset, no proxy is used. Pass off to make that explicit for air-gapped scans
--quietSuppress the interactive stderr chrome (banner, live progress ticker, and the “Scan complete” summary). Coverage FAIL/WARN lines and fatal errors are still printed so a quiet scan can never read as clean when it was not. Findings still go to stdout / --output. Mutually exclusive with --progress
--reader-threadsNDedicated filesystem reader threads. Default is one direct reader
--regex-dfa-limitSIZEPer-regex lazy-DFA cache CEILING, e.g. “256KB” or “1MB” (default 1 MiB). Bounds the worst-case per-thread DFA cache for pathological/state-heavy patterns; typical detectors stay well under it, so lowering this does NOT meaningfully cut peak memory (it’s a safety ceiling, not a general memory lever). Lowering can force complex regexes to slower NFA simulation; raise it only for unusually large patterns. Config: regex_dfa_limit in .keyhog.toml; this flag overrides it
--require-gpuRequire a usable GPU stack before scanning and keep GPU execution as a hard contract; unavailable initialization or runtime dispatch exits 12
--s3-bucketBUCKETScan a public or path-style S3 bucket via ListObjectsV2
--s3-endpointURLOptional S3 endpoint for S3-compatible APIs
--s3-prefixPREFIXOptional S3 object prefix to limit the scan
--scan-commentsTreat credentials inside source-code comments (// … / # … / /* … */ / <!– … –>) as first-class findings instead of applying the default comment-context confidence penalty. By default keyhog downgrades the confidence of credentials it sees inside a comment because the most common case is an engineer pasting an EXAMPLE token into a doc comment. The drawback is that genuine secrets pasted into a TODO (“rotate this key, Bob”) or a debug-trace comment never surface. Pass --scan-comments for repos where comments are part of the threat surface: shared snippets directories, leak post-mortems, training corpora, and CTF-style audits.
-s, --severitySEVERITYMin severity to report: info, client-safe, low, medium, high, critical Possible values: info, client-safe, low, medium, high, critical.
--show-secretsShow full credentials (default: redacted)
--sourceNAME[:PARAMS]Construct a compiled-in source by canonical name
--stdinScan stdin
--streamEmit a redacted [stream] preview line on stderr for every REPORTED finding (SEVERITY SERVICE/DETECTOR PATH:LINE redacted), so a quick human- or CI-scrapeable summary lands on stderr while the full formatted report (text/json/sarif/jsonl) goes to stdout or --output. The preview stream is consistent with that report and the exit code: every streamed line corresponds to a finding that survived suppression, the confidence floor / --min-confidence, and baseline filtering, it never previews a match the report drops
--threadsNNumber of parallel scanning threads (default: number of CPU cores)
--timeoutTIMEOUTPer-request HTTP verification timeout in seconds (default: 5). This does not impose a deadline on scanning; use --per-chunk-timeout-ms for the scanner’s optional chunk deadline
--update-baselinePATHUpdate an existing baseline file with new findings
--urlURL...Scan JavaScript, source maps, or WASM binaries at URLs for secrets
--verifyVerify discovered credentials via API calls
--verify-batchConservative verify mode: serialises live verifications per service (max-concurrent-per-service = 1) on top of the --verify-rate cap. Use for repos with lots of legitimate findings (test fixtures, vendored examples) where bursting a provider’s auth endpoint would get the scan IP rate-limited or blocked. Implies --verify
--verify-concurrencyNMaximum in-flight verification requests per service (default: 5)
--verify-oobEnable out-of-band callback verification via an embedded interactsh client. For webhook- and callback-shaped credentials, OOB verification proves the credential is exfil-capable: we mint a per-finding subdomain on the configured collector, embed it in the verification probe, and confirm the service actually called back. Off by default. See docs/src/reference/oob-verification.md for the threat model and self-hosting guidance
--verify-rateRPS5.0Steady-state cap for verification calls per service, in requests-per-second. Default 5.0. Drop this to be polite to upstream APIs when scanning a tree with hundreds of legitimate findings (test fixtures, examples); every finding produces a live verify call and most public APIs throttle aggressively. The limiter applies even with --verify-batch (which adds per-service serialisation on top)
--window-overlapSIZEStreaming window overlap size in bytes (default: 128KB)

Hyperscan database cache location is explicit scan configuration: use keyhog scan --cache-dir <DIR> or .keyhog.toml [system].cache_dir. Autoroute calibration evidence is also explicit scan configuration: use keyhog scan --autoroute-cache <PATH|off> or .keyhog.toml [system].autoroute_cache. GPU region-presence parity/debug recall-floor runs use .keyhog.toml [tuning].gpu_recall_floor = true. Authenticated GPU routes score eligible candidates through the separate quantized VYRE program; CPU-owned rows and the shared policy tail remain on the CPU.

Custom S3 and GCS endpoints never receive ambient cloud credentials unless the operator explicitly passes --allow-s3-credential-forward or --allow-gcs-token-forward. Private cloud endpoints additionally require --allow-private-cloud-endpoint (or [http].allow_private_endpoint = true).

keyhog config --effective [SCAN FLAGS]

Prints the resolved scan and report policy and exits without scanning. This is the operator-visible way to prove what KeyHog would run after compiled defaults, .keyhog.toml, and CLI overrides are merged. The output includes report format, severity floor, dedup scope, secret visibility, client-safe/test-fixture policy, and lockdown alongside backend, detector, scanner, source-limit, verification, and cache settings. It also prints validate_decode, the scanner’s decoded- payload safety policy, so the operator can see the exact recursive-decoding contract covered by the autoroute identity.

config --effective accepts the same config-affecting flags as scan, including --config, --fast, --deep, --precision, source limits, detector paths, confidence floors, and the positional path shorthand.

keyhog config --effective
keyhog config --effective --config .keyhog.toml --precision .
keyhog config --effective --limit-stdin-bytes 32MB --no-ml
ArgumentValueDefaultDescription
<PATH>PATH...Path(s) to scan. Pass several to scan multiple roots in one run (keyhog scan a/ b/ c/); nested or duplicate roots fold into their covering parent. Positional shorthand for --path (single root only)
--access-targetsReport the resource each credential opens (its “door”). A finding says where a credential is. It does not say which database, bucket, tenant, or account that credential reaches, which is the first thing a responder needs in order to rank it. The address almost always sits next to the credential (in the same connection string, the same .env, the same variable block) and no detector can see it: a companion regex is bounded to a few lines and is written to capture the other half of the CREDENTIAL, not the resource. This pass runs after the scan, over the findings the report is about to publish, and attaches typed targets: account, tenant, endpoint, database, resource. Which providers are understood is Tier-B data (crates/core/data/access-targets.toml), not a hardcoded list. Redaction-safe by construction. Connection-string rules skip userinfo with a non-capturing group, any candidate whose digest matches a credential in the same report is dropped, and evidence carries only the rule id, line, column, span length, and line distance. No document text is ever emitted. Bounded: file context is indexed at most once per file, over at most 1 MiB of it, under a 256 MiB whole-pass ceiling. Findings the pass could not inspect (git history, container layers, stdin, unreadable paths) are reported as coverage gaps, so an empty target list never reads as “this credential opens nothing”. Purely additive: findings are never added, dropped, reordered, or edited. --format json-envelope gains an access_targets object; every other format is untouched. Default off, so a report produced without this flag is byte-identical.
--action-receipt (hidden)PATHWrite an internal composite-Action receipt bound to the completed report
--allow-gcs-token-forwardForward the ambient GCS bearer token to a custom GCS endpoint you trust. Off by default; googleapis.com endpoints do not need this. This flag is intentionally explicit because it can send a bearer token to a third-party host
--allow-private-cloud-endpointAllow web, hosted-git, and cloud sources to reach an endpoint whose host, literal or DNS-resolved, is private, loopback, link-local, or cloud-metadata. OFF by default: the shared SSRF screen refuses every such endpoint. Enable ONLY for a trusted private-network deployment, such as an on-premises web application or self-hosted object store. This flag (or its [http].allow_private_endpoint TOML equivalent) is the ONLY way to relax the screen. No environment variable can silently turn KeyHog into an SSRF proxy for internal services
--allow-s3-credential-forwardForward ambient AWS credentials to a custom S3 endpoint you trust. Off by default; AWS-owned endpoints do not need this. This flag is intentionally explicit because it can send AWS identity material to a third-party host
--allow-script-verifyPermit detector script: verification for trusted detector corpora. Off by default because scripts execute verifier-supplied code with credential-adjacent context. Prints an explicit warning when active
--autoroute-cachePATH|offOverride the persistent autoroute calibration cache file. Use an absolute path, or off to disable persistence. Config: [system].autoroute_cache in .keyhog.toml; this flag overrides it.
--autoroute-calibrateRun this scan as an explicit autoroute calibration probe: benchmark parity-checked backend candidates and persist the fastest-correct decision for each workload bucket. Normal scans never benchmark on cache miss; they use persisted evidence or fail closed without scanning. An explicit --backend is diagnostic only
--autoroute-gpuAllow autoroute calibration to include GPU candidates for eligible workload buckets. Normal scans still use persisted calibration only
--azure-container-urlURLScan an Azure Blob Storage container URL. Include a SAS query string for private containers
--azure-prefixPREFIXOptional Azure Blob prefix to limit the scan
--backendBACKENDSelect persisted autoroute or explicitly force one diagnostic backend. Accepted values are listed below Possible values: auto, gpu-cuda, gpu-cuda-region-presence, gpu-metal, gpu-metal-region-presence, gpu-wgpu, gpu-wgpu-region-presence, simd, simd-regex, cpu, cpu-fallback.
--baselinePATHSuppress findings that match an existing baseline file
--batch-pipelineForce the coalesced batch scan pipeline instead of the fused filesystem pipeline. This is an explicit calibration/diagnostic control, not an ambient environment switch. Config: [system].batch_pipeline; this flag overrides it
--benchmarkRun the built-in backend benchmark corpus and exit. This measures backend throughput over KeyHog’s own corpus; it never scans an operator-supplied target and never writes a report. Passing a scan target (PATH, --path, --stdin) or a report destination (--output) alongside it used to exit 0 having silently ignored both, so an operator could read “benchmark winner: …” as a completed scan of their tree. Those combinations now fail closed with the conflict named.
--binaryScan binary files for hardcoded strings
--bitbucket-endpointBITBUCKET_ENDPOINThttps://api.bitbucket.org/2.0Bitbucket Cloud API endpoint root
--bitbucket-tokenAPP_PASSWORDBitbucket app password for –bitbucket-workspace. Prefer KEYHOG_BITBUCKET_TOKEN so the token is not exposed in the process list
--bitbucket-usernameUSERNAMEBitbucket username for –bitbucket-workspace. May be supplied through KEYHOG_BITBUCKET_USERNAME
--bitbucket-workspaceWORKSPACEScan all repositories in a Bitbucket Cloud workspace
--cache-dirDIROverride the Hyperscan compiled-database cache directory. This is explicit CLI/TOML configuration, not an environment variable: pass an absolute path under your home directory or the per-user keyhog temp cache root. Config: [system].cache_dir in .keyhog.toml; this flag overrides it.
--calibration-cachePATHExplicit per-detector Bayesian calibration cache for confidence scoring. Normal scans are hermetic and ignore any default keyhog calibrate cache unless this flag or [system].calibration_cache supplies a path. The file must already exist and parse cleanly; damaged or missing explicit caches fail before scanning so score changes are reproducible.
--configPATHLoad configuration from a specific file path
--correlateReport cross-file credential correlations alongside the findings. Joins one credential value seen at several file paths, across the detector boundary that per-detector dedup never crosses, and provider credentials whose halves are separate detectors split across files of one directory (an AWS access key in main.tf, its secret in .env). Which providers have halves is Tier-B data, not a hardcoded list, and an ambiguous directory reports nothing rather than a guess. Additive only: --format json-envelope gains a correlations array and --format text a summary block. Findings and every other format are unchanged, so a default scan is byte-identical without this flag.
--create-baselinePATHCreate a new baseline file from current findings and exit
--daemon[auto|on|mass|off]Daemon routing: auto (default, use a live daemon for eligible warm requests), on (require the warm stdin/single-file route), mass (stream bounded directory, Git, archive, binary, remote, or cloud source batches to a daemon started with daemon start --mass), or off (force in-process). Bare --daemon means on. Startup and request latency depend on the corpus, backend, cache state, host, and input. See keyhog daemon start --help. Socket: the daemon route connects to the shared default resolution ($XDG_RUNTIME_DIR, then the OS cache directory, then the OS temporary directory) unless --daemon-socket <path> points it at a daemon bound elsewhere (daemon start --socket <path>). Unix only: Windows rejects explicit auto and on; explicit off is accepted as a portable declaration of in-process execution. Optional value. Possible values: auto, on, mass, off.
--daemon-socketPATHConnect the daemon route to a daemon bound on a non-default socket. By default scan --daemon uses $XDG_RUNTIME_DIR/keyhog.sock, then the OS user-cache directory, then the OS temporary directory. Pass the same path a daemon was started on (keyhog daemon start --socket <path>) to reach a fixed-location daemon (e.g. a shared/system or systemd-managed instance). Combining it with --daemon=off is rejected as contradictory.
--decode-depthDEPTHMaximum depth for recursive decoding (1-10, default: 10)
--decode-size-limitSIZEMaximum prepared chunk size admitted to decode-through (default: 512KB)
--dedupDEDUPcredentialDeduplication scope for findings Possible values: credential, file, none.
--deepDeep recovery mode: scans entropy candidates in source files, removes comment confidence penalties, keeps heuristic evidence alongside ML for entropy candidates, sets decode depth 10, and admits one 1 MiB chunk into decode-through. Compatible explicit knobs override this BASE
-d, --detectorsDETECTORSdetectorsDetector TOML directory
--detectors-modeMODEHow an explicitly selected custom corpus participates in the embedded corpus. Omitted preserves the established replace behavior Possible values: replace, overlay.
--developer-compile-embedded-detectors (hidden)Developer-only escape hatch: allow in-process compilation of embedded or custom detectors
--docker-imageIMAGEScan a Docker image by unpacking docker image save
--dogfoodEmit a structured --dogfood JSON trace to stderr after the scan: every credential that was matched but suppressed, with the reason, both example/test/placeholder markers (kind: example_suppressed) AND shape/heuristic gates such as UUID-v4, bare-hex digest, base64 blob, dashed serial, or repetitive run (kind: shape_suppressed, reason names the gate), plus bounded static-recovery expressions rejected as malformed (kind: static_recovery_rejected). Detail events are bounded; exact aggregate rejection counts and detail_events_dropped remain visible after the bound is reached. Credentials are redacted (prefix and suffix shown, middle elided), and recovery rejections contain no source bytes. Useful when keyhog reports zero findings and you want to know whether a match was made and silenced, recovery rejected an expression, or the candidate never reached the engine
--effective (required)Print the resolved scan configuration and exit without scanning. Accepts the same config-affecting flags as keyhog scan, so operators can prove the compiled defaults, TOML config, and CLI overrides that would reach the scanner for the same scan invocation.
--entropy-bpe-max-bytes-per-tokenRATIOBPE “rare-not-random” suppression bound in bytes-per-token (default: 2.2). A surviving entropy/generic candidate whose cl100k_base bytes-per-token is above this is treated as word-like (dotted API paths, prose) and dropped. Lower = more aggressive suppression (higher precision, lower recall); a large value effectively disables the gate
--entropy-source-filesEnable entropy scanning in source code files
--entropy-thresholdBITSEntropy threshold in bits per byte (default: 4.5)
--evidence-policyPOLICYFinding evidence tiers that produce a non-zero CI exit. default blocks likely and confirmed; paranoid also blocks review. Findings remain visible under either policy Possible values: default, paranoid.
--exclude-pathsPATH...Explicit paths or glob patterns to exclude from scanning
--fastFast mode: pattern matching only. No decode, no entropy, no ML scoring. Maximum speed. A preset is a BASE: it seeds defaults, then compatible explicit knobs override it (e.g. --fast --decode-depth 2 re-enables shallow decode on top of the fast base). Entropy-only knobs conflict because fast mode disables entropy, so accepting them would create a no-op flag
--formatFORMATtextOutput format. json is a bare findings array for pipelines; prefer json-envelope for scan status, coverage gaps, and backend recoveries in one document (KH-1435 / KH-1474) Possible values: text, json, json-envelope, jsonl, jsonl-envelope, sarif, csv, github-annotations, gitlab-sast, html, junit.
--fused-batchNFused filesystem pipeline chunk batch size
--fused-depthNFused filesystem pipeline channel depth
--gcs-bucketBUCKETScan a Google Cloud Storage bucket via the JSON API
--gcs-endpointURLOptional GCS endpoint override for compatible APIs or tests
--gcs-prefixPREFIXOptional GCS object prefix to limit the scan
--git-blobsGIT_BLOBSScan repository blobs from refs, reflogs, stashes, and unreachable objects. Commit blobs are collected by parent-tree diff (added, changed, and deleted sides); every ref tip under refs/ plus HEAD, root commits, and unreadable parents fall back to a full tree walk
--git-diffBASE_REFScan only changed lines between two git refs (e.g., –git-diff main)
--git-diff-pathGIT_DIFF_PATHPath to git repository for –git-diff (defaults to current directory)
--git-historyPATHScan reachable commits using added lines from each commit patch
--git-stagedScan exact staged index blobs, never substituted working-tree bytes
--github-allInclude every supported collaboration surface for –github-collaboration. This is the concise equivalent of passing all six –github-* surface flags
--github-api-endpointURLGitHub-compatible API endpoint for –github-collaboration
--github-collaborationOWNER/REPOGitHub repository whose explicitly selected collaboration surfaces are scanned
--github-discussionsInclude discussion text and comments from –github-collaboration
--github-gistsInclude public gist revisions and comments for the repository owner
--github-issuesInclude issue text and comments from –github-collaboration
--github-orgORGScan all repositories in a GitHub organization
--github-pull-requestsInclude pull request text, issue comments, and review comments
--github-releasesInclude release notes, including drafts and prereleases, plus every release asset name and label, from –github-collaboration
--github-tokenPATGitHub personal access token for –github-org or –github-collaboration. Prefer KEYHOG_GITHUB_TOKEN so the token is not exposed in the process list
--github-wikiInclude every readable wiki revision from –github-collaboration
--github-wiki-urlURLExplicit clone URL for the wiki selected by –github-wiki
--gitlab-endpointGITLAB_ENDPOINThttps://gitlab.comGitLab API endpoint root, for example https://gitlab.example.com
--gitlab-groupGROUPScan all projects in a GitLab group, including subgroups
--gitlab-tokenPATGitLab personal access token for –gitlab-group. Prefer KEYHOG_GITLAB_TOKEN so the token is not exposed in the process list
--gpu-batch-input-limitSIZEGPU batch-input buffer byte budget, e.g. “256MB” or “1GB”. Overrides the VRAM-adaptive default (128 MiB–1 GiB by detected VRAM); the value is clamped into that range. Larger buffers scan more bytes per GPU dispatch on big inputs at higher VRAM cost. Config: gpu_batch_input_limit in .keyhog.toml; this flag overrides it
--hide-client-safeDrop every client-safe finding before reporting. Use this for bug-bounty / exfiltration-impact workflows where keys that are public by design (Sentry DSN, Stripe pk_*, Firebase web, Mapbox pk., PostHog project, Google Maps browser, Mixpanel project, Algolia search, Datadog browser RUM) are noise: the vendor expects them to ship in client bundles and no attacker gains server-side access from finding one. Default off: client-safe findings still appear in scan output at the CLIENT-SAFE tier (below LOW) so a misconfigured “publishable” key wired into a server-only detector still surfaces. --hide-client-safe is the explicit opt-in to silence them.
--incrementalIncremental scan: skip files whose metadata and content match the spec-bound Merkle index. The index is updated after successful scanning. This works in process and with --daemon=mass for daemon-local filesystem roots. If acquisition yields only unchanged files, backend routing and scanner dispatch do not start. Pass --incremental-cache <path> to override the default location
--incremental-cachePATHOverride the merkle-index cache file location
--insecureSkip TLS certificate verification for every outbound HTTP request. Needed when scanning through Burp / mitmproxy / corporate-MITM CAs that present self-signed certificates. Off by default. This flag (or its TOML equivalent) is the ONLY way to disable verification: no environment variable can turn it off, so an ambient toggle can’t silently expose secrets to a MITM
--limit-azure-blob-bytesSIZEMaximum bytes downloaded for one Azure blob
--limit-binary-decompiled-bytesSIZEMaximum Ghidra decompiled-output bytes accepted for parsing
--limit-binary-read-bytesSIZEMaximum bytes read for binary strings extraction
--limit-cloud-max-objectsNMaximum objects listed from one S3/GCS/Azure container before truncating
--limit-docker-image-config-bytesSIZEMaximum bytes accepted for Docker/OCI image config and manifest JSON
--limit-docker-tar-entry-bytesSIZEMaximum bytes allowed for one Docker tar entry
--limit-docker-tar-total-bytesSIZEMaximum cumulative bytes unpacked for one Docker/OCI image, summed across the image tar and every layer tar
--limit-gcs-object-bytesSIZEMaximum bytes downloaded for one GCS object
--limit-git-blob-bytesSIZEMaximum bytes read from one git blob
--limit-git-chunksNMaximum chunk count emitted by a git blob-history scan
--limit-git-line-bytesSIZEMaximum bytes buffered for one line of git stdout
--limit-git-total-bytesSIZEMaximum aggregate bytes emitted by a git blob-history scan
--limit-hosted-git-pagesNMaximum hosted-git listing pages or GitHub collaboration API requests
--limit-s3-object-bytesSIZEMaximum bytes downloaded for one S3 object
--limit-stdin-bytesSIZEMaximum bytes accepted from –stdin before failing closed
--limit-web-response-bytesSIZEMaximum HTTP response bytes scanned by –url
--lockdownLockdown mode: maximum security at the cost of throughput. Enables every protection in keyhog_core::apply_protections(true) (mlock, refuse-on-coredump-leak, refuse-on-disk-cache), forces HTTPS-only verifier, refuses to write any cache to disk, and hard-aborts if any protection fails to take. Use this when keyhog is running inside EnvSeal or otherwise in a security-critical embedding
--matcher-cacheDIR|offOverride the MatcherArtifact cache directory. Persists the eager compiled matcher graph across process invocations. This is distinct from --cache-dir, which only stores Hyperscan .db shards. Use an absolute directory, or off to disable. Config: [system].matcher_cache in .keyhog.toml; this flag overrides it.
--max-commitsMAX_COMMITSMax git commits to traverse
--max-file-sizeSIZEMaximum file size to scan. Files larger than this are listed in the end-of-scan “files skipped: exceeded –max-file-size” summary. Default is 100 MiB, the FilesystemSource ceiling. Files above the 1 MiB window size are read in overlapping ~1 MiB windows (so memory stays bounded regardless of file size), up to this cap
--min-confidenceFLOATMinimum confidence score (0.0 - 1.0) to report findings (default: 0.40)
--min-secret-lenNMinimum credential length for entropy-discovery candidates (default: 16). Named detectors keep their own shape-specific length gates
--ml-thresholdTHRESHOLDRaise the global confidence floor (0.0 to 1.0). Takes effect as max(min_confidence, ml_threshold), so it tightens but never loosens the floor set by --min-confidence. Despite the name, this raises the floor for ALL findings, not only ML-scored ones, and still applies when --no-ml disables ML scoring. A detector’s explicit min_confidence in its TOML remains that detector’s effective floor. Absence leaves the canonical floor untouched
--ml-weightWEIGHTOverride every detector’s ML scoring weight for diagnostics/benchmarks
--no-autoroute-gpuKeep GPU candidates out of autoroute calibration even when TOML enables them
--no-batch-pipelineKeep the fused filesystem pipeline even when [system].batch_pipeline is true
--no-colorDisable ANSI color in the report and the stderr summary, regardless of whether the output is a TTY (the NO_COLOR convention is also honored)
--no-configIgnore any ambient .keyhog.toml: skip the walk-up discovery from the scan root and reject an explicit --config. The scan then runs on the compiled-in shipped defaults (the Tier-A SHIPPED_* floors/disables) and nothing else. This is the hermetic, reproducible config used by CI gates and the benchmark harness, so the measured behavior is the shipped default BY DESIGN and cannot silently drift when a stray .keyhog.toml appears on an ancestor path; the hermetic-config tests pin that contract
--no-decodeSkip decoding base64/hex encoded content
--no-default-excludesDisable every default exclusion for this scan. Two separate defaults are turned off. The walker stops skipping lock files, minified and bundled assets, build outputs, and vendored trees, so their bytes are read. The scanner also stops dropping findings whose path is a minified or vendored bundle (.min.js, .bundle.js, .min.css, node_modules/, site-packages/, wp-includes/, and similar), so a credential a build pipeline inlined into app.min.js is reported instead of silently discarded. Expect more noise: random byte sequences in third-party bundles do collide with credential shapes. Without this flag, findings dropped by the second rule are counted and reported as a coverage gap, so you can see how many there were before deciding to rerun.
--no-entropyDisable entropy-based detection
--no-entropy-ml-scoringScore entropy-discovery candidates with the bare entropy heuristic instead of routing them through the MoE (the model is authoritative by default). The default ML path is a recall-safe precision win on the detector-owned model mode; this opt-out selects bare entropy-only scoring. It does not change detector policy and has no effect when --no-entropy or --no-ml is set
--no-gpuDisable GPU probing and GPU backend acquisition for this scan
--no-keyword-low-entropyDisable the lower-floor generic-keyword-secret bridge for anchored values (PASSWORD=, *_PASS=, secret:, api_key= …). Anchored candidates must then satisfy the stricter generic-secret policy. No effect unless the generic keyword bridge would otherwise fire
--no-mlDisable ML-based confidence scoring
--no-suppress-test-fixturesOpt out of the bundled test-fixture suppression list. By default keyhog suppresses well-known public demo credentials (Stripe’s docs example sk_live_4eC39..., GitHub’s docs example ghp_aBcD..., the keyhog test fixtures, etc.) so the report stays focused on real leaks rather than tutorial copies. Pass this flag when you intentionally want those surfaced. Useful for differential benchmarking against gitleaks / trufflehog (which do NOT suppress these), or for auditing the suppression list itself
--no-unicode-normDisable Unicode normalization (not recommended)
--no-verifyDisable credential verification, overriding verify = true in .keyhog.toml
--oob-serverHOSToast.funInteractsh server for OOB verification. Defaults to projectdiscovery’s public collector at oast.fun. Use a self-hosted server for sensitive scans; the collector sees correlation IDs and the IPs of services that call back, never the credential itself. Only meaningful with --verify-oob; clap rejects the flag without it instead of silently ignoring it (the prior behavior gave false confidence that an override had been applied)
--oob-timeoutSECS30Per-finding OOB wait timeout in seconds. Detector specs may set their own timeout_secs; this value is the global default. The upper bound is max(this value, 120s), so a detector can always wait at least 120s for a delayed webhook even when this default is lower. Lower = faster scans, higher = catches services with delayed webhooks (e.g., queued mail delivery). Requires --verify-oob
-o, --outputOUTPUTWrite findings to file
-p, --pathPATHScan a directory or file
--per-chunk-timeout-msMSHard deadline per chunk scan in milliseconds. Default unset = no operator deadline; decode still has its internal bomb guard
--perf-traceRaise --profile to its diagnostic level: add higher-overhead per-pattern, per-decoder, and backend timing traces on stderr
--precisionHigh-precision mode for mass scanning: minimise false positives at the cost of some recall. Disables entropy discovery and the relaxed keyword bridge, retains ML scoring for remaining candidates, raises the minimum confidence floor to 0.85, and uses decode depth 1. Explicit confidence flags may tighten but cannot lower that floor. Entropy-only knobs conflict because precision mode disables entropy
--profileEmit low-overhead stage, resource, build, policy, source, and measured workload identity evidence to stderr at scan end
--profile-outPATHWrite the complete causal scan profile as JSON to PATH at scan end. Implies --profile; the artifact is written atomically
--progressShow progress bar
--proxyURLRoute outbound HTTP through a proxy (http://burp:8080, socks5://127.0.0.1:9050, etc.). This flag (or its TOML equivalent) is the ONLY way to set a proxy: no environment variable is consulted, and ambient HTTPS_PROXY / HTTP_PROXY / ALL_PROXY is ignored, so a stray env proxy can never silently reroute secret-bearing traffic. When unset, no proxy is used. Pass off to make that explicit for air-gapped scans
--quietSuppress the interactive stderr chrome (banner, live progress ticker, and the “Scan complete” summary). Coverage FAIL/WARN lines and fatal errors are still printed so a quiet scan can never read as clean when it was not. Findings still go to stdout / --output. Mutually exclusive with --progress
--reader-threadsNDedicated filesystem reader threads. Default is one direct reader
--regex-dfa-limitSIZEPer-regex lazy-DFA cache CEILING, e.g. “256KB” or “1MB” (default 1 MiB). Bounds the worst-case per-thread DFA cache for pathological/state-heavy patterns; typical detectors stay well under it, so lowering this does NOT meaningfully cut peak memory (it’s a safety ceiling, not a general memory lever). Lowering can force complex regexes to slower NFA simulation; raise it only for unusually large patterns. Config: regex_dfa_limit in .keyhog.toml; this flag overrides it
--require-gpuRequire a usable GPU stack before scanning and keep GPU execution as a hard contract; unavailable initialization or runtime dispatch exits 12
--s3-bucketBUCKETScan a public or path-style S3 bucket via ListObjectsV2
--s3-endpointURLOptional S3 endpoint for S3-compatible APIs
--s3-prefixPREFIXOptional S3 object prefix to limit the scan
--scan-commentsTreat credentials inside source-code comments (// … / # … / /* … */ / <!– … –>) as first-class findings instead of applying the default comment-context confidence penalty. By default keyhog downgrades the confidence of credentials it sees inside a comment because the most common case is an engineer pasting an EXAMPLE token into a doc comment. The drawback is that genuine secrets pasted into a TODO (“rotate this key, Bob”) or a debug-trace comment never surface. Pass --scan-comments for repos where comments are part of the threat surface: shared snippets directories, leak post-mortems, training corpora, and CTF-style audits.
-s, --severitySEVERITYMin severity to report: info, client-safe, low, medium, high, critical Possible values: info, client-safe, low, medium, high, critical.
--show-secretsShow full credentials (default: redacted)
--sourceNAME[:PARAMS]Construct a compiled-in source by canonical name
--stdinScan stdin
--streamEmit a redacted [stream] preview line on stderr for every REPORTED finding (SEVERITY SERVICE/DETECTOR PATH:LINE redacted), so a quick human- or CI-scrapeable summary lands on stderr while the full formatted report (text/json/sarif/jsonl) goes to stdout or --output. The preview stream is consistent with that report and the exit code: every streamed line corresponds to a finding that survived suppression, the confidence floor / --min-confidence, and baseline filtering, it never previews a match the report drops
--threadsNNumber of parallel scanning threads (default: number of CPU cores)
--timeoutTIMEOUTPer-request HTTP verification timeout in seconds (default: 5). This does not impose a deadline on scanning; use --per-chunk-timeout-ms for the scanner’s optional chunk deadline
--update-baselinePATHUpdate an existing baseline file with new findings
--urlURL...Scan JavaScript, source maps, or WASM binaries at URLs for secrets
--verifyVerify discovered credentials via API calls
--verify-batchConservative verify mode: serialises live verifications per service (max-concurrent-per-service = 1) on top of the --verify-rate cap. Use for repos with lots of legitimate findings (test fixtures, vendored examples) where bursting a provider’s auth endpoint would get the scan IP rate-limited or blocked. Implies --verify
--verify-concurrencyNMaximum in-flight verification requests per service (default: 5)
--verify-oobEnable out-of-band callback verification via an embedded interactsh client. For webhook- and callback-shaped credentials, OOB verification proves the credential is exfil-capable: we mint a per-finding subdomain on the configured collector, embed it in the verification probe, and confirm the service actually called back. Off by default. See docs/src/reference/oob-verification.md for the threat model and self-hosting guidance
--verify-rateRPS5.0Steady-state cap for verification calls per service, in requests-per-second. Default 5.0. Drop this to be polite to upstream APIs when scanning a tree with hundreds of legitimate findings (test fixtures, examples); every finding produces a live verify call and most public APIs throttle aggressively. The limiter applies even with --verify-batch (which adds per-service serialisation on top)
--window-overlapSIZEStreaming window overlap size in bytes (default: 128KB)

keyhog detectors

Lists every detector in the effective corpus. With no --detectors flag, KeyHog uses the first installed corpus found in the user data directories, system data directories, or beside the executable. If none exists, it uses the embedded corpus. An explicit path always replaces that search and fails closed when missing or invalid.

keyhog detectors                  # human-readable, grouped by service
keyhog detectors --format json    # one JSON array of detector objects
keyhog detectors --format json | jq length
keyhog detectors --search aws     # id/name/service/keyword substring filter
keyhog detectors --search aws --verbose  # full matching specs
keyhog detectors --audit          # validate the loaded corpus; errors exit 3
keyhog detectors --fix --dry-run  # preview safe verifier-template rewrites

--fix only performs the mechanically safe single-brace to double-brace verification-template rewrite; other audit findings require an explicit edit. --format is mutually exclusive with --audit and --fix.

ArgumentValueDefaultDescription
--auditAudit 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
-d, --detectorsDETECTORSdetectorsDetector TOML directory
--dry-runShow the rewrites --fix would make without writing them. No-op unless --fix is also set
--fixApply 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
--formatFORMATOutput format for the detector listing. text (default) is the grouped, human-readable summary; json emits the structured detector array. This is the canonical flag, it matches scan --format so the two surfaces share one convention (CLI-01). Only text/json apply to a detector listing, so the format set is intentionally narrower than scan’s. Mutually exclusive with --audit / --fix (they emit their own structured formats) Possible values: text, json.
--mechanismsPrint the generated mechanism manifest: which recovery mechanisms each detector actually declares. KeyHog advertises regex matching, structural validation, entropy scoring, BPE token efficiency, decode recovery, companion confirmation, live verification, and detector-owned suppression, but nothing in the product will tell you which of those a given detector uses. This does, and it derives every answer from the loaded corpus: each mechanism is a predicate over detector TOML fields and the field that made it active is reported as its evidence, so there is no per-detector table in Rust to drift. A mechanism KeyHog cannot express yet is reported as unavailable with the reason rather than omitted, because a missing row cannot be told apart from “no detector uses this”. Pairs with --search to scope the manifest, and with --format json for the machine-readable document. Does not scan.
-s, --searchSEARCHFilter detectors by substring match (case-insensitive) against id, name, service, and keywords. Useful for finding detectors in the 934-strong corpus (e.g. keyhog detectors --search aws).
-v, --verbosePrint the matching-policy summary (regexes, keywords, companions, verification presence) instead of the grouped service summary. Pairs naturally with --search. Use --format json for the redaction-safe declared schema, including verification structure and test coverage

keyhog explain <DETECTOR_ID>

Explain the loaded detector. Includes keywords, patterns, companion rules, verification endpoint, and detector-owned entropy/BPE/length/suppression policy. Use --compiled-plan to print resolved companion and cross-detector evidence operations.

keyhog explain stripe-secret-key
ArgumentValueDefaultDescription
<DETECTOR_ID> (required)DETECTOR_IDDetector ID to explain (e.g. aws-access-key, github-pat-fine-grained). Use keyhog detectors to list available IDs
--bloom-evidencePATHRead a bloom-evidence-v1 receipt produced by keyhog bloom-diagnostic. The receipt must match the selected detector corpus and prove exact enabled-versus-bypassed finding parity
--compiled-planPrint the detector’s compiled evidence plan, including resolved capture groups, direction, structural scope, and admission semantics
-d, --detectorsDETECTORSdetectorsDetector TOML directory. When omitted, KeyHog discovers an installed corpus or uses the embedded corpus. An explicitly named missing path is an error

keyhog guard <add|remove|up|down|list|status|reconcile|rebuild|feed>

Manages perpetual repository and filesystem guard protection. Connects to the daemon and sends guard control frames. When no daemon is available, reports that clearly instead of silently doing nothing. The command requires the Unix daemon transport and exits unsupported on Windows.

SubcommandAliasesDescription
addRegister a repository or filesystem root for continuous guard protection. Waits for initial reconciliation to complete before returning. When guarding a Git repository in repo mode, also attempts to install the managed pre-commit hook (skipped if a foreign hook already exists, or if --no-hook is passed)
downStop the background guard daemon cleanly. Persisted root registrations and durable indexes remain on disk and resume on the next guard up
feedExpose continuous transition feed and event log with causes across guarded roots
helpPrint this message or the help of the given subcommand(s)
listList all registered guard roots and their current states
rebuildDelete and recreate the durable guard store for a root. Use after store corruption or when the persisted state is irrecoverably stale. The root is re-registered and a full reconciliation is triggered
reconcileForce a full reconciliation of a guarded root after an intentional policy or filesystem change
removeStop protecting a root and remove its persisted non-secret state. Also removes any KeyHog-owned Git pre-commit hook unless --keep-hook is passed
statusPrint the exact state and current policy identity of a guarded root. When no root is specified, summarizes all registered roots
upStart or ensure the background guard daemon is running and ready. When the daemon is already running, reports that it is active. Reconciles registered roots loaded from the durable store

keyhog guard add

ArgumentValueDefaultDescription
<ROOT> (required)ROOTRoot path to guard
--modeMODErepoGuard mode: repo uses Git object IDs for exact staged-content identity; filesystem uses content hashes without immutable Git OIDs
--no-hookDo not install or update the Git pre-commit hook during registration
--socketPATHOverride the socket path

keyhog guard down

ArgumentValueDefaultDescription
--socketPATHOverride the socket path

keyhog guard feed

ArgumentValueDefaultDescription
--formatFORMAThumanOutput format: human or json
--limitLIMIT50Maximum number of recent transitions to display (default 50)
--rootROOTFilter feed to a specific root path
--socketPATHOverride the socket path

keyhog guard help

No arguments.

keyhog guard list

ArgumentValueDefaultDescription
--socketPATHOverride the socket path

keyhog guard rebuild

ArgumentValueDefaultDescription
<ROOT> (required)ROOTRoot path to rebuild
--modeMODErepoGuard mode: repo or filesystem. Defaults to repo
--socketPATHOverride the socket path

keyhog guard reconcile

ArgumentValueDefaultDescription
<ROOT> (required)ROOTRoot path to reconcile
--socketPATHOverride the socket path

keyhog guard remove

ArgumentValueDefaultDescription
<ROOT> (required)ROOTRoot path to unguard
--keep-hookKeep the Git pre-commit hook in place when unregistering
--socketPATHOverride the socket path

keyhog guard status

ArgumentValueDefaultDescription
<ROOT>ROOTRoot path to inspect (summarizes all registered roots when omitted)
--formatFORMAThumanOutput format: human or json
--socketPATHOverride the socket path

keyhog guard up

ArgumentValueDefaultDescription
--backendBACKENDForce a specific scan backend (default auto uses autoroute)
--socketPATHOverride the socket path

keyhog watch [PATH]...

Foreground subcommand that watches one or more directories for file changes and re-scans each changed file. Useful for IDE-side feedback. It does not connect to or appear in keyhog daemon status; the independent keyhog daemon is a Unix-socket service used only by eligible keyhog scan --daemon requests. Pass several roots to monitor them with a single watcher; nested or duplicate roots fold into their covering parent, mirroring keyhog scan. Every root must be a directory. The scanner and selected backend stay warm: automatic routing consumes the persisted warm-runtime decision for each exact single-file workload, while --backend remains a diagnostic override.

keyhog watch src/                 # watch the source tree
keyhog watch src/ config/         # watch several roots in one process
keyhog watch                      # watch the current directory
ArgumentValueDefaultDescription
<PATH>PATH....Director(ies) to watch recursively. Pass several to monitor multiple roots in one foreground watcher (keyhog watch src/ config/); nested or duplicate roots fold into their covering parent, mirroring keyhog scan. Each root must be a directory. Defaults to the current directory
--backendBACKENDSelect persisted autoroute or explicitly force one diagnostic backend. Accepted values are listed below. Without valid installer calibration, change scans fail closed without scanning Possible values: auto, gpu-cuda, gpu-cuda-region-presence, gpu-metal, gpu-metal-region-presence, gpu-wgpu, gpu-wgpu-region-presence, simd, simd-regex, cpu, cpu-fallback.
--cache-dirDIROverride the Hyperscan compiled-database cache directory
-d, --detectorsDETECTORSdetectorsDetector TOML directory. When omitted, KeyHog discovers an installed corpus or uses the embedded corpus. An explicitly named missing path is an error
--max-consecutive-failuresN8Exit after this many consecutive per-file scan engine failures so a wedged scanner cannot silently drop secrets under editor saves (KH-1334 / KH-1462). Default 8
--max-file-sizeBYTESMaximum bytes per changed file (same default as keyhog scan, 100 MiB). Pass 0 to use the built-in default. Oversized editor saves are skipped with a loud error rather than OOM-ing the single-threaded watcher (KH-1461)
--quietQuiet mode: only print findings (suppress “watching X” status)

keyhog hook <install|uninstall|run>

Manages the git pre-commit hook. See Pre-commit hook for usage.

SubcommandAliasesDescription
helpPrint this message or the help of the given subcommand(s)
installInstall a git pre-commit hook in the current repository
runRun the git pre-commit hook scan
uninstallRemove the KeyHog pre-commit hook from the current repository

keyhog hook help

No arguments.

keyhog hook install

ArgumentValueDefaultDescription
--forceReplace an existing non-KeyHog pre-commit hook

keyhog hook run

ArgumentValueDefaultDescription
<PATH>PATH...Path(s) to scan. Pass several to scan multiple roots in one run (keyhog scan a/ b/ c/); nested or duplicate roots fold into their covering parent. Positional shorthand for --path (single root only)
--access-targetsReport the resource each credential opens (its “door”). A finding says where a credential is. It does not say which database, bucket, tenant, or account that credential reaches, which is the first thing a responder needs in order to rank it. The address almost always sits next to the credential (in the same connection string, the same .env, the same variable block) and no detector can see it: a companion regex is bounded to a few lines and is written to capture the other half of the CREDENTIAL, not the resource. This pass runs after the scan, over the findings the report is about to publish, and attaches typed targets: account, tenant, endpoint, database, resource. Which providers are understood is Tier-B data (crates/core/data/access-targets.toml), not a hardcoded list. Redaction-safe by construction. Connection-string rules skip userinfo with a non-capturing group, any candidate whose digest matches a credential in the same report is dropped, and evidence carries only the rule id, line, column, span length, and line distance. No document text is ever emitted. Bounded: file context is indexed at most once per file, over at most 1 MiB of it, under a 256 MiB whole-pass ceiling. Findings the pass could not inspect (git history, container layers, stdin, unreadable paths) are reported as coverage gaps, so an empty target list never reads as “this credential opens nothing”. Purely additive: findings are never added, dropped, reordered, or edited. --format json-envelope gains an access_targets object; every other format is untouched. Default off, so a report produced without this flag is byte-identical.
--action-receipt (hidden)PATHWrite an internal composite-Action receipt bound to the completed report
--allow-gcs-token-forwardForward the ambient GCS bearer token to a custom GCS endpoint you trust. Off by default; googleapis.com endpoints do not need this. This flag is intentionally explicit because it can send a bearer token to a third-party host
--allow-private-cloud-endpointAllow web, hosted-git, and cloud sources to reach an endpoint whose host, literal or DNS-resolved, is private, loopback, link-local, or cloud-metadata. OFF by default: the shared SSRF screen refuses every such endpoint. Enable ONLY for a trusted private-network deployment, such as an on-premises web application or self-hosted object store. This flag (or its [http].allow_private_endpoint TOML equivalent) is the ONLY way to relax the screen. No environment variable can silently turn KeyHog into an SSRF proxy for internal services
--allow-s3-credential-forwardForward ambient AWS credentials to a custom S3 endpoint you trust. Off by default; AWS-owned endpoints do not need this. This flag is intentionally explicit because it can send AWS identity material to a third-party host
--allow-script-verifyPermit detector script: verification for trusted detector corpora. Off by default because scripts execute verifier-supplied code with credential-adjacent context. Prints an explicit warning when active
--autoroute-cachePATH|offOverride the persistent autoroute calibration cache file. Use an absolute path, or off to disable persistence. Config: [system].autoroute_cache in .keyhog.toml; this flag overrides it.
--autoroute-calibrateRun this scan as an explicit autoroute calibration probe: benchmark parity-checked backend candidates and persist the fastest-correct decision for each workload bucket. Normal scans never benchmark on cache miss; they use persisted evidence or fail closed without scanning. An explicit --backend is diagnostic only
--autoroute-gpuAllow autoroute calibration to include GPU candidates for eligible workload buckets. Normal scans still use persisted calibration only
--azure-container-urlURLScan an Azure Blob Storage container URL. Include a SAS query string for private containers
--azure-prefixPREFIXOptional Azure Blob prefix to limit the scan
--backendBACKENDSelect persisted autoroute or explicitly force one diagnostic backend. Accepted values are listed below Possible values: auto, gpu-cuda, gpu-cuda-region-presence, gpu-metal, gpu-metal-region-presence, gpu-wgpu, gpu-wgpu-region-presence, simd, simd-regex, cpu, cpu-fallback.
--baselinePATHSuppress findings that match an existing baseline file
--batch-pipelineForce the coalesced batch scan pipeline instead of the fused filesystem pipeline. This is an explicit calibration/diagnostic control, not an ambient environment switch. Config: [system].batch_pipeline; this flag overrides it
--benchmarkRun the built-in backend benchmark corpus and exit. This measures backend throughput over KeyHog’s own corpus; it never scans an operator-supplied target and never writes a report. Passing a scan target (PATH, --path, --stdin) or a report destination (--output) alongside it used to exit 0 having silently ignored both, so an operator could read “benchmark winner: …” as a completed scan of their tree. Those combinations now fail closed with the conflict named.
--binaryScan binary files for hardcoded strings
--bitbucket-endpointBITBUCKET_ENDPOINThttps://api.bitbucket.org/2.0Bitbucket Cloud API endpoint root
--bitbucket-tokenAPP_PASSWORDBitbucket app password for –bitbucket-workspace. Prefer KEYHOG_BITBUCKET_TOKEN so the token is not exposed in the process list
--bitbucket-usernameUSERNAMEBitbucket username for –bitbucket-workspace. May be supplied through KEYHOG_BITBUCKET_USERNAME
--bitbucket-workspaceWORKSPACEScan all repositories in a Bitbucket Cloud workspace
--cache-dirDIROverride the Hyperscan compiled-database cache directory. This is explicit CLI/TOML configuration, not an environment variable: pass an absolute path under your home directory or the per-user keyhog temp cache root. Config: [system].cache_dir in .keyhog.toml; this flag overrides it.
--calibration-cachePATHExplicit per-detector Bayesian calibration cache for confidence scoring. Normal scans are hermetic and ignore any default keyhog calibrate cache unless this flag or [system].calibration_cache supplies a path. The file must already exist and parse cleanly; damaged or missing explicit caches fail before scanning so score changes are reproducible.
--configPATHLoad configuration from a specific file path
--correlateReport cross-file credential correlations alongside the findings. Joins one credential value seen at several file paths, across the detector boundary that per-detector dedup never crosses, and provider credentials whose halves are separate detectors split across files of one directory (an AWS access key in main.tf, its secret in .env). Which providers have halves is Tier-B data, not a hardcoded list, and an ambiguous directory reports nothing rather than a guess. Additive only: --format json-envelope gains a correlations array and --format text a summary block. Findings and every other format are unchanged, so a default scan is byte-identical without this flag.
--create-baselinePATHCreate a new baseline file from current findings and exit
--daemon[auto|on|mass|off]Daemon routing: auto (default, use a live daemon for eligible warm requests), on (require the warm stdin/single-file route), mass (stream bounded directory, Git, archive, binary, remote, or cloud source batches to a daemon started with daemon start --mass), or off (force in-process). Bare --daemon means on. Startup and request latency depend on the corpus, backend, cache state, host, and input. See keyhog daemon start --help. Socket: the daemon route connects to the shared default resolution ($XDG_RUNTIME_DIR, then the OS cache directory, then the OS temporary directory) unless --daemon-socket <path> points it at a daemon bound elsewhere (daemon start --socket <path>). Unix only: Windows rejects explicit auto and on; explicit off is accepted as a portable declaration of in-process execution. Optional value. Possible values: auto, on, mass, off.
--daemon-socketPATHConnect the daemon route to a daemon bound on a non-default socket. By default scan --daemon uses $XDG_RUNTIME_DIR/keyhog.sock, then the OS user-cache directory, then the OS temporary directory. Pass the same path a daemon was started on (keyhog daemon start --socket <path>) to reach a fixed-location daemon (e.g. a shared/system or systemd-managed instance). Combining it with --daemon=off is rejected as contradictory.
--decode-depthDEPTHMaximum depth for recursive decoding (1-10, default: 10)
--decode-size-limitSIZEMaximum prepared chunk size admitted to decode-through (default: 512KB)
--dedupDEDUPcredentialDeduplication scope for findings Possible values: credential, file, none.
--deepDeep recovery mode: scans entropy candidates in source files, removes comment confidence penalties, keeps heuristic evidence alongside ML for entropy candidates, sets decode depth 10, and admits one 1 MiB chunk into decode-through. Compatible explicit knobs override this BASE
-d, --detectorsDETECTORSdetectorsDetector TOML directory
--detectors-modeMODEHow an explicitly selected custom corpus participates in the embedded corpus. Omitted preserves the established replace behavior Possible values: replace, overlay.
--developer-compile-embedded-detectors (hidden)Developer-only escape hatch: allow in-process compilation of embedded or custom detectors
--docker-imageIMAGEScan a Docker image by unpacking docker image save
--dogfoodEmit a structured --dogfood JSON trace to stderr after the scan: every credential that was matched but suppressed, with the reason, both example/test/placeholder markers (kind: example_suppressed) AND shape/heuristic gates such as UUID-v4, bare-hex digest, base64 blob, dashed serial, or repetitive run (kind: shape_suppressed, reason names the gate), plus bounded static-recovery expressions rejected as malformed (kind: static_recovery_rejected). Detail events are bounded; exact aggregate rejection counts and detail_events_dropped remain visible after the bound is reached. Credentials are redacted (prefix and suffix shown, middle elided), and recovery rejections contain no source bytes. Useful when keyhog reports zero findings and you want to know whether a match was made and silenced, recovery rejected an expression, or the candidate never reached the engine
--entropy-bpe-max-bytes-per-tokenRATIOBPE “rare-not-random” suppression bound in bytes-per-token (default: 2.2). A surviving entropy/generic candidate whose cl100k_base bytes-per-token is above this is treated as word-like (dotted API paths, prose) and dropped. Lower = more aggressive suppression (higher precision, lower recall); a large value effectively disables the gate
--entropy-source-filesEnable entropy scanning in source code files
--entropy-thresholdBITSEntropy threshold in bits per byte (default: 4.5)
--evidence-policyPOLICYFinding evidence tiers that produce a non-zero CI exit. default blocks likely and confirmed; paranoid also blocks review. Findings remain visible under either policy Possible values: default, paranoid.
--exclude-pathsPATH...Explicit paths or glob patterns to exclude from scanning
--fastFast mode: pattern matching only. No decode, no entropy, no ML scoring. Maximum speed. A preset is a BASE: it seeds defaults, then compatible explicit knobs override it (e.g. --fast --decode-depth 2 re-enables shallow decode on top of the fast base). Entropy-only knobs conflict because fast mode disables entropy, so accepting them would create a no-op flag
--formatFORMATtextOutput format. json is a bare findings array for pipelines; prefer json-envelope for scan status, coverage gaps, and backend recoveries in one document (KH-1435 / KH-1474) Possible values: text, json, json-envelope, jsonl, jsonl-envelope, sarif, csv, github-annotations, gitlab-sast, html, junit.
--fused-batchNFused filesystem pipeline chunk batch size
--fused-depthNFused filesystem pipeline channel depth
--gcs-bucketBUCKETScan a Google Cloud Storage bucket via the JSON API
--gcs-endpointURLOptional GCS endpoint override for compatible APIs or tests
--gcs-prefixPREFIXOptional GCS object prefix to limit the scan
--git-blobsGIT_BLOBSScan repository blobs from refs, reflogs, stashes, and unreachable objects. Commit blobs are collected by parent-tree diff (added, changed, and deleted sides); every ref tip under refs/ plus HEAD, root commits, and unreadable parents fall back to a full tree walk
--git-diffBASE_REFScan only changed lines between two git refs (e.g., –git-diff main)
--git-diff-pathGIT_DIFF_PATHPath to git repository for –git-diff (defaults to current directory)
--git-historyPATHScan reachable commits using added lines from each commit patch
--git-stagedScan exact staged index blobs, never substituted working-tree bytes
--github-allInclude every supported collaboration surface for –github-collaboration. This is the concise equivalent of passing all six –github-* surface flags
--github-api-endpointURLGitHub-compatible API endpoint for –github-collaboration
--github-collaborationOWNER/REPOGitHub repository whose explicitly selected collaboration surfaces are scanned
--github-discussionsInclude discussion text and comments from –github-collaboration
--github-gistsInclude public gist revisions and comments for the repository owner
--github-issuesInclude issue text and comments from –github-collaboration
--github-orgORGScan all repositories in a GitHub organization
--github-pull-requestsInclude pull request text, issue comments, and review comments
--github-releasesInclude release notes, including drafts and prereleases, plus every release asset name and label, from –github-collaboration
--github-tokenPATGitHub personal access token for –github-org or –github-collaboration. Prefer KEYHOG_GITHUB_TOKEN so the token is not exposed in the process list
--github-wikiInclude every readable wiki revision from –github-collaboration
--github-wiki-urlURLExplicit clone URL for the wiki selected by –github-wiki
--gitlab-endpointGITLAB_ENDPOINThttps://gitlab.comGitLab API endpoint root, for example https://gitlab.example.com
--gitlab-groupGROUPScan all projects in a GitLab group, including subgroups
--gitlab-tokenPATGitLab personal access token for –gitlab-group. Prefer KEYHOG_GITLAB_TOKEN so the token is not exposed in the process list
--gpu-batch-input-limitSIZEGPU batch-input buffer byte budget, e.g. “256MB” or “1GB”. Overrides the VRAM-adaptive default (128 MiB–1 GiB by detected VRAM); the value is clamped into that range. Larger buffers scan more bytes per GPU dispatch on big inputs at higher VRAM cost. Config: gpu_batch_input_limit in .keyhog.toml; this flag overrides it
--hide-client-safeDrop every client-safe finding before reporting. Use this for bug-bounty / exfiltration-impact workflows where keys that are public by design (Sentry DSN, Stripe pk_*, Firebase web, Mapbox pk., PostHog project, Google Maps browser, Mixpanel project, Algolia search, Datadog browser RUM) are noise: the vendor expects them to ship in client bundles and no attacker gains server-side access from finding one. Default off: client-safe findings still appear in scan output at the CLIENT-SAFE tier (below LOW) so a misconfigured “publishable” key wired into a server-only detector still surfaces. --hide-client-safe is the explicit opt-in to silence them.
--incrementalIncremental scan: skip files whose metadata and content match the spec-bound Merkle index. The index is updated after successful scanning. This works in process and with --daemon=mass for daemon-local filesystem roots. If acquisition yields only unchanged files, backend routing and scanner dispatch do not start. Pass --incremental-cache <path> to override the default location
--incremental-cachePATHOverride the merkle-index cache file location
--insecureSkip TLS certificate verification for every outbound HTTP request. Needed when scanning through Burp / mitmproxy / corporate-MITM CAs that present self-signed certificates. Off by default. This flag (or its TOML equivalent) is the ONLY way to disable verification: no environment variable can turn it off, so an ambient toggle can’t silently expose secrets to a MITM
--limit-azure-blob-bytesSIZEMaximum bytes downloaded for one Azure blob
--limit-binary-decompiled-bytesSIZEMaximum Ghidra decompiled-output bytes accepted for parsing
--limit-binary-read-bytesSIZEMaximum bytes read for binary strings extraction
--limit-cloud-max-objectsNMaximum objects listed from one S3/GCS/Azure container before truncating
--limit-docker-image-config-bytesSIZEMaximum bytes accepted for Docker/OCI image config and manifest JSON
--limit-docker-tar-entry-bytesSIZEMaximum bytes allowed for one Docker tar entry
--limit-docker-tar-total-bytesSIZEMaximum cumulative bytes unpacked for one Docker/OCI image, summed across the image tar and every layer tar
--limit-gcs-object-bytesSIZEMaximum bytes downloaded for one GCS object
--limit-git-blob-bytesSIZEMaximum bytes read from one git blob
--limit-git-chunksNMaximum chunk count emitted by a git blob-history scan
--limit-git-line-bytesSIZEMaximum bytes buffered for one line of git stdout
--limit-git-total-bytesSIZEMaximum aggregate bytes emitted by a git blob-history scan
--limit-hosted-git-pagesNMaximum hosted-git listing pages or GitHub collaboration API requests
--limit-s3-object-bytesSIZEMaximum bytes downloaded for one S3 object
--limit-stdin-bytesSIZEMaximum bytes accepted from –stdin before failing closed
--limit-web-response-bytesSIZEMaximum HTTP response bytes scanned by –url
--lockdownLockdown mode: maximum security at the cost of throughput. Enables every protection in keyhog_core::apply_protections(true) (mlock, refuse-on-coredump-leak, refuse-on-disk-cache), forces HTTPS-only verifier, refuses to write any cache to disk, and hard-aborts if any protection fails to take. Use this when keyhog is running inside EnvSeal or otherwise in a security-critical embedding
--matcher-cacheDIR|offOverride the MatcherArtifact cache directory. Persists the eager compiled matcher graph across process invocations. This is distinct from --cache-dir, which only stores Hyperscan .db shards. Use an absolute directory, or off to disable. Config: [system].matcher_cache in .keyhog.toml; this flag overrides it.
--max-commitsMAX_COMMITSMax git commits to traverse
--max-file-sizeSIZEMaximum file size to scan. Files larger than this are listed in the end-of-scan “files skipped: exceeded –max-file-size” summary. Default is 100 MiB, the FilesystemSource ceiling. Files above the 1 MiB window size are read in overlapping ~1 MiB windows (so memory stays bounded regardless of file size), up to this cap
--min-confidenceFLOATMinimum confidence score (0.0 - 1.0) to report findings (default: 0.40)
--min-secret-lenNMinimum credential length for entropy-discovery candidates (default: 16). Named detectors keep their own shape-specific length gates
--ml-thresholdTHRESHOLDRaise the global confidence floor (0.0 to 1.0). Takes effect as max(min_confidence, ml_threshold), so it tightens but never loosens the floor set by --min-confidence. Despite the name, this raises the floor for ALL findings, not only ML-scored ones, and still applies when --no-ml disables ML scoring. A detector’s explicit min_confidence in its TOML remains that detector’s effective floor. Absence leaves the canonical floor untouched
--ml-weightWEIGHTOverride every detector’s ML scoring weight for diagnostics/benchmarks
--no-autoroute-gpuKeep GPU candidates out of autoroute calibration even when TOML enables them
--no-batch-pipelineKeep the fused filesystem pipeline even when [system].batch_pipeline is true
--no-colorDisable ANSI color in the report and the stderr summary, regardless of whether the output is a TTY (the NO_COLOR convention is also honored)
--no-configIgnore any ambient .keyhog.toml: skip the walk-up discovery from the scan root and reject an explicit --config. The scan then runs on the compiled-in shipped defaults (the Tier-A SHIPPED_* floors/disables) and nothing else. This is the hermetic, reproducible config used by CI gates and the benchmark harness, so the measured behavior is the shipped default BY DESIGN and cannot silently drift when a stray .keyhog.toml appears on an ancestor path; the hermetic-config tests pin that contract
--no-decodeSkip decoding base64/hex encoded content
--no-default-excludesDisable every default exclusion for this scan. Two separate defaults are turned off. The walker stops skipping lock files, minified and bundled assets, build outputs, and vendored trees, so their bytes are read. The scanner also stops dropping findings whose path is a minified or vendored bundle (.min.js, .bundle.js, .min.css, node_modules/, site-packages/, wp-includes/, and similar), so a credential a build pipeline inlined into app.min.js is reported instead of silently discarded. Expect more noise: random byte sequences in third-party bundles do collide with credential shapes. Without this flag, findings dropped by the second rule are counted and reported as a coverage gap, so you can see how many there were before deciding to rerun.
--no-entropyDisable entropy-based detection
--no-entropy-ml-scoringScore entropy-discovery candidates with the bare entropy heuristic instead of routing them through the MoE (the model is authoritative by default). The default ML path is a recall-safe precision win on the detector-owned model mode; this opt-out selects bare entropy-only scoring. It does not change detector policy and has no effect when --no-entropy or --no-ml is set
--no-gpuDisable GPU probing and GPU backend acquisition for this scan
--no-keyword-low-entropyDisable the lower-floor generic-keyword-secret bridge for anchored values (PASSWORD=, *_PASS=, secret:, api_key= …). Anchored candidates must then satisfy the stricter generic-secret policy. No effect unless the generic keyword bridge would otherwise fire
--no-mlDisable ML-based confidence scoring
--no-suppress-test-fixturesOpt out of the bundled test-fixture suppression list. By default keyhog suppresses well-known public demo credentials (Stripe’s docs example sk_live_4eC39..., GitHub’s docs example ghp_aBcD..., the keyhog test fixtures, etc.) so the report stays focused on real leaks rather than tutorial copies. Pass this flag when you intentionally want those surfaced. Useful for differential benchmarking against gitleaks / trufflehog (which do NOT suppress these), or for auditing the suppression list itself
--no-unicode-normDisable Unicode normalization (not recommended)
--no-verifyDisable credential verification, overriding verify = true in .keyhog.toml
--oob-serverHOSToast.funInteractsh server for OOB verification. Defaults to projectdiscovery’s public collector at oast.fun. Use a self-hosted server for sensitive scans; the collector sees correlation IDs and the IPs of services that call back, never the credential itself. Only meaningful with --verify-oob; clap rejects the flag without it instead of silently ignoring it (the prior behavior gave false confidence that an override had been applied)
--oob-timeoutSECS30Per-finding OOB wait timeout in seconds. Detector specs may set their own timeout_secs; this value is the global default. The upper bound is max(this value, 120s), so a detector can always wait at least 120s for a delayed webhook even when this default is lower. Lower = faster scans, higher = catches services with delayed webhooks (e.g., queued mail delivery). Requires --verify-oob
-o, --outputOUTPUTWrite findings to file
-p, --pathPATHScan a directory or file
--per-chunk-timeout-msMSHard deadline per chunk scan in milliseconds. Default unset = no operator deadline; decode still has its internal bomb guard
--perf-traceRaise --profile to its diagnostic level: add higher-overhead per-pattern, per-decoder, and backend timing traces on stderr
--precisionHigh-precision mode for mass scanning: minimise false positives at the cost of some recall. Disables entropy discovery and the relaxed keyword bridge, retains ML scoring for remaining candidates, raises the minimum confidence floor to 0.85, and uses decode depth 1. Explicit confidence flags may tighten but cannot lower that floor. Entropy-only knobs conflict because precision mode disables entropy
--profileEmit low-overhead stage, resource, build, policy, source, and measured workload identity evidence to stderr at scan end
--profile-outPATHWrite the complete causal scan profile as JSON to PATH at scan end. Implies --profile; the artifact is written atomically
--progressShow progress bar
--proxyURLRoute outbound HTTP through a proxy (http://burp:8080, socks5://127.0.0.1:9050, etc.). This flag (or its TOML equivalent) is the ONLY way to set a proxy: no environment variable is consulted, and ambient HTTPS_PROXY / HTTP_PROXY / ALL_PROXY is ignored, so a stray env proxy can never silently reroute secret-bearing traffic. When unset, no proxy is used. Pass off to make that explicit for air-gapped scans
--quietSuppress the interactive stderr chrome (banner, live progress ticker, and the “Scan complete” summary). Coverage FAIL/WARN lines and fatal errors are still printed so a quiet scan can never read as clean when it was not. Findings still go to stdout / --output. Mutually exclusive with --progress
--reader-threadsNDedicated filesystem reader threads. Default is one direct reader
--regex-dfa-limitSIZEPer-regex lazy-DFA cache CEILING, e.g. “256KB” or “1MB” (default 1 MiB). Bounds the worst-case per-thread DFA cache for pathological/state-heavy patterns; typical detectors stay well under it, so lowering this does NOT meaningfully cut peak memory (it’s a safety ceiling, not a general memory lever). Lowering can force complex regexes to slower NFA simulation; raise it only for unusually large patterns. Config: regex_dfa_limit in .keyhog.toml; this flag overrides it
--require-gpuRequire a usable GPU stack before scanning and keep GPU execution as a hard contract; unavailable initialization or runtime dispatch exits 12
--s3-bucketBUCKETScan a public or path-style S3 bucket via ListObjectsV2
--s3-endpointURLOptional S3 endpoint for S3-compatible APIs
--s3-prefixPREFIXOptional S3 object prefix to limit the scan
--scan-commentsTreat credentials inside source-code comments (// … / # … / /* … */ / <!– … –>) as first-class findings instead of applying the default comment-context confidence penalty. By default keyhog downgrades the confidence of credentials it sees inside a comment because the most common case is an engineer pasting an EXAMPLE token into a doc comment. The drawback is that genuine secrets pasted into a TODO (“rotate this key, Bob”) or a debug-trace comment never surface. Pass --scan-comments for repos where comments are part of the threat surface: shared snippets directories, leak post-mortems, training corpora, and CTF-style audits.
-s, --severitySEVERITYMin severity to report: info, client-safe, low, medium, high, critical Possible values: info, client-safe, low, medium, high, critical.
--show-secretsShow full credentials (default: redacted)
--sourceNAME[:PARAMS]Construct a compiled-in source by canonical name
--stdinScan stdin
--streamEmit a redacted [stream] preview line on stderr for every REPORTED finding (SEVERITY SERVICE/DETECTOR PATH:LINE redacted), so a quick human- or CI-scrapeable summary lands on stderr while the full formatted report (text/json/sarif/jsonl) goes to stdout or --output. The preview stream is consistent with that report and the exit code: every streamed line corresponds to a finding that survived suppression, the confidence floor / --min-confidence, and baseline filtering, it never previews a match the report drops
--threadsNNumber of parallel scanning threads (default: number of CPU cores)
--timeoutTIMEOUTPer-request HTTP verification timeout in seconds (default: 5). This does not impose a deadline on scanning; use --per-chunk-timeout-ms for the scanner’s optional chunk deadline
--update-baselinePATHUpdate an existing baseline file with new findings
--urlURL...Scan JavaScript, source maps, or WASM binaries at URLs for secrets
--verifyVerify discovered credentials via API calls
--verify-batchConservative verify mode: serialises live verifications per service (max-concurrent-per-service = 1) on top of the --verify-rate cap. Use for repos with lots of legitimate findings (test fixtures, vendored examples) where bursting a provider’s auth endpoint would get the scan IP rate-limited or blocked. Implies --verify
--verify-concurrencyNMaximum in-flight verification requests per service (default: 5)
--verify-oobEnable out-of-band callback verification via an embedded interactsh client. For webhook- and callback-shaped credentials, OOB verification proves the credential is exfil-capable: we mint a per-finding subdomain on the configured collector, embed it in the verification probe, and confirm the service actually called back. Off by default. See docs/src/reference/oob-verification.md for the threat model and self-hosting guidance
--verify-rateRPS5.0Steady-state cap for verification calls per service, in requests-per-second. Default 5.0. Drop this to be polite to upstream APIs when scanning a tree with hundreds of legitimate findings (test fixtures, examples); every finding produces a live verify call and most public APIs throttle aggressively. The limiter applies even with --verify-batch (which adds per-service serialisation on top)
--window-overlapSIZEStreaming window overlap size in bytes (default: 128KB)

keyhog hook uninstall

No arguments.

keyhog daemon <start|stop|status> (Unix only)

The optional foreground daemon holds a compiled scanner for repeated eligible stdin and single-file scans.

SubcommandAliasesDescription
helpPrint this message or the help of the given subcommand(s)
startStart a daemon process that holds a compiled scanner and serves scan requests over a Unix socket. Blocks until daemon stop is invoked
statusPrint uptime, scans served, active scans, detector count, and backend policy
stopStop the running daemon by sending it a Shutdown over the socket

keyhog daemon help

No arguments.

keyhog daemon start

ArgumentValueDefaultDescription
--backendBACKENDForce a daemon scan backend instead of using persisted autoroute. The default auto mode requires persisted calibration. Missing or invalid evidence prevents readiness. Use an explicit backend only for diagnostics and hermetic daemon tests. Possible values: auto, gpu-cuda, gpu-cuda-region-presence, gpu-metal, gpu-metal-region-presence, gpu-wgpu, gpu-wgpu-region-presence, simd, simd-regex, cpu, cpu-fallback.
--cache-dirDIROverride the Hyperscan compiled-database cache directory
--detectorsDETECTORSdetectorsDetector directory (same default as keyhog scan --detectors)
--massEnable bounded directory, Git, archive, binary, remote, and cloud batches from keyhog scan --daemon=mass. Warm one-file requests remain available on the same socket
--mass-gpu-primaryRequire each completed mass transaction to prove that GPU processed more than half of all non-empty payload bytes. The client validates the terminal receipt and fails instead of accepting CPU-majority work
--request-timeout-secsSECS300Max seconds a client connection may sit without completing one request frame before the daemon closes it and reclaims the slot
--socketPATHOverride the default socket path. KeyHog otherwise uses $XDG_RUNTIME_DIR/keyhog.sock, then the OS user-cache directory, then the OS temporary directory. A daemon started here is reachable by daemon stop/status --socket AND by scans via keyhog scan --daemon --daemon-socket <same path>. Pass the matching path so a fixed-location daemon (e.g. a systemd unit) actually serves scans, not just admin commands.

keyhog daemon status

ArgumentValueDefaultDescription
--socketPATHNo description.

keyhog daemon stop

ArgumentValueDefaultDescription
--socketPATHNo description.

See Daemon and warm scans for option semantics, auto / on / off routing, eligibility, readiness, socket resolution, identity, shutdown, timeout, coverage, and exits.

keyhog diff <FILE_A> <FILE_B>

Compare two baseline files produced by scan --create-baseline. A credential present only in the older baseline is verification_unknown, not resolved, because disappearance from source does not prove provider revocation.

keyhog scan . --create-baseline baseline.json
git checkout pr-branch
keyhog scan . --create-baseline pr.json
keyhog diff baseline.json pr.json

Pass --hide-unchanged to omit the unchanged section from human output, or --json for a stable CI-readable comparison. Baseline-only removed findings return exit 1 because their verification state is unknown.

To verify credentials removed between two text artifacts, keep both versions on disk only for the command lifetime and opt in to network verification:

keyhog diff old.env new.env --artifacts --verify-removed --json

The report emits only removed_still_live, removed_inactive, or verification_unknown. It never emits the credential. A live or unknown removal returns exit 1. Only provider-confirmed inactive removals can pass.

Artifact-only options are rejected during baseline comparison. Binary inputs must use keyhog scan --binary; artifact diff never decodes them implicitly.

ArgumentValueDefaultDescription
<BEFORE> (required)BEFOREBaseline file A, or the older artifact when –artifacts is set
<AFTER> (required)AFTERBaseline file B, or the newer artifact when –artifacts is set
--artifactsScan the two inputs as artifacts instead of loading baseline JSON
--detectorsDETECTORSDetector TOML directory used by –artifacts (default: auto-discover)
--hide-unchangedSuppress the UNCHANGED section (default: shown)
--jsonEmit results as JSON instead of human-readable text. Useful for CI that wants to gate merges on regressions programmatically
--max-artifact-bytesMAX_ARTIFACT_BYTESMaximum bytes read from each artifact (default: 67108864)
--verify-removedVerify credentials found only in the older artifact
--verify-timeoutVERIFY_TIMEOUTPer-credential verification timeout in seconds (default: 5)

keyhog triage

Import a current versioned redacted finding envelope and write separate runtime-suppression and pattern-training artifacts. Every record must carry the scanner’s exact public evidence.provenance object. Provenance binds the 16-hex active detector digest, nullable pattern index, candidate channel, source role, and context class. The input accepts stable detector IDs and BLAKE3 finding/context/scope identities only. It rejects unknown fields, stale detector or pattern identities, free-form reasons, raw paths, raw context, and credential values.

keyhog triage \
  --input findings.redacted.json \
  --suppressions suppressions.json \
  --pattern-feedback pattern-feedback.json

On Unix, the command creates new regular files with private permissions through held no-follow parent-directory descriptors. Input reads and failed-output cleanup use the same descriptor-relative boundary, so parent replacement cannot redirect the operation. Input and output paths must be distinct and cannot use symbolic links or parent components. Existing output files are not overwritten. Windows builds fail before reading the envelope until equivalent held-handle, reparse-point-safe I/O is available.

Scopes are exact, path, repository, and pattern-feedback-only. Path and repository scopes carry BLAKE3 identities, not names or filesystem locations. Only dismissed exact, path, and repository records produce immediate runtime suppressions. Every validated record produces pattern feedback. pattern-feedback-only can never produce runtime suppression.

ArgumentValueDefaultDescription
--input (required)PATHCurrent versioned redacted finding envelope
--pattern-feedback (required)PATHNew file for pattern-training feedback
--suppressions (required)PATHNew file for immediate scoped runtime suppressions

keyhog calibrate

Show or update the per-detector Bayesian (Beta-α/β) calibration counters. Used to teach the scorer that detector X has produced N true positives and M false positives in your environment. Scans use the counters only when --calibration-cache <PATH> or [system].calibration_cache explicitly points at the file.

keyhog calibrate --show                       # print current counters
keyhog calibrate --tp aws-access-key          # record one TP
keyhog calibrate --fp generic-api-key         # record one FP

Pass --cache <PATH> to point at a non-default counter file (the default lives under the platform cache directory, normally $XDG_CACHE_HOME/keyhog/calibration.json). Existing corrupted or schema-incompatible cache files fail closed and are not overwritten.

ArgumentValueDefaultDescription
--cachePATHOverride the calibration cache path. Defaults to $XDG_CACHE_HOME/keyhog/calibration.json
--fpDETECTOR_IDMark these detector IDs as confirmed false positives (β += 1 each)
--showPrint 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))
--tpDETECTOR_IDMark these detector IDs as confirmed true positives (α += 1 each). Use --tp repeatedly: --tp aws-access-key --tp github-pat-fine-grained

keyhog calibrate-autoroute

Runs the local stdin/filesystem scan-policy and workload-bucket sweep, verifies backend parity, and persists fastest-correct routing evidence for those normal auto scans. Git, container, web, and other environment-backed source classes remain in the installer’s calibration sweep. The command compiles one scanner per preset. It reuses immutable detector, GPU literal, and GPU phase-two program artifacts, then resets workload-shaped accelerator state before each representative. It composes the measured shared literal and backend-shaped phase-two preparation costs into each matching one-shot decision. Candidate measurement order rotates across workload bands to limit fixed-order thermal bias. This avoids per-probe process startup without turning cold GPU evidence into warm evidence. --autoroute-cache <PATH> selects the evidence file; off is rejected because calibration must persist its result. --policy <default|fast|deep|precision|all> selects the policy to refresh. It defaults to all for the complete install sweep. --quiet suppresses per-probe progress but still prints the final summary. Statistically overlapping route timings are inconclusive: the command exits 2 and publishes no generation rather than selecting an unproved winner. Rerun keyhog calibrate-autoroute on an idle host. An explicit --backend is only a diagnostic override and does not replace autoroute evidence.

ArgumentValueDefaultDescription
--autoroute-cachePATHOverride the persistent autoroute cache file every probe writes to. Must be a writable path. Calibration exists to PERSIST routing decisions, so off (which disables persistence) is rejected up front rather than failing every probe closed. Defaults to the same cache a normal scan reads, so a plain keyhog calibrate-autoroute primes exactly what later scans resolve against.
--execution-packsDIRBind persisted route evidence to this authenticated execution-pack generation. Calibration binds to the authenticated generation in the platform cache directory on its own, so an ordinary install needs no flag. Name a directory only to bind against a generation that lives elsewhere; it fails closed when the directory does not authenticate.
--measurement-receipts (hidden)PATHInternal receipt sink used by the all-policy parent transaction
--no-configCalibrate the compiled-in defaults instead of the repository config. Routing decisions are stored under the RESOLVED scan configuration, so calibration must resolve the same .keyhog.toml walk-up the scans that follow it resolve. Skipping the file writes every decision under a digest no scan in that repository requests, and the next keyhog scan fails closed with “none matching config digest”. Pass this to prime a host baseline that is independent of whatever directory calibration ran in. Installers do exactly that, and an operator whose repository carries a .keyhog.toml reruns the bare command inside the repository.
--policyPOLICYallSelect which scan policy to calibrate. all preserves the install-time sweep. Select one policy when you need to repair or refresh only the configuration you run. Possible values: default, fast, deep, precision, all.
--quietSuppress the per-probe progress lines; print only the final summary
--signing-key (hidden)PATHExecution pack verification key used to authenticate staged execution packs

keyhog backend

Prints hardware probe results and a diagnostic per-tier heuristic matrix: which SIMD ISA was detected and whether Hyperscan, CUDA, or wgpu initialized. The matrix is not the scan --backend auto decision; normal automatic scans use persisted fastest-correct calibration. Use keyhog backend --autoroute to inspect that evidence, including distinct cold-aware one-shot and warm-daemon routes, and --probe-bytes only for heuristic what-if work.

The human autoroute view is intentionally concise: it reports cache health, coverage, selected GPU routes, and the recalibration command. Add --verbose to expand every workload decision and parity receipt. --json remains the complete stable representation for CI and tooling.

keyhog backend

--probe-bytes <N> and --patterns <N> are what-if inputs to the diagnostic heuristic matrix only; neither changes the corpus nor predicts persisted autoroute. On an eligible GPU host, --self-test reports two VYRE-owned probes: vyre_literal_set for the direct match-triple diagnostic and gpu_region_presence for the production scan route. The production probe owns scan eligibility. A direct-mode limitation is reported as known when classified and warning otherwise, but only a production-path or required GPU capability failure makes the health report fail. When no eligible physical GPU exists, the normal self-test emits one gpu_adapter probe with status skip and exits 0; --require-gpu changes that probe to fail and exits 4. --no-gpu explicitly requests the skip without initializing a GPU. The JSON report lists healthy_gpu_backends and sets route_selection to not_measured. A health probe does not recommend a route. Use keyhog backend --autoroute to inspect persisted measured evidence. --json is available for self-test and autoroute inspection output. A failed self-test emits the complete report and exits 4. An explicit or required GPU scan whose route fails exits 12; a normal automatic scan reports stable-input recovery when it can preserve full coverage.

ArgumentValueDefaultDescription
--autorouteInspect the persisted autoroute calibration cache: which resolved scan configs and workload buckets have a fastest-correct backend decision, the cold-aware one-shot and warm-daemon routes, confidence basis, and whether the cache is stale for this build. Read-only; pairs with --json. Use this to diagnose an “autoroute calibration required” routing error and identify the exact unproved workload bucket
--autoroute-cachePATH|offInspect this explicit autoroute cache file instead of the platform default. Use the same absolute path passed to scan --autoroute-cache or configured as [system].autoroute_cache; off inspects the disabled state
--jsonEmit backend --self-test or backend --autoroute as stable JSON for CI health gates / scripted inspection
--no-gpuDisable GPU probing for backend inspection/self-test
--patternsPATTERNSCompiled 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. Omit it to use the live compiled embedded corpus
--probe-bytesPROBE_BYTESProbe the workload size in the diagnostic hardware heuristic matrix. This does not predict scan --backend auto, which uses persisted fastest-correct calibration evidence
--require-gpuFail closed when backend self-test cannot use a real GPU
--self-testRun the GPU self-tests (MoE compute kernel + VYRE direct-match diagnostic + production region-presence dispatch). Prints PASS/FAIL with adapter info and exits with code 4 on failure so CI can gate a release on real GPU functionality. Reports SKIP and exits zero without a non-software adapter unless –require-gpu is set
--verboseInclude every workload decision and parity receipt in human-readable autoroute inspection. The default view is a concise health and route summary; --json remains the complete machine-readable representation

keyhog bloom-diagnostic

Measure the production Bloom gate on a benchmark-owned corpus fixture. This command emits a bloom-evidence-v1 receipt that proves enabled-versus-bypassed finding parity for a given detector corpus; keyhog explain --bloom-evidence and keyhog doctor --bloom-evidence consume that receipt.

ArgumentValueDefaultDescription
--corpus-root (required)PATHRoot directory used to resolve fixture-relative corpus paths
--fixture (required)PATHJSON fixture naming the corpus and its exact negative input files

keyhog scan-system

Recursive system-wide credential audit. Walks every mounted drive (skipping pseudo-filesystems and, by default, network mounts), discovers every .git repository on the way, and runs the same scan + git-history pipeline that keyhog scan --git-history uses on each. Honors a hard --space <N> ceiling on total bytes scanned so it cannot accidentally exhaust a CI runner. Does NOT honor .gitignore unless --respect-gitignore is passed (an attacker stashing leaked keys would .gitignore them).

keyhog scan-system                                  # local mounts, git history on
keyhog scan-system --include-network                # also walk NFS/SMB/sshfs
keyhog scan-system --space 50G --no-git-history     # cap + skip history walks
keyhog scan-system --lockdown                       # forbids --include-network
ArgumentValueDefaultDescription
--cache-dirDIROverride the Hyperscan compiled-database cache directory
--detectorsDETECTORSdetectorsDetector directory (same as keyhog scan --detectors)
--include-networkInclude 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
--lockdownApply hardening protections (mlocked + coredump-blocked) and refuse the operations that weaken detection or expand attack surface. See keyhog scan --lockdown for the full list
--no-git-historySkip 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
--outputOUTPUTOutput JSON path. Defaults to stderr (text format) if unset
--respect-gitignoreHonor .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
--spaceSPACE50GHard 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
--threadsNNumber of parallel scanning threads (default: number of CPU cores)

--threads configures a process-global Rayon pool. Reusing the same width in one process is supported when KeyHog created the pool. An externally initialized pool is rejected even at the requested width because its stack size, naming, and ownership cannot be attested. A different live width is also an operator-visible error. Effective config and autoroute identity record the actual KeyHog-owned width.

scan-system always runs its own in-process scanner, whether the daemon is active or inactive. It uses persisted autoroute evidence and has no explicit backend override. Missing, stale, or incomplete evidence selects no backend for the affected batch; the report records partial coverage and names the required calibration.

keyhog completion <bash|zsh|fish|powershell|elvish>

Emits a shell-completion script. Pipe into the shell’s completion location.

keyhog completion bash > /etc/bash_completion.d/keyhog
keyhog completion zsh > "${fpath[1]}/_keyhog"
keyhog completion fish > ~/.config/fish/completions/keyhog.fish
keyhog completion powershell >> $PROFILE
keyhog completion elvish > ~/.config/elvish/lib/keyhog.elv
ArgumentValueDefaultDescription
<SHELL> (required)SHELLShell to generate completions for Possible values: bash, elvish, fish, powershell, zsh.

Install maintenance

keyhog install

ArgumentValueDefaultDescription
--forceReinstall even if execution packs are already present and valid

keyhog doctor

ArgumentValueDefaultDescription
--autoroute-cachePATH|offInspect this explicit autoroute cache file instead of the platform default. Use the same absolute path passed to scan --autoroute-cache or configured as [system].autoroute_cache; off inspects the disabled state. Without it, doctor reports the platform-default cache, which is not the file a project-configured scan uses
--bloom-evidencePATHRead a bloom-evidence-v1 receipt produced by keyhog bloom-diagnostic. The receipt must match this binary’s detector corpus and prove exact enabled-versus-bypassed finding parity

keyhog uninstall

ArgumentValueDefaultDescription
--yesActually remove the binary. Without this, uninstall is a safe dry run that only reports what would be removed

Linux uses one GPU-capable artifact that probes CUDA and WGPU at runtime, so uninstall has no backend or artifact-variant selector.

There is no keyhog update or keyhog repair. KeyHog has no self-update path: automatic releases publish crates.io packages only, and no workflow builds, signs, or uploads release binaries. Update and repair the same way you installed, with cargo install --locked --force keyhog. See Install.

Root options

These are root-command options. --version and --full are not scan flags; they print identity information and exit. Each subcommand also has its own --help.

ArgumentValueDefaultDescription
--fullInclude the hardware probe in version output. This initializes GPU/SIMD discovery, so it is explicit instead of controlled by ambient env
-h, --helpPrint help
-V, --versionPrint version, build information, and statistics

Display controls are command-specific: scan --no-color disables report and summary ANSI output, while detectors --verbose prints matching-policy summaries. Use detectors --format json for the complete detector schema.

Configuration

A verified-installer KeyHog release runs with zero hand-written configuration: the installer calibrates every eligible backend, after which keyhog scan . uses the canonical tuned defaults. A freshly built multi-backend binary must first run keyhog calibrate-autoroute; a portable single-backend build has no routing choice. Everything on this page is an optional policy override, not a substitute for required autoroute evidence.

Precedence

KeyHog first chooses the configuration-file layer:

  1. --no-config skips file discovery and conflicts with --config.
  2. --config PATH selects that file explicitly.
  3. Otherwise, KeyHog walks from each scan root toward the filesystem root and uses the first .keyhog.toml found for that root.

A multi-root scan may use discovery only when every root resolves the same configuration identity, or when every root resolves no file. Different repository policies fail before scanning. Pass one --config PATH, split the scan by repository, or use --no-config.

After the file layer is chosen, ordinary settings resolve from left to right:

compiled typed default  →  selected .keyhog.toml  →  explicit CLI value

A CLI option that you do not pass does not erase the file value. With --no-config, it falls through directly to the compiled default. There is no system or user configuration-file tier.

Relative paths in .keyhog.toml resolve from the directory containing that file. Relative CLI paths resolve from the caller’s working directory. A malformed .keyhog.toml, unknown table or key, invalid value, or unreadable explicit path fails closed before any scan output is written.

Some detection settings compose rather than use simple replacement. Presets seed a base before compatible explicit knobs apply. Detector confidence floors, entropy bands, and BPE ceilings use the field-specific rules in How detection works. The sections below state the operator-layer precedence for each of those fields.

Detector policy also has explicit provenance. keyhog explain <detector-id> prints fields from the loaded detector TOML. keyhog config --effective prints the resolved scan policy, including whether the BPE ceiling is an explicit scan-override or the compiled scan-fallback. During scanning, an eligible detector’s declared BPE ceiling wins over that fallback. An explicit [scan] value wins over detector ceilings, and the CLI value wins over [scan].

The effective view also prints report format, severity floor, dedup scope, secret visibility, client-safe/test-fixture policy, lockdown, verification enablement, timeout, concurrency, requests/second, TLS, OOB, and proxy policy. Proxy URLs are never echoed: http_proxy is reported only as unset, off, or configured so credentials embedded in a proxy URL cannot leak into logs.

Core settings

This table maps each operator-facing knob to its TOML owner and CLI spelling. Defaults come from the owning typed configuration (ScanConfig::default() for scanner policy and the corresponding source/system policy type elsewhere). A dash means that layer intentionally has no surface.

SettingDefault.keyhog.toml keyCLI flagEffect
Detector corpusdiscovered, else embeddeddetectors--detectorsSelect a detector TOML directory. A config-relative path resolves from the config directory; a CLI path resolves from the caller’s working directory.
Detector compositionreplacedetectors_mode--detectors-modeSelect one of the modes in the detector composition table.
Min confidence0.40[scan].min_confidence--min-confidenceDrop findings scoring below this (0.0-1.0). Bench-tuned for max F1.
Decode depth10[scan].decode_depth--decode-depthMax recursive decode passes, e.g. base64(hex(url(secret))) (1-10). A zero value also disables bounded static JavaScript XOR/AES recovery.
Decode size limit512KBdecode_size_limit--decode-size-limitMaximum prepared chunk admitted to decode-through. Large files are windowed, so this is not a whole-file limit.
Decoded payload validationon--Validate decoded payloads (including UTF-8 validity) before recursive scanning. This engine safety policy is always included in config --effective and the autoroute identity; it has no public override.
Entropy enabledonno_entropy = true disables--no-entropyShannon-entropy detection for novel high-entropy strings.
Entropy in sourceoffentropy_source_files--entropy-source-filesRun entropy inside .py/.js/.go/… (off by default to cut FPs).
Entropy threshold4.5[scan].entropy_threshold--entropy-thresholdScan-wide Shannon-entropy control in bits/byte. It is not a blanket replacement for detector entropy_low/entropy_high/entropy_very_high/length-bucket floors: each entropy detection path composes it with the owning detector’s evidence band. For keyword-free admission the exact formula and margin are owned by that detector’s TOML and shown by keyhog explain. Named-detector heuristic confidence uses this resolved value as its partial entropy tier and the scoring tier 1.3 bits above it as its full tier, so the setting can change confidence without changing regex matches. The byte-entropy domain is [0.0, 8.0]; non-finite and out-of-range requests fail closed.
BPE word-like bound2.2[scan].entropy_bpe_max_bytes_per_token--entropy-bpe-max-bytes-per-tokenWith no explicit scan setting, detector TOML bpe_max_bytes_per_token wins over this compiled fallback. A [scan] value or the CLI flag becomes the visible Tier-A override for every BPE-enabled detector (CLI wins). Invalid, zero, negative, NaN, and infinite bounds fail closed. An eligible candidate above its resolved cl100k_base UTF-8 bytes-per-token ceiling is word-like and dropped; detector-owned canonical hex keys and encoded-text evidence bypass this language-likeness gate. Lower = higher precision/lower recall. Detectors for which token efficiency is inappropriate declare bpe_enabled = false and skip tokenization. config --effective reports entropy_bpe_policy = scan-override for explicit scan values and scan-fallback otherwise.
Entropy min length16[scan].min_secret_len--min-secret-lenMinimum credential length for entropy-discovery candidates. Named detectors keep their own shape-specific length gates.
Keyword low-entropyongeneric_keyword_low_entropy--no-keyword-low-entropyAdmit credential-keyword-anchored values (PASSWORD=, *_PASS=, secret: …) on the generic-keyword-secret detector’s lower floor. Shape/context policy and, when enabled, MoE scoring carry precision. Disabling restores the stricter generic-secret floor and can drop real low-randomness credentials.
Entropy ML enableon---no-entropy-ml-scoringPermit each entropy owner’s compiled ml.entropy_mode. The scan switch can disable detector-owned ML but cannot choose authority. No effect when entropy or ML is disabled. This knob is CLI-only: there is no .keyhog.toml key for it, and writing one fails closed as an unknown key.
ML enabledonno_ml = true disables--no-mlInclude the on-device MoE contribution in confidence policy. Disabling it changes which ambiguous candidates clear the resolved floor and makes entropy discovery use its non-ML scoring path.
ML weight overridedetector policyml_weight--ml-weightExplicitly replace every detector TOML’s ML scoring weight (0.0..=1.0) for diagnostics or controlled benchmarks.
Additional scan confidence floorunset[scan].ml_threshold--ml-thresholdDespite its historical ML-oriented name, the live resolver composes this as max(scan min_confidence, ml_threshold). It therefore tightens every finding that uses the global scan floor; a detector-specific floor still replaces that global floor.
Unicode normonno_unicode_norm = true disables--no-unicode-normNormalise homoglyphs before matching (anti-evasion).
Scan commentsoff---scan-commentsTreat secrets in code comments at full confidence (default downgrades them).
Threads#cores[scan].threads--threadsParallel scan workers.
Reader threads1[scan].reader_threads--reader-threadsDedicated filesystem read workers. Values above one add ordered reassembly.
Fused batch1024[scan].fused_batch--fused-batchMaximum chunks per fused filesystem batch; the 1 MiB byte ceiling usually cuts large-input batches first.
Fused depth0 (rendezvous)[scan].fused_depth--fused-depthQueued fused filesystem batches. The default keeps no completed batch resident while another is scanned.
Per-chunk timeoutoff[scan].per_chunk_timeout_ms--per-chunk-timeout-msOptional hard deadline per chunk scan in milliseconds.
Dedup scopecredential[scan].dedup--dedupcredential / file / none.
Credential verificationoffverify--verify / --no-verifyThe explicit CLI enable or disable wins over discovered configuration. The Action always passes one of these flags; its default verify: 'false' therefore prevents committed configuration from silently enabling credential egress.
HTTP verification timeout5 secondstimeout--timeoutPer-request verifier deadline; it does not bound scanning. Use per_chunk_timeout_ms for the optional scanner chunk deadline.
Verification concurrency5 per serviceverify_concurrency--verify-concurrencyMaximum in-flight verification requests per service; zero is rejected. Distinct from the requests/second limiter.
Verification request rate5.0 RPS per service---verify-rateSteady-state request-rate ceiling. --verify-batch additionally forces concurrency to one.
Max file size100 MiBmax_file_size--max-file-sizeWalker skips files larger than this.
GPU batch input limitVRAM-adaptive (128 MiB to 1 GiB)[scan].gpu_batch_input_limit--gpu-batch-input-limitSets the CLI coalesced-batch and per-dispatch byte budget and is clamped to 128 MiB through 1 GiB. The pipeline can lower it further to keep its in-flight batches within host RAM headroom. A stricter backend ceiling still wins. Larger literal-presence requests shard between chunks and split an oversized chunk into overlap-preserving physical windows while retaining one logical result row. Retired MegaScan spellings are rejected.
Severity floor(all)[scan].severity--severityMinimum severity to report: info/client-safe/low/medium/high/critical.
Output formattext[scan].format--formattext/json/json-envelope/jsonl/jsonl-envelope/sarif/csv/github-annotations/gitlab-sast/html/junit.
Evidence exit policydefault[scan].evidence_policy--evidence-policydefault blocks likely and confirmed findings while leaving review findings visible; paranoid also blocks review. The policy changes exit status, not report retention.
Show secretsoffshow_secrets--show-secretsPrint plaintext credentials. Never enable in CI/logs.
Incremental cacheoff[scan].incremental / [scan].incremental_cache--incremental / --incremental-cacheBLAKE3 Merkle skip-cache. The read-free skip requires mtime, inode change time, and size to match; a file whose change time moved is re-read and re-hashed. Trusted clean-file hits count as complete coverage. A run containing only unchanged files skips backend routing and scanner dispatch startup.
Hyperscan cache dirplatform cache dir[system].cache_dir--cache-dirCompiled-database cache directory. Must be an absolute user-owned path under the home directory or per-user keyhog temp cache root.
Autoroute cache fileplatform cache file[system].autoroute_cache--autoroute-cachePersisted fastest-correct backend decisions. Use an absolute file path or off to disable persistence. Missing, stale, invalid, incomplete, or quarantined evidence selects no backend, leaves the affected batch unscanned, and returns incomplete coverage.
MatcherArtifact cache dirplatform cache dir[system].matcher_cache--matcher-cachePersisted eager compiled matcher graph reused across process invocations. Distinct from Hyperscan --cache-dir .db shards. Default-on mirrors Hyperscan’s local shard cache (unsigned, identity-bound). --lockdown disables it. Use an absolute directory or off to disable. Identity binds binary, features, detector digest, matcher-relevant config digest, pack generation, backend, and runtime identity; mismatches miss and rebuild. LazyRegex residency is not retained.
Bayesian calibration cacheoff[system].calibration_cache--calibration-cacheExplicit per-detector confidence calibration file written by keyhog calibrate. Missing or damaged explicit files fail closed before scanning.
GPU runtime policyauto[system].gpu--no-gpu / --require-gpuauto probes when routing can use GPU, off skips GPU init, and required fails closed when no usable GPU stack is available. Printed by keyhog config --effective and included in autoroute scan identity.
Low-level calibration GPU controloff[system].autoroute_gpu--autoroute-gpu / --no-autoroute-gpuApplies only to direct scan --autoroute-calibrate diagnostics. The canonical keyhog calibrate-autoroute command always measures every eligible backend, including GPU. Normal scans only consume persisted evidence.
Coalesced batch pipelineoff[system].batch_pipeline--batch-pipeline / --no-batch-pipelineDiagnostic/calibration route that bypasses the fused filesystem pipeline. Printed by keyhog config --effective and included in autoroute scan identity.
AWS canary issuer extensionsembedded baseline[aws].canary_accounts / [aws].knockoff_accounts-Extra 12-digit AWS account IDs treated as canary-token issuers during offline access-key metadata classification and verification suppression.
Scanner tuningcompiled scanner defaults[tuning]-Detection/recall route gates that affect engine work selection. Configuration identity and runtime snapshots use the same complete resolved record. Ambient KEYHOG_* tuning env vars are ignored.
Confidence prefixesembedded scanner setknown_prefixes-Replace the scan-wide list of credential prefixes that raise confidence. Empty entries fail closed. Prefer detector TOML shape/keyword policy for one secret type.
Secret-context keywordsembedded scanner setsecret_keywords-Replace the scan-wide positive context words used by generic confidence scoring. Empty entries fail closed.
Test-context keywordsembedded scanner settest_keywords-Replace the scan-wide test/mock context words used by confidence policy. Empty entries fail closed.
Placeholder keywordsembedded scanner setplaceholder_keywords-Replace the scan-wide placeholder markers used by confidence policy. Empty entries fail closed.
Backendauto---backend <BACKEND>auto, cpu (cpu-fallback), simd (simd-regex), gpu-cuda (gpu-cuda-region-presence), gpu-metal (gpu-metal-region-presence), or gpu-wgpu (gpu-wgpu-region-presence). Aliases are accepted spellings of the same backend, not extra routing candidates. CUDA, Metal, and WGPU remain separate measured candidates with distinct route labels and timing evidence. Auto uses a persisted fastest-correct decision for the exact workload bucket; missing, stale, incomplete, or runtime-quarantined state leaves the affected batch unscanned and forces non-success status.

The scan worker pool is process-global. Repeated in-process scans may reuse the same resolved width when KeyHog created the pool. A later request for a different width fails before scanner construction because Rayon cannot resize an initialized global pool. An externally initialized pool is rejected even at the requested width because KeyHog cannot attest its stack size, thread names, or ownership. The actual KeyHog-owned width is included in effective config and autoroute identity.

Autoroute also distinguishes runtime lifetime. Each GPU calibration record contains the first real dispatch and warm trials. A normal one-shot scan derives a cold-aware winner; a ready daemon derives a persistent-runtime winner from the warm GPU evidence in the same record. These routes may select different backends without changing detector policy or canonical matches. Options that the daemon protocol cannot represent (custom detector/config policy, explicit backend/GPU controls, source modes, verification, and similar orchestration) stay in process under --daemon=auto and fail explicitly under --daemon=on.

Source limits

Source byte/count ceilings resolve through the same precedence chain: compiled SourceLimits::default().keyhog.toml [limits] → CLI --limit-* flags.

LimitDefault.keyhog.toml keyCLI flag
Stdin bytes10 MiB[limits].stdin_bytes--limit-stdin-bytes
Web response bytes10 MiB[limits].web_response_bytes--limit-web-response-bytes
S3 object bytes10 MiB[limits].s3_object_bytes--limit-s3-object-bytes
GCS object bytes10 MiB[limits].gcs_object_bytes--limit-gcs-object-bytes
Azure blob bytes10 MiB[limits].azure_blob_bytes--limit-azure-blob-bytes
Cloud listed objects100000[limits].cloud_max_objects--limit-cloud-max-objects
Docker tar entry bytes128 MiB[limits].docker_tar_entry_bytes--limit-docker-tar-entry-bytes
Docker config/manifest bytes16 MiB[limits].docker_image_config_bytes--limit-docker-image-config-bytes
Docker tar total bytes8 GiB[limits].docker_tar_total_bytes--limit-docker-tar-total-bytes
Git stdout line bytes10 MiB[limits].git_line_bytes--limit-git-line-bytes
Git aggregate or hosted-clone materialized bytes256 MiB[limits].git_total_bytes--limit-git-total-bytes
Git blob bytes10 MiB[limits].git_blob_bytes--limit-git-blob-bytes
Git emitted chunks or hosted-clone entries500000[limits].git_chunks--limit-git-chunks
Hosted-git listing pages or GitHub collaboration API requests1000[limits].hosted_git_pages--limit-hosted-git-pages
Binary strings bytes64 MiB[limits].binary_read_bytes--limit-binary-read-bytes
Ghidra output bytes50 MiB[limits].binary_decompiled_bytes--limit-binary-decompiled-bytes

Every one of these caps is exact and inclusive. A cap of N bytes admits an input of exactly N bytes and refuses N + 1. A cap of N items admits exactly N items.

$ printf 'x%.0s' $(seq 1024) | keyhog scan --stdin --limit-stdin-bytes 1024B
$ printf 'x%.0s' $(seq 1024) | keyhog scan --stdin --limit-stdin-bytes 1023B
error: stdin exceeds 1023 byte limit

Which source honors which limit

A limit belongs to one source class. --limit-git-blob-bytes does not bound a cloud object, and --max-file-size does not bound a git blob. Set the cap that matches the input you are scanning.

LimitApplies to
--max-file-sizeFilesystem files, and every member extracted from an archive, compressed stream, or document on the filesystem
--limit-stdin-bytes--stdin only
--limit-web-response-bytes--url responses, and every cloud and hosted-git API response body (listings included)
--limit-s3-object-bytes / --limit-gcs-object-bytes / --limit-azure-blob-bytesOne object body from that store
--limit-cloud-max-objectsObjects listed from one S3, GCS, or Azure container
--limit-docker-tar-entry-bytesOne entry in the image tar or in any layer tar
--limit-docker-image-config-bytesImage config, manifest, and index JSON
--limit-docker-tar-total-bytesCumulative unpacked bytes for the WHOLE image, summed across the image tar and every layer tar. A budget that covers the largest layer is not enough if the layers together exceed it
--limit-git-line-bytesOne line of git plumbing output, counting the line and not its newline
--limit-git-total-bytesAggregate bytes a --git-history, --git-diff, or --git-staged scan emits. Checked between chunks, so the last chunk may carry the total past the budget
--limit-git-blob-bytesOne blob object under --git-blobs and --git-staged. Under --git-history and --git-diff the same value is the flush size for one diff hunk, so lowering it splits chunks instead of dropping content
--limit-git-chunksChunks a history, diff, or staged scan emits
--limit-hosted-git-pagesListing pages per GitHub org, GitLab group, Bitbucket workspace, or Slack channel walk
--limit-binary-read-bytesBytes read from one binary for strings extraction under --binary
--limit-binary-decompiled-bytesGhidra decompiled output accepted for parsing

Two derived caps have no flag and follow --max-file-size. Archive and compressed-stream extraction stops at four times the per-file cap, so the default 100 MiB file cap allows 400 MiB of expansion per container. A CRX package additionally refuses any entry whose compression ratio exceeds 1000.

What you see when a limit is exceeded

Exceeding a limit is never silent. Input a cap excluded is recorded as a coverage gap, not dropped, because a scan that quietly read less than you asked for reports a clean that was never measured.

You get, in every case: a WARN naming the input, its measured size, and the cap; a source error row in the report; and a non-zero exit. When the cap excluded every requested input, the run refuses to report a result at all.

$ keyhog scan --git-blobs . --limit-git-blob-bytes 64B
WARN git blob exceeds the per-blob size cap; NOT scanned oid=037d4125 size=65 cap=64
WARN source: failed to access git source: git blob 037d4125 at c1.txt exceeds
     per-blob size cap (65 bytes > 64 bytes); blob was not scanned
FAIL 2 source error row(s) emitted: requested input was NOT fully scanned.

A count of zero is refused up front rather than accepted as a scan of nothing. --limit-cloud-max-objects 0, --limit-git-chunks 0, and --limit-hosted-git-pages 0 all fail at argument parsing.

A hosted-git listing that does not fit its page budget is the one case that refuses even a partial result. Repositories the listing never reached would otherwise be reported clean, so the whole source fails instead.

Availability in this build

Each limit needs its source backend compiled in. keyhog config --effective lists every limit on every build, and marks the ones this binary cannot reach:

$ keyhog config --effective | grep limit_binary
limit_binary_read_bytes = unavailable (requires the `binary` feature in this keyhog build)
limit_binary_decompiled_bytes = unavailable (requires the `binary` feature in this keyhog build)

An unavailable limit has no CLI flag, and its .keyhog.toml key is rejected with the same feature name rather than accepted and ignored.

Library note: ScanConfig::max_file_size and ScanConfig::dedup are scan pipeline settings, not regex-engine settings. The CLI applies them through the filesystem source and final deduplication stage; FilesystemSource::new uses the same DEFAULT_MAX_FILE_SIZE_BYTES as ScanConfig::default() so the shipped default cannot drift.

Presets

PresetTOMLCLIWhat it does
Fastfast = true--fastKeeps named regex and multiline detection, but disables recursive decode, entropy discovery, and ML scoring. This is the widest recall tradeoff and is refused under --lockdown.
Deepdeep = true--deepEnables source-file entropy, keeps heuristic evidence instead of an ML-only entropy veto, removes comment confidence penalties, sets decode depth 10, raises prepared decode-chunk admission to 1 MiB, and keeps the 0.40 floor.
Precisionprecision = true--precisionDisables entropy discovery and the relaxed keyword-low-entropy bridge, keeps ML enabled, sets decode depth 1, and clamps global and detector confidence floors to at least 0.85.

--fast, --deep, and --precision are scan presets. They are mutually exclusive and conflict with --no-decode and --no-entropy.

A preset seeds a base. Compatible explicit options then refine that base. For example, --deep --decode-depth 3 uses the deep preset with decode depth 3, and --deep --min-confidence 0.9 raises its confidence floor. Two refinements are one-directional. Under --precision, --min-confidence may raise the 0.85 floor but cannot lower it. A preset that disables the relaxed keyword floor cannot be used with a flag that assumes that path is active.

--lockdown is not a fourth preset. It is a Linux-only, fail-closed execution security mode and may be required by [lockdown] require = true; that config key does not enable it. Lockdown refuses fast and other completeness-reducing switches, and the scan remains in process.

--profile is not a named configuration profile. It emits low-overhead fixed scanner-stage timings and one causal operator-run record to standard error. The record names the source, workload, backend, cache, thread configuration, input totals, run states, CPU time, memory, observed process threads, exact binary SHA-256, enabled-feature SHA-256, target triple, build profile, compiler, allocator, linked-backend SHA-256, detector-corpus SHA-256, enabled-detector BLAKE3, compiled-plan BLAKE3, hashed detector provenance, complete resolved-configuration BLAKE3, performance-policy BLAKE3, preset, applied protection state, source adapters, hashed source-target BLAKE3, hashed source-partition BLAKE3, raw source bytes, source-unit fanout, decode-derived bytes, completed backend-dispatch bytes, and stable size/fanout buckets. Byte domains that their source adapter cannot yet distinguish remain explicitly unavailable instead of becoming measured zeroes. It never includes source content, credential values, raw paths, raw URLs, or raw configuration values. Use --perf-trace when you need the higher-overhead per-pattern and backend diagnostic counters. Neither flag selects the fast, deep, or precision preset.

Policy tables

Each setting has one TOML owner. The main reporting, entropy, routing-identity, and worker settings live under [scan]; the core-settings table above names the few canonical root keys (presets, verification, decode/ML switches, and scan-wide keyword lists). Other tables own source, detector, system, and security policy. Unknown keys and retired duplicate spellings fail closed. When migrating an older file, move the retired flat scan keys named by the parser under [scan] and rename exclude_paths to [scan].exclude.

detectors and detectors_mode

The corpus path and composition mode are two independently resolved settings. Each follows the ordinary default, file, then CLI precedence. Their composition semantics are defined once in Detectors.

# /srv/acme/app/.keyhog.toml
detectors = "keyhog-detectors"
detectors_mode = "overlay"

The file above selects /srv/acme/app/keyhog-detectors, regardless of the caller’s working directory:

keyhog scan /srv/acme/app --config /srv/acme/app/.keyhog.toml

A CLI path overrides only the path. If the file still supplies overlay, that mode applies to the CLI directory. Override both settings when you want a full replacement:

keyhog scan /srv/acme/app \
  --config /srv/acme/app/.keyhog.toml \
  --detectors /opt/acme/keyhog-detectors \
  --detectors-mode replace

If neither the file nor CLI supplies a mode, a selected directory uses replace. A mode without a detector path in either layer is an error. A missing, non-directory, empty, or invalid explicit corpus is also an error. Overlay ID collisions fail before scanning. None of these errors falls back to the embedded corpus.

[scan]

The canonical owner for scan execution and reporting policy. This includes severity, evidence_policy, min_confidence, ml_threshold, decode_depth, entropy policy, format, exclude, worker and fused-pipeline sizing, chunk timeout, dedup, incremental scanning, and the GPU batch-input limit.

[scan]
severity = "high"
evidence_policy = "default" # use "paranoid" to block review-tier findings
min_confidence = 0.40       # raise toward 0.85 for fewer false positives
decode_depth = 10           # 1-10, same ceiling as --decode-depth
exclude = ["**/test/fixtures/**", "vendor/"]
threads = 8
reader_threads = 2
fused_batch = 1024
fused_depth = 0
per_chunk_timeout_ms = 30000

[detector.<id>]: per-detector overrides

Apply an override to the exact ID shown by keyhog detectors or keyhog explain <id>:

[detector.generic-api-key]
enabled = false

[detector.twilio-api-key]
min_confidence = 0.6

enabled = false removes the matching detector after replace or overlay composition and before scanner compilation. Detectors that require it are removed transitively. Surviving conflicts and subsumes relations to it are pruned. The override cannot restore a detector absent from a replacement corpus. An unknown ID produces a warning. Disabling every loaded detector, including dependency removals, fails before scanning.

A detector confidence floor resolves in this order:

  1. [scan].min_confidence supplies the global floor.
  2. [detector] min_confidence in the active detector TOML replaces the global floor for that detector.
  3. .keyhog.toml [detector.<id>] min_confidence replaces the detector’s declared floor.
  4. The precision preset clamps the resolved global and detector floors to at least 0.85.

There is no CLI per-detector override. A scan-wide --min-confidence changes the global floor but does not replace a detector-specific floor. Shipped availability and detector floors have no hidden Rust override list.

[lockdown]

[lockdown]
require = true              # refuse to run unless --lockdown is passed

A repo that demands hardened scanning sets this so a plain keyhog scan fails closed instead of silently running unhardened. See the scan --help output for the current --lockdown checks.

[system]

[system]
trusted_bin_dirs = ["/nix/store/example-system-bin/bin"]
cache_dir = "/home/alice/.cache/keyhog"
autoroute_cache = "/home/alice/.cache/keyhog/autoroute.json"
matcher_cache = "/home/alice/.cache/keyhog-matcher-artifacts"
calibration_cache = "/home/alice/.cache/keyhog/calibration.json"
gpu = "auto"
autoroute_gpu = false
batch_pipeline = false

trusted_bin_dirs extends the absolute directory allowlist used for external binaries such as git and docker. This is for Nix/Guix or other non-standard install roots. Relative paths are rejected because the trust boundary must not depend on the process working directory.

cache_dir overrides the Hyperscan compiled-database cache directory. It uses the same precedence as scan flags: compiled platform default, then TOML, then --cache-dir. Relative paths, symlinks, paths outside the user’s home or the per-user keyhog temp cache root, and paths owned by another user fail closed.

autoroute_cache overrides the persisted autoroute calibration evidence file. It uses the same precedence as scan flags: compiled platform default, then TOML, then --autoroute-cache. The value must be an absolute file path or off. The cache path is printed by keyhog config --effective; it is storage configuration, not part of the scan identity digest.

matcher_cache overrides the MatcherArtifact cache directory used to reuse the eager compiled matcher graph across process invocations. This is not the Hyperscan --cache-dir database cache: a directory that only contains hs-*.db shards still pays the detector-spec compile floor. When unset, KeyHog defaults to dirs::cache_dir()/keyhog-matcher-artifacts (sibling of the Hyperscan keyhog/ cache root so lockdown’s past-findings audit of <cache>/keyhog is not tripped by matcher graphs). Pass an absolute directory or off. The trust model matches Hyperscan .db shards: the artifact is unsigned local state bound by binary/config/detector digests under a uid-owned allowlisted path; --lockdown disables MatcherArtifact entirely rather than reading unsigned detector graphs. The cache key binds binary identity, target/features, detector corpus digest, matcher-relevant config digest (scanner tuning / disabled detectors / confidence floors / regex DFA limit, not thread counts, exclude paths, or volatile cache locations), pack/generation identity when packs apply, and backend-relevant runtime identity. A mismatch misses and rebuilds; a foreign matcher is never served. Cache hits validate and decode each matcher section directly from one capped artifact buffer instead of allocating a second complete section set. LazyRegex programs remain compile-on-first-use, so peak RSS stays near the MemoryFootprint baseline rather than retaining every detector regex.

calibration_cache opts a scan into per-detector Bayesian confidence calibration written by keyhog calibrate. The scanner never reads the default calibration file implicitly. The value must be an absolute file path in TOML; missing, unreadable, corrupt, or schema-incompatible explicit files fail closed before scanning. The resolved path, entry count, and digest are printed by keyhog config --effective.

gpu resolves GPU init policy. auto leaves GPU available to autoroute and explicit GPU backends, off behaves like --no-gpu, and required behaves like --require-gpu. The resolved value is printed by keyhog config --effective and is part of the autoroute scan identity.

autoroute_gpu is a low-level control for direct scan --autoroute-calibrate diagnostics. The supported maintenance command, keyhog calibrate-autoroute, always supplies GPU candidate admission so every eligible backend is a peer. Normal scans do not hash or benchmark from this value; they consume persisted fastest-correct decisions. A direct calibration that excludes an otherwise eligible GPU is stored under a diagnostic-only config identity, so its incomplete candidate set cannot replace normal all-candidate evidence. A calibration with GPU disabled by the resolved runtime policy shares the matching CPU-only scan identity.

batch_pipeline forces the coalesced batch pipeline. Leave it false for the default fused filesystem route; set it only for calibration, diagnostics, or pipeline parity checks. The resolved value is printed by keyhog config --effective and is part of the autoroute scan identity.

[http]

[http]
proxy = "off"
insecure_tls = false
allow_private_endpoint = false

proxy is an explicit outbound proxy URL or off; ambient proxy environment variables are ignored. insecure_tls disables certificate validation for outbound HTTP and should be limited to controlled interception environments. allow_private_endpoint permits cloud source endpoints that resolve to private, loopback, link-local, or metadata addresses; it is off by default to preserve the SSRF boundary. CLI flags override these values. All three settings are operator-visible and never enabled by an ambient environment variable.

[aws]

[aws]
canary_accounts = ["609629065308"]
knockoff_accounts = ["000000000001"]

canary_accounts and knockoff_accounts extend the embedded AWS canary-token issuer baseline used by offline access-key metadata. Each entry must be a 12-digit AWS account ID. Invalid IDs fail closed as configuration errors. Configured accounts are part of the resolved scan config, keyhog config --effective prints their count, and daemon scans route in-process because a running daemon cannot consume client-local [aws] config.

[tuning]

[tuning]
fallback_hs = true
hs_prefilter_max_len = 4096
hs_shard_target = 320
fallback_anchor = true
homoglyph_gate = true
homoglyph_ascii_skip = true
fallback_reverse = false
prefilter_truncate = true
fallback_prefix_gate = false
decode_focus = true
confirmed_suffix_gate = true
confirmed_companion_gate = true
no_candidate_gate = true
fallback_localizer = true
gpu_recall_floor = false
chunk_lane_threshold = 65536

These keys tune scanner-internal detection and recall route gates. They are operator-visible resolved config, included in the autoroute config digest, and printed by keyhog config --effective. They do not have CLI flags because per-run hidden recall changes would invalidate installer calibration. hs_shard_target controls Hyperscan patterns-per-shard during compile; changing it affects compile/cache shape and autoroute identity but not detector recall. confirmed_companion_gate skips confirmed patterns whose required mid-literals are all absent in the chunk (recall-identical to running those patterns cold). fallback_localizer moves plain phase-two candidates to one ASCII anchor index. It is enabled by default, which avoids compiling and scanning the full portable phase-two marking set when the anchor index can localize candidates. Autoroute still measures both settings because the faster route depends on the workload. Each setting creates a distinct autoroute configuration that must be calibrated before automatic use. gpu_recall_floor forces the VYRE region-presence path to compute the full CPU trigger net during parity/debug scans and report any GPU under-fire it recovers. Authenticated GPU routes score eligible candidates through a separate bounded quantized VYRE program. CPU and SIMD use the same fixed-point model, while CPU-owned rows and the shared confidence-policy tail remain on the CPU. chunk_lane_threshold sets the byte boundary between coalesced small-chunk lanes and independently scheduled large chunks. It accepts values from ScannerTuningConfig::CHUNK_LANE_THRESHOLD_MIN through ScannerTuningConfig::CHUNK_LANE_THRESHOLD_MAX, currently 1 through one less than the platform usize maximum. Zero and the platform maximum fail closed as configuration errors. The default is 65536 bytes.

[allowlist]

file selects the line-based allowlist file (default .keyhogignore at the scan root). require_reason, require_approved_by, and max_expires_days enforce governance before any suppression is active. Missing required metadata, expired entries, malformed entries, or expiry windows beyond the configured limit fail closed with an operator-visible config error. See Suppressions.

[guard]

The guard section configures the perpetual repository and filesystem guard runtime. See Guard workflow for operational details.

KeyTypeDefaultDescription
hot_index_memorystring64MBMemory budget for the clean attestation cache index (for example 64 megabytes).
max_pending_events_per_rootinteger8192Maximum queued filesystem events per root.
max_pending_events_totalinteger65536Maximum total queued filesystem events across all roots.
coalesce_windowstring100msEvent coalescing window before applying state transitions.
scanner_residencystringwarmScanner residency mode (warm or idle-unload).
scanner_idle_timeoutstring5mScanner idle-unload timeout. After this duration without guard activity, the residency label reports idle-unload.
scrub_intervalstringdisabledPeriodic re-scan interval for current roots. Catches changes that filesystem events missed.
state_pathstringdisabledDurable guard state path (e.g. ~/.local/state/keyhog/guard.redb). Persists root records and attestations across daemon restarts. Rejected in lockdown mode.
subtree_max_filesinteger10000Maximum files for one subtree reconciliation.
subtree_max_depthinteger64Maximum depth for one subtree reconciliation.

Where the numbers live

  • Canonical detection defaults: crates/core/src/config.rs (ScanConfig::default).
  • Scanner route tuning defaults: crates/scanner/src/scanner_config.rs (ScannerTuningConfig).
  • TOML schema + merge precedence: crates/cli/src/config.rs (ConfigFile, apply_config_file).
  • The resolved struct the live scanner reads (defaults + file + flags folded into one): crates/cli/src/orchestrator_config.rs (resolve_scan_configResolvedScanConfig). The scanner, router, reporter, and verifier consume that resolved policy rather than independently re-reading raw arguments.

Environment variables

KeyHog keeps scan policy out of the environment. Detection, suppression, routing, limits, and output are resolved in this order:

  1. a CLI flag;
  2. .keyhog.toml, discovered from the scan root or selected with --config;
  3. the compiled default.

Environment variables authenticate an explicitly selected remote source, control standard terminal diagnostics, or help the operating system choose a runtime directory. They do not select a source or change detector policy.

Backend selection has no environment override. Use --backend auto, --backend cpu (cpu-fallback), --backend simd (simd-regex), --backend gpu-cuda (gpu-cuda-region-presence), or --backend gpu-wgpu (gpu-wgpu-region-presence).

For example, keep a GitHub token out of the process arguments:

KEYHOG_GITHUB_TOKEN="$GITHUB_TOKEN" \
  keyhog scan --github-org example-org

Setting KEYHOG_GITHUB_TOKEN without --github-org or --github-collaboration does not add a GitHub source.

Direct reads in the release binary

The production-source gate production_env_reads_stay_on_the_allowlist restricts direct Rust environment reads to the names and owners below.

Terminal, diagnostics, and daemon paths

VariableRead byEffect
NO_COLORCLI style layerA present, non-empty value disables ANSI styling. An empty NO_COLOR= does not.
RUST_LOGtracing subscriberSelects diagnostic log filters. The built-in directive is keyhog=warn. This changes diagnostics, not findings.
RUST_BACKTRACERust runtimeEnables panic backtraces according to the standard Rust runtime rules.
PATHkeyhog doctorChecks whether the installed KeyHog directory is on PATH and whether another keyhog shadows it. Scan subprocesses use the trusted-binary resolver rather than a bare PATH lookup.
XDG_RUNTIME_DIRUnix daemon socket resolverUses $XDG_RUNTIME_DIR/keyhog.sock when set.

Without XDG_RUNTIME_DIR, the Unix daemon uses the platform cache directory plus keyhog/server.sock. If no cache directory is available, it uses the platform temporary directory plus keyhog/server.sock. Override this per process with daemon start/stop/status --socket and scan --daemon-socket.

Remote-source credentials

These variables are read only after the matching source flag has selected a remote source. A CLI credential flag takes precedence over its hosted-source environment variable.

VariableSelected source and behavior
KEYHOG_GITHUB_TOKEN--github-org or --github-collaboration; GitHub personal access token.
KEYHOG_GITLAB_TOKEN--gitlab-group; GitLab personal access token.
KEYHOG_BITBUCKET_USERNAME--bitbucket-workspace; Bitbucket Cloud username.
KEYHOG_BITBUCKET_TOKEN--bitbucket-workspace; Bitbucket app password or token.
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY--s3-bucket; both must be present for SigV4 authentication. If only one is present, the source fails instead of sending an unsigned request.
AWS_SESSION_TOKEN--s3-bucket; optional temporary-credential token.
AWS_REGION, AWS_DEFAULT_REGION--s3-bucket; signing region. AWS_REGION wins, then AWS_DEFAULT_REGION, then endpoint inference, then us-east-1.
GOOGLE_OAUTH_ACCESS_TOKEN, GCS_BEARER_TOKEN--gcs-bucket; bearer token. GOOGLE_OAUTH_ACCESS_TOKEN wins when both are set.

Ambient AWS credentials are not forwarded to a non-AWS S3 endpoint without the explicit credential-forwarding flag. Proxy and TLS behavior also remain explicit. HTTP_PROXY, HTTPS_PROXY, and ALL_PROXY do not route KeyHog verification or HTTP source requests. Use --proxy, [http] proxy, --insecure, or [http] insecure_tls. KeyHog deliberately does NOT read KEYHOG_INSECURE_TLS. No proxy or TLS environment variable participates in routing or certificate policy.

Platform directory discovery

KeyHog also uses the dirs crate and the standard temporary-directory API. These APIs can consult platform environment variables even though KeyHog does not read those names directly.

Platform inputWhat it can locate
HOME and the Unix XDG_CACHE_HOME, XDG_CONFIG_HOME, and XDG_DATA_HOME conventionsDefault cache, configuration, detector-discovery, and daemon-fallback paths on Unix-like systems.
Windows known-folder APIs, commonly rooted by LOCALAPPDATA, APPDATA, and the user profileCache, data, and configuration paths on Windows.
The platform temporary-directory setting, such as TMPDIR on Unix or TEMP/TMP on WindowsLast-resort daemon socket parent and temporary work files.

These paths can change where KeyHog reads or writes caches and optional user configuration. They do not override a detector, source, or output setting. Use explicit CLI or .keyhog.toml paths when an automation job must not depend on the runner’s home-directory conventions.

Installer script environment

The installer scripts are separate programs from the KeyHog binary. They install a bundle supplied with --from-file and never contact the network.

VariableScriptEffect
NO_COLORbothA non-empty value disables installer styling.
HOMEinstall.shSupplies the default install root $HOME/.local/bin.
LOCALAPPDATAinstall.ps1Supplies the default install directory and cache roots.
USERPROFILEinstall.ps1Locates PowerShell completion directories.
PATHinstall.ps1Checks whether the selected install directory is already reachable.

CI-only autoroute fixture variables

The ci-lean and test builds compile an authenticated timing-fixture seam for autoroute integration tests. Official release builds do not compile this seam.

VariableRequired value or effect
KEYHOG_CI_AUTOROUTE_TIMING_FIXTUREconfidence-separated-v1 or overlapping-v1; replaces measured trial timings with the selected deterministic fixture during calibration.
KEYHOG_CI_AUTOROUTE_FIXTURE_AUTHMust equal bench-backend-parity-v1; without this sentinel, fixture use fails.

Do not set these variables in ordinary scans. They exist only to make the real calibration path deterministic in CI.

Replacing removed behavior variables

KeyHog-owned environment variables for scan behavior are ignored. Use the supported interface instead:

NeedUse
Backend override--backend <BACKEND> with auto, cpu, simd, gpu-cuda, or gpu-wgpu
GPU requirement or disablement--require-gpu, --no-gpu, or [system].gpu
Autoroute calibrationkeyhog calibrate-autoroute or scan --autoroute-calibrate
Scanner concurrency and per-chunk limits--threads, [scan].threads, reader_threads, per_chunk_timeout_ms, fused_batch, and fused_depth
Detector corpus--detectors, --detector-mode, or the top-level detectors setting
Cache and trusted binary roots[system].cache_dir, [system].autoroute_cache, and [system].trusted_bin_dirs
Detection tuning[tuning]
AWS canary and knockoff account lists[aws] canary_accounts and knockoff_accounts
Verifier and source proxy or lab TLS override--proxy, [http] proxy, --insecure, or [http] insecure_tls
Dogfood capture--dogfood

Normal scans do not benchmark on an autoroute cache miss. Invalid or missing normal-scan evidence leaves the affected batch unscanned and forces non-success status. Run keyhog calibrate-autoroute to restore healthy automatic routing. See Configuration for the complete schema and Autoroute calibration for the evidence contract.

Exit codes

The table below is the KeyHog 0.5.81 process contract. The canonical numeric definitions live in crates/cli/src/exit_codes.rs and are rendered in keyhog --help and keyhog scan --help.

ExitMeaning
0Success. No finding blocks the active evidence policy, no incremental-cache failure occurred, and source coverage is complete. Under the default policy, review-tier findings can remain visible.
1At least one finding blocks the active evidence policy, but none were confirmed live.
2User or operator error, including invalid arguments or configuration and operator-correctable I/O.
3System or local environment failure, including other low-level I/O, a fatal daemon service failure, or an explicitly selected SIMD backend failure.
4A maintenance health or self-test command reported an unhealthy state.
10A scan confirmed a live credential.
11A scanner thread panicked. Scan state is not trustworthy.
12An explicitly selected or required GPU path could not execute.
13A requested source failed or input coverage was incomplete and no finding outcome took precedence.
130SIGINT or Ctrl-C interrupted the process.

Capture the code safely

Do not run a non-zero scan as a bare command under set -e if you intend to inspect the result. Put it on the left side of ||:

rc=0
keyhog scan . --verify || rc=$?

case "$rc" in
  0)   echo "no policy-blocking findings" ;;
  1)   echo "findings block the active evidence policy" ;;
  10)  echo "live credential confirmed"; exit 1 ;;
  2)   echo "fix arguments, configuration, or operator input"; exit 1 ;;
  3)   echo "repair or retry this runner"; exit 1 ;;
  4)   echo "maintenance health check failed"; exit 1 ;;
  11)  echo "scanner panic; discard this scan result"; exit 1 ;;
  12)  echo "required GPU path unavailable"; exit 1 ;;
  13)  echo "source or coverage incomplete; do not report clean"; exit 1 ;;
  130) echo "interrupted"; exit 130 ;;
  *)   echo "unknown KeyHog exit: $rc"; exit 1 ;;
esac

Completed scan precedence

For a completed normal scan, resolve_scan_exit applies this order:

  1. scanner panic, 11;
  2. at least one live credential, 10;
  3. at least one finding that blocks the active evidence policy, 1;
  4. incremental-cache or autoroute-cache persist failure, 3;
  5. incomplete source coverage, 13;
  6. policy success, 0.

This means a blocking finding from the covered portion remains 1 or 10 even when coverage is incomplete. The coverage warning remains visible. Automation must not infer complete coverage from a finding code.

Autoroute calibration is a separate scan mode, but it does not hide the scan’s own result. keyhog scan . --autoroute-calibrate still exits 1 when a finding blocks the active evidence policy, and 0 only when no finding blocks and the calibration succeeded. Calibration publishes only evidence that passed its checks, and an inconclusive or failed calibration returns an error instead. This matters because the documented first-run command is a calibrating scan: a real policy-blocking leak cannot read as a successful warm-up.

0: policy success

A normal scan returns 0 only when it can make a successful claim under the active evidence policy. The default policy keeps review findings visible without blocking; --evidence-policy paranoid makes them block. A source or expansion gap prevents a zero-blocking-finding scan from returning 0.

Maintenance subcommands also use 0 for their successful state. For example, doctor returns 0 when the installation is healthy.

1: findings block the active policy

The default evidence policy blocks likely and confirmed. Paranoid policy also blocks review. Verification states other than live do not override the scanner evidence verdict, so skipped, dead, revoked, and verification-error findings can still return 1 when their tier blocks. A live finding returns 10.

2: user or operator error

Examples include:

  • an unknown flag or invalid flag combination;
  • invalid .keyhog.toml;
  • a detector corpus that fails to load or validate;
  • a missing or invalid baseline;
  • a required daemon that is unavailable, ineligible, or fails its trust or protocol checks. This is --daemon=on, where you asked for the warm route as a hard contract. --daemon=auto, the default, never exits 2 because of a daemon problem: any daemon failure falls back to an in-process scan and the scan’s own exit code is returned;
  • a failed or inconclusive autoroute calibration operation;
  • missing, stale, invalid, incomplete, or quarantined autoroute evidence on a normal automatic scan;
  • I/O classified as not found, permission denied, connection refused, invalid input, invalid data, or already exists.

A normal automatic scan needs a valid autoroute decision for the exact workload class, detector corpus, config, binary, and host. Without one there is no measured-correct backend to select, so the scan fails closed: nothing is scanned, stdout carries no findings document, and stderr names the state and the repair. An empty findings document reads as a clean tree, so an unroutable scan writes none. KeyHog never benchmarks at scan time and never substitutes scalar execution for a missing decision. Inspect the state and repair it with:

keyhog backend --autoroute --json
keyhog calibrate-autoroute

backend --autoroute reports the same unhealthy state as 4, its maintenance health code.

An explicit --backend request is different. It bypasses automatic selection for that diagnostic run and keeps its own fail-closed execution contract.

3: system or local environment failure

This code covers a low-level I/O error not classified as operator-correctable, an incremental-cache failure, an autoroute decision-cache persist failure (the scan reported no findings but could not save its routing decision), a fatal daemon listener or connection-handler spawn failure, or an explicitly selected SIMD/Hyperscan path that cannot execute. A selected or required GPU failure is 12.

4: maintenance health failure

scan does not return 4. Maintenance commands use it for unhealthy states:

  • doctor could not establish a healthy installation;
  • backend --self-test failed;
  • backend --autoroute found quarantined, calibration_required, disabled, stale, or invalid routing state.

Use the structured diagnostic surfaces when automation needs details:

keyhog backend --self-test --json
keyhog backend --autoroute --json

10: live credential

For scan --verify, at least one credential was accepted by its verification service. This takes precedence over other findings and incomplete coverage.

11: scanner panic

A scanner thread panicked. Partial findings and counts are not trustworthy. The CLI flushes its diagnostic streams and exits immediately with 11 so a later accelerator teardown cannot replace this code.

12: selected or required GPU unavailable

This applies when --require-gpu, [system].gpu = "required", an explicit GPU backend, or a GPU calibration candidate cannot execute. The same code covers a daemon GPU route that fails before readiness. KeyHog does not substitute CPU or SIMD for these explicit contracts.

An automatically selected accelerated backend that faults at runtime is different. When exact recovery is possible, KeyHog retains completed work, replays only unprocessed stable input through a measured-correct peer, records the recovery, and follows normal completed-scan exit semantics. If recovery cannot cover the input, the scan is incomplete rather than clean.

13: source failed or coverage incomplete

This code protects the clean claim. Examples include:

  • Git history requested for a non-repository or an invalid ref;
  • a requested remote source that produced no scan data;
  • an unreadable file;
  • a file skipped by --max-file-size;
  • a truncated archive;
  • a source or decode expansion limit that left requested input uncovered.

If no finding outcome takes precedence, the scan returns 13. Fix the source, credentials, ref, permissions, or limit and scan the uncovered input again.

The report is always written, including when every source failed to read. That report carries scan covered nothing plus the reason each source failed, so a CI job never has to pre-seed a placeholder file to have something to publish.

A report that cannot be written is a different failure and says so. It exits 2, because the output path is operator-correctable, and names the path and the I/O error:

error: the scan completed but its report could not be written to /srv/out/keyhog.json: atomically writing report /srv/out/keyhog.json: Permission denied (os error 13)

Read that as “fix the output path”, not “the scan could not cover your input”. The two used to share a signature; they no longer do.

Not every coverage gap reaches the exit code

Each gap reason carries one of two severities, and the severity decides whether it can produce 13:

SeverityWhat it meansExit with no findings
AdvisoryThe bytes were examined. A file was deliberately skipped, or a derived layer such as decode-through was not expanded.0
FailingThe bytes were not covered, or their line identity is untrustworthy.13

Advisory reasons include default exclusion policy (...), binary (extension or content sniff), matches dropped by the vendored/minified path policy, exceeded a configured size cap, and scanner decode-through declined by --decode-size-limit. Failing reasons include unreadable (permission denied or I/O error), source emitted error rows, Git object unreadable or wrong object kind, archive or container extraction truncated, and scan covered nothing. The full split lives in severity() in crates/cli/src/reporting.rs.

One scan can carry both classes, and then the failing one decides. A file over --max-file-size is advisory on its own, but the source that could not read it also emits source emitted error rows, which is failing, so that scan exits 13. Read the reasons rather than counting the rows.

The advisory rows are the ones to plan for. A tree whose only credentials sit in vendor/, inside a compiled binary, inside a minified bundle, or inside an encoded value too large to decode reports partial, exits 0, and prints No secrets detected in the scanned files. on stdout, with the warning on stderr only. A gate that reads the exit code alone treats that as clean. Rerun with --no-default-excludes when those trees hold code you wrote. See tell a real clean from a skipped input.

130: interrupted

SIGINT is a process boundary, not a clean partial result. On Unix, the signal handler writes an interruption diagnostic and exits immediately with 130. On other supported platforms, the Ctrl-C task uses the same code.

Findings cannot be forced to zero

KeyHog has no --exit-zero flag. Accept a known finding through a reviewed suppression instead. The next scan then computes its exit from the remaining unsuppressed findings and coverage state. Choose by scope:

  • Findings that predate adoption: record them once in a committed baseline. See Fail only on new secrets.
  • One reviewed value, path, or detector: use .keyhogignore or .keyhogignore.toml. See Suppressions.

Exit 13 cannot be suppressed. Coverage is a property of the input, not of the findings, so fix the source, permission, ref, or limit instead.

Guard subcommand exit codes

keyhog guard subcommands use the same numeric exit codes with guard-specific state mapping:

Guard stateExit codeCondition
current0The root is proven clean.
blocked1Unsuppressed findings were detected.
dirty, stopped, indexing, degraded, stale-policy13The root is not proven clean or coverage is incomplete.

guard rebuild uses the same mapping after re-adding the root. A rebuild that completes with current returns 0; a rebuild that leaves the root stopped or indexing returns 13.

.keyhogignore.toml reference

Use .keyhogignore.toml for exceptions that need more than one condition. Put the file at the filesystem scan root. A single-file scan uses the file’s parent directory. A source mode without a filesystem path uses the current directory.

KeyHog also loads the line-based .keyhogignore. A finding is suppressed when either file matches. [allowlist].file can select a different line-based file, but it does not move or disable .keyhogignore.toml. There is no negation or last-rule-wins behavior.

Rule composition

Each rule is a [[suppress]] table. Predicates in one table use AND. Separate tables use OR.

# Suppress one reviewed AWS fixture value and nothing broader.
[[suppress]]
detector = "aws-access-key"
path_eq = "fixtures/aws.env"
credential_hash = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"

# Suppress low-or-lower Stripe findings only below test directories.
[[suppress]]
service = "stripe"
severity_lte = "low"
path_regex = '(^|/)tests/'

In the first rule, changing the path, detector, or hash makes the rule fail to match. In the second rule, a medium Stripe finding still reports. A low finding outside a tests directory also reports.

Fields

FieldTypePredicate
literal_truebooleanExplicit unconditional match. Only true is a predicate.
detectorstringExact detector ID
servicestringExact service
severitystringExact severity
severity_ltestringSeverity at or below the threshold
path_eqstringExact finding path
path_containsstringFinding path contains the substring
path_starts_withstringFinding path starts with the prefix
path_ends_withstringFinding path ends with the suffix
path_regexstringFinding path matches the regular expression
credential_hashstringExact SHA-256 hex digest reported as credential_hash

Severity values are info, client-safe, low, medium, high, and critical. severity_lte = "low" includes info, client-safe, and low. Other string comparisons are exact and case-sensitive.

The path fields inspect the path stored on the finding. They do not perform line-based .keyhogignore glob matching. Use path_regex when an exact, prefix, suffix, or substring comparison is not enough. Archive member paths include every container, for example bundle.zip//examples/demo.env. A finding without a path does not match a path-scoped rule.

Unconditional rules

An empty table is rejected:

[[suppress]]

literal_true = false by itself is also rejected. To suppress every finding, you must state that policy explicitly:

[[suppress]]
literal_true = true

Combining literal_true = true with another predicate is equivalent to using the other predicate alone.

Failure behavior

A missing file means that no declarative rules are active. A present file that cannot be read or parsed stops the scan with exit 2. An empty table, unknown field inside [[suppress]], or unsupported severity also stops the scan. KeyHog does not fall back to an empty declarative policy.

A valid rule with the wrong case, path, detector, or hash does not match. It does not produce an error. A file with no [[suppress]] tables loads no rules. Run the same scan after adding a rule and confirm that only the reviewed finding disappears.

Tell a real clean from a skipped input

An empty findings list has two very different causes. KeyHog read your input and found nothing. Or KeyHog never read your input. This page shows you how to tell those apart on any workload.

The most dangerous ambiguity is no longer a total source failure: a read-nothing scan exits 13. It is a partially read scan whose only missing content is classified as an advisory skip. That scan can exit 0 while the envelope says partial, so neither an empty findings list nor the process code proves that the specific files you care about reached the detector pipeline. Check the byte count and gap reasons together.

Read one number first:

keyhog scan . --format json-envelope -o keyhog.json
jq '.metadata.source_bytes_scanned' keyhog.json

source_bytes_scanned is the count of source bytes that actually entered the detector pipeline. 0 means nothing was scanned. A read-nothing scan now also exits 13, which makes this check louder rather than redundant: a scan that read SOME bytes but not the ones you cared about still exits 0, and this field is what catches that.

The coverage check

Run this after any scan whose result you intend to act on:

rm -f keyhog.json
rc=0
keyhog scan . --format json-envelope -o keyhog.json || rc=$?
jq '{
  bytes: .metadata.source_bytes_scanned,
  chunks: .metadata.source_chunks_scanned,
  status: .scan_status,
  gaps: .coverage_gap_summary,
  findings: (.findings | length)
}' keyhog.json
printf 'keyhog exit=%s\n' "$rc"

Treat the result as a real clean only when bytes is greater than zero and the gap reasons are acceptable for this source boundary. Compare against an inventory or expected eligible-byte range when you have one; raw repository size is not a valid oracle because exclusions and decode expansion move the counter in opposite directions.

Read the structured fields, not the warnings. It is tempting to build a CI check by searching stderr for the word WARN, but this page is about detecting an ABSENCE, and a text search that silently matches nothing returns exactly the answer you were hoping for. KeyHog also has more than one wording for most failure classes, so a search for one of them passes over the others. The exit code and coverage_gap_summary are complete and stable; the prose is neither. Use the warnings to find out WHICH path or object failed, after a structured field has already told you that something did.

What each field means

source_bytes_scanned counts bytes handed to detectors. Decoding can raise it above the on-disk size, because a decoded layer is scanned in addition to the raw bytes it came from. A 32 MiB minified bundle scanned as bundle.js reports about 38 MiB for that reason.

source_chunks_scanned counts the units of work. One small file is one chunk. Files above the 1 MiB window size become several chunks. Empty stdin reports one chunk and zero bytes, so chunk count alone does not prove coverage.

scan_status is one of five values.

ValueMeaning
successNo coverage gap was recorded and no backend recovery happened.
complete_after_recoveryCoverage is complete after an authenticated backend fault completed through exact recovery. Missing or invalid autoroute evidence is not recovery; it records a partial scan.
partialAt least one coverage gap was recorded.
cancelledThe run was interrupted.
failedThe run failed before it could report.

coverage_gap_summary is an array of {reason, count} rows. Each row names one class of input that KeyHog did not fully scan.

partial is not a reject rule

An ordinary working-tree scan of a Git repository reports partial:

keyhog scan . --format json-envelope -o keyhog.json
jq '{status: .scan_status, gaps: .coverage_gap_summary}' keyhog.json
{
  "status": "partial",
  "gaps": [
    {
      "reason": "binary (extension or content sniff)",
      "count": 1
    },
    {
      "reason": "exclusion policy (default excludes such as lock files, minified/bundled assets, vendored and build-output trees; --git-staged also counts repository `.keyhogignore` matches here)",
      "count": 6690
    }
  ]
}

The default skips for .git/, lockfiles, vendored trees, and minified bundles are counted as coverage gaps, and on this repository that is 6,690 of them. Almost every real repository therefore reports partial. A CI rule that fails on partial fails on every scan and gets switched off within a week.

Gate on the gap reasons you care about instead:

jq -e '[.coverage_gap_summary[]
        | select(.reason | test("exclusion policy") | not)]
       | length == 0' keyhog.json

That expression exits non-zero when any gap other than the exclusion-policy gap is present. On the sample above it exits 1, because of the binary row, which is the behavior you want: the exclusion gap is expected and the binary gap is worth a look. Add .metadata.source_bytes_scanned > 0 to the same expression when the input is one you can size in advance.

Gap reasons

Every reason string below comes from the scanner. The wording in the report is longer; the fragment shown here is enough to match on.

Reason fragmentWhat was not scannedWhat to do
exceeded --max-file-sizeA file larger than the cap, 100 MiB by default.Raise --max-file-size with a unit, or scan the file on its own.
binary (extension or content sniff)A file KeyHog classified as binary. A directory walk does not reinterpret it as text. A directory containing only skipped binaries also gets scan covered nothing and exits 13; a mixed tree can exit 0 with this advisory row.Expected for images. Never read it as coverage of an executable. --no-default-excludes does not change it. Use keyhog scan --binary <file> on a build with the binary feature.
unreadable (permission denied or I/O error)A file KeyHog could not open.Fix permissions or rerun with the right identity.
default exclusion policy (lock files, minified/...)A path removed by the default skips. Your own .keyhogignore and --exclude-paths removals are NOT counted here.Expected on repositories. See the next section for the limits.
source emitted error rowsA source returned an error for part of its input.Read the stderr warnings, which name the exact path or object.
source scan truncated by aggregate source capInput past a total-bytes ceiling.Raise the source cap or partition the input.
archive or container extraction truncated by an unpack budgetArchive or image members past the expansion budget.Read the stderr warnings, which name the exact cap. Unpack with a trusted tool and scan the result.
Git-LFS pointerThe blob behind an LFS pointer file.Run git lfs pull, then rescan.
git object unreadableA Git object that could not be read.Repair the repository, then rescan.
scanner decode-through truncated by budget/capDeeper encoded layers inside a chunk.Raise --decode-depth.
scanner decode-through declined by --decode-size-limitEverything encoded inside an oversize chunk.Raise --decode-size-limit.
scanner structured decode-through skipped by size capEncoded values inside a large structured file, such as a Kubernetes data block.Split the file. The structured parse cap has no CLI flag.
scanner chunk abandoned at its per-chunk deadlineThe remaining bytes of one chunk.Raise --per-chunk-timeout-ms.
binary deep analysis degraded to strings-onlyStructured analysis of a native binary.Expected without a working deep-analysis backend. Strings were still scanned.
binary unreadableA native binary that could not be opened.Fix permissions, then rescan.

Exit codes and coverage

Exit 13 means a requested source failed or coverage was incomplete. It is the loud failure. Two examples, both real:

keyhog scan --docker-image registry/app:no-such-tag
WARN source: failed to read source: failed to export docker image: ...
error: a requested scan source failed to read and produced no data (see the
warnings above). Not reporting "clean": that scan did not run.

Exit code 13.

keyhog scan --s3-bucket no-such-bucket
WARN source: failed to read source: S3 source listing failed: bucket request
returned 404 Not Found; objects were not scanned.

Exit code 13.

A total source failure still writes a report

Both examples above exit 13 and write a report. So does an oversize file scanned on its own, and so does a directory whose only file is unreadable. --format json-envelope -o keyhog.json always produces a parseable envelope, carrying whatever findings there were and the gaps naming what was not covered:

{
  "scan_status": "failed",
  "coverage_gap_summary": [
    {"reason": "scan covered nothing (zero source bytes read; ...)", "count": 1},
    {"reason": "source emitted error rows (requested input was not fully scanned)", "count": 1},
    {"reason": "exceeded a configured size cap (--max-file-size or the matching --limit-*-bytes)", "count": 1}
  ]
}

When a scan reaches report writing and the output path is writable, exit 13 comes with an envelope naming the uncovered input. A missing report is not evidence of a source gap: inspect the exit code and stderr. An invalid or unwritable output path exits 2 and names that path.

Findings are never discarded because part of the input failed. A directory holding one readable file with a credential and one unreadable file reports the finding, both gap rows, and exits 1.

A CI job can therefore parse the report unconditionally:

rm -f keyhog.json
rc=0
keyhog scan "$TARGET" --format json-envelope -o keyhog.json || rc=$?
jq '{bytes: .metadata.source_bytes_scanned, status: .scan_status,
     gaps: .coverage_gap_summary}' keyhog.json
printf 'keyhog exit=%s\n' "$rc"

Keep the rm -f. Its original reason is gone, but a report left by an earlier run is still indistinguishable from a fresh one if anything ever stops this scan writing. Give every scan its own output path, or delete the path before you write to it.

Exit 13 does not track coverage gaps. It tracks source error rows. That distinction decides whether your CI job notices.

A gap that is a skip leaves the exit code alone. The scan reports partial and exits 0:

GapExit
binary (extension or content sniff)0
exclusion policy (...)0
scanner decode-through declined by --decode-size-limit0

That last row is the one to take seriously. It means a credential inside an encoded payload was never decoded, on the default preset, over an ordinary file, and your build did not fail. In any file over 1 MiB only the tail is decode-reachable, so the same payload is found at the end of the file and missed in the middle of it. See Encoded payloads: position decides.

A gap that is an error also emits a source emitted error rows row, and that is what produces exit 13:

GapAlso emitsExit
exceeded --max-file-sizesource emitted error rows13
unreadable (permission denied or I/O error)source emitted error rows13
scan covered nothingzero source bytes read, whatever the cause13

So a directory whose content is partly skipped exits 0 with scan_status partial, a directory with one unreadable file exits 13, and a scan that read nothing at all exits 13. All are partial. Only the first is quiet.

When the covered part of the input does have findings, the findings exit code wins: 1 for findings, or 10 for a confirmed live credential under --verify. The coverage gap stays in the report either way. Do not read exit 1 as complete coverage, and do not read exit 0 as coverage at all. Read coverage_gap_summary and source_bytes_scanned.

Exit codes lists every code KeyHog returns.

Shipped cases where a clean scan is wrong

Each case below returns an empty findings list over input that contains a live-shaped credential. Two of them are now fixed and are kept because the shape still catches people out; the surviving silent one is the Git-history archive. Check for each on the workloads where it applies.

Minified and vendored paths report nothing (fixed)

A directory containing only app.min.js reports no findings, and its source_bytes_scanned count is zero. The file is skipped by the default exclusion policy. That is now loud: the scan exits 13 with two gap rows, a scan covered nothing row and a default exclusion policy row naming which policy did it.

--no-default-excludes now makes the walker read the file AND turns off the post-match drop, so the credential is reported. Without that flag, every finding whose path ends in .min.js, .bundle.js, or .min.css, or sits under node_modules/, bower_components/, jspm_packages/, site-packages/, wp-includes/, wp-content/plugins/, wp-content/themes/, public/plugins/, public/static/, public/vendor/, static/vendor/, dist/vendor/, dist/assets/, or vendor/assets/ is still dropped after matching. Each drop is counted and reported as its own coverage-gap row naming how many findings were dropped, so the suppression is visible rather than silent.

Measured: a directory holding only app.min.js with a planted credential reports zero findings by default and exit 13, and with --no-default-excludes reports the credential and exits 1.

--dogfood shows the individual suppressed matches and their reason, which is more detail than the gap-row count:

keyhog scan dist/ --no-default-excludes --dogfood --format json-envelope

The stderr trace names the reason vendored_minified_path.

To scan a bundle you own, copy it to a path outside those names and scan the copy:

cp dist/app.min.js /tmp/audit/app.js
keyhog scan /tmp/audit

The same bytes report the finding at that path.

An archive reached through Git history is not opened

Container handling applies to files on disk. It does not apply to Git objects or to cloud object bodies. The same archive gives different coverage depending on how you reach it:

keyhog scan repo/ --no-default-excludes --format json-envelope
keyhog scan --git-history repo --format json-envelope

The working-tree scan descends into the archive and reports the credential inside it. The Git-history scan reports a binary (extension or content sniff) gap for that blob, scan_status partial, and exit 0.

The gap row is there, so this is not fully silent. It is still easy to misread: a binary gap normally means an image or a compiled object you did not want scanned, and here it means an archive nobody opened. --git-blobs reports the same gap and exits 13, so the two Git commands disagree on loudness over the same bytes.

Unpack committed archives and scan the result as a separate job when history is in scope. See Container images and OCI layers.

A total ignore rule scans nothing (fixed)

A .keyhogignore containing path:** matches every path, so the scan reads zero source bytes. That now exits 13 and carries a scan covered nothing gap row, and the text report says the scan covered nothing instead of reporting no secrets. Path rules in .keyhogignore and --exclude-paths still record no gap of their own, so a too-broad rule that leaves SOME bytes readable stays invisible in the report. Only the read-nothing case is loud.

Measured: --exclude-paths '**' and a .keyhogignore containing path:** each exit 13 with scan_status partial, zero source_bytes_scanned, and a scan covered nothing gap row.

Find out whether an allowlist file is in effect before you trust a clean scan on a repository whose ignore file you did not write:

keyhog config --effective

The allowlist_file line names the .keyhogignore that will be loaded. Read that file, then confirm source_bytes_scanned is a plausible size for the tree.

Prove your check works

Plant a credential-shaped value, scan, and confirm your check reports it. A canary is a fake credential you place on purpose so you can prove the scan reached it.

Generate the value instead of copying one. KeyHog suppresses the well-known documentation samples on purpose, so sk_live_4eC39HqLyjWDarjtT1zdp7dc and AKIAIOSFODNN7EXAMPLE both report zero findings and teach you nothing:

mkdir -p /tmp/keyhog-canary
printf 'STRIPE_SECRET_KEY=sk_live_%s\n' \
  "$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)" \
  > /tmp/keyhog-canary/canary.env
keyhog scan /tmp/keyhog-canary --format json-envelope | jq '.findings | length'

Expect 1. If your pipeline reports 0 for that input, the pipeline is broken, not the repository.

Put the canary inside the shape you actually scan. A canary in a plain file proves nothing about a scan whose real input is a container layer, an archive member, or a Git commit that no longer exists in the working tree.

Out-of-band verification

Out-of-band (OOB) verification checks whether a service calls an interactsh collector after KeyHog sends a detector-defined probe. It is useful for webhook, mail, and callback credentials. A callback proves the behavior named by the detector. It does not prove the credential’s full permissions.

OOB is off by default. In v0.5.81, the shipped detector corpus contains no [detector.verify.oob] block. --verify-oob therefore changes no shipped finding. Use it only with a reviewed custom detector corpus that declares an OOB probe.

Prerequisites

You need all of the following:

  1. A custom detector with a valid [detector.verify] request, explicit allowed_domains, and a [detector.verify.oob] block.
  2. An {{interactsh}}, {{interactsh.host}}, {{interactsh.url}}, or {{interactsh.id}} token in that verifier’s URL, header, or body.
  3. A reachable interactsh collector. The default is the public oast.fun collector.
  4. DNS and HTTPS egress from the KeyHog host to the collector.
  5. Network egress from the verified service to the collector protocol selected by the detector.
  6. --verify --verify-oob on the scan command.

The collector host must pass KeyHog’s SSRF checks. A self-hosted collector must resolve to a public address. A loopback, link-local, RFC 1918, or cloud-metadata address is rejected even when you configured an explicit proxy.

Audit the custom corpus before scanning:

keyhog detectors --detectors ./company-detectors --audit

keyhog scan ./repo \
  --detectors ./company-detectors \
  --detectors-mode overlay \
  --verify \
  --verify-oob \
  --oob-server oast.fun \
  --oob-timeout 30 \
  --format json-envelope \
  --output keyhog-results.json

--oob-server and --oob-timeout require --verify-oob. --verify-oob requires --verify. Clap rejects invalid combinations before a scan begins.

Detector configuration

Add OOB configuration to an otherwise complete detector. This excerpt shows the verification portion:

[detector.verify]
method = "POST"
url = "https://api.example.test/probe"
allowed_domains = ["api.example.test"]
body = '{"callback":"{{interactsh.url}}/keyhog-probe"}'

[detector.verify.success]
status = 200
policy = "status_with_error_backstop"

[detector.verify.oob]
protocol = "http"
timeout_secs = 30
policy = "oob_and_http"

The detector validator rejects these unsafe or ineffective shapes:

  • An OOB block without an interactsh token.
  • An interactsh token without an OOB block.
  • OOB on a multi-step verifier.
  • An unapproved verification destination.

Never put {{match}} or a secret companion into a collector URL, collector header, or callback payload. The credential belongs only in the request to the legitimate provider endpoint.

Tokens

TokenExpanded value
{{interactsh}}Bare per-finding collector host
{{interactsh.host}}Bare per-finding collector host
{{interactsh.url}}Full https:// collector URL
{{interactsh.id}}Per-finding ID without the collector suffix

KeyHog creates a 24-character per-session correlation ID and appends a 24-character random suffix for each finding. The resulting per-finding ID is 48 lowercase alphanumeric characters. Token values are sanitized to the DNS hostname character set before interpolation. They are not URL-encoded.

Policies and outcomes

PolicyHTTP resultMatching callbackFinding result
oob_and_httpLiveObservedlive
oob_and_httpLiveNot observeddead
oob_and_httpNot liveNot consultedOriginal HTTP result
oob_onlyLive or deadObservedlive
oob_onlyLive or deadNot observeddead
oob_onlyRate-limited or errorNot observedOriginal HTTP result
oob_optionalAnyEitherOriginal HTTP result

protocol = "dns", "http", "smtp", or "any" selects which collector interaction counts. Use "any" only when any of those callbacks proves the detector’s intended behavior.

--oob-timeout is the default wait for a detector that omits timeout_secs. A detector-specific timeout replaces that default. In v0.5.81, the CLI value is not a strict upper bound on detector-specific timeouts. The runtime cap is 120 seconds or the CLI value, whichever is larger.

Machine-readable result

The default output remains redacted. A successful OOB finding can contain this metadata. All values below are synthetic:

{
  "verification": "live",
  "metadata": {
    "oob_observed": "true",
    "oob_protocol": "Http",
    "oob_remote_address": "203.0.113.42",
    "oob_timestamp": "2026-07-25T12:00:00Z",
    "oob_unique_id": "aaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbb"
  }
}

When no callback arrives, oob_observed is "false". When strict oob_and_http skips the wait because HTTP already failed, metadata also contains "oob_skipped":"http-failed-under-oob-and-http". If the session is disabled, metadata contains oob_disabled and verification is an {"error":"..."} object.

JSON and JSONL place these values in the finding’s metadata object. SARIF prefixes each property with metadata., for example properties["metadata.oob_observed"]. Text and the remaining projections use their normal finding-metadata representation.

OOB metadata is not a credential, but it identifies a scan session and callback source. Store it with the same access controls as the rest of the report. Never use --show-secrets for a retained OOB report.

The process exit follows the normal verification contract. A live OOB finding makes the scan exit 10. A dead, rate-limited, or error finding exits 1 when findings are present and none is live. OOB infrastructure failure does not by itself produce a special process exit.

Failure behavior

  • No --verify-oob: OOB-required detectors fail closed before sending any HTTP probe. The metadata contract is oob_disabled = "no active OOB session".
  • Collector handshake failure: KeyHog prints one stderr warning with the server and a redacted error. Ordinary HTTP verifiers continue. OOB-required findings fail closed before their provider probe.
  • Collector disabled during a wait: the finding becomes error, and oob_disabled records the reason.
  • No matching callback before the timeout: the observation is not an infrastructure error. The selected OOB policy determines dead or preserves the HTTP result.
  • Poll errors: the poller backs off from one second to at most 32 seconds. Once it is degraded, pending waits fail closed instead of treating missing callbacks as dead credentials.

Transport errors redact collector URLs before they reach warnings or finding errors.

Collector privacy

The collector sees the session and per-finding correlation IDs. It also sees the callback source IP, timestamp, protocol, and raw callback payload. That payload can contain provider-selected headers or body data. Review the detector probe and the provider’s callback behavior before using a public collector.

The collector does not receive the scanned repository path, commit, or finding metadata from KeyHog. KeyHog sends the credential to the legitimate provider verification endpoint, not to the collector. A badly designed custom detector or a provider that reflects credential material in its callback can violate that separation. This is why OOB detector review is a prerequisite.

Use a self-hosted collector for regulated code, customer repositories, or any provider whose callback payload is not known to be non-sensitive.

Self-hosting interactsh

Your domain needs public DNS delegation to a publicly reachable host. Install and run the upstream server:

go install github.com/projectdiscovery/interactsh/cmd/interactsh-server@latest

interactsh-server \
  -domain "$YOUR_DOMAIN" \
  -ip "$YOUR_PUBLIC_IP" \
  -listen-ip 0.0.0.0 \
  -tls-cert "/etc/letsencrypt/live/$YOUR_DOMAIN/fullchain.pem" \
  -tls-key "/etc/letsencrypt/live/$YOUR_DOMAIN/privkey.pem"

Then pass the public domain:

keyhog scan ./repo \
  --detectors ./company-detectors \
  --detectors-mode overlay \
  --verify \
  --verify-oob \
  --oob-server "$YOUR_DOMAIN" \
  --format json-envelope \
  --output keyhog-results.json

Other integrations

Recipes for hook managers, CI systems, Rust embedding, and notifications. The dedicated pre-commit and CI guides own those workflows. The pages below are platform recipes, not second specifications.

Pick your integration class:

ClassPage
Git hook managers: pre-commit, pre-push, Husky, lefthookGit hook managers
CI systems: GitHub Actions, GitLab CI, CircleCI, Drone, Buildkite, Jenkins, DockerCI systems
Rust library, another CLI, SARIF for Code ScanningEmbedding KeyHog
Slack, Discord, webhooksAlerts and notifications

Install the release with the verified installer, which records the host’s autoroute evidence. A source-built multi-backend binary used outside the GitHub Action must run keyhog calibrate-autoroute before its first automatic scan. A portable single-backend build has no routing choice.

For the full contract behind a command, use the focused reference instead of treating a copied snippet as a second specification:

TaskStart here
Protect local commitskeyhog hook install
Gate a pull requestCI integration
Scan a large tree or choose a policyDetection settings and hardware
Suppress an accepted findingSuppressions
Interpret a failureExit codes
Tell a clean scan from a skipped inputCoverage truth

Allowlists and baselines

When you have known-but-unfixable findings (rotated test keys, public demo creds, fixtures), use a baseline:

# Once
keyhog scan . --create-baseline .keyhog-baseline.json

# Forever after
keyhog scan . --baseline .keyhog-baseline.json

Baseline JSON is strict: unknown root or entry fields fail closed instead of silently changing suppression policy. The legacy v1 entry status field is accepted only for compatibility and is never serialized or used as a policy decision. Review baseline edits like code and regenerate them with --create-baseline when the identity set is intentionally changed.

For per-file/per-line allowlists, the moving parts live in two separate files. Scan execution policy has one canonical [scan] owner; unknown tables and retired flat spellings fail closed:

.keyhog.toml at the repo root:

[scan]
severity       = "high"
min_confidence = 0.4
threads        = 8
exclude        = ["vendor/**", "node_modules/**", "**/*.lock"]

.keyhogignore (or .keyhogignore.toml) alongside it - gitignore- style path globs plus detector:<id> and hash:<sha256> entries:

# silence all hits from this detector
detector:http-basic-auth

# gitignore-style path globs
vendor/**
node_modules/**
**/*.lock

See the .keyhogignore.toml reference for the full schema.

Exit codes

Use the canonical exit-code reference for the full numeric contract. In CI, findings and verified-live credentials block the change; configuration, system, backend, incomplete-coverage, panic, and interruption outcomes also fail the job because the requested security control did not complete. Never normalize every nonzero result to “findings found.”


Choose a scan policy for scale

# Lightweight staged-content check; independent of host autoroute state
keyhog scan --fast --git-staged --backend cpu

# Deep release/security gate; uses calibrated automatic routing
keyhog scan . --deep --severity high

# High-precision policy for a large tree where false-positive review dominates
keyhog scan /large/tree --precision --severity high

# Force GPU for a diagnostic/benchmark run
keyhog scan . --backend gpu-wgpu

# Write the versioned JSONL stream to a file
keyhog scan . --format jsonl-envelope --output findings.jsonl

--fast, --deep, and --precision intentionally resolve different detection policies and can produce different findings. Hardware and automatic backend selection must not. Measure the chosen policy on the real corpus and let persisted calibration choose among every measured-correct backend for that exact host and workload. See Configuration presets and Backends and routing before changing policy or forcing an engine.

Troubleshooting

SymptomLikely causeFix
Exit 12 with a selected-GPU diagnosticRequired, explicit, or calibration GPU execution could not start or completeRun keyhog backend --self-test, repair the GPU stack, and recalibrate; normal automatic runtime faults instead produce a visible complete-after-recovery receipt when the stable bytes can be replayed
Findings count drops vs prior runBaseline, detector corpus, scan policy, or .keyhog.toml changedCompare the effective config, detector digest, baseline, and input scope from both runs
Pre-commit hook is slowScanning the whole repo on every commitUse --git-staged not scan .
SARIF report is too large for the consumerThe selected scope produced more findings than the consumer acceptsNarrow the scanned source, use a reviewed baseline, or choose an explicit severity policy; do not hide an incomplete upload
Detection misses a known tokenDetector absent from the loaded corpus / --fast disabled decode recursion or entropy discoveryRe-run with the embedded corpus and --deep; file an issue if it still misses

Git hook managers

Run KeyHog before a commit or a push leaves the machine. The dedicated pre-commit guide owns the supported hook; the recipes here are for hook managers that drive it.

The local recipes pass an explicit --backend cpu so they do not depend on machine-local autoroute state.

Pre-commit hook (Git)

Use the canonical pre-commit guide for installation, hook ownership, staged-content semantics, bypass auditing, performance, and removal.

Pre-push hook (Git)

Pre-commit is the fastest local gate. A pre-push history scan also finds a credential introduced by an earlier commit on the checked-out branch. Save this as .git/hooks/pre-push and make it executable:

#!/usr/bin/env bash
set -euo pipefail

keyhog scan --git-history . --backend cpu

This scans added lines across all commits reachable from local HEAD. It does not depend on the remote name, upstream branch, or network access. It is broader and slower than a staged scan. KeyHog’s nonzero status is returned unchanged, so findings and incomplete scans both block the push. CI remains the authoritative gate because git push --no-verify bypasses local pre-push hooks.

pre-commit framework

The pre-commit framework recipe lives with the raw Git hook workflow so both installation paths share one behavioral contract.

Husky / lefthook

Husky (.husky/pre-commit)

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

keyhog scan --fast --git-staged --backend cpu

Lefthook (lefthook.yml)

pre-commit:
  parallel: true
  commands:
    keyhog:
      run: keyhog scan --fast --git-staged --backend cpu
      fail_text: "secrets detected - see output above"

CI systems

One recipe per CI system. CI secret scanning owns the contract: what to fail on, how to preserve coverage, and which exit codes mean what. These are the platform-specific ways to invoke it.

A source-built multi-backend binary must run keyhog calibrate-autoroute before its first automatic scan. A portable single-backend build has no routing choice.

GitHub Actions

Use the GitHub Action guide for the composite Action, inputs and outputs, baseline adoption, monorepo partitions, SARIF publication, and failure behavior. Use the CI guide when a GitHub workflow needs direct CLI flags such as --git-history or --git-blobs.

GitLab CI

Use the canonical GitLab CI workflow. It owns installation, GitLab SAST output, artifact retention, and exit semantics.

CircleCI

Use the canonical CircleCI workflow. It owns shell setup, scan status, and artifact handling.

Drone CI

Use the canonical Drone workflow. For another CI runner, use the generic shell workflow.

Buildkite

Use the canonical Buildkite workflow.

Jenkins

Use the canonical Jenkins workflow.

Docker / Docker Compose

Scan a repo from a one-shot container without installing anything on the host:

# No published registry image yet - build once from the repo (the Dockerfile
# ships in the repo root), then run the scan:
docker build -t keyhog:local https://github.com/santhreal/keyhog.git
docker run --rm -v "$PWD":/src keyhog:local \
  scan /src --backend cpu --format text

docker-compose.yml:

services:
  keyhog:
    build: https://github.com/santhreal/keyhog.git
    volumes:
      - ./:/src:ro
    command: scan /src --backend cpu --format json-envelope

To scan a built image, use the Docker/OCI source so layers, manifests, and source coverage are handled by KeyHog instead of manually unpacking an archive:

keyhog scan --docker-image my-image:latest

Embedding KeyHog

Call KeyHog from your own code instead of shelling out to it, or hand its output to another tool.

As a library (Rust)

Add to Cargo.toml:

[dependencies]
keyhog-core = "0.5"        # detector specs + Chunk/ChunkMetadata
keyhog-scanner = "0.5"     # CompiledScanner

(Detectors ship inside keyhog-core as a static-embedded TOML corpus; there is no separate keyhog-detectors crate.)

Minimal scan:

use keyhog_core::{Chunk, ChunkMetadata, RawMatch};
use keyhog_scanner::CompiledScanner;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Built-in embedded detectors - no disk I/O, fail-closed on corrupt bundled TOML.
    let specs = keyhog_core::load_embedded_detectors_or_fail()?;
    // …or load from a directory of TOMLs:
    // let specs = load_detectors(std::path::Path::new("detectors"))?;

    let scanner = CompiledScanner::compile(specs)?;

    let bytes = std::fs::read("config.yaml")?;
    let chunk = Chunk {
        data: String::from_utf8_lossy(&bytes).into_owned().into(),
        metadata: ChunkMetadata {
            source_type: "filesystem".into(),
            path: Some("config.yaml".into()),
            ..Default::default()
        },
    };
    let matches = scanner.scan(&chunk)?;
    for m in &matches {
        println!(
            "{}:{} (detector {})",
            m.location.file_path.as_deref().unwrap_or("<memory>"),
            m.location.line.unwrap_or(0),
            m.detector_id
        );
    }
    // RawMatch stays in process; this projection is safe to serialize or report.
    let _report_safe: Vec<_> = matches.iter().map(RawMatch::to_redacted).collect();
    Ok(())
}

For directory-tree / git / docker walking, drive keyhog-sources or shell out to the CLI - CompiledScanner is one chunk at a time by design.

The no-backend scan and scan_coalesced methods are deterministic portable CPU calls. Explicit scan_with_backend and scan_coalesced_with_backend calls return typed ScanError values when a selected backend cannot initialize or finish. They never terminate the embedding process and never substitute a different engine. You can probe startup eligibility with warm_backend; the CLI owns the separate mapping from terminal scanner errors to process exit status.

Successful calls return Vec<RawMatch> inside the typed Result. Credential, SensitiveString, raw or deduplicated matches, and source Chunk values can contain plaintext or encoded secret bytes and therefore refuse implicit serde output. Convert raw matches with RawMatch::to_redacted, or emit the verification pipeline’s VerifiedFinding, before JSON, logging, disk, or network output. Only a protected private protocol should explicitly reveal secret bytes.

For finer-grained control of individual detector features:

[dependencies]
keyhog-scanner = { version = "0.5", default-features = false, features = ["ml", "decode", "entropy"] }

Embedded in another CLI

Shell out:

use std::process::Command;
let out = Command::new("keyhog")
    .args(["scan", "--format", "jsonl-envelope", "--min-confidence", "0.4", "."])
    .output()?;
if !matches!(out.status.code(), Some(0 | 1)) {
    return Err(std::io::Error::other(format!(
        "keyhog did not complete the requested scan: {}",
        String::from_utf8_lossy(&out.stderr)
    )).into());
}
for line in out.stdout.split(|b| *b == b'\n') {
    if line.is_empty() { continue; }
    let record: serde_json::Value = serde_json::from_slice(line)?;
    if matches!(record.get("record_type").and_then(|v| v.as_str()), Some("header" | "summary")) {
        continue;
    }
    let finding = record;
    // ... do whatever
}

Or invoke the scan subcommand directly from a wrapper script:

keyhog scan /path/to/project --format jsonl-envelope --min-confidence 0.4

SARIF for GitHub Code Scanning

The composite Action is the safest way to create, upload, and retain SARIF:

- uses: santhreal/keyhog@v0
  with:
    format: sarif
    upload-sarif: 'true'
    fail-on-findings: 'true'

Grant security-events: write as shown in the GitHub Action guide. The Action uploads before it enforces findings, keeps a workflow artifact, and makes only a fork pull request’s restricted-token upload advisory. Trusted upload failures fail the job.

For another SARIF consumer, write the file directly:

keyhog scan . --format sarif --output keyhog.sarif

The command exits 1 when a finding blocks the active evidence policy and 10 on a verified-live finding. Arrange report publication in an always-run or post step, then restore the exact scan status. KeyHog tags findings with CWE-798 and OWASP A07:2021.

Alerts and notifications

Send a finding somewhere a human will see it.

Slack / Discord / webhook alerts

Post a one-line summary on every finding:

#!/usr/bin/env bash
set -euo pipefail
set +e
findings_json="$(keyhog scan . --format json-envelope --min-confidence 0.4)"
scan_status=$?
set -e
case "$scan_status" in
  0|1) ;;
  *) echo "keyhog scan did not complete (exit $scan_status)" >&2; exit "$scan_status" ;;
esac
count="$(echo "$findings_json" | jq '.findings | length')"
if [ "$count" -gt 0 ]; then
  curl -X POST -H 'Content-type: application/json' \
    --data "{\"text\":\"⚠ keyhog: $count secret(s) detected in $(basename "$PWD")\"}" \
    "$SLACK_WEBHOOK_URL"
  exit 1
fi
exit "$scan_status"

For Discord, replace text with content. For PagerDuty, use the events/v2/enqueue endpoint with severity critical for --severity critical findings.

VYRE integration

KeyHog pins six VYRE runtime crates to exact version =0.7.2 at reviewed upstream commit 8be30afe43fb54e38965dd9e9ae46a1b39b824a2. Every workspace crate shares that immutable source identity through Cargo.lock; KeyHog carries no vendored VYRE tree and never resolves VYRE through machine-local paths.

Production ownership

VYRE supplies accelerated primitives. KeyHog still owns detector compilation, backend eligibility, persisted autoroute evidence, extraction, suppression, confidence, verification, and reporting. A VYRE result is therefore never a second interpretation of a detector and never bypasses the shared finding pipeline.

VYRE capabilityKeyHog ownerProduction use
GPU fused literal evidencekeyhog-scanner::engine::gpu_region_dispatch, keyhog-scanner::gpu::backend::resident_evidenceOne fused dispatch produces the candidate-detector bitmap and complete positions for detector-derived confirmed anchors and generic assignment stems. WGPU adapters that lack either required timestamp feature use the exact borrowed fused primitive because the resident VYRE API requires device timestamps. Dispatches honor the smaller of the live VRAM/config budget and the backend ceiling. Oversized batches shard between chunks. Oversized individual chunks use overlap-preserving physical windows whose presence and position rows reduce into one logical row on the selected WGPU or CUDA peer. The common path starts with a 65,536-record, 768 KiB position buffer. A dense batch is counted exactly and replayed once with a bounded larger buffer. No partial position set is accepted.
GPU literal artifacts and cachekeyhog-scanner::engine::{gpu_artifacts,gpu_cache}Compiles one ordered detector-derived matcher containing trigger and positioned-evidence segments. The local key combines a program-kind prefix with a SHA-256 hash of KeyHog’s cache-format version and the exact length-delimited rows. VYRE rejects incompatible wire envelopes when loading.
GPU regex-DFA admissionkeyhog-scanner::engine::phase2_gpu_dfaNarrows eligible prefixless phase-two work; host extraction remains authoritative. KeyHog first compiles one catalog shard, then recursively splits only when VYRE proves that the DFA exceeds its state cap. Each resulting shard requires one full-batch dispatch.
Quantized confidence scoringkeyhog-scanner::confidence::{quantized,quantized_vyre}Runs the authenticated fixed-point model through one bounded asynchronous VYRE score program for GPU-owned rows. CPU and SIMD routes use the same integer artifact. The score dispatch has its own slot, fence, and retirement lifecycle; it is not fused into the resident literal program. Invalid UTF-8, empty, oversized, and unquantizable rows remain explicitly CPU-owned.
Ordered GPU device setskeyhog-scanner::gpu::device_set, keyhog-cli::orchestrator::dispatch::backendDeduplicates cross-API aliases by physical topology, authenticates every required adapter, allocates bounded resident slots all-or-nothing, assigns contiguous weighted source ranges, dispatches devices concurrently, and retires results in source order. One member failure invalidates the complete set.
Declarative rule evaluationkeyhog-core::suppression::ruleEvaluates .keyhogignore.toml rules through the shared rule representation.

The portable build retains the CPU-side VYRE support libraries used by these shared primitives while omitting WGPU/CUDA drivers and their startup probes. Those libraries are not a separate scan backend: cpu-fallback remains KeyHog’s Aho-Corasick trigger path plus Rust-regex extraction.

Backend and parity contract

The GPU path produces phase-one candidate triggers, optional phase-two admission rows, and complete literal positions that replace equivalent host localization passes. Host regex extraction remains authoritative. GPU and CPU routes use the same decode, built-in suppression, confidence, and scanner postprocessing. Release parity canonicalizes results before comparing the chunk-indexed match multiset, including every finding field and multiplicity. It does not compare backend emission order. Canonical report ordering is a separate postprocessing contract. An empty or structurally different GPU result is a failure, not a successful scan.

VYRE does not choose the scan backend. --backend auto accepts only a current persisted KeyHog calibration record that proves correctness and measures every eligible backend for the exact binary, detector/config digests, host, runtime, device, and workload bucket. Missing, stale, or incomplete proof is an invalid autoroute state. See Autoroute calibration.

Diagnostics

Use these operator surfaces instead of implementation-specific environment variables:

keyhog backend
keyhog backend --self-test --json
keyhog calibrate-autoroute
keyhog scan PATH --backend gpu-wgpu --profile

--backend gpu-wgpu is a diagnostic/benchmark override. It proves neither automatic selection nor a valid calibration record. GPU initialization, runtime, parity, and calibration failures remain visible in the command result and exit status. An explicit or required GPU route that fails dispatch exits 12. A normal automatic scan replays exact unprocessed ranges from the same stable snapshot through the fastest remaining measured-correct peer, records those ranges, retains completed GPU work, and quarantines the affected workload identity.

Feature boundaries

Build featureVYRE surface
portableCPU-side VYRE support primitives only; no VYRE scan backend or GPU driver
gpuRuntime-probed CUDA, native Metal, and WGPU drivers behind the shared GPU contract

The retired per-rule megakernel catalog and environment-selected GPU side routes are not production KeyHog backends. Backend names and runtime policy are the canonical CLI/TOML values documented in Backends and routing and Configuration.

Architecture

This is the map: where everything lives and how a byte becomes a finding. It links to the authoritative in-code docs rather than restating them, so there is one source of truth per fact. Read this first; then jump to the cited module.


Repository layout

Every top-level directory, one line each. Code is Rust under crates/; everything else is data, tooling, docs, or eval harness.

DirRole
crates/Rust workspace: runtime code only (six crates; see below).
detectors/Embedded detector TOMLs (data, not code). One file = one secret type; drop a file to add a detector without rewriting detection logic. The generated catalog owns the current count. See the detector reference.
rules/Tier-B data (e.g. aws-canary-accounts.toml); same drop-in model as detectors/.
ml/Python pipeline for embedded weights.bin: harvest → blend → train → gate (retrain_loop.sh). Trains; crates/scanner serves.
benchmarks/Eval harness (bench/): corpora, scanner adapters, scorer, regression/differential gate, README leaderboard.
tests/Repo-level integration tests (Docker, install, cross-OS). Per-crate tests live under each crate’s tests/.
fuzz/cargo-fuzz targets (structure-aware, one sink per target).
tools/Build-time generators (gen_contracts.py, gen_companion_contracts.py). Large gitignored SecretBench corpus.
scripts/Maintained dev/release entrypoints and organization/product-truth gates. One-off corpus rewrite scripts do not ship.
docs/src/The single canonical documentation set, built and deployed as mdBook.
demo/Self-contained demo deployment (app + infra + scripts).
metrics/Star and project-health metrics.

Internal execution planning lives in the private Santh monorepo, not in this public repository.


The crates and their layering

Dependencies point one way: core and profile are foundations and depend on no other KeyHog crate; cli sits on top and wires the rest together. This DAG is enforced by Cargo and must stay acyclic (domain logic never imports CLI/transport/UI).

                          cli
            orchestration · transport · process exits
             ┌─────────────┼──────────────┬──────────┐
             ▼             ▼              ▼          ▼
          scanner        sources       verifier   profile
          detection       inputs       live checks  timing · run state
             │  ╲          │  ╲           │          ▲
             │   ╲         │   ╲ optional │          │
             │    └────────┼────┼─────────┼──────────┘
             └─────────────┼────▼─────────┘
                           ▼
                          core
        types · detector registry · reports · dedup · caches

scanner and verifier depend on core. scanner also records fixed stages through profile. sources depends on core and, for network-enabled source features, reuses verifier for shared SSRF and request-signing policy. cli selects features, composes all five libraries, and owns the operator-run profile session.

CrateOwnsStart reading at
coreEmbedded detector loading, detector specs, the Finding/Credential types, reporters, dedup, allowlists, the Merkle incremental-scan cache, and confidence-calibration data.crates/core/src/lib.rs, spec.rs, finding.rs, report/
profileAllocation-free fixed-stage timing, causal run identity, state transitions, process resource sampling, and portable JSON and text profile records.crates/profile/src/lib.rs
scannerThe detection engine: hardware probing and backend dispatch, prefilters, compile, scan, decode-through, entropy, ML confidence, multiline handling, and suppression. Persisted CLI route selection is intentionally not owned here.crates/scanner/src/compiled_scanner/ (construction and lifecycle), engine/mod.rs (execution flow), adjudicate/, pipeline/, lib.rs
sourcesWhere bytes come from: filesystem, Git (staged/diff/history), stdin, Docker, S3, GCS, Azure Blob, GitHub, GitLab, Bitbucket, web, HAR, strings, and optional binary/decompiler inputs.crates/sources/src/lib.rs
verifierTurning a candidate into a verified-live credential: per-detector verify endpoints, SSRF/bogon guards, OOB, rate limiting.crates/verifier/src/lib.rs, verify/, ssrf.rs
cliThe user-facing binary: argument parsing, the scan orchestrator, daemon/watch, baselines, calibrate, hook installer, output formatting.crates/cli/src/lib.rs, args/, orchestrator/; main.rs owns process/signal startup only

The crate graph does not imply that every build exposes every source or backend. The official and default CLI builds enable the full documented network-source set. The portable, ci-lean, and ci profiles deliberately remove different accelerator or source features. Library callers select their own feature set.

Load-bearing boundary owner map

The crate DAG is not the whole shipping boundary. The Action entrypoint, automatic crates.io publication, and each load-bearing library or CLI handoff have one definitional owner. Wrappers may compose these owners; they must not restate their policy.

BoundaryDefinitional owner
Marketplace metadata, documented inputs/outputs, and top-level composite stepsaction.yml
Repository-local Action metadata consumed by GitHub workflows.github/actions/keyhog/action.yml
Action input validation, authenticated binary acquisition, scan invocation, exit mapping, and output publication.github/actions/keyhog/run-scan.sh
Automatic version, changelog, and crates.io publication.github/workflows/release.yml
CLI argument dispatch and setup-error exit routingcrates/cli/src/lib.rs::cli_main
Completed-scan exit precedencecrates/cli/src/orchestrator/run.rs::resolve_scan_exit
Curated source-crate export surfacecrates/sources/src/api.rs
Live-verification construction and executioncrates/verifier/src/lib.rs::VerificationEngine
Deduplicated match to report-safe finding conversioncrates/core/src/finding.rs::VerifiedFinding::from_deduped
Scanner execution flowcrates/scanner/src/engine/mod.rs

This table is enforced by scripts/org_audit.py: every required boundary must remain paired with its exact owner row, every file must exist, and every named symbol must resolve. Merely retaining the same unordered set of paths is not enough. A move therefore updates implementation and architecture in the same change instead of leaving a plausible but stale owner behind.


The pipeline: bytes → finding

The end-to-end flow, stage by stage, each pointing at the crate/module that owns it. The scan engine’s own header doc (engine/mod.rs) is the authoritative, method-level version of steps 2-4.

  1. Acquire bytes: a source yields file-path + content chunks. crates/sources/src/ (filesystem/, git/, stdin.rs, docker/, s3/, gcs.rs, cloud/azure_blob.rs, github_org.rs, github_collaboration.rs, gitlab_group.rs, bitbucket_workspace.rs, hosted_git/, web/, har.rs, strings.rs, binary/).
  2. Phase 1: trigger production (which detectors could fire, and where). Swappable backend: scalar CPU literal/regex, SIMD Hyperscan (engine/backend_triggered.rs, engine/scan_coalesced.rs), or the GPU fused resident literal-evidence route (engine/gpu_region_dispatch.rs). It produces one “which detectors may match here” bitmap plus optional confirmed-anchor and generic-keyword positions per chunk. The fast prefilters (simdsieve, bigram_bloom, alphabet_filter, prefix_trie) live at crates/scanner/src/; detector-to-matcher construction lives in compiled_scanner/compile.rs, compiler.rs, and compiler/.
  3. Phase 2: extraction (the shared tail, identical for CPU and GPU): per-chunk confirmed → phase2 capture → generic → entropy → ML (engine/extract.rs, engine/phase2*.rs, engine/backend_triggered.rs, engine/scan.rs). Decode-through (base64/hex/url/unicode/json) runs here and recurses: decode/.
  4. Finish raw matches: scanner-owned suppression, confidence, and cross-chunk seam reassembly run in engine/scan_postprocess/, engine/process.rs, and engine/boundary/mod.rs. Confidence + ML scoring live in confidence/, ml_scorer.rs, and ml_scorer/; context inference lives in context/. The per-match policy here (suppression gates · example/placeholder · checksum · confidence penalties) is governed by one invariant; see Match adjudication: one policy, one chokepoint below.
  5. Verify (optional and networked): for detectors with a [detector.verify] plan, the verifier sends a credential-derived request to the declared service behind SSRF, bogon, and rate-limit guards.
  6. Resolve and report: the CLI orchestrator applies scan-level policy and allowlists; core deduplication and reporters emit text/JSON/SARIF and support baseline comparison. crates/cli/src/orchestrator/postprocess.rs, crates/cli/src/orchestrator/reporting.rs, crates/core/src/dedup.rs, and crates/core/src/report/ own these steps.

keyhog_core::VerifiedFinding::from_deduped is the conversion boundary from a deduplicated match to a report-safe finding. It initializes the complete finding shape, including measured entropy and redacted companions, so verifier, skipped, and diff paths cannot silently drift when the report contract grows.

The CLI keeps one owner for each finding graph as it advances through this pipeline. Scan-level suppression compacts the raw-match vector in place. Deduplication moves each accepted match into its group, verification partitions the deduplicated vector in place, and baseline suppression compacts the final finding vector in place. A stage may allocate indexes or output slots, but it does not clone the complete old graph while building a replacement graph.

An allocation-profile build assigns every retained heap allocation to the innermost active profile stage. Its allocation header preserves that owner when another stage or thread frees the value. Allocations outside a stage belong to the explicit root owner. Stage and root live-byte totals must equal the process allocator total, so retained memory cannot disappear into an unattributed remainder.

The accelerated batch path is two-phase and coalesced. A file with no phase-one hit stops only when the shared no-hit admission proof also rules out phase-two patterns, generic assignments, and enabled entropy analysis. This proof uses the active corpus’s compiled generic-keyword stems and the owning detector’s keyword_free_min_len plus effective Shannon floor; it does not substitute the embedded corpus or a scanner-wide run length for a focused custom corpus. Chunk size never disables an active detector path; overlapping source and scanner windows bound work instead. Portable CPU, Hyperscan, CUDA, and WGPU share this proof. Large filesystem scans may instead use the fused reader/scanner pipeline so I/O and scanning overlap; crates/cli/src/orchestrator/dispatch.rs and dispatch/fused.rs own that execution choice. Both paths feed the same scanner and report contracts. Backend choice must change performance only, never finding semantics.

Within the shared SIMD/GPU coalesced tail, detector, generic, and entropy candidates retain their per-chunk state while precomputed ML feature rows are scored in one deterministic CPU batch. Final scores return to the originating chunk before its cap, decode postprocess, seam handling, and report adjudication run. Every backend uses this confidence path; VYRE owns GPU detection only.

Execution surfaces

The CLI owns process-level routing. The scanner crate exposes explicit backend execution; it does not read the autoroute cache or silently choose from local hardware. This keeps library calls deterministic and makes CLI routing inspectable.

WorkloadExecution surfaceRouting and ownership
One in-process scankeyhog scan ... --daemon=offFull orchestrator; persisted one-shot autoroute evidence or an explicit diagnostic --backend.
Mass directories or source batcheskeyhog scan --daemon=mass ...Streams bounded batches to a mass-enabled daemon (daemon start --mass).
Perpetual repository guard and staged commitskeyhog guard and keyhog scan --git-stagedDaemon-resident guard runtime with in-memory Git OID clean attestation cache and watcher reconciliation.
Repeated eligible stdin or single-file scans on Unixkeyhog daemon start, then keyhog scan ...Client checks request eligibility and peer identity; a calibrated daemon uses warm-runtime autoroute evidence. Invalid startup state prevents readiness. Persisted quarantine is labeled autoroute-degraded, and affected requests fail closed without scanning.
Continuous local directory monitoringkeyhog watchForeground watcher with its own compiled scanner and warm-runtime autoroute policy; not the daemon and not reported by daemon status.
Persisted backend selection lives under
crates/cli/src/orchestrator/dispatch/backend.rs and
orchestrator/dispatch/backend/. Daemon transport and lifecycle live under
crates/cli/src/daemon/. See the operator references for cache-miss,
cold-versus-warm, and active-versus-inactive daemon behavior.

The routing package keeps measurement, proof, and persistence separate:

BoundaryOwner
Candidate measurement and cross-backend parity probesbackend/calibration.rs
One-shot and warm-daemon route decision policybackend/evidence.rs
Statistical trial evidence and confidence intervalsbackend/evidence/timing.rs
Secret-safe, complete finding identity used for paritybackend/evidence/match_identity.rs
Workload identity and bucketingbackend/workload.rs
Host and accelerator identitybackend/host.rs
Cache schema, exact artifact/build identity, bounded codec, validation, inspection, and locked persistencethe matching modules under backend/store/

This separation is deliberate: persisted bytes cannot define routing policy, inspection cannot bypass cache validation, and performance evidence cannot silently weaken detection parity.

Execution-pack dependency direction

The execution-pack cutover has one dependency direction. Treat code that points against this direction as migration code, not as a second supported design.

install or update
  detector TOML -> validation -> canonical detector IR
  canonical detector IR -> route classifier + policy/backend programs
  programs -> route-scoped matcher sections -> exact finding parity
  parity-proven packs -> installation-key signatures -> atomic pack generation
  binary + authenticated packs + pack-bound calibration -> one published generation

normal scan startup
  route decision -> authenticate one selected mapped pack
  selected sections -> owned route classifier + selected detector runtime
  discard authenticated mapping pages -> release mapping after hydration
normal scan
  source adapter -> chunks -> owned route classifier -> selected runtime -> RawMatch
  RawMatch -> CLI post-filter -> verifier -> reporter

The arrows are ownership boundaries:

OwnerMay depend onMust not depend on
Install compilerDetector schema, validators, pack codec, every eligible backend compilerSource adapters, reporters, ordinary scan state
Route classifierAuthenticated literal index, decoder identity, chunk metadataBackend materialization, detector TOML parsing, reporting
Selected detector runtimeAuthenticated canonical detector IR, selected policy/backend sections, VYRE orchestration for GPUSource adapters, autoroute persistence, CLI, reporters, an unselected backend
Source adaptersCore chunk and source contractsScanner internals, execution packs, reporters
CLI orchestratorSources, route decisions, selected runtime, verifier, reportersDetector-local execution ownership
ReportersRedacted findings and coverage stateDetector compilation, source acquisition, backend selection

install.sh and install.ps1 generate a protected 32-byte signing key under the per-user cache root and run keyhog compile-execution-packs before autoroute calibration, so detector compilation is paid once at install instead of on every scan. The compiler builds every policy pack in a sibling staging directory, fsyncs every pack, signature, manifest, and directory, moves any current generation aside as a backup, then publishes by one rename and restores the backup if that rename fails. A failed pack build fails the install: the installer refuses the new binary and rolls it back to the previous working one. With no installed generation a scan parses the embedded detector corpus in-process, which is slower and is reported as a warning rather than substituted silently.

keyhog doctor reports the installed pack path, authenticates the manifest and every detached signature, and fails health when route evidence is missing its pack binding or names a different generation.

keyhog calibrate-autoroute binds the routing evidence it publishes to that installed generation whenever the generation authenticates, so an ordinary install produces bound evidence with no extra flag.

Calibration authenticates the installed manifest and every detached pack signature before measuring routes. The versioned autoroute cache stores the manifest digest and every policy/backend pack identity. A missing policy pack, a replaced manifest, changed pack bytes, a different installation key, or binary, target, feature, and detector drift invalidates the calibration transaction.

A normal installed scan maps one policy pack, authenticates it, and decodes canonical detector execution IR instead of parsing the embedded TOML corpus. Authentication pages are discarded before section hydration. Each section faults back only when decoded into owned runtime state, and the mapping is released before the scan begins. Detector specs move once from decoded IR into shared ownership used by the orchestrator and scanner compiler; startup no longer clones the complete corpus for scanner construction. Normal one-shot scans also skip eager regex cache warming; explicit resident and calibration paths retain their deliberate warm transition. A normal scan never parses detector TOML, compiles regex or backend programs, benchmarks a route, or maps a losing backend. A missing, stale, incompatible, or incomplete generation is an invalid autoroute state. It is not permission to construct the old universal scanner or replay through another backend.

CPU sections contain canonical scalar programs. SIMD sections contain signed native Hyperscan shards for phase one and every phase-two scope. GPU sections contain VYRE orchestration receipts with the complete fused matcher bytes and the exact target, runtime, driver, device, and limits identity. A selected scan deserializes these artifacts directly from the authenticated pack. It does not compile patterns, rebuild GPU literal rows, or construct an unselected backend. VYRE remains the sole owner of device programs, dispatch, and GPU-resident memory. An exact CPU or SIMD scanner also omits GPU literal rows, regex-bound rows, matcher programs, peer state, and upload/readback scratch. Those allocations exist only after an exact GPU route or calibration census selects a usable VYRE peer.

An exact SIMD scanner does not retain the scalar phase-one automaton. Before first use, its lazy plan shares the canonical literal allocation with scanner construction. After materialization, it retains native Hyperscan shards and only the literals rejected by Hyperscan for exact host recovery. Phase-two keyword catalogs and alphabet screens borrow detector-owned strings while they build. Only synthesized keyword stems and VYRE matcher rows allocate new bytes.

Detector-indexed matcher relationships use flat u32 data plus row offsets. SIMD pattern mappings, confirmed-suffix rows, and structural detector partitions therefore retain two contiguous vectors per table rather than one heap allocation and pointer-sized header per detector or pattern. Their builders ingest flat row/value pairs, so scanner construction also avoids temporary per-row vectors.

Frozen runtime indexes retain only populated rows and final-sized storage. Detector-relation maps omit detectors with no relations, metadata and generic ownership maps release duplicate-heavy builder capacity, and matcher vectors discard geometric growth slack before entering the compiled scanner.

Cache retention follows the live workload. The process-wide detector-regex index stores bounded weak references, so compiled regex programs disappear when the last scanner using them drops. Fragment-reassembly shards start with one scope row, grow geometrically only as distinct (prefix, path) scopes arrive, keep the existing hard ceiling, and shrink to the minimum when the workload cache is cleared.

Scanner construction releases compiler-only keyword catalogs, diagnostics, route-neutral literal strings, and decoded detector schemas as soon as their final runtime owner has been built. These inputs are gone before the compiled scanner crosses into health and scan-state measurement.

The CLI then returns freed compiler arenas to the allocator before the runtime is exposed. Mimalloc builds collect every Rayon worker heap plus the caller heap; Linux glibc builds trim the process heap. Collection runs once at scanner construction, never in the per-chunk scan path.

The KeyHog-owned Rayon pool reserves the standard 2 MiB Rust worker stack. Scanner parsing and traversal are iterative, so the previous 8 MiB reservation only multiplied per-worker virtual memory without protecting a required call depth.

With live verification disabled, the orchestrator retains only the resolved policy needed for configuration and receipt identity. Detector verification graphs are dropped after scanner construction; verifier candidate queues, caches, HTTP clients, and OOB state are constructed only inside the enabled postprocess path.

Every mapped byte has one owner: pack metadata, detector IR, route classifier, regex programs, suppression policy, or the selected backend. Header, table, and alignment padding belong to pack metadata. The ownership ledger must sum to the complete mapping length.

Pack files use read-only shared mappings. Concurrent scanners fault the same immutable physical pages while they authenticate and hydrate a generation, rather than allocating one input copy per process. Each process retains only its owned decoded runtime after releasing the transient mapping.

Worker-local scratch is lazy and route-scoped. Uppercase, checksum-decode, and generic-keyword pools retain at most one scan chunk per worker; decode-fact maps start empty. Host anchor candidates retain at most one scan chunk. Single-chunk VYRE upload/readback buffers retain at most one scan chunk, while coalesced VYRE buffers retain at most the portable dispatch grid so repeated GPU batches do not reallocate. Outliers are zeroed where they can contain source bytes and released before that worker serves another route.

Failure and recovery contract

KeyHog separates trust failures from recoverable execution failures:

  • Complete: the selected backend covered the input normally.
  • Complete after recovery: an authenticated, automatically selected backend faulted. KeyHog warned visibly and counted every recovered range, chunk, and byte. Runtime faults retain completed dispatches and replay only unprocessed ranges through a proven recovery peer.
  • Incomplete: some requested bytes or transformation was not scanned. Missing, invalid, or quarantined autoroute state selects no backend and leaves the affected batch unscanned. The scan may report findings from independently covered input, but it cannot report clean.
  • Fatal trust or explicit-contract failure: invalid policy, corrupt or unauthenticated artifacts, or an explicitly required backend cannot be substituted.

Recovery is an owned execution path, not a silent fallback. It must operate on the same stable source snapshot, preserve finding parity, merge results deterministically, identify every replayed interval, and remain absent during autoroute calibration so a backend that needs recovery cannot be certified fastest-correct.

Process and exit ownership

The library crates do not terminate the process. core, scanner, sources, and verifier return values or errors to their caller. The CLI owns the operator-visible exit:

  1. crates/cli/src/main.rs installs the Unix SIGINT handler before starting the runtime. SIGINT writes the interruption diagnostic and exits 130.
  2. crates/cli/src/lib.rs::cli_main dispatches subcommands. Successful subcommands return std::process::ExitCode; setup and execution errors pass through cli_error_exit_code.
  3. crates/cli/src/orchestrator/run.rs::resolve_scan_exit owns completed scan precedence: scanner panic, live credentials, findings, incremental-cache failure, incomplete source coverage, then clean success. Autoroute calibration has its explicit success path.
  4. A scanner-thread panic sets the shared panic marker. The CLI flushes the diagnostic streams and exits 11 immediately instead of allowing a later accelerator teardown to replace the documented code.

Normal automatic autoroute recovery is part of a completed scan, so it keeps the ordinary finding or clean code. An explicit backend contract that cannot be honored is an error before completed-scan precedence applies. See Exit codes for every number and shell examples.

Finding identity and dedup

There is one identity contract with stage-specific keys, not interchangeable “same finding” guesses:

StageOwnerKeyWhy
Window overlap and raw collectorcrates/scanner/src/engine/windowed_support.rs::record_window_match; crates/scanner/src/scan_state.rs::ScanState::into_matches(detector_id, credential, source_offset)Adjacent 1 MiB windows overlap by 128 KiB, and more than one backend signal can surface the same span. The source-offset key removes duplicate raw hits without merging separate occurrences on different lines.
Raw-match correlation helpercrates/core/src/finding.rs::RawMatch::deduplication_key(detector_id, credential)Tests and internal correlation can ask whether two raw matches carry the same detector/value before a report scope is applied. It is not a report key because it intentionally excludes location.
User-selected report scopecrates/core/src/dedup.rs::dedup_matchesDedupScope::Credential: (detector_id, credential); DedupScope::File: (detector_id, credential, source + file_path + commit); DedupScope::None: no groupingThis is the operator-visible grouping. The primary location is the lowest source offset; additional locations use (source, file_path, line, commit) so structured/decode aliases on the same source line collapse.
Cross-detector report collapsecrates/core/src/dedup.rs::dedup_cross_detector(credential_hash, primary_file_path) after dedup_matchesOne secret value can match several detectors. This keeps one reported finding, chooses the best detector deterministically, and records alternate detector evidence as companions while preserving file-scoped reports.
Reporter-local location cleanupcrates/core/src/report/sarif.rs(file_path, line, offset) within one reported findingOutput adapters may remove repeated locations for format stability. They do not decide scan/report identity.

The required seam test is scan_windowed_overlap_dedups_end_to_end: a token placed wholly inside the 128 KiB overlap must scan as one raw match and one final reported finding.

Match adjudication: one policy, one chokepoint

Governing invariant. Whether a candidate match becomes a reported finding, and at what confidence, is a pure function of the value and its context, never of which emission path produced it. A value that is a ${} shell template, a name-name:v1 public identifier, or Config-Word-and-Word-only policy prose is not a secret no matter whether the entropy detector, the generic keyword bridge, the weak-anchor post-pass, or the hot-pattern fast path surfaced it. Phase-2 has several emission paths; they exist for speed and recall, not to each carry their own copy of policy.

Detector-local canonical and transport-decoded hexadecimal key-material rules follow the same boundary. Scanner construction compacts declared lengths, keywords, suffixes, and exclusions into detector-indexed programs. Named, generic, and entropy candidate paths execute those programs; only stable public compatibility helpers without a compiled scanner inspect DetectorSpec directly. Generic assignment processing resolves its entropy-policy owner and canonical-policy owner from one normalized key lookup.

The same construction step compiles hot scalar execution facts such as generic classification, minimum length and confidence, severity, structural password slots, exact detector keywords, and public-identifier assignment markers. Emission paths address that cache-local record by detector index. Once all matchers and policies are built, CompiledScanner drops DetectorSpec itself. The CLI also releases the decoded detector corpus before a non-verifying scan; only --verify retains it for verifier-plan construction. The flexible structure remains a configuration, verification, and introspection schema, not a second owner during ordinary scanning. The interner owns each unique string once as a lookup-map key, with no parallel arena. Resolution and cross-detector relation indexes clone those same allocations instead of storing another copy of each detector ID.

Every public scanner constructor reaches one full-corpus quality gate before it builds matchers or probes backends. This also applies when you construct DetectorSpec values in memory instead of loading TOML. The gate rejects invalid detector fields and duplicate IDs with detector-indexed configuration errors.

Each detector index addresses one compiled plan containing its interned primary and entropy-fallback metadata, execution facts, canonical/decoded key-material program, entropy floor and policy, ML policy, credential-shape gate, suppression policy, weak-anchor state, and compiled companions. Those policies remain separate modules by responsibility, but their runtime ownership and index alignment live in one structure rather than parallel vectors.

The same plan owner compiles detector decode_transforms declarations into one active-corpus reverse and Caesar admission program. The decoders do not read the scanner-global confidence prefix list. A custom corpus therefore changes both matching and evasion recovery through the same detector digest.

Scanner construction also snapshots the ordered decoder registry. Decode execution, decode admission, and autoroute workload sketches all read that same immutable snapshot. Each decoder supplies a stable name and version. Those descriptors contribute to the detector digest, so cached routing evidence does not survive a decoder-plan change. If you register a decoder after scanner construction, the existing scanner does not change. Compile another scanner to use the new decoder.

The rule. Emission paths produce CandidateMatch values and typed signals; adjudicate_match owns the ordered suppression verdict. Path owners may compute context-specific facts (entropy shape, generic bridge boundaries, named detector policy), but they do not invent an untyped final drop reason:

emission paths (entropy · generic/keyword bridge · weak-anchor · hot fast path · GPU)
        │  each yields CandidateMatch { detector, span, value }
        ▼
adjudicate_match(CandidateMatch, MatchCtx)
   1. explicit/process signals
   2. generic/entropy/hot-pattern signals
   3. named-detector suppression
   4. final report-floor policy
        ▼
   Verdict::Suppressed(stage_name)  |  Verdict::Reported(confidence)

MatchCtx carries one explicit signal family at a time. The Verdict names the deciding StageId, which is what dogfood telemetry records. Shared shape policy lives under suppression::shape; path-specific callers convert its result into the matching typed signal before adjudication.

Why this shape. Candidate discovery necessarily differs by detector family, but the final vocabulary and ordering of suppression decisions must not. Typed signals preserve the context each path needs while keeping one auditable verdict pipeline and one telemetry reason per decision.

The ML model (weights.bin)

The scanner serves a Mixture-of-Experts confidence model embedded at build time (crates/scanner/src/weights.bin, include_bytes!). It is trained out-of-band by the Python pipeline in ml/:

ml/harvest_corpus.py   real labelled candidates (CredData), harvested at a LOW
                       report floor so sub-floor hard negatives are captured
        │
ml/train_classifier.py blend synthetic + real, file-grouped split (no leakage),
                       train the 55-feature detector-conditioned MoE, gate on
                       held-out F1 plus
                       aggregate plus recall-sensitive class/detector recall
        │
ml/retrain_loop.sh     one command: harvest → train → (--write) ship weights.bin
                       → (--verify) rebuild + per-detector-FP bench gate,
                       fail-closed revert on any regression

Because the model is compile-time-embedded, a new model is only observable after a rebuild, which is why --verify rebuilds before benching. The adjacent crates/scanner/src/model_card.json carries the model hash, training inputs, and gate metrics; build.rs refuses a card/weights mismatch and embeds the summary shown by keyhog --version.

Model-driven confidence reductions require an entry in crates/scanner/src/pattern_calibration.json for the exact detector corpus digest, detector ID, pattern index, candidate channel, source role, and pre-verification context. Each entry carries positive and negative held-out support, recall at the blocking floor, Brier score, and expected calibration error. Missing, stale, unsupported, or under-supported entries abstain. Generic assignment and entropy channels cannot use pattern calibration.

Scanner construction also compiles the detector-conditioned feature facts used by that model, including service identity, verifier and companion presence, generic/structural classification, phase-2 ownership, and entropy family. Inference indexes that compact immutable policy and does not reinterpret the loaded detector schema for each candidate. The public training oracle compiles the same facts from the supplied detector before extracting its feature row.

Detector-owned compiled validation

Offline validation is declared in each detector TOML’s validators array. A declaration selects a typed shared primitive and supplies that secret type’s prefixes, layout widths, bounds, and confidence floor. Scanner construction compiles those declarations into the same immutable detector plan as matching, entropy, suppression, companions, and ML policy.

Named matches dispatch directly to their detector plan. Generic and entropy candidates use a first-byte index compiled from the active corpus instead of walking a global validator registry. CRC32/base62 comparison is allocation-free; base64 validation reuses zeroed per-thread scratch storage. Boundary extension returns its validation decision with the final credential slice, and ML pending rows carry that decision to final reporting. No candidate is revalidated after model inference, and custom detector corpora never inherit an embedded service table.


Where do I find X?

I want to…Go to
Add/edit a detectordetectors/<name>.toml (data; see CONTRIBUTING.md for the schema)
Understand the scan flow at method levelcrates/scanner/src/engine/mod.rs header
Change how confidence is scoredcrates/scanner/src/confidence/, ml_scorer.rs
Add a suppression gate / change what counts as a non-secretthe one gate list public_noncredential_shape; see “Match adjudication” above (never inline a looks_like_* call in an emission path)
Retrain / improve the ML modelml/retrain_loop.sh (+ ml/README.md)
Change an entropy entry path or weak-anchor floorthe owning detector TOML (entropy_roles, entropy_floor, entropy_high)
Add or tune offline validationthe owning detector TOML validators declaration
Add or tune reverse or Caesar recoverythe owning detector TOML decode_transforms declaration
Add an input sourcecrates/sources/src/
Add live verification for a detector[detector.verify] in the TOML + crates/verifier/src/verify/
Change output formattingcrates/cli/src/format.rs, crates/cli/src/orchestrator/reporting.rs
Change process exit codes or precedencecrates/cli/src/exit_codes.rs, crates/cli/src/lib.rs::cli_error_exit_code, crates/cli/src/orchestrator/run.rs::resolve_scan_exit, and crates/cli/src/main.rs for Unix SIGINT
Add a benchmark / change the gatebenchmarks/bench/
Verify a performance or detection claimbenchmarks/ (the README numbers regenerate from here)
Change backend selection or autorouteBackends and routing, Autoroute calibration
Operate the daemon or plan a mass scanDaemon and warm scans, Mass repository and cloud scanning
Operate perpetual repository guarding or instant pre-commit gatingPerpetual guard, Pre-commit hook
Understand detection, suppression, or verificationDetection, Suppressions, Verification
Configure scanning or findings outputConfiguration, Output formats

Contributing

KeyHog is open source. The repo is at github.com/santhreal/keyhog. Bug reports, feature requests, detector additions, and PRs are all welcome.

Quick paths

WhatHow
Report a bugOpen an issue with a minimal reproducer.
Report a security issueUse GitHub private vulnerability reporting first. If unavailable, email security@santh.dev; PGP is not required.
Add a detectorAdd one detector TOML with its inline truth pair, then add its adversarial contract.
Fix an FPFind the regex / shape gate that’s firing. Tighten it. Add a negative test that would catch the regression.
Document something undocumentedEdit the canonical page under docs/src/; the site rebuilds from that mdBook source.
Describe an operator-visible changeAdd one validated TOML under changes/; the release preparer owns every changelog.

Repo layout

keyhog/
  crates/
    core/             # Detector spec, raw match types, severity, embed
    scanner/          # The scanner engine itself
    sources/          # Filesystem, git, web, docker, S3/GCS/Azure Blob, hosted-git backends
    verifier/         # Live credential verification
    cli/              # The `keyhog` binary, subcommand dispatch
  detectors/          # Embedded detector TOMLs: one secret type per file
  crates/cli/data/
    suppressions/     # Test-fixture suppression list, baked into the binary
  docs/               # This documentation (mdBook source)
  install.sh          # Linux/macOS install script
  install.ps1         # Windows install script

The Rust workspace is at the root; each crates/ member is a standalone crate with its own Cargo.toml. See Architecture for crate ownership and the end-to-end scan flow before moving code across boundaries.

Building

git clone https://github.com/santhreal/keyhog
cd keyhog
cargo build --release -p keyhog
./target/release/keyhog --version

For development:

cargo build               # debug build
cargo test -p keyhog-scanner --lib

Reproduce the two scanner library closures used by hosted CI before changing feature ownership:

# Broad CPU/SIMD closure used on GPU-less hosted runners.
cargo test -p keyhog-scanner --lib \
  --no-default-features --features ci-lean -- --test-threads=4

# Default scanner closure, including compiled GPU dispatch.
cargo test -p keyhog-scanner --lib -- --nocapture

ci-lean is a maintainer profile: it keeps the broad data and SIMD detection surface while omitting GPU dispatch. The smaller user-facing CI edition is the CLI’s ci feature.

Adding a detector

Detector truth has two layers. The compact positive/negative pair lives beside the detector policy and protects every TOML edit. The separate contract adds multiple envelopes, evasions, performance, and scale coverage. Both are required; neither substitutes for the other.

  1. Write the detector TOML at detectors/<service>-<thing>.toml. Use an existing detector as a template; the schema is documented in Detectors.

  2. Add the inline truth pair in that same TOML:

    [[detector.tests]]
    pattern_index = 0
    test_positive = "SERVICE_API_KEY=<valid-shaped-test-value>"
    test_negative = "SERVICE_API_KEY=YOUR_API_KEY_HERE"
    negative_class = "identifier"
    

    Use synthetic or vendor-published test material, never a live credential. Add one indexed row for every pattern. The positive must emit this detector’s exact ID; the named negative must remain silent through the production scan path.

  3. Write the adversarial contract at crates/scanner/tests/contracts/<id>.toml. At minimum, include:

    • 2 positives (env-var shape, quoted shape)
    • 2 negatives (placeholder, EXAMPLE token in the body)
    • 2 evasions (real-world shapes you’ve seen in actual leaks: Bearer header, JSON body, URL query param, multi-line config)
    • A perf block with fixture_bytes + max_microseconds
    • A scale block with fixture_bytes + min_findings + max_seconds
  4. Run the detector truth gates locally:

    cargo test -p keyhog-scanner --test detector_inline_test_truth
    cargo test -p keyhog-scanner --test contracts_runner
    cargo test -p keyhog-scanner --test detector_pattern_hard_negative_gates
    

    Must pass before you push. CI re-runs it with strict env vars set, which exercise more aggressive adversarial corpus.

  5. Open a PR. A maintainer reviews the detector for:

    • Service is real and not duplicated by an existing detector.
    • Keywords are short, distinctive, and unlikely to FP.
    • Regex captures the right group and rejects obvious placeholders.
    • Verify endpoint (if present) is read-only and won’t trigger side-effects on the upstream service.

Adding a suppression filter

If you find an FP cluster of 5+ findings that all share a shape, the right fix is a new shape filter rather than 5 individual suppressions. The flow:

  1. Reproduce. Get the FPs into a .envseal-sealed corpus or a public sanitized fixture you can commit.

  2. Find the existing owner. Search crates/scanner/src/suppression/shape/ for the same operation before adding a helper. Extend the narrowest existing shape module; path, prose, public-identifier, canonical-shape, and randomness policy already have separate owners.

  3. Wire it through the shared policy boundary. suppression/api.rs exposes the composed shape decisions and adjudicate/ owns the final suppression verdict. Do not add an emission-path-only looks_like_* check: CPU, SIMD, GPU, generic, entropy, and fast-prefix paths must reach the same decision.

  4. Add a unit test. Inputs that should trip the filter (5+ variants), inputs that should not (3+ legitimate credentials).

  5. Run the contract gate. New filters must not break any contract evasion. If they do, the contract is right and the filter is wrong. Tighten the filter.

Style

  • Rust edition 2021, MSRV 1.89.
  • Run cargo +stable fmt -- --check and the relevant package’s clippy target. Treat lints as bug leads; avoid behavior-free contortions for style-only findings.
  • Split modules by responsibility, ownership, readability, and testability. File length is a prompt to inspect cohesion, not an architecture rule by itself.
  • No #[ignore] on tests. A flaky test gets fixed or deleted, not silenced.
  • No todo!() / unimplemented!() / panic!("not implemented") in shipped code paths.
  • Comments explain WHY, not WHAT. Names carry WHAT.

Tests

cargo test -p keyhog-core --lib          # detector spec / embed
cargo test -p keyhog-scanner --lib       # engine
cargo test -p keyhog --lib               # CLI / orchestrator
cargo test -p keyhog --test e2e_binary   # full-binary end-to-end
cargo test -p keyhog-scanner --test contracts_runner   # per-detector contract gate
cargo test -p keyhog-scanner property::scanner_fuzz    # proptest

Run the narrowest behavioral gate that proves the change, then the affected package suite. Runtime depends on build profile, host, corpus, enabled features, and cache warmth; command output is the timing evidence for that run.

Describe a published change

Add one change fragment for every operator-visible behavior or API change:

category = "Fixed"
summary = "Preserve the exact report exit status in direct CI jobs."
crates = ["cli"]

Save the file under changes/ with a lowercase, hyphenated name. Fragments are optional. If you omit one, the automatic release uses the successful push commit subject. Do not edit the newest changelog section.

Every successful main CI run increments the patch version, consumes the fragments, commits the generated changelogs, and publishes the crates.io packages. See Releases for categories, crate ownership, and failed-upload recovery.

License

License: MIT OR Apache-2.0.

By contributing, you agree that your contributions use this license. Read the MIT terms and Apache-2.0 terms.

Releases

A successful main CI run publishes KeyHog automatically. You do not choose a version or sign a release.

Release a push

Push your change to main. The CI workflow must finish successfully. The automatic release workflow then:

  1. increments the workspace patch version;
  2. generates the root and crate changelogs;
  3. updates Cargo.toml, Cargo.lock, all changelogs, and operator-facing version pins;
  4. commits the generated files and creates a lightweight version tag;
  5. dispatches the publish job for that tag;
  6. publishes all six crates to crates.io in dependency order; and
  7. creates the GitHub Release for that tag from its changelog section.

For example, a successful push at 1.2.3 produces 1.2.4.

Step 5 exists because a tag pushed with the workflow token raises no push event. workflow_dispatch is one of the two events the workflow token can start, so the bump job dispatches the publish itself. A tag you push by hand publishes through the push trigger instead.

The workflow does not run for pull requests, failed CI runs, or cancelled CI runs. It does not build release assets, signatures, attestations, or SBOMs. The GitHub Release carries changelog notes only.

Write a changelog fragment

Fragments are optional. Without one, the workflow uses the successful push commit subject for every crate.

Use a fragment when different crates need a precise note:

category = "Changed"
summary = "Use one allocation for each decoded candidate batch."
crates = ["core", "scanner"]

Save the file under changes/ with a lowercase kebab-case name and a .toml extension. A fragment contains exactly category, summary, and crates.

Valid categories are:

  • Added
  • Changed
  • Deprecated
  • Removed
  • Fixed
  • Security

Performance and Documentation are accepted as aliases of Changed so existing notes publish under the Keep a Changelog heading.

Valid crate names are cli, core, profile, scanner, sources, and verifier. The release commit consumes the fragment. Any crate not covered by a fragment receives the push commit subject.

Configure crates.io trusted publishing

Each published package is configured on crates.io with a trusted publisher for this repository’s release.yml workflow, with no environment. crates.io rejects workflow_run JWTs, so publishing runs under the push (tag) or workflow_dispatch trigger. The workflow requests id-token: write and uses rust-lang/crates-io-auth-action first; it falls back to the CARGO_REGISTRY_TOKEN repository secret only if that exchange fails.

The trusted publisher must be authorized for all six packages, in publication order:

  1. keyhog-profile
  2. keyhog-core
  3. keyhog-verifier
  4. keyhog-sources
  5. keyhog-scanner
  6. keyhog

The repository workflow token also needs contents: write. It uses that permission only for the generated release commit and lightweight version tag.

Recover a failed upload

Rerun the failed Automatic crates.io release workflow. The workflow recognizes an existing generated release commit whose parent is the successful CI commit. It reuses that version. scripts/publish.sh skips versions already visible on crates.io and resumes at the first missing package.

If a newer main push supersedes the successful commit before the release begins, the older workflow exits. The newer successful CI run releases the combined state.

Check release automation

Run the focused checks locally:

make release-check

The command validates patch increments, generated changelogs, version ownership, and workflow triggers. It does not commit, tag, push, or publish.

Changelog

The authoritative changelog lives in the repo root as CHANGELOG.md. Versions follow Semantic Versioning – patch bumps for bug fixes, minor for new features, major for breaking changes.

The full file is rendered below.


Changelog

All notable changes to KeyHog. Versions follow Semantic Versioning.

[0.5.81] - 2026-08-20

Added

  • feat(detectors): audit and expand checksum and structured offline validators (Jwt, Uuid, HexHash, LuhnChecksum, PatternShape, Base62Crc32) across detector corpus to eliminate false positives without false negatives (Row 152).
  • feat(guard): populate GuardPolicyIdentity digests for ignore file, suppressions, configuration, and source policy, triggering state transition to StalePolicy and attestation invalidation on policy file modifications (Row 142).
  • feat(hook): utilize prepared execution pack in pre-commit hook run for zero runtime compilations and sub-second execution (Row 145).
  • bench(product): add product-level criterion benchmarks for CLI startup, hook execution, guard status, core evaluation, and verifier evaluation (Row 147).
  • feat(guard): continuous guard transition feed and event log surface with causal attribution across registered roots (Row 146).
  • feat(cli): enhance pass-gate terminal output craft with structured volume, blob counts, bytes scanned, and execution timing (Row 143).
  • feat(guard): offline guard status and list inspectability reading durable store from disk with optional root summary (Row 140).
  • feat(cli): optimize startup execution path for informational commands with fast zero-allocation dispatch, deferred runtime initialization, and zero detector corpus parsing (Row 138).
  • feat(installer): multi-dimensional artifact invalidation and regeneration across detector corpus changes, configuration updates, and calibration changes (Row 135).
  • feat(installer): update recommendation parity and complete artifact generation on binary replacement (Row 134).
  • feat(build): audit and enforce release binary symbol stripping and zero DWARF debuginfo bloat via Cargo.toml [profile.release] and profile divergence gates (Row 139).
  • feat(detectors): add and update competitor recall parity detectors with verified contracts and zero false-positive constraints for sidekiq-secret, jumpcloud-api-key, disqus-api-key, configcat-sdk-key, curl-auth-user, datadog-application-key, bitly-access-token, aws-bedrock-api-key, anthropic-admin-api-key, and airtable-api-key (Row 161).
  • fix(detectors): one detector owns the AWS Bedrock long-lived API key. The unanchored ABSK + 109-269 base64 body shape is now a second pattern on aws-bedrock-api-key instead of a separate aws-amazon-bedrock-api-key-long-lived detector with an identical name, service, severity, keyword set and confidence policy. An anchored key matched both, so the cross-detector fold reported whichever id sorted first on identical scores. Detector count 934.
  • fix(cli): keyhog detectors --audit --detectors <DIR> loads the named corpus ungated and prints the per-detector report. It previously ran through the fail-closed corpus gate, exited 2 with no report, and made the repair command named by detector_compile_failed useless.
  • fix(coverage): the vendored/minified drop count is what --no-default-excludes recovers. It counted adjudication events, so one planted key in a wp-includes/ assignment reported as 2 dropped matches. It is now keyed by (path, credential), so a credential dropped by several detectors or shape gates counts once.
  • fix(guard): a degraded root reports repeat coverage loss instead of dropping it. The watcher overflow branch returned an ignore for Degraded, so a second buffer overflow left the coverage-loss count and the operator-visible reason stale, while the core transition table keeps (Degraded, CoverageLost) legal for exactly that report. A stale-policy root still ignores overflow, because reconciliation is what clears it.
  • fix(artifacts): a missing execution-pack verification key names the artifact class, the expected file pattern, and keyhog install. The manifest loader previously said the manifest “has no sibling signing.key”, which named neither the class nor a repair.
  • fix(profile): runtime compile surface counters record only at the spec boundary. FlatKeywords::compile, the keyword matcher parts, the detector length and canonical-hex policies, and the derived CompiledValidatorIndex all incremented on the load path, so a scan that hydrated a prepared execution pack and compiled nothing reported 1822 detector-execution-policy compilations. A lazily anchored verifier regex is counted by record_lazy_regex_compile alone.
  • fix(routing): an install and the scans it serves share one autoroute rules identity. The identity was the raw detector spec hash when a scan compiled the corpus and the pack’s compiled plan digest when it hydrated an installed generation, so the two routes never agreed: a keyhog install in a repository that carries detectors/ published a table that the same scan run from any other directory rejected with detector digest mismatch; cache is for a different corpus, and exited 2 with nothing scanned. Both routes now use the canonical corpus route identity a pack carries, computed over the normalized detector set with self-test fixtures and declaration order excluded, and taken before per-invocation confidence floors are composed, because autoroute_config_digest already carries those and a hydrated pack cannot see them.
  • fix(scanner): a scan that maps the installed execution-pack generation performs zero runtime compilations across all 13 compile surfaces. Two prepared surfaces still compiled: the assignment-keyword matcher was hydrated with an empty secret-keyword list and then rebuilt on the first scanned line against the 20 keywords the default config carries, and the detector-plan resolution and relation indices recorded a compile whenever a pack hydration reached the builders they share with the spec path.
  • fix(cli): a piped scan reports which scanner it ran. Scanner materialization and compiled-cache state were printed only on an interactive terminal, so a redirected or CI scan could not tell an installed-pack mapping from an in-process developer compile. --quiet still silences both.
  • fix(precision): a snake_case identifier path is not a credential. A scan of KeyHog’s own tree under the paranoid policy reported 8 entropy-token findings on dotted configuration keys and Rust field accesses (plausibility.leading_slash_base64_min_len, &entropy_match.value), because the source-identifier gate demanded a camelCase segment and was defeated by a leading reference sigil. The self-scan is clean again, and dotted paths that carry real credential entropy stay visible.
  • fix(routing): pack reuse is decided by corpus identity, not by the presence of a detectors/ directory. A working directory carrying the same corpus the installed generation was built from now hydrates that pack instead of compiling the corpus in process, and a corpus that differs by even one detector gets neither the pack nor its calibration: the scan exits 2 naming detector digest mismatch with no report document.
  • fix(routing): a .keyhog.toml detector disable no longer changes the autoroute route identity. The corpus route identity is taken before the disable filter, and autoroute_config_digest no longer hashes min_confidence or disabled_detectors, because an install publishes 4 calibrated policy configs and any per-detector knob made every scan reject the whole table and exit 2. Their only routing-relevant effect, pattern-set size, is already carried by the workload key’s pattern bucket, and matcher_resolved_config_digest still binds both. Previously installed autoroute caches are rejected on identity: re-run keyhog install.
  • fix(scanner): a decoded twin no longer downgrades a credential. Decode-union dedup kept the first (detector, credential) match and dropped the rest, so a Kubernetes Secret whose base64 data: value decodes to AWS_ACCESS_KEY_ID=... reported the role-less raw match at review and exited 0 while the decoded match that proved the assignment role was discarded. The survivor keeps the raw coordinate and takes the stronger evidence verdict, so the same finding is likely and blocks.
  • fix(daemon): the daemon rules identity is a property of the rule set, not of the route that loaded it. Client and daemon derived it from different inputs, so a scan against a warm daemon could reject its own rules as stale. One owner (detector_rules_digest) now computes it from the detector specs for both sides.
  • fix(reporting): an Action receipt accepts failed alongside partial on exit 11 and 13. Row 163 made a total source failure report failed, which the receipt validator then rejected as contradictory semantics on the very scans it was meant to certify.
  • fix(scanner): the incremental read-free skip now requires the inode change time to agree. The skip trusted (mtime, size) alone, so a tamper that rewrote a file with a planted secret and restored its mtime with set_times was skipped unread on every later scan. The cache entry and the chunk record path carry the kernel-owned ctime, a row without one is never trusted, the persisted schema is version 5 (v4 caches cold-start once), and platforms without a change time run without the read-free skip. Row 90 adversarial kind 1 is closed end to end.
  • ci: every regression suite runs once. Ten scanner recall and contract targets and the CLI row-107 queue-depth suite were compiled into all_tests and also named by their own CI step, so each ran twice per pass; the aggregator now defers to the named steps, and the local GPU lane names them explicitly. Atomic fetch_update call sites move to the renamed try_update.
  • feat(reporting): fail closed scan document status on source failure and oversized stdin (Row 163).
  • feat(artifacts): fail closed with EXIT_USER_ERROR and actionable repair instructions on stale or mismatched execution-pack artifact identity inputs (Row 129).
  • feat(installer): acceptance gate for clean install on empty cache with zero runtime compilations across all surfaces (Row 130).
  • feat(compiler): install-time compilation and zero scan invocation for small compilers across entropy, assignment keywords, and detector metadata (Row 128).
  • feat(cache): load-only scan execution and zero compilation fallback on prepared artifact caches (Row 127).
  • feat(installer): unified installed artifact registry connecting installer production, updater regeneration, and scan loading (Row 126).
  • feat(profile): instrument runtime compile surface counters across all 13 compiler surfaces and 4 phases (Row 125).
  • fix(cli): eliminate noisy internal execution-pack fallback warning on clean scan passes and enforce unpolluted structured output (Row 144).
  • feat(entropy): refine BPE entropy evaluations and token boundary chunking to support backtick quotes, preserve internal ampersands and punctuation in complex secret keys, expand character class filters with tildes and trigger bytes, permit 16-character mixed-case alphanumeric tokens with digits under credential context, and add generic-high-entropy-string detector and contract specification (Row 154).
  • feat(gates): single authoritative structural gate architecture consolidating no_inline_tests_in_src and no_cwd_relative_source_reads workspace-wide and eliminating redundant per-crate gap tests (Row 149).
  • feat(scanner): instrument dynamic anchored regex compilation paths with LAZY_REGEX_COMPILE_EVENTS runtime counters to prevent invisible un-cached compilations (Row 150).
  • feat(benchmarks): publish honest multi-corpus benchmark data across mirror and competitor corpora with transparent F1, precision, recall, runtime metrics, and drift-prevention gate coverage (Row 151).
  • feat(benchmarks): establish harness default config and install parity between CLI pure-Rust portable default and benchmark SIMD requirements with fail-closed non-SIMD handling and regression testing (Row 155).
  • feat(execution-pack): optimize startup footprint and execution pack pre-installation floor with lazy canonical IR compilation and zero-compile scan path (Row 158).
  • feat(profile): instrument fine-grained candidate confirmation and phase-2 verification timing metrics across suffix gating, companion gating, anchor collection, extraction, fragments, and dedup (Row 159).

Added

  • feat(compiled_artifact): add canonical CompiledArtifactClass enumeration and CompiledArtifactIdentity contracts.
  • feat(cache): add validate_and_tighten_matcher_artifact_cache_dir to auto-repair loose default cache directory permissions to 0700 without disabling cache.

Added

  • feat(daemon): report active watcher backend, latency tier, and polling interval in guard status (Row 123).
  • fix(backend): GPU route explanation parity reporting compiled-in feature state when GPU hardware is physically present rather than false probe misses (Row 156).
  • Benchmark corpus synthetic packs & representative test coverage (Row 162). Fixed AWS Access Key token shape in the built-in benchmark corpus template to match 20-character credential length (AKIA + 16 chars). Added integration tests verifying benchmark corpus structure, metadata, planted credential shapes, and synthetic execution pack finding parity invariants.
  • perf(scanner): optimize startup memory floor and scanner structure layouts (Row 153). Pack LazyRegexState flags into a single atomic byte, shrink CsrU32 to exact boxed slices, flatten GenericKeywordStemSet byte buckets, dynamically scale LRU thread-local caches, scale DashMap absence cache shards dynamically with host parallelism, and box immutable compiled pattern slices.
  • refactor(core): unify atomic durable writes across state artifacts and scanner caches into keyhog_core::state_file (Row 148).
  • fix(daemon): filter out ignored and excluded paths (.git, target, node_modules, ignore_paths, default excludes) in guard filesystem watcher to prevent unnecessary reconcile transactions (Row 141).
  • The isolated policy children of an all-policy calibration now inherit the parent’s configuration mode. calibrate-autoroute runs the four scan policies in four child processes, and the argv it built for them carried --policy, --autoroute-cache and --measurement-receipts but not --no-config. install.sh asks for the host baseline, the parent honored it, and every child resolved whatever .keyhog.toml the install directory happened to carry: a 40 minute install published 629 decisions across four digests no ordinary scan requests, while a plain scan resolved a fifth digest holding four decisions, and the first scan after the install exited 2 with none matching config digest. The child argv is now built in one place that forwards --no-config, --quiet and --execution-packs. A second test reads the flag list off the clap command at run time and fails on any flag with no recorded forward-or-own decision, so a new calibration flag cannot be added without deciding whether it crosses the process boundary.
  • Both installers now prove the calibrated cache can serve an ordinary scan before reporting success. An install could finish clean and be unusable: keyhog doctor compiles one bundled detector and scans with an explicit ScanBackend::CpuFallback, so it passes on a host whose next auto-routed scan exits 2 for want of a matching decision, which is exactly what the child-argv defect above produced. After calibration each installer scans a throwaway two-file directory with no backend override and no calibration flag, asking for the same baseline configuration calibration measured, and fails the install (rolling back, as with a failed doctor) unless the scan runs. Edge case 8.7 drives an installer through a binary that calibrates and then refuses to route, and the parity gate pins the check on both platforms.
  • keyhog calibrate-autoroute now measures the configuration the scans it serves resolve. Calibration passed --no-config to its own probes, so a run inside a repository with a .keyhog.toml published every decision under the compiled-in baseline digest while the scans in that repository asked for the resolved one, and the documented remedy for autoroute calibration required produced a cache those scans could never hit. Configuration is now resolved by the same .keyhog.toml walk-up a scan performs. install.sh and install.ps1 pass the new --no-config flag, because an install runs from an arbitrary directory and primes a host baseline; both probe calibrate-autoroute --help first, so a binary that predates the flag still calibrates. The digest gate now pins both modes.
  • The install-from-build proof no longer assumes the build under test carries Hyperscan. tests/install/fixtures/install_from_local_build_posix.sh pinned --backend simd for its three post-install scans. Only a --features simd build has that backend, and a portable binary refuses the request with exit 2 instead of substituting one, so two checks read a correct routing refusal as a detection failure. Linux CI happens to build --features simd, which is why the proof stayed green there, while the macOS lane builds portable,gpu. The fixture now reads keyhog backend and selects simd or cpu from the compiled-capabilities line, refusing to guess when that line is absent. scripts/dogfood-all-os.sh carried the same four hardcoded overrides against macOS and Windows ships.
  • The CLI test suite no longer assumes the build under test carries Hyperscan. Nineteen files under crates/cli/tests pinned --backend simd on the binary they spawned. A portable build refuses that request with exit 2 and no stdout, so the format contracts read a routing refusal as malformed output (json: Error("EOF while parsing a value", line: 1, column: 0)), and the GPU recovery harness in crates/cli/src/testing.rs named SimdCpu as the recovery peer on a build with no Hyperscan. Under default features cargo test -p keyhog --test all_tests failed 34 of 858; CI builds --features simd, which is why it stayed green there. The suite now takes its backend from one cfg-selected DIAGNOSTIC_BACKEND constant, and the recovery harness resolves its peer the way production does, from what the build carries. Default-features failures drop to the 11 pre-existing target_spec_org_contracts budgets.
  • install.ps1 grew the switches install.sh already had: -NoCalibrate, -NoPrompt, -Help. A Windows install had no way to skip the autoroute measurement phase that a POSIX install skips with --no-calibrate, so every Windows install paid the full ladder, and a bounded install proof could not be written at all. The parity gate that exists to catch this asserted only that each script documents at least one flag, and could not fail on it; it now compares the two documented sets, folding -NoCalibrate to --no-calibrate, and requires every documented flag to be a real case arm or param() entry. A second test pins the skip to the branch that replaces the calibration call on both platforms, so a notice printed beside the measurement rather than in place of it fails. install.sh --help also stopped depending on a pinned line range of its own header, which was one line from going stale.
  • The calibration ladder now measures the decoding state at three size bands instead of one. decode_admitted is a keyed routing dimension, and a family is reusable evidence only when at least two of its bands were measured, so a single decode-heavy probe left every decoding scan uncalibrated. The ladder probes decode-heavy content at 4 KiB, 64 KiB, and 256 KiB, in the subcommand and in the shell fallback path.
  • A calibrated route now covers the size bands calibration proved it covers, instead of only the exact band it sampled. Every lookup was an exact match on the complete workload key. The reachable key grid is about 1,450,610 cells (13,685 valid byte/chunk/maximum-file triples, two decode states, 53 source execution classes) and the installer ladder measures a few hundred of them, so a real scan almost never landed on a measured cell: a two-file directory scan against a freshly calibrated 626-class cache differed from every stored key and exited 2, and adding one file to a directory that had just scanned broke it again. A size band nobody measured is now served only by measured invariance. KeyHog collects every calibrated decision sharing the workload’s pattern band, decode state, and source-class set, needs at least two such bands, and reconciles them with the same rule that reconciles the repeated points inside one band: the served backend was measured at every band and proved slower at none, and bands that agree on the backend while splitting on the phase-2 localizer plan resolve to the compiled default plan every one of them measured. A band whose own evidence resolves no route, a real backend crossover, and any GPU route all withdraw the reuse and the scan still fails closed. Nothing is benchmarked, guessed, or substituted at scan time. GPU routes are excluded because GPU correctness, not only GPU speed, varies with input size: batch input caps and per-slot capacities bind to the measured shape, and a parity receipt proves that shape and no other.
  • Calibration ran under a GPU policy no ordinary scan requests, so every measured decision was invisible. calibrate-autoroute passed --no-gpu to its own probes on any host without an eligible GPU. --no-gpu resolves gpu_runtime_policy = Disabled, which is hashed into the autoroute config digest, so the whole generation was persisted under a digest no scan resolves. On such a host that was the entire cache: an install that finished cleanly wrote 635 decisions across four scan policies, and the next keyhog scan reported 7 calibrated config(s), none matching config digest and exited 2. Declining GPU candidates now means omitting --autoroute-gpu, which is deliberately outside that digest. A test derives the preset list from the source at run time and fails when calibration argv and plain scan argv resolve different digests, for the default policy and every preset, with and without GPU admission.
  • Calibration binds to the installed execution-pack generation without being asked. Binding required --execution-packs, a hidden flag install.sh never passed, so keyhog doctor reported route binding MISSING on a freshly calibrated host and printed a repair command naming a flag that does not appear in --help. keyhog calibrate-autoroute now binds to the authenticated generation in the platform cache directory when one is present, in both the single-policy and all-policy paths; an explicit --execution-packs still fails closed when the directory it names does not authenticate. The doctor repair line is now keyhog calibrate-autoroute.
  • A one-shot accelerator route is no longer scored from a single cold sample presented as a zero-width confidence interval. Setup cost (Hyperscan database load, GPU context creation) is measured once, as trial zero, and the scan alone six more times. Both confidence bounds were max(cold, warm), so whenever setup dominated, they collapsed onto that one sample: across a real 158-class calibration on an AVX-512 host, 929 of 940 SIMD one-shot intervals had zero width, and cold exceeded the warm median in 940 of 940. A zero-width interval never overlaps a peer, so SIMD was reported as a measurably separated loser against cpu-fallback on every one-shot route, from one measurement, even where its own warm scans were faster. The one-shot cost of each trial is now the measured setup plus that trial’s scan, so the interval keeps the warm width and only shifts. Medians are unchanged.
  • Autoroute route classes no longer depend on the content of the bytes being scanned. The workload key mixed in phase-1 admission counts, phase-2 keyword trigger density, decode candidate counts and byte totals, and per-source chunk/payload ratios and span buckets. Calibration cannot enumerate those: they are measurements of whatever a caller happens to hand over. Every real scan therefore missed the persisted decision table and exited 2 with autoroute calibration required, and calibrating one directory stopped working as soon as a file was added to it. The key is now the enumerable shape of the work: byte, chunk, max-file and pattern buckets, whether the workload does any decoder work at all, and the set of source classes with their size provenance. The decoder dimension was a 14-bit mask of which decoder families the sampled bytes contained: a 117-byte .env produced 0x00000401 while the probe ladder, which generates its own text, produces 0, so the two never met. Calibration still logs the phase-2 keyword trigger counts it observed on the keyhog::routing target, and each persisted point still records its exact sample byte count, chunk count, and measurement shape digest. AUTOROUTE_CACHE_VERSION moves 57 to 58; a v57 cache is rejected with the existing recalibrate message.
  • refactor(scanner): move rayon to dev-dependencies and replace internal usages with standard worker pools (Row 119).
  • feat(config): add guard configuration section to example TOML and docs truth (Row 131).
  • feat(profile): directional queue attribution distinguishing producer backpressure from consumer starvation (Row 133).
  • feat(gates): validate allowlists across reality and enforce meta-gate audit against unvalidated bypasses (Row 137).
  • fix(daemon): filter out ignored and excluded paths (.git, target, node_modules, [scan].exclude, .gitignore, .keyhogignore, default excludes) in guard filesystem watcher matching scan path semantics with dynamic ignore matcher reloading (Row 141).
  • fix(scanner): classify standard configuration and credentials paths (credentials, .credentials, config, .config, secrets, .secrets, .conf, .properties, .txt) as structured INI context to prevent false downgrade of AWS credentials to unsupported-context (Row 164).
  • fix(scanner): clamp decode-through window overlap to enforce strictly advancing window progress across UTF-8 scalar boundaries in release builds.
  • fix(cli): add parse_decode_size_limit rejecting empty and sub-4B --decode-size-limit inputs with actionable error diagnostic.
  • fix(detectors): resolve evasion gaps, required literal routing, and Unicode whitespace boundary handling across 8 detector specifications (apple-push-notification-key, google-artifact-registry-key, near-api-credentials, netrc-password, twitter-ads-api-credentials, webex-access-token, wechat-api-credentials, wordpress-api-token).
  • test(scanner): consolidate per-detector regression execution into sequential full-coverage suite to prevent parallel runner memory exhaustion.
  • Removed the dead signed binary-asset release channel. No workflow built, signed, or uploaded release binaries, but install.sh, install.ps1, keyhog update, and keyhog repair all still consumed that channel. Each searched backward for a release that still carried a complete asset bundle, so the dead channel did not fail: sh install.sh silently installed v0.5.47 while the current version was v0.5.80. Removed keyhog update and keyhog repair, the download/signature/asset-selection half of crates/cli/src/installer (including the unreachable self-replace, backup/rollback, and orphan-reaping machinery), and the network half of both install scripts. --from-file is now required to install and still verifies a sibling .minisig and .sha256; without it the scripts print cargo install keyhog --locked. Retired the KEYHOG_VERSION and GITHUB_TOKEN installer environment variables, the --repair/--version installer flags, and the dead EXIT_REPAIR_FAILED/EXIT_UPDATE_AVAILABLE exit aliases. Update and repair are now cargo install --locked --force keyhog.
  • Added gate scripts/gates/release_channel_coherence.py (with --self-test): an install or update path may not consume GitHub release assets that no workflow produces, and a prose reference to a named workflow job must resolve to a real job. The second half catches the trust-model comment in installer/release.rs that cited a sign job which never existed.
  • scripts/gates/tests_wired.py was vacuously green and hid 108 CI-orphan test files. It folded shell backslash continuations but not YAML block scalars, so ci.yml’s run: > step read as a bare cargo test -p keyhog with no target filter, the all-targets shape that short-circuits orphan detection for a whole crate. It also scanned only .github/workflows/*.yml, so the twelve GPU targets that run from scripts/ci_local.sh, the only lane that proves GPU finding parity, counted as running nowhere. The gate now folds > and | per YAML semantics and follows a script a workflow invokes. Wired the 89 cli and 19 scanner orphans: pure in-process files into all_tests, binary-spawning suites into explicit --test steps in the integration-cli and scanner lanes. This surfaces 13 real contract violations that had never run, 12 organizational-surface budgets and the crates.io publish contract.
  • The install edge-case battery now ships a network tripwire instead of a mock GitHub API. tests/install/linux/edge_cases.sh puts a curl on the sandbox PATH that records the URL and fails, and case 23.1 asserts across the whole battery that it never fired. The old mock served release assets, so a reintroduced fetch would have been answered rather than caught.
  • Restored the install-time execution-pack producer, so detector compilation no longer runs on every scan. With no installed execution-pack generation, scan parses and compiles the embedded 926-detector corpus in process on every invocation. Measured on a 16-core AVX-512 host, scan setup cost 284 ms wall and 1570 ms CPU with no packs against 66 ms and 110 ms with them: 4.3x wall and 14x CPU spent before any file is read. The only automatic producer lived in crates/cli/src/installer/execution_packs.rs, reachable solely from the self-install path fed by the retired binary-asset release channel, and was removed with that channel. The surviving producer, keyhog compile-execution-packs, is manual, needs a 32-byte signing key that nothing created, and no script referenced it. install.sh and install.ps1 now generate that key under the per-user cache root and publish a generation before autoroute calibration, in both the install and --calibrate modes, and fail the install rather than leave a scanner that recompiles its corpus every run. install_script_parity.rs pins both scripts and the publish-before-calibrate order; execution_pack_install.rs covers every rejection branch of the key validator through the real compiler.
  • The stale-pack error named keyhog update and self-update, both removed above, so a generation that no longer authenticated against the binary told operators to run a command that does not exist. It now names keyhog compile-execution-packs and install.sh --calibrate.
  • crates/cli/tests/unit/installer_execution_generation.rs was declared in no manifest, so it never compiled, and it covered the installer-side signing-key validator that no longer exists. Its two uncovered contracts, a wrong-length key and a non-regular-file key, moved to execution_pack_install.rs against the live compile-execution-packs validator, and the file was deleted. The orphan watchdog in gap_all, which had never run in CI either, is what found it.
  • readme_exit_codes_match_cli_contract pinned five hand-picked prose fragments of the README exit-code table and could not see a new exit code at all. It now derives the expected rows from exit_codes::DEFINITIONS, so adding a code turns it red until the README documents it, and a documented code with no definition also fails.
  • Autoroute calibration refused every generation on multi-core hosts, so no install could complete. calibrate-autoroute measured 180 workload probes, then discarded the whole routing generation because the 4 MiB through 32 MiB classes reported workload class changes its confidence-supported backend across measured points. That check compared the two points’ backends directly, with no test of whether the measurements separated them. They did not: across three retries of the same 4 MiB bucket the daemon route reported simd-regex and cpu-fallback on alternating runs, which is run-to-run variance between two statistically indistinguishable backends, not a crossover. Publication is all-or-nothing, so one such class cost the entire cache, install.sh rolled back, and every later scan exited 2 with autoroute calibration required. Backend selection across points now resolves through resolve_route_across_points, which keeps the lowest-complexity backend that is measured at every point and proved slower at none: a disagreement no point separates reconciles, and a disagreement some point does separate is still refused as a real crossover, with an error that says so. calibrate-autoroute now exits 0 and publishes 158 route decisions from 235 measured points on a 16-core AVX-512 host.
  • Both installers required a GPU literal sidecar that nothing produced, so every install failed closed. install.sh and install.ps1 refused any --from-file install without a sibling <binary>.gpu-literals.tar.gz, and --from-file is now the only install mode. The sole producer of those artifacts is keyhog-scanner-artifacts, a development binary that is not shipped, and the tarball packaging existed only in CI and in test fixtures, so no user could satisfy the requirement. This is the same defect class as the execution packs above: a required artifact with no producer. Added keyhog compile-gpu-literals, which compiles the detector corpus embedded in the binary into the host matcher artifacts and publishes them, atomically per file, into the runtime program cache. A missing sidecar now generates through the installed binary rather than failing; a sidecar that IS supplied is still signature-checked, checksum-checked, and archive-validated before extraction. Generation failure still fails the install and rolls the binary back, because finishing without matchers would put detector compilation back on every scan.
  • fix(daemon): fail-closed reconciliation and root degradation on watcher channel disconnection (Row 120).
  • fix(daemon): attribute multi-path watcher events across all enclosing roots and trigger subtree reconciliation on pathless events (Row 121).
  • fix(gates): enforce shrink-only ratchet in no_silent_fallbacks gate (Row 136).

[0.5.80] - 2026-08-17

Changed

  • style: format guard massive diff test and git sources modules.

Fixed

  • Include known reason and repair command in daemon warm-route errors and startup banner instead of hiding them behind a generic fallback. Apply the same fix to the daemon status command. Make is_work_request exhaustive so adding a new Request variant causes a compile error. Add regression tests pinning daemon server pure-function behaviors before modularization.

[0.5.79] - 2026-08-16

Changed

  • ci(release): fallback token and sync floating major tag on release.
  • Optimize Git staged manifest acquisition and index verification: pre-populate staged blob sizes directly from in-memory .git/index and fast-check index fingerprints against .git/index metadata and trailing checksum to eliminate redundant loose-object disk reads and subprocess forks during perpetual guard commit scans.
  • Add massive staged diff simulation suite (regression_cli_guard_massive_diff_simulation.rs) verifying 1,000-file and 5,000-file diff performance and RAM bounds under 10MB during daemon-served commit transactions.

[0.5.78] - 2026-08-16

Changed

  • fix(scanner): gate expand_triggered_patterns independently of decode feature.

[0.5.77] - 2026-08-16

Changed

  • fix(ci): format scan_postprocess, update dogfood hashes for doc fixtures, and bump action version.

[0.5.76] - 2026-08-16

Added

  • keyhog-profile: ProfileConfig, ProfileName, KnownProfile, and environment lookup routines for zero-allocation profile resolution and secure credential memory zeroization.

Changed

  • Stream JSON and SARIF finding envelopes directly into buffered writers during CLI report generation, eliminating intermediate allocations in JSON/JSONL envelope reporters and sorting allocations in SARIF reporter.
  • Update CLI scan format matrix and stdin regression suites to assert canonical 22-field CSV rows with metadata and additional-locations columns.
  • Short-circuit decoded-match suppression checks in scan post-processing and preserve exact deduplication ordering.
  • fix(core): rerun build script on GITHUB_SHA changes to prevent stale git hash in CI cache.

[0.5.75] - 2026-08-14

Changed

  • Findings now carry a canonical evidence verdict with exact review, likely, or confirmed tier and reason code. Evidence also retains schema-1 secret-safe candidate provenance: detector-corpus digest, pattern ordinal, producer channel, source role, and pre-verification context class. Public reports use optional evidence_score instead of confidence; JSON and JSONL report schema 2, baseline schema 2, and daemon wire 15 reject stale records that lack exact evidence. Deduplication retains the strongest proof while keeping candidate provenance owned by the reported detector independently from the scanner’s internal score, and live verification upgrades the verdict to confirmed/live-verification without discarding the candidate identity. Shared execution-pack routes retain the authenticated compiled detector-plan digest.
  • The default scan policy returns exit 1 for new likely or confirmed findings while keeping review findings visible with exit 0. Set [scan].evidence_policy = "paranoid" or pass --evidence-policy paranoid to make review-tier findings block. One-shot, daemon, staged-guard, and GitHub Action paths use the same policy classification. Coverage, panic, cache, and live-verification exit precedence remains fail closed.
  • Action report receipts preserve system-error and scanner-panic exits when review-tier findings remain visible. Guard commit proves the protected terminal receipt fits the daemon frame before consuming its transaction. Published scanners that predate evidence-policy flags retain their equivalent paranoid behavior only when the Action explicitly requests paranoid; they reject the newer default policy.
  • Emitted candidates in JSON, JSONL, TOML, YAML, dotenv, and INI configuration receive candidate-bounded source-role classification with exact value/candidate spans and borrowed key-path spans. Only candidates admitted for emission or ML scoring initialize parsing; each bounded source is parsed at most once per scan and later candidates reuse the source index. TOML and YAML key paths build in one forward pass, and the index stores path spans in a shared arena. Commented examples, empty INI settings, and commented INI section headers do not invalidate later evidence. The compact role and parser confidence survive adjudication in the 16-byte sidecar. Malformed or truncated syntax and unsupported, over-nested, or over-budget input abstain without suppressing the finding.
  • Emitted candidates in Rust, JavaScript/TypeScript, and Python receive exact lexical source roles for strings, identifiers, regex definitions, test fixtures, command arguments, and option declarations. Parsing is candidate-triggered and capped at 64 KiB; malformed, truncated, unsupported, and over-budget code abstains without changing candidate recall.
  • Emitted candidates in Markdown, roff/man pages, shell scripts, Dockerfiles, and Containerfiles receive bounded source roles for prose, inline code, shell-fenced commands, structured configuration fences, option declarations, environment assignments, and command argument values. JSON, JSONL, TOML, YAML, dotenv, and INI fences reuse their strict structured parser and abstain on malformed syntax instead of treating the entire fenced block as prose. Structured detector and rule fields derive regex-definition, test-fixture, and prose roles from validated Tier-B markers. Malformed, truncated, unsupported, and over-budget input abstains without changing candidate recall.
  • Detector-owned grammars keep MongoDB Atlas key pairs, command-line password arguments across shell, Dockerfile, PowerShell, CI, and programmatic literal contexts, and quoted Helicone values with token-shape or provider-owned context while rejecting Atlas identifiers, nested password-option names, and OpenAI sibling assignments. JWT detection retains its existing short-signature recall floor; provider-owned Scalr context applies a narrower 20-character floor. Known-prefix candidates used as assignment keys no longer absorb a quoted = separator as base64 padding, while quoted provider values retain legitimate padding. These decisions do not use repository or path exclusions.
  • Detector TOML schema 5 binds synthetic positives and named hard negatives to exact pattern ordinals and rejects those fields under older manifests. A deterministic regex-HIR generator exercises every shipped pattern. Schema-5 enforcement-capable semantic policies require direct positive, named hard-negative, and generated sibling-prefix evidence; schema-4 policies retain their prior validity. The schema identity change rejects prior detector-corpus caches, execution packs, and autoroute decisions.
  • Model-driven confidence reductions require an exact pattern calibration key bound to the detector corpus digest, detector ID, pattern index, candidate channel, source role, and pre-verification context. Missing, stale, unsupported, or under-supported calibration entries abstain; populated entries must satisfy held-out positive and negative support, recall, Brier score, and calibration-error floors.
  • The redacted real-repository quality gate validates each persisted identity receipt against the freshly captured candidate binary and measures unlabeled noise per MLOC. Version proof and executable hashing use one held immutable snapshot, so path replacement cannot mix binary identities. Labeled findings and deterministic canaries contribute to recall without inflating the noise density. Inputs are bounded regular files, class density ceilings cannot exceed 2.0 findings per MLOC, and unlabeled findings cannot reuse ground-truth hashes. The Make target requires explicit operational evidence; the nightly workflow validates the committed schema and boundary contract without claiming a fresh repository measurement.
  • Hosted Action release proofs now default to the workspace’s published 0.5.75 scanner instead of the obsolete 0.5.70 crate.
  • Scanner detector TOML schema 4 accepts typed capture, anchor, allowed-source, and required-evidence roles. Omitted declarations preserve current findings and serialization; declaring them under an older corpus schema fails closed. The schema identity change rejects prior detector-corpus caches and execution packs. Autoroute reports a detector corpus digest mismatch and instructs keyhog calibrate-autoroute. Detector-plan schema version 3 persists the resolved policy, rejects stale sections, and explain labels omitted scalar roles as compatibility defaults.
  • Merge remote-tracking branch ‘origin/main’.
  • Scanner candidates retain their producer channel and exact canonical pattern ordinal through ML scoring and final adjudication without changing public RawMatch output, ordering, deduplication, or caps. Matcher section schema version 6 persists the ordinal and rejects stale or out-of-range provenance during hydration.
  • keyhog triage imports only the current versioned redacted finding envelope and emits separately versioned scoped runtime-suppression and pattern-training artifacts. Records consume the scanner’s exact public evidence.provenance, binding the active detector digest, nullable pattern index, candidate channel, source role, context class, and channel-specific detector owner; typed mutually exclusive scopes, closed reasons, input and record limits prevent credential, context, path, and stale-policy persistence. Unix input reads, create-new outputs, and cleanup resolve through held no-follow directory descriptors so parent replacement cannot redirect I/O. Windows fails closed until equivalent reparse-point-safe held-handle I/O is available. Reassembled finding IDs resolve only through the canonical public suffix. Any detector corpus change invalidates every persisted triage artifact. Pattern-feedback-only decisions cannot produce runtime suppressions.

Fixed

  • Structured envelope formats (json-envelope, jsonl-envelope, sarif, csv, gitlab-sast) record scan_status: "failed" and document the coverage gap on total source failure (oversized stdin or unreadable source), while raw JSON reporting preserves valid report emission and fails closed with exit code 13 (EXIT_SOURCE_FAILED).
  • Credential-anchored entropy now rejects dotted source/property identifiers through the shared shape gate while retaining exact structured dotted-token recall.
  • Action autoroute calibration applies the same published-scanner evidence-policy compatibility rule as the scan runner: legacy scanners may omit the unsupported flag only for explicit paranoid. Scanner panics mark report metadata partial so exit-11 Action receipts remain valid.
  • Build-time model calibration artifacts now retain LF bytes on Windows checkouts, keeping the model-card SHA-256 receipt portable.
  • Staged-guard profiling now counts one authenticated blob payload once even when the staged index contains multiple path aliases; each alias still receives its own source-context scan.

[0.5.74] - 2026-08-14

Changed

  • fix(release): ignore Marketplace-only tags.

[0.5.73] - 2026-08-14

Changed

  • fix(release): preflight registry dependencies.

[0.5.72] - 2026-08-13

Changed

  • release: publish the tag the bump job creates.

Fixed

  • Release tags now publish: the bump job dispatches the crates.io publish for the tag it created, and that job creates the tag’s GitHub Release from its changelog section. A tag pushed with the workflow token raises no push event, so tags v0.5.51 through v0.5.71 were never published.

[0.5.71] - 2026-08-13

  • Scanner tests: coverage ratchet falls back to each detector’s test_positive example (with test_path) when proptest cannot generate from the regex, closing the 818/922 gap to 922/922. 87 detectors use regex features (\b, (?-i), (?:^|[^A-Za-z])) outside proptest’s generatable subset; 17 are path-restricted or suppressed on generic paths. The ratchet now validates every regex detector’s regex→compile→scan wiring.

  • Scanner: a connection-string finding whose password sub-field is a placeholder is suppressed. A detector for a credentialled URL captures the whole scheme://user:password@host span, so postgresql://app:<password>@localhost/db in a .env.example reached the report as a critical finding; the password is now read out of the URL and tested on its own for the wrapped template (<password>, {{db_pass}}, ${DB_PASSWORD}), the unbraced shell reference ($DB_PASSWORD), the single-byte mask (xxxxxxxx), and the placeholder vocabulary. A real password in the same position still reports.

  • CI: pull requests run the merge lane and skip feature-matrix, which stays required on pushes to main and on tags. The twelve detector-contract steps that shared one command line collapse into a single invocation.

  • CI: test binaries build under a new ci-test profile, identical to release-fast except that thin LTO and 16 codegen units are dropped. The sources lane links 116 test binaries, so cross-crate optimization was paid once per binary for nothing a test can observe. Measured on a 16-core host: the sources target matrix compiles in 97 s instead of 866 s, and the suite runs in 73 s with the same results. Shipped-binary builds, smoke tests, install proofs, and dogfood runs stay on release-fast.

  • Required CI runs the complete sources target matrix once, removing the earlier default all_tests replay before the all-feature run.

  • Sources: align filesystem profiling coverage with the default direct reader’s bounded batch handoff, retaining exact acquire, walk, read, and input-total assertions.

  • Scanner: reuse bounded CPU trigger scratch for clean phase-one admission rows, eliminating one zeroed trigger-bitmap allocation per unique no-hit chunk while preserving exact hit bitmaps.

  • CPU confidence scoring now evaluates large authenticated quantized batches across the configured Rayon worker pool while preserving bit-exact row order; batches below 64 candidates remain serial.

  • Sources: filesystem mmap admission, binary, Ghidra, and Docker reads now reuse descriptor metadata captured by the shared safe-open validation instead of querying the opened file again. Windowed buffered fallback refreshes descriptor metadata before its later cap check. No-follow, regular-file, advisory-lock, and size-cap behavior remains fail-closed.

  • Sources: whole-file reads up to 16 MiB now fill the post-open stat-sized buffer directly before the bounded growth probe. Concurrent shrink, growth through the hard cap, no-follow opening, and advisory locking retain their existing behavior.

  • Scanner: coalesced CPU and SIMD scheduling now stores small-lane membership in one flat index buffer instead of one heap allocation per lane. Chunk order, large-chunk isolation, worker partitioning, and the 512 KiB lane ceiling are unchanged.

  • Core: raise dedup additional-locations subquadratic tripwire ceiling to 3.25x to absorb CI thread-CPU jitter (observed 3.03x flake).

  • Scanner: sensitive-path keyword-free confidence ownership test pins ordinary entropy_very_high cannot demote admitted hits.

  • Scanner: sensitive-path keyword-free entropy keeps ML as lift and scores against the sensitive very-high band so assignment RHS like VALUE=<token> in secrets.env is not soft-dropped.

  • Scanner contracts: redis-sentinel evasion uses REDIS_SENTINEL_AUTH= (non-comment) so comment soft-suppression cannot hide the credential.

  • Generic assignment RandomByteBlob suppression requires decoded NUL evidence (entropy-path coherence) so JWT/API_KEY/TOKEN opaque secrets remain reportable; corrected-primary-role parity uses ExactCpuScanners for SIMD pack selection.

  • cargo fmt: collapse path-include blank lines and deny_unknown schema count expression.

  • Cut duplicate required library-surface core/sources --lib suites already owned by focused/sources jobs.

  • Fix CLI/docs/gate drift from unfinished guard landing: snapshot, exit-code help, docs reference, HOME allowlist, path-split daemon/guard unit tests, sources discovery no-unwrap move.

  • Adjust WordPress/redis-sentinel fixtures and vendor-token boundary path metadata for focused scanner contracts.

  • Align required CI with MUST-RETURN gates (library/sources/CLI/detector/install-scripts/feature-matrix); keep macos/fuzz/windows/static overnight. Re-enable path-filtered action-e2e and push/PR keyhog dogfood.

  • Required CI again gates feature-matrix compile/dogfood coverage and the install-scripts battery (scenarios/edge cases/static analysis), not only overnight.

  • Required CI covers library surface, sources/CLI aggregators, detector recall contracts, SARIF compliance, feature-matrix, and install-scripts. Overnight keeps macos/windows builds, fuzz-smoke, static linkage, CLI property/adversarial/reliability, and scanner property fuzz.

  • Install-from-build proofs pass an explicit --backend simd after --no-calibrate, matching autoroute fail-closed behavior when no cache exists.

  • Action E2E now tests the published v0.5.70 crate, and Windows guard dispatch returns an explicit unsupported-transport error without compiling the Unix daemon client.

  • Unix mass-daemon filesystem scans now accept --incremental, retain the compiled scanner across transactions, skip unchanged clean files before read and dispatch, rescan every file that produced a finding, and atomically publish the spec-bound Merkle generation. Cache publication failures retain system-error exit 3.

  • Daemon-local mass filesystem scans now retire all bounded batches after one drain request instead of requiring one client request round trip per batch. Each result remains a separately bounded response under socket backpressure, with exact source order, findings, gaps, and terminal receipts preserved.

  • Ordinary unbounded filesystem scans now classify archive symlinks during the configured metadata walk, eliminating a redundant full-tree directory traversal while preserving one counted refusal per expandable symlink. Byte-budgeted and descriptor-relative long-path scans retain their bounded safety paths.

  • Filesystem scans now use one direct reader by default and omit the intermediate ordered-reassembly thread. Explicit --reader-threads N values above one retain the parallel ordered path for measured storage workloads; finding order and bounded scanner backpressure are unchanged.

  • Warm incremental scans now defer backend routing and scanner dispatch until source acquisition emits a changed chunk. An all-unchanged filesystem tree closes both fused and coalesced batch streams without starting scanner work, and trusted clean-file Merkle hits count as complete coverage instead of a zero-byte failure.

  • Warm all-clean mass-daemon incremental scans now carry both metadata and content-confirmed skip counts across the wire, preserving successful coverage when no source bytes require dispatch.

  • Daemon responses now serialize directly into the bounded transport frame instead of allocating a complete JSON body and then copying it into a second buffer. Wire bytes and the 64 MiB frame ceiling are unchanged.

  • Added the perpetual repository and filesystem guard: a daemon-resident runtime that registers Git repositories and filesystem trees as guarded roots, maintains a 7-state machine per root (Stopped, Indexing, Current, Degraded, StalePolicy, StaleManifest, StaleTree), and applies a closed set of 12 transition events through a centralized transition function. The guard tracks a GuardPolicyIdentity spanning build, detector, suppression, ignore, config, decode-policy, source-policy, guard-schema, and report-semantics digests. A policy identity change invalidates all existing clean attestations and transitions active roots to StalePolicy. A HotAttestationIndex (64 MiB LRU) caches clean blob attestations to avoid re-scanning blobs that were already proven clean under the current policy. A RootRegistry holds canonical path bytes, filesystem identity (device + inode), mode (repo or filesystem), and terminal sequence number per root.

  • Added a staged Git manifest acquisition path that reads git diff --cached --raw -z --no-renames to enumerate newly staged objects, computes a BLAKE3 fingerprint of the Git index for race detection, and resolves object sizes via gix. The manifest distinguishes added, modified, removed, and type-changed entries.

  • Added event normalization and a bounded reconciliation protocol. Filesystem events are normalized into GuardEvent variants (Create, Modify, Remove, Rename, ReconcileSubtree, Barrier), coalesced within a configurable window, and buffered in a bounded EventBuffer with monotonic sequence numbers and overflow detection. GuardReconciliationConfig bounds subtree reconciliation by file count and depth.

  • Bumped the daemon wire protocol from v12 to v13 and added guard transaction frames: GuardCommitBegin, GuardCommitBlob, and GuardCommitFinish requests, plus GuardCommitPlan, GuardCommitReceipt, GuardAdded, GuardRemoved, GuardStatusResult, and GuardReconcileStarted responses. A GuardWireManifestEntry type carries staged manifest entries over the wire.

  • Added CLI guard commands (keyhog guard add|remove|list|status|reconcile) with daemon client integration, human and JSON status output, and exit code 13 for degraded, stale, stopped, or indexing states.

  • Added a [guard] configuration section to .keyhog.toml with typed settings for hot index memory budget, event queue caps, coalesce window, scanner residency, idle-unload timeout, scrub interval, and subtree reconciliation bounds.

  • Added a daemon guard runtime that holds the live root registry, hot attestation index, policy identity, and transaction ID counter in process. The runtime applies state transitions, invalidates attestations on policy identity change, and allocates monotonic transaction IDs for commit transactions.

  • Added a daemon-resident filesystem watcher for guard roots. The watcher uses native platform APIs (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows) to receive change events without polling. Events are normalized into GuardEvent variants, buffered in a bounded EventBuffer per root, and processed through the guard state machine on a coalesce window. Overflow triggers full subtree reconciliation.

  • Added scanner residency tracking to the guard runtime. The residency label (active, resident, idle-unload) reports whether the guard is actively using the scanner or has been idle past the 5-minute unload threshold. GuardStatus now reports real pending event counts from the watcher instead of a placeholder.

  • Added a durable guard state store backed by redb. When [guard].state_path is set, the daemon persists root records and clean attestations across restarts. The store enforces owner-only file permissions (0600), rejects symlinked paths, and creates all tables during schema initialization. On restart, roots are restored as stopped (never current) and the watcher is re-registered for each root that still exists on disk. In lockdown mode ([lockdown] require = true), the durable store is disabled and the guard operates in ephemeral mode.

  • Added the keyhog guard rebuild <root> subcommand. Rebuild removes a root from the guard (clearing its durable store entries) and re-adds it, triggering a fresh baseline reconciliation. Use it after store corruption or when persisted state is irrecoverably stale.

  • Added periodic guard scrub. When [guard].scrub_interval is set, the daemon watcher loop periodically triggers reconciliation for all current roots, catching changes that filesystem events missed (NFS, bind mounts, external edits that bypass inotify). Omit the setting to disable scrubbing.

  • Added a clean-shutdown marker to the durable store. The daemon marks the service as unclean on startup and clean on graceful shutdown, enabling detection of unclean restarts.

  • MatcherArtifact cache hits now validate and decode the three persisted matcher sections directly from the capped artifact file buffer. Startup no longer allocates and copies a second complete section set before hydration.

  • Confirmed phase-two shared-anchor collection now reuses the bounded worker-candidate scratch for eligible-pattern and literal-id lists. Repeated chunks no longer allocate two temporary vectors per confirmed-anchor pass.

  • Scanner runtime snapshots now use the same complete resolved tuning type and default resolver as configuration identity. The duplicate runtime-only performance configuration record is removed.

  • Added one performance-evidence reference that distinguishes canonical generated receipts from historical investigation reports and defines the executable, workload, host, route, lifecycle, parity, and coverage fields required for comparisons.

  • Autoroute cache schema v57 authenticates runtime-compiled GPU programs against the exact executable and detector corpus while retaining manifest-digest binding for installed GPU sidecars. Calibration from a standalone release binary now persists GPU route evidence instead of rejecting every measured workload when no sidecar is installed.

  • Autoroute JSON inspection now exposes the active GPU artifact binding and each authenticated ordered-device route body, including per-device topology, throughput weights, and resident budgets.

  • Detector property gates now preserve declared source-admission paths, compare Caesar prefix admission at exact token boundaries, compile backend-specific CPU and SIMD plans, and retain minimized parity cases. The WordPress token contract again carries its required wpcom owner anchor.

  • Complete autoroute sweeps now retry measured-point backend and recovery-route disagreements instead of treating timing variance as a permanent calibration failure. Non-timing failures remain non-retryable and leave the staged generation unpublished.

  • All-policy autoroute calibration now stages every isolated policy child into one generation and publishes the live cache once only after all children and their exact route receipts validate. Failure to prepare an eligible GPU peer aborts the generation instead of silently measuring a reduced CPU/SIMD candidate set.

  • Persistent daemon autoroute now compiles the runtime-policy GPU peer census instead of materializing a scalar-only scanner before loading authenticated GPU decisions.

  • Concurrent direct-GPU scan workers now serialize complete resident dispatch rings around the scanner-owned slot set. A second worker could previously observe the depth-one slot in flight, misclassify the healthy GPU as failed, leave one batch unscanned, and report partial coverage.

  • Builds without the git feature no longer compile the staged guard-commit client or reference Git-only source APIs. Their daemon rejects guard-commit frames with an actionable feature error instead of failing the portable build.

  • Filesystem discovery prunes default-excluded directories (for example node_modules/) during the walk and counts each pruned directory once in the Excluded coverage signal. Linux unbounded walks abort at the first ENAMETOOLONG and finish via descriptor-relative metadata-only discovery when ignore overrides allow it, and extensionless names get a cheaper content sniff before full reads.

  • Nested archive scans stream compressed tarballs (.tar.gz, .tgz, and nested compressed tar members) member-by-member with a single inflate, instead of retaining each full decompressed image or paying a second inflate to probe TeX provenance. Uncompressed tar and ZIP still gate TeX provenance on header/central-directory names so nested compressed payloads cannot false-trigger a second full member pass. Peak resident memory for archive extraction stays bounded by the compressed input, decoder state, and the largest member under the active caps.

  • Build route-scoped CPU, SIMD, and acquired VYRE GPU execution packs during verified POSIX installation, prove their exact findings against the scalar oracle, authenticate every pack with an installation-local key, measure every bundled source execution class in streamed and known-size form, persist the authenticated manifest and exact policy/backend pack identities with calibration evidence, and restore the previous binary, pack generation, and autoroute cache together when compilation, identity validation, health checking, interruption, or autoroute calibration fails. Binary self-update now rebuilds candidate detector packs and pack-bound calibration before committing the replacement, with the same rollback contract. keyhog doctor now authenticates the installed pack generation and verifies the exact route-cache binding. Pack publication now reaps dead-process stages and replaced backups, while recovering an unambiguous interrupted backup before recompilation. Installed scans now load the embedded detector corpus from the authenticated policy execution pack and fail closed when that exact pack is absent or corrupt. Scanner construction now shares that decoded corpus instead of cloning every detector specification. Normal scan startup no longer warms global regex caches unconditionally.

  • Installed scans now hydrate matcher graphs, native phase-one and phase-two Hyperscan databases, and fused VYRE matcher artifacts directly from the selected authenticated execution pack. CPU and SIMD routes no longer rebuild matcher graphs, SIMD does not compile Hyperscan databases at scan time, and GPU routes validate the exact calibrated VYRE peer before deserializing its signed matcher bytes. Development and explicit custom-detector scans retain their deliberate in-process compiler path.

  • Authenticated execution-pack pages use random-access mmap advice, disable Linux transparent huge-page promotion, and are discarded before matcher hydration; the selected mapping is released after construction. Normal scans fault back only the selected sections while decoding them into owned runtime state, so a small section access cannot retain a 2 MiB huge page and the complete pack mapping no longer overlaps the resident scanner.

  • Read-only execution-pack mappings now have a Linux cross-process RSS contract: two scanners that fault the same immutable backend program must account those pages as shared clean memory rather than private copies.

  • Worker-local scanner scratch now has route-specific retention ceilings. Anchor-dense CPU/SIMD candidates retain at most one scan chunk, single-chunk VYRE buffers are zeroed and capped to one chunk, and coalesced VYRE buffers retain at most the portable dispatch grid instead of keeping an outlier allocation for the worker lifetime.

  • Autoroute now measures each resident VYRE pipeline depth supported by an acquired GPU peer and persists the selected depth, submit/retire capability, and divided per-slot input and match capacities. Asynchronous depths two through four use independent resident IO slots, restore readbacks to logical row order, and keep haystack, presence, region-control, and positioned-match storage inside one aggregate device-memory ceiling. A changed capability or capacity invalidates the route before dispatch.

  • Finding finalization now moves one graph through scan-level suppression, deterministic deduplication, verification eligibility, report rules, and baseline filtering. In-place compaction replaces full-vector filtering and partition copies, and canonical dedup key ordering no longer materializes an intermediate key/value vector.

  • Allocation-tracked profiles now enforce live-byte conservation across every stage plus the explicit outside-span root owner, proving that every retained heap allocation has one reported owner even when another stage or thread frees it.

  • Non-verifying scans now release the decoded DetectorSpec corpus after scanner construction. The compiled detector-indexed plans retain the report metadata and execution policy they need. The metadata interner now owns each unique string once without a parallel arena, and resolution plus cross-detector relation indexes reuse those same detector-ID allocations. Only --verify keeps specifications for verifier-plan construction.

Fixed

  • BetterLeaks memory comparisons now reject route-mismatched measurement files with a coverage diagnostic. The release gate validates the exact catalog-derived workload and execution-route set before reading RSS metrics, instead of raising a raw lookup error.

  • Coverage claims now come from executable contracts instead of a boolean file ledger. The ungrounded FILE_GATE_MATRIX.toml audit artifact and its self-referential existence and column tests are removed; production boundary, error, adversarial, and end-to-end behavior remains owned by the tests that execute those paths.

  • macOS scanner library CI no longer fails on wgpu dual-slot overlap or a backblaze-shaped proptest seed. Dual-slot overlap is asserted only when the acquired GPU peer supports async timed resident dispatch; Metal/wgpu without TIMESTAMP_QUERY stays on the borrowed sync path and already has exact finding parity covered. The decode-generic property now rejects tokens that also match a named detector at top level, and the live GPU test lock clears poison so one adapter failure cannot cascade the rest of the suite.

  • A missing execution-pack generation no longer makes a valid binary unusable. Local and air-gapped installs can legitimately ship only the binary; when no generation directory exists, scans now compile the authenticated embedded detector corpus and keyhog doctor reports a warning instead of failing health checks. A present but invalid generation still fails closed rather than falling back.

  • The generic OAuth client_secret detector no longer reports canonical UUID identifiers. Its detector-owned value policy now rejects the exact UUID shape while preserving opaque, base64, and canonical-hex client secrets. Scanner integration contracts now construct the exact CPU, SIMD, or GPU route they exercise, and shared-state counter and IaC tests are isolated from parallel-suite interference.

  • Read-only Linux scans no longer fail while preparing filesystem discovery metadata. Path sorting now uses an anonymous memory-backed spool instead of creating a file through TMPDIR, so a container started with --read-only can scan mounted input without a writable scratch filesystem. Empty directories remain incomplete coverage and exit 13 rather than reporting clean.

  • Verified glibc container builds now accept the findings verdict from autoroute calibration. Calibration preserves normal scan exit codes, so reviewed fixture scans may return 1 after persisting valid evidence; the image build still aborts on every calibration error code.

  • Windows Action scans no longer reject extended-length workspace paths at the drive prefix. Filesystem root validation now waits until \\?\D:\ includes a real path component before calling symlink_metadata, while still checking every traversable component for symlinks. Branch/SHA Action contract tests now exercise the portable CPU backend they actually build, and an absent checkout is asserted as partial coverage with exit 13.

  • Anchored-regex fail-closed cases now compile in scanner library test builds. The shared unit suite imports the production type through its crate path, so no-default-feature and macOS CI lanes execute the same compile-failure contracts as the scanner integration aggregator.

  • Explicit private-endpoint consent now reaches WebSource. --allow-private-cloud-endpoint and [http].allow_private_endpoint = true were passed into the shared HTTP configuration but WebSource ignored them and rejected every private, loopback, and on-premises URL before HTTP. Web scans now honor the explicit opt-in across initial requests and redirects. The default remains fail-closed, and the narrow autoroute loopback exception still cannot follow redirects to unrelated private or metadata endpoints.

  • A client that walks away no longer kills the daemon. keyhog daemon start inherited SIGPIPE = SIG_DFL from the process-wide reset in main, which exists so keyhog scan | head dies quietly like any other Unix filter. That disposition is right for a one-shot report writer and wrong for a server: when a client abandoned a connection while the daemon was writing the reply, the write(2) raised SIGPIPE and the kernel killed the whole daemon. Measured, three connections that sent one hello frame and closed without reading were enough, and reading part of a large ScanResults frame then closing did it every time. The daemon serialises scan execution, so one client hitting Ctrl-C, timing out, or half-reading a result terminated the warm scanner for every other client on the machine, with no log line and a stale socket file left behind. The trigger was the single most ordinary thing a client can do and it was reachable by any process that could open the socket. The daemon service now restores SIG_IGN before it binds, so a departed peer surfaces as EPIPE on that one connection: the handler logs it, drops the connection, releases its admission permit and its fragment lease, and the process keeps serving. Clients suppress SIGPIPE only for the lifetime of a daemon socket, so the piped-stdout behaviour the reset exists for is unchanged. This is a live-connection fix and not a panic fix: every shipped profile sets panic = "abort", so a scanner panic still takes the daemon down and the per-request panic handler is unreachable there.

  • daemon stop and daemon status no longer report a live daemon as absent. Every connect failure was wrapped in no daemon at <path> (already stopped?), so a daemon that was running but wire-incompatible, untrusted, or too busy to answer looked exactly like an empty socket path. Measured: with every scan admission held by clients that had sent a partial frame, both commands reported no daemon while the daemon was answering health checks fine, and an operator had no way left to reclaim it. Two changes. The accept loop no longer waits for a scan permit before it can hand a connection to a handler, and a small admission pool is reserved for Hello, Health and Shutdown with a short read deadline so it cannot be squatted by idle peers. And connect failures now carry their kind, so an absent socket reports absence while a live peer is named with the identity a version-independent administration channel could read, and is left untouched rather than clobbered.

  • Shutdown now delivers in-flight results before it acknowledges. The wire contract promised a flush; the daemon acknowledged immediately, left the accept loop and exited, so a client whose scan was mid-flight got a closed socket instead of its findings. Draining on scan execution alone was not enough either, because the active-scan count drops to zero as soon as the scanner returns while the results frame is still unwritten. Shutdown now refuses new work, waits for each in-flight request to finish executing and to have its response written, then acknowledges, bounded so one wedged transaction cannot make the daemon unstoppable. ScanPath also enforces its own documented contract server-side: it opens a no-follow handle, refuses anything that is not a regular file, and re-checks the inode afterwards, so a directory argument can no longer make the daemon walk an entire tree, and a path replaced mid-scan fails closed instead of reporting findings for substituted content.

  • --perf-trace no longer aborts the process it is measuring. Every keyhog scan --perf-trace run died with index out of bounds: the len is 2 but the index is 2 and signal 6 (exit 134) at the end of the scan, after the report was written. confirmed_profile_dump indexed the per-pattern timing tables with its own scanner’s pattern count, but those tables are process-global OnceLocks sized by whichever scanner initialized them first, and on a GPU-enabled build a single-pattern probe scanner warms them before the 2,700-pattern corpus is compiled. The recording sites already used .get() and dropped out-of-range indices; only the dump indexed directly. The dump now reads exactly the rows that exist. This was the diagnostic every performance investigation reaches for first, so the tool for measuring the scanner was the one guaranteed to crash on it, and the crash arrived after useful output, which made it read as a scan-end failure rather than a profiler bug.

  • A phase-2 GPU admission catalog that cannot cover its pattern set is now refused instead of trusted. A GPU miss is only sound as “no covered pattern matched”, so a catalog that omits a pattern the CPU prefilter would have marked can report absence for something the GPU never scanned. complete was derived from lowering failures alone, so always-active patterns dropped by the candidate filter for any other reason (a gate prefix literal, most visibly) were silently excluded from the covered set while the catalog still claimed completeness. On the shipped corpus that set is empty and the claim was vacuously true, so no finding was ever lost; the hole was latent, not live. Coverage is now computed against what the CPU would actually mark, and a catalog with any uncovered pattern is dropped so CPU admission stays authoritative. Shard construction is bounded at 64 shards and stops at the first uncovered pattern, because every shard is a separate dispatch over the same haystack and a pool that shatters is a dispatch storm rather than an accelerator, measured at 1,547 shards and 294 s on one 1.25 MB batch before the bound.

  • A file that is truncated while the scan reads it no longer kills the scan. The whole-file read path mapped the file and read through the mapping. There is no race-free way to do that: an ftruncate from any other process invalidates the page-cache pages past the new EOF, and the next touch of the mapping raises SIGBUS. There is no handler, so the process died with signal 7. No report, no findings, no exit code a pipeline could interpret, and every other file in that scan lost with it. Measured on a plain keyhog scan <file> against a file a second thread was truncating and refilling: 4 of 8 trials died at 800 KiB, 1 of 6 at 128 KiB. That is not an exotic input. scan-system walks live filesystems where logs rotate, so one rotating file could destroy a whole-system scan. The read now goes through the already-open descriptor instead of a mapping, keeping the same symlink-resistant open, the same advisory shared lock, the same post-open re-stat, and the same hard 2 GiB ceiling. A file that SHRANK ends the read early and a file that GREW contributes its extra bytes, and neither can fault. The cost is one owned copy that this path was already paying, because a borrowed mapping could never be moved into the decoded String. Retry is deliberately not used and would be the wrong tool: the fault was designed out rather than survived. Two sites still map files and are tracked separately: the overlapping-window reader for files above the window size, and the compressed-input reader.

  • A scan that could not read its input now writes a report saying so, instead of writing nothing. When every requested source produced zero data (an oversized file alone, an unreadable file alone, --docker-image on a tag that does not exist, --s3-bucket on a bucket that does not exist, a cap that excluded every input), run() printed a diagnostic and returned BEFORE report emission. With -o out.json that created no file at all: not an empty envelope, not a gap row, nothing. The loudest failure in the product was the only one with no machine-readable output, so a CI job uploading out.json as an artifact got a missing artifact and had to infer why from stderr prose. The shipped generic-shell and Drone recipes pre-seeded an empty envelope before the scan purely to work around it, which is decent evidence the behaviour was always wrong. The report is now always written, carrying the findings (if any) and an explicit statement of what was not covered and why; the exit code still carries the verdict, unchanged at 13. Partial coverage was already correct and is unchanged: a tree with one readable file holding a credential and one unreadable file still reports the credential, both gap rows, and exit 1.

  • --autoroute-calibrate no longer suppresses the findings exit code. resolve_scan_exit returned success for any calibrating scan that did not panic, so keyhog scan --autoroute-calibrate <tree> && echo clean printed clean on a tree with leaks while the report next to it named the credential. Calibration is a side effect of a scan, not a different operation, and it must not mask the scan’s verdict. This is the documented first-run command in our own installers, so the one scan most likely to be wired into a gate was the one that could not fail it. Findings now outrank calibration: exit 1 (or 10 for a live credential) when a calibrating scan finds something. Below findings, calibration may still report success on an incomplete sample, because its workload is a deliberately partial measurement rather than a claim about the tree.

  • Autoroute calibration now resolves a statistical dead heat instead of persisting no decision at all. Selection required one route’s 95% confidence interval to lie entirely below every peer’s, and the only tie rule demanded exact nanosecond median equality between backends, which never once fired on real evidence. Overlapping-but-unequal timings therefore produced no route, so benchmarks/corpora/homefield (cpu-fallback 4.507 s [3.08, 11.49] against gpu-wgpu 4.462 s [4.40, 4.92], every interval overlapping every other) and this repository’s own crates/ tree persisted nothing, and every later scan of them completed through scalar correctness recovery: the slowest outcome reachable from a measurement whose entire content is that the backends are indistinguishable. A route now stays in contention unless some peer is proved faster, meaning that peer’s whole interval lies below its own. Among the survivors only those whose median falls inside the fastest route’s own 95% upper bound are eligible, so a wide error bar can never rescue a measurably worse median. That set is then ordered by backend complexity, because when nothing is proved faster the backend that needs no accelerator bring-up and always runs is the honest choice, and it is the same choice on every rerun of the same evidence. resolve_measured_route remains the strict proof and still backs confidence_separated, so a dead heat reports confidence_separated: false and a fourth selection_basis value, unseparated-dead-heat-lowest-complexity-backend, rather than posing as a proved win. JSON consumers matching on selection_basis must accept that fourth value. Measured after the change: homefield and crates/ both persist a decision that a later scan consumes with no recovery, with findings byte-identical to an explicit --backend simd-regex run. This subsumes and replaces the exact peer-median tie rule.

  • A failed cache write can no longer discard a completed scan. ScanDispatcher::run ended with self.router.commit()?, so a scan that read 100% of its input and found credentials reported NOTHING when $XDG_CACHE_HOME was read-only or full. Persisting a routing decision is not part of producing findings. The failure is now a loud stderr line plus a non-zero exit under --autoroute-calibrate (where persisting the decision WAS the requested operation), with the findings reported either way. Retry is deliberately the wrong tool here and is not used: the write is already atomic and lock-guarded, and a read-only cache directory does not become writable on a second attempt. Separately, a failed route quarantine no longer discards the batch that had already been scanned successfully through visible recovery: findings are collected before the bookkeeping, not after it.

  • A routing failure that says nothing about the matches no longer throws them away. Any AutorouteRoutingError captured from any batch discarded the entire finding set. AutorouteRoutingError now carries a kind set at each construction site: RoutingUnavailable (a cache miss, an accelerator that did not come up, a measurement that did not persist) keeps the findings, warns, and records a new FAIL-class BatchNotRouted coverage gap so the run still cannot read as clean; FindingsUntrustworthy (a candidate backend whose output diverged from the scalar reference, an unstable reference, a batch that was never scanned) stays fatal, because there we do not know which finding set we are holding. Scalar correctness recovery is the reference implementation, not a degraded mode, so its findings were the most trustworthy in the report and discarding them was pure loss.

  • A calibrated autoroute decision can be found again. scan --autoroute-calibrate without an explicit --autoroute-gpu wrote every decision under a resolved-config digest that no scan would ever request, so the immediately following identical scan reported scan config digest mismatch and completed through scalar correctness recovery. The cause was one field in the config digest recording whether calibration excluded an eligible GPU. On any host or build with no GPU candidate that exclusion is vacuous and the two host profiles are byte-identical, so the digest was the only thing that differed, and it differed on every run. The field is removed. The property it was reaching for is unchanged and still enforced where it belongs: a route generation is keyed by the persisted host profile, which carries the eligible backend census plus the complete GPU device, runtime, driver and batch-limit identity, so CPU-only evidence still cannot replay under a scan that admits a GPU. Measured on homefield and mirror, repeated scans of the same corpus with the same binary and config: 0% cache hit before, 100% after. Cache schema version moves 50 to 51 so an existing cache is superseded with a clear message rather than a config mismatch.

  • A coverage ceiling in the calibration sample no longer discards the whole calibration. Calibration rejected any candidate trial whose scanner coverage counters moved at all. A decode_oversize_skips on one chunk is a deterministic property of the sample under the resolved configuration, not a degraded backend: max_decode_bytes is part of the config digest, so every candidate skips the same bytes and the replaying scan skips them again. The rejection also fired on the scalar reference trial, before anything was compared. Measured on crates/: a 346-second sweep across eight workload buckets exited 2 and persisted nothing, and every later scan of that tree routed through scalar correctness recovery. The guard now compares each candidate’s coverage shape against the scalar reference’s, per counter and for exact equality, so a candidate that covered different bytes than the reference is still refused. A sample with a non-empty coverage shape prints one warning naming the counters and the sample identity, so a persisted decision measured over skipped bytes is never silent.

  • A credential inside a minified or vendored bundle is reachable again, and a dropped one is counted. Every finding whose path ended .min.js, .bundle.js, or .min.css, or sat under node_modules/, site-packages/, wp-includes/, dist/assets/ and similar, was discarded before it reached the report. The drop was unconditional, left no trace on any surface, and no flag defeated it, so a live sk_live_ key that a build pipeline had inlined into app.min.js produced [] and exit 0. Build tooling inlines API keys into bundles routinely, which made this the one leak class KeyHog could not report at all while saying nothing was detected. Two changes: --no-default-excludes now disables this suppression as well as the walker skip, so the flag disables every default exclusion instead of only the one you could see; and a suppressed finding is counted and reported as a vendored/minified path policy coverage-gap row naming the count and the flag that recovers it. The row is WARN class, so an ordinary scan of a tree with vendored code still exits 0.

  • A scan that read zero bytes no longer reports as clean. A .keyhogignore containing path:** gave exit 0, scan_status success, zero bytes, zero chunks, an empty coverage_gap_summary, and the line No secrets detected in the scanned files. Every signal said the tree was clean and nothing had been examined. --exclude-paths '**', an empty directory, and a directory whose only entry is an unfollowed symlink all had the same shape. A scan that reads no source bytes now emits a FAIL-class scan covered nothing coverage-gap row and exits 13, and the text report states that the scan covered nothing instead of that nothing was detected. There are two such rows, because the remedies differ: one for no skip was counted (nothing was there to read) and one for every candidate was skipped by exclusion or skip policy (policy hid it).

    This is a user-visible exit-code change. A scan whose target legitimately contains nothing scannable moves from exit 0 to exit 13. That includes keyhog scan --stdin on an empty stream, an empty directory, a pure vendored tree, a generated-artifacts directory, and a CI matrix partition with no files in its slice. That is intended: git diff | keyhog scan --stdin against the wrong base ref produces an empty diff, and reporting that as clean is the exact failure that makes mass scanning untrustworthy. Guard the producer ([ -s "$f" ] before the pipe) rather than suppressing the exit code. There is no opt-out flag, deliberately, because a flag that suppresses coverage failures would recreate the false affordance fixed above. A scan that reads bytes and finds nothing is unaffected and still exits 0, and a scan that covered some input and failed on the rest still reports every finding it got alongside the gap: exit 13 never means findings were discarded.

  • The exclusion coverage-gap row said something untrue. It read exclusion policy (.keyhogignore, --exclude-paths, or lock/minified/vendored defaults), but only the default policy ever produced it; files removed by an operator’s own .keyhogignore or --exclude-paths are not counted. The row now says which of the two it means and states that user removals are not in the number.

  • keyhog_profile::reset() left the previous round’s measurements in place. It cleared the runtime-level stores and the legacy mirrors, and never touched the per-worker shards, so stage times, call counts, latency buckets and min/max, stage windows, typed counters, input bytes, cache counts and indexed counters all survived it. Benchmarks call profile_reset() between measured rounds precisely to discard warm-up, so round two was reporting round one’s numbers as its own. Nothing failed and no output looked wrong; the second measurement was simply the first one plus the second. reset() now clears every per-run accumulator it owns, and a test asserts each family is empty afterwards. The indexed-counter half was reported by the perf-consolidation lane; the shards were the general case underneath it.

  • A vendor detector no longer claims a credential it cannot attribute. Nine service-named detectors owned patterns carrying no evidence of their own vendor, so every match was attribution by coincidence. akamai-api-credentials held a bare client_secret[=:\s"']+([a-zA-Z0-9+/=]{30,50}), which made Akamai the de-facto owner of every OAuth client secret; wordpress-api-token held a bare access[_\-\s]*token alternative; authentik-token held a bare Authorization: Bearer <40+ alnum> that bearer-authorization already owns; budibase-credentials held bare INTERNAL_API_KEY, JWT_SECRET and (COUCH_DB|MINIO|REDIS)_PASSWORD assignments. Each is now anchored to its own evidence: the vendor name, the vendor host in a bounded window (*.luna.akamaiapis.net), or the vendor’s own token prefix (akab-). Measured against the per-file vendor ground truth in benchmarks/corpora/homefield/kingfisher/manifest.jsonl, wrong-vendor attributions fell from 114 to 87 of 386 attributed findings while correct ones rose from 272 to 282. Ten detectors had been losing their own inline test_positive to an over-broad sibling and now surface it (avaya, aws-cognito, azure-government, checkmarx, discord-oauth, jumio, elevenlabs, internalio, supabase-jwt, bearer-authorization). No credential was lost to the tightening: across benchmarks/corpora/mirror/corpus, homefield, ioc-recovery-v3 and this repository’s own crates/, zero files went from at least one finding to none. That zero is controlled rather than asserted: the same measurement reports 7 blinded files when the two replacement detectors below are removed and the tightening is left in place.

    This moves findings between detector_ids. A baseline, allowlist or suppression keyed on detector_id, or on a detector_id plus credential-hash pair, may stop matching for the affected services, and severity and rotation guidance change with the attribution. On homefield: akamai-api-credentials 14 to 4, authentik-token 4 to 0, wordpress-api-token 13 to 0, budibase-credentials 1 to 0 and klaviyo-api-key 6 to 2, against bearer-authorization 36 to 39, elevenlabs-api-key 1 to 4, jfrog-api-key 1 to 4, square-access-token 4 to 6 and onelogin-client-secret 0 to 1. The credential set is unchanged; only which detector claims it moved.

  • A self-hosted GitLab or Bitbucket endpoint had no destination screen, so the operator’s token went wherever it was pointed. hosted_git::validated_api_endpoint checked the scheme, embedded credentials, and the query/fragment, and never once asked where the request was going. --gitlab-endpoint https://169.254.169.254, https://10.0.0.5, or http://127.0.0.1:9 was accepted and the PRIVATE-TOKEN header carried there; Bitbucket’s Basic credential the same. Every other remote source already refused this: S3, GCS and Azure screen through cloud::parse_http_endpoint, and WebSource refuses loopback outright. Hosted git was the one hole, and the repository already contained a test asserting the hole was there, pinning the transport error as expected behaviour with a comment that a future SSRF screen should flip it. Both endpoints now go through crate::endpoint_screen, which is the single owner of the decision and screens the literal host against the fleet-canonical keyhog_verifier::ssrf classifier and then re-screens every resolved address, so a public hostname whose A record points at a metadata address is refused too. The addresses that passed the screen are pinned into the client with resolve_to_addrs, so reqwest cannot re-resolve between the check and the connect; that half was found by the security-boundary lane. Skipped when a proxy is configured, because the proxy owns DNS then.

    This is a user-visible change for on-premises deployments. A self-hosted GitLab or Bitbucket on a private address is an ordinary enterprise configuration, and --gitlab-endpoint https://gitlab.internal.corp now exits 13 with refusing gitlab endpoint: host is a private, loopback, link-local, or cloud-metadata address (SSRF) unless --allow-private-cloud-endpoint is passed. That flag already existed and already governed the cloud object stores; it now means what its name says across every remote source rather than three of five. Add it for a trusted internal endpoint. It is deliberately not implied by supplying an endpoint, because the whole failure being fixed is that supplying an endpoint was treated as consent to send a credential to it.

  • keyhog scan <path> --benchmark scanned nothing and exited 0. --benchmark runs KeyHog’s own built-in corpus and exits; it never reads an operator-supplied target and never writes --output. Passing both was accepted, both were silently discarded, and the run reported success: keyhog scan ./src --output report.json --benchmark printed benchmark winner: simd-regex at 159.02 MiB/s, exited 0, and wrote no file. In CI that line reads as a completed scan of ./src. The flag now conflicts with PATH, --path, --stdin and --output, so the combination exits 2 naming the conflict instead of throwing the request away. Benchmarking a specific corpus was never what the flag did; use a normal scan with --profile for that.

  • Lowering --decode-size-limit silently reduced recall. A chunk larger than the limit was declined for decode-through with nothing recorded anywhere, while the neighbouring path that truncates decoder output has always counted a decode_truncations gap. So the decline that skips the pass entirely was the quiet one. Measured on the 2,399-file homefield corpus: --decode-size-limit 64K reported 1,623 findings against 2,239 at the 512 KiB default, 616 fewer, with an empty coverage_gap_summary and nothing on stderr. A chunk denied decode-through now records a WARN-class scanner decode-through declined by --decode-size-limit coverage gap that names the flag in the structured coverage_gap_summary reason, not only in terminal prose, so a CI wrapper reading the envelope gets the remedy. It stays at zero on an ordinary scan, because no chunk reaches the compiled 512 KiB default. WARN rather than FAIL is deliberate and was settled by the lanes that own exit semantics: the raw bytes were examined, only a derived layer was skipped, which is the same class as the existing decode-truncation and structured-oversize rows. The recall half is a separate open defect: while the 1 MiB window size exceeds the 512 KiB decode limit, the interior of any larger file is decode-unreachable regardless of this counter.

    The counter was recorded only in scan_inner, which the coalesced SIMD route bypasses, so the gap was backend-dependent: on this repository’s crates/ tree, --backend cpu reported one declined chunk and --backend simd reported none, for byte-identical findings. Recall never differed between backends; only the operator’s warning did, which is the worse failure because it is invisible. The recorder is now paired with record_file_scanned at every site, which is the event that already has one call per chunk per route by contract. Surfaced by the autoroute lane’s calibration guard rejecting a candidate on the counter mismatch.

  • The oversized-input coverage row named a flag that had not fired. SourceSkipEvent::OverMaxSize is raised by at least eleven distinct caps: --max-file-size, --limit-stdin-bytes, --limit-git-blob-bytes, the two Docker tar caps, the S3/GCS/Azure per-object caps, the archive per-entry cap and the windowed-mmap sanity cap. The row read exceeded --max-file-size and advised re-scanning with a larger cap, so --git-blobs <repo> --limit-git-blob-bytes 64B told the operator to raise --max-file-size, which leaves the blobs skipped. It now names the cap family and points at the per-cap warnings above it, which already name the exact flag. Reported by the limits lane.

  • The GCS token-forwarding warning fired on the flag rather than on the act. --allow-gcs-token-forward printed its consent notice when the flag was parsed, from the CLI’s dedicated-flag branch. The equivalent --source gcs:BUCKET\nPREFIX\nENDPOINT\ntrue reaches the same source with the same effect and printed nothing, so one of two equivalent entry paths was silently quieter. The notice now lives in gcs_bearer_token, at the point an ambient token is actually carried to a non-Google endpoint, mirroring what the S3 path already did. Both entry paths are identical, and the warning fires only when a token is genuinely forwarded rather than whenever the flag is present.

  • A detector pattern with no basis in its own documented format is gone. jetadmin-credentials carried jet_[a-zA-Z0-9]{24,}, which appears nowhere in that detector’s documented format (JET_ADMIN_/JET_ prefixed environment variables) and, because detector patterns compile case-insensitively, matched any long Jet-prefixed identifier. Measured against a 2.2 GB cargo registry it produced 256 findings, and all 256 were Microsoft JET database engine constants in windows-0.58.0/src/Windows/Win32/Storage/Jet/mod.rs (JET_bitSetUniqueNormalizedMultiValues, JET_errDatabasePatchFileMismatch, and 254 more). Zero true positives. That tree now reports one finding, which is this detector’s own test_positive vendored inside a published keyhog-core crate. The three assignment-anchored patterns cover the documented format, including the detector’s own inline test.

  • Five vendor detectors could not match their own vendor’s current credential format. elevenlabs-api-key accepted only sk_ plus 32 hex while ElevenLabs issues 48, so all three ground-truth samples in homefield were being attributed to Klaviyo; it also matched sk_ inside lsv2_sk_<32 hex>_<10 hex>, taking LangSmith keys from langsmith-api-key, so it now requires a token boundary and captures whole-value. onelogin-client-secret accepted only 64 hex where OneLogin also issues lowercase alphanumeric. jfrog-api-key owned only the retired AKCp8 prefix and not the artifactory_access_token assignment that replaced it. square-access-token owned only sq0atp-/sq0csp- and not the EAAA OAuth token that replaced them. akamai-api-credentials required client_token with an underscore and a 32-character floor, where real EdgeGrid tokens are hyphenated and 18 to 19 characters after akab-.

  • A single large file cost roughly 3.8x its own size in peak memory, so a big enough file simply ran out of RAM. Every existing benchmark was many-small-files, so the large-file regime was never measured. Three separate causes, all in the path from read to scan. The filesystem reader collected EVERY window of one file into a Vec and sent nothing until the whole file was read, so a 300 MiB file held all ~343 of its 1 MiB windows live at once and the scan pool sat idle through the entire read: sampling /proc showed one thread accumulating 617 MB with 31 cores doing nothing. The windowed mmap never released pages it had already walked past, so the whole file stayed resident on top of that. And every queue bound between the source and the scan workers counts CHUNKS, not bytes, which describes ~128 KiB per batch on a small-file corpus and ~32 MiB on one big file, so the large-file regime carried over a gigabyte of queue headroom and was split into only ~11 work units for 32 cores. The reader now streams each file’s windows in byte-bounded parts (a small file is still exactly one send, unchanged), the slicer hands back each stride with MADV_DONTNEED as it leaves it behind, and the fused batch cut is byte-aware as well as count-aware. Measured on one 300 MiB file, isolating this change alone: peak RSS 1,156,720 KB to 772,972 KB and 4.79 s to 3.78 s; on 1 GiB, 3,131,944 KB to 804,400 KB and 13.89 s to 9.76 s. The 300 x 1 MiB control improved too (862,896 KB to 766,216 KB), so the cost was removed rather than moved. Total CPU-seconds are unchanged, so the wall-clock gain is read/scan overlap that was not happening before, not less work. Peak RSS is now flat in file size rather than proportional to it: on the shipped binary, 347,068 KB at 300 MiB against 379,400 KB at 1 GiB, +9% across a 3.5x size increase, against +171% before. A 1 GiB file needs 0.37 GB where it previously needed 3.1 GB. Findings are byte-identical: 2,863 on benchmarks/corpora/mirror/corpus with an identical canonical set digest, and 25 of 25 secrets planted at every one of the 21 ways a 20-byte credential can straddle a window cut are still found exactly once, with the correct absolute byte offset and line. The large absolute improvement on the shipped binary is mostly the detector-compilation and scratch-retention work from the memory lane; this change removed the term that scales with file size, theirs removed the constant, and the two compose.

    Persisted autoroute calibration invalidates once. Batches are now cut on bytes as well as chunk count, which changes the (byte-total, chunk-count) workload key calibration measures against, so the compiled-in fused_batch_bytes is hashed into the autoroute config digest. Any calibration persisted before this change reads as a config mismatch and is measured again on the next --autoroute-calibrate run. That is intended rather than incidental: replaying a decision timed under different batching would be measuring something else. No flag or output changes, and a scan that has never calibrated is unaffected.

Added

  • One retry policy, in keyhog_core::retry, with retries counted by cause. Bounded attempts (3), one backoff (5 ms doubling to a 40 ms ceiling), and one classification of transient versus permanent. There is deliberately no catch-all cause: an unclassified failure is permanent and is not retried, so making a new failure recoverable requires naming it in keyhog_profile::RetryCause. A permission denial, an absent operator-supplied path, and every cap refusal (the docker tar entry-count cap, the docker unpack budget, the PDF work budget, --max-file-size, the seventeen configured source limits) are permanent by intent: they fail identically on every attempt, and retrying a hostile input turns a denial-of-service defence into a denial of service. retry::open_enumerated exists to design the stat-then-open race out rather than retry it, taking metadata from the open descriptor so no second path lookup can resolve to a different inode. Every retry ATTEMPT is counted through the profiler and surfaced in --profile output, including attempts on operations that eventually succeeded, because a retry that fires is evidence of a defect rather than a success.

  • The autoroute cache reports its hit rate. Every automatic scan now prints one stderr line: autoroute cache: 100.0% hit (2 hit / 2 lookup(s)), where one lookup is one batch asking for its route. A scan with misses names the cause (cache-rejected, bucket-absent, runtime-class-unproved, route-quarantined, gpu-peer-identity-changed, and three more), the count of distinct uncalibrated buckets, and the one repair that fixes that cause. Previously nothing counted a hit, so “is the cache earning its keep” was unanswerable and a key that could never hit looked exactly like a corpus nobody had calibrated. Run with -v to list every distinct uncalibrated bucket under keyhog::routing, so one recalibration can cover all of them instead of learning about one bucket per run. The line prints in every output mode; --format json -o <file> previously suppressed the whole routing summary, which is the shape CI uses. The same outcomes feed the profiler cache family as autoroute-decision and autoroute-calibration, so --profile-out carries hits, misses and hit_rate_ppm.

  • --profile answers the questions a slow scan actually raises, and leads with the answer. It reported per-stage span timings with call counts and percentiles, which is the evidence for a conclusion and not the conclusion. Six families are now measured and reported. MEMORY: peak resident from the kernel high water, the engine-init floor taken as resident memory on entry to scanning, input-driven resident as peak minus floor, amplification, per-scanner-thread resident, and allocation volume and peak owned per stage. PARALLELISM: per-worker busy and blocked time from outermost spans only, so nesting never double-counts; idle time against pool capacity; achieved speedup as process CPU over wall; efficiency against logical CPUs; an Amdahl ceiling from measured serial work; and time spent inside instrumented regions while not on CPU, which is where a large pool loses its speedup without ever going idle. SERIAL PHASES: per-stage wall-clock windows giving average concurrency, plus an exclusivity measure that separates a real barrier from an inclusive wrapper whose children are the parallel work, so a span covering the whole scan is not reported as a bottleneck. THROUGHPUT: MiB/s and files/s overall, per phase and per micro-function. ATTRIBUTION: cost per call, per file, per byte, per detector family and per backend. CACHE AND REUSE: hit rates for autoroute decisions, calibration reuse, incremental unchanged-skips, matcher artifacts and verifier results, through one CacheId vocabulary so the question has a single answer instead of one per subsystem. Retry attempts are counted by cause and reported as a finding, because a retry that fires is a failure that was not designed out. The first line of the human summary is now the conclusion, for example bottleneck memory-floor 481.3 MiB of the 489.2 MiB peak is standing the engine up, not the input: 11.0 B of input produced only 8.0 MiB of extra resident memory, with the span detail below it as evidence. Measured on the mirror corpus, crates/, and a 300 MiB file: the profiler independently reproduces the engine-init floor at 481 to 485 MiB across every workload, a 27.9 MB per-scanner-thread scratch slope over a five-point thread sweep, and 3.74x resident amplification on the large file, all of which previously required /usr/bin/time and shell loops. Every derived value is an integer in thousandths or parts per million, so two --profile-out records diff exactly and an unchanged run cannot look changed.

    This changes --profile output and the --profile-out document. The stderr report gains the summary above the existing span table, and the JSON gains stage_concurrency, worker_occupancy, queue_depths, blocked_waits, caches, indexed_counters, retries and insight. Every new field carries a serde default, so a reader of older records still decodes. The profile schema minor moves 2.7 to 2.8. Default scan output, findings and exit codes are unchanged. Recording stays free when profiling is off: the disabled path is one relaxed atomic load with no clock read, and the new counters return before touching anything when no runtime is current.

  • Measurement primitives so a subsystem records through the profiler instead of its own stopwatch. serial_span declares a region that runs with the pool idle; add_stage_bytes gives a micro-function a real MiB/s; record_cache_hit and record_cache_miss take a fixed CacheId; record_retry takes a fixed RetryCause; counter_span times a region that sits inside a stage leaf without double-counting it; add_indexed_counter holds a per-slot family such as the per-decoder cost table, where a slot outside the fixed range is counted as dropped rather than folded into a neighbour. decision_timer is deliberately different from the rest: it reads the clock unconditionally, because a measurement the product acts on, such as autoroute calibration, must produce the same value whether or not an operator passed --profile, or the flag would change routing. Two stages are new, autoroute-calibration and boundary-scan, the second of which makes chunk-seam rescanning visible where it was previously folded anonymously into the phase-two leaves.

  • Two detectors for credential shapes the corpus could not attribute. oauth-client-secret owns the vendor-neutral client_secret assignment (RFC 6749 section 2.3.1), which had no owner, so the shape was previously reported as Akamai. It is built from detector TOML data alone with no new code, reusing the existing structural-slot primitives, and resolution_priority = -1 ranks it below every service-named client-secret detector (azure, google, okta, keycloak, cognito, discord, onelogin, avaya, jumio, checkmarx) so an attributable secret is never reported as the neutral shape. It recovers 7 files on benchmarks/corpora/homefield that no detector matched at all (pingidentity, gitalk, webex, huawei, onelogin, intra42 and a betterleaks fixture). Placeholders stay silent without any new suppression code, because the captured slot excludes <, >, $, { and } and carries a 20-byte floor: client_secret=${OAUTH_CLIENT_SECRET}, client_secret=<your-secret-here> and client_secret=secret do not match. facebook-access-token owns the Graph API EAA prefix, which facebook-oauth-secret never covered because it holds only the 32-hex app secret; the three Facebook access-token ground-truth files in homefield were being attributed to WordPress, two of them reported only through that wrong detector, so anchoring WordPress to its own evidence would otherwise have dropped them. Its pattern carries an explicit (?-i), which is load-bearing rather than decorative: detector patterns compile case-insensitively by default, and without it the caseless eaa matched the lowercase prefix of sha256 image digests (nginx@sha256:eaa35988...) and produced false positives on mirror negatives. The embedded corpus goes from 923 detectors to 925.

Changed

  • The phase-two confirmation pass no longer builds two anchored verifier regexes per pattern when it reads one. extract_anchored compiled both the \A(?:src) verifier and the \A(?s:.)(?:src) left-context variant for every eligible pattern, and allocated a capture-slot buffer for each on every call. A candidate at byte 0 is rare, so the plain verifier was an entire extra regex compiled per pattern that nothing ever read, and a pattern with no capture group never reads the slots at all. Each variant is now built only when some candidate position actually consults it, and a non-grouped pattern resolves through find, off the lazy DFA, without running the capture engine. This is worth more wall time than instruction count suggests: regex construction is one-time per pattern but serialized on whichever worker touches it first, so it does not parallelize. The same pass also replaces a per-chunk HashSet of present suffix literals and a binary search run per anchor candidate with reusable per-worker bitsets, skips the per-line homoglyph normalization on chunks that preprocessing already proved unchanged, and settles the raw-text comparison by pointer identity rather than a whole-buffer memcmp. Measured on a frozen 5,554-file copy of crates/: 4.10% fewer retired instructions, median wall 9.42 s to 8.55 s. On the 15,000-file mirror corpus: 1.96% fewer instructions. Findings are byte-identical on both corpora under --backend cpu and --backend simd.

  • Scanning an eleven-byte file no longer costs 480 MB. CompiledScanner::compile built a regex::Regex for every pattern, companion and generated homoglyph variant in the corpus and kept all of them resident, and ScanOrchestrator::new then called warm(), which ran each one against a sample. A compiled corpus pattern is on the order of 200 KB of NFA, one-pass DFA and Teddy-prefilter state, and the embedded corpus declares 1,709 patterns and 178 companions, so a scan paid roughly 450 MB before reading any input. That is a floor, not a workload cost: the same amount was spent whether the target was eleven bytes or a repository, which is why a constrained CI runner could be killed by a scan of almost nothing. Detector patterns are still VALIDATED at construction, through the same builder the scan path uses, so a malformed or oversized regex and an out-of-range capture group are rejected loudly before a scan can start; the validation build is simply not retained. Phase-1 literal gating means a real scan reaches a small fraction of the corpus, and each reached pattern compiles once on first use through the existing process-wide regex cache. Measured with /usr/bin/time on two release-fast binaries built from one commit, median of three: an eleven-byte file at --threads 1 goes 480,460 KB to 67,000 KB and at --threads 32 goes 486,288 KB to 110,748 KB; the 60 MB mirror corpus goes 596,080 KB to 207,332 KB and 848,360 KB to 456,408 KB; a 28 MiB source tree goes 962,136 KB to 701,012 KB and 1,653,140 KB to 1,393,000 KB. The corpus-proportional part of the floor falls from 0.489 MB per detector to 0.041. Findings are byte-identical, verified by digest on the mirror corpus and on a source tree at both thread counts.

    CompiledScanner::warm() changed meaning. It warms the shared runtime matchers every chunk touches (the multiline structural regexes, the shared assignment regex, the generic-assignment value bridge) and no longer force-compiles the per-detector patterns, because doing that re-materialised the whole corpus on every invocation, including one-shot single-file and pre-commit scans that reach a handful of detectors. It stays idempotent and cheap to repeat.

  • The phase-2 prefilter’s per-worker regex scratch is bounded. Phase2AlwaysActivePrefilter passed one 64 MiB constant to both size_limit and dfa_size_limit on all four of its RegexSet builders. The first is a per-process compile budget; the second is a lazy-DFA cache allocated per worker thread per batch, so the nominal per-worker ceiling scaled with the batch count. The compile budget stays at 64 MiB and the cache ceiling is now a separate 4 MiB. Cache size only decides how much of the automaton is memoized, never which patterns the set reports, so this is match-equivalent; measured neutral on peak resident memory and wall time on both corpora at one and thirty-two threads.

    Peak memory still grows with worker count on a real scan, and no size knob reduces it. The remaining per-worker term is the regex crate’s lazy-DFA cache, allocated per compiled regex per thread and retained in that regex’s pool for the life of the process, dominated by the anchored phase-2 verifiers. Lowering the per-regex ceiling does not help: measured at 1 MiB, 256 KiB and 64 KiB on two binaries at one, eight and thirty-two threads, peak resident memory moves under one percent while wall time degrades about fivefold, because the meta engine abandons the lazy DFA for slower engines that allocate comparable per-thread state. What governs the term is how many distinct patterns a worker activates, not the size of any one automaton, so reducing it is detection-routing work rather than a tuning constant.

Changed

  • Expose confirmed_companion_gate on [tuning] and resolved/autoroute config identity (default on), so operators can disable the mid-literal confirmed-pass skip the same way as confirmed_suffix_gate.
  • Restore pure structural base64url parsing in jwt_segments, reserve structural payload/header decoding for analyze, replace runtime panic macro paths in BPE token count cache initialization with a compile-time safe TOKEN_CACHE_CAPACITY constant, support legacy var declarations in bounded CryptoJS recovery, and use one containment relation for miss-clustering TP, FP, and FN accounting.
  • Cold one-shot and incremental scans now reuse a persisted MatcherArtifact of the eager compiled matcher graph across process invocations (format v4), with CacheId hit/miss/invalidation in profile output, fail-closed identity checks, soft-fail when cache prep fails, and –lockdown disabling the cache.
  • Companion-gate derived AC/literal tables use a bounded per-thread LRU keyed by detector digest + active pattern set, and parsed-arm memo is capacity-capped, so heterogeneous trigger mixes do not rebuild from a single-slot thrash or grow unbounded.
  • Restore reusable phase-1 absence proofs for small rejected repeated payloads (≤128 KiB), and size-gate markerless bounded-window decode skips so short trailing slices still decode.
  • 39 process-safe scanner test files are wired into the all_tests aggregator. Process-global decoder-registry and allocation targets plus the RSS-sensitive execution-pack mapping contract run in isolated CI processes. The recall_locks_wired.py gate is widened from checking only regression_*.rs to checking all top-level test files. CI workflow duplication is eliminated by extracting composite actions for workspace repair and Vectorscan install. All workspace compile warnings are fixed (zero warnings from cargo check –workspace).
  • fix(release): consume legacy unreleased notes.

Fixed

  • Base64 decode memo retains successful UTF-8 text only after a second sighting of the same candidate, so unique-blob corpora no longer keep a second full-size copy of every decode for the whole chunk. Failures stay memoized immediately.
  • Companion-literal presence scratch resizes to the active literal count and fill(false)s every chunk (not only the non-grow branch), with a regression test that seeds stale true bits then grows the literal set.
  • Corrected operator-visible help text and docs for five flags whose descriptions diverged from the implementation: –ml-threshold (applies to all findings, not just ML), –fast (also disables ML scoring), –oob-timeout (upper bound is max(value, 120s), not the value alone), –dogfood (credentials are redacted with prefix and suffix, not prefix only), and exit-code 3 (autoroute-cache persist failure applies when no findings are reported, not when findings exist). Updated the workspace authors contract test to match the binding identity.
  • Preserve scanner-materialization context on installed execution-pack compile failures, and remove the unused record_matcher_artifact_pack_hit helper that contradicted CLI profile attribution policy.
  • Autoroute calibration times candidates on the route-neutral phase-1 plan (no CPU trigger prefill on the clock); production still fills hints after CpuFallback selection so backend comparison stays fair.
  • Autoroute CpuFallback selections now fill deferred CPU trigger hints on the route-neutral phase-1 plan so production automatic scans reuse them.
  • Make filesystem/windowed phase-1 representative reuse symmetric: both chunks must agree on windowed-ness, and windowed pairs also require the same path, so vocab-clean proofs cannot jump across paths or source classes.
  • Move companion presence-scratch growth regression out of src into tests/unit/root_facade and expose companions_deny_absent via the testing facade so KH-GAP-004 no-inline-tests stays green.
  • Cache entropy configuration digests and use capacity-aware vocabulary absence marks so hot-path hashing and capped finding heaps stay correct.
  • Drop unused chunk_is_markerless_single_line helper and replace em dashes in scanner comments/SPEC with ASCII punctuation so the zero-warnings / prose gates stay green.
  • Re-cache entropy_evidence_config_digest on CompiledScanner (widened vocab key) and invalidate on with_config / clear_fragment_cache so hot windowed lookups avoid rehash without ignoring the known in-place config mutation path.
  • Extract vocabulary absence helpers under the scanner source-size cap and add a companion-gate test override so suffix-gate cold-regex differentials stay measurable.
  • Remove a redundant always_active_absence_proven self-assignment that tripped clippy::redundant_locals under -D warnings.
  • Vocab-stage absence memo keys include mutable scan settings (unicode_normalization, min_confidence, match/decode caps, penalize_test_paths) so clean proofs cannot survive in-place config edits.
  • Windowed absence memos bind to exact ordered content, only engage for parent filesystem/windowed slices, reuse the batch entropy configuration digest, and drop new keys at capacity instead of clearing unrelated proofs.

[0.5.70] - 2026-08-10

Changed

  • fix(profile): fail-closed overlapping allocation session peaks.

Security

  • Fail-closed overlapping allocation sessions instead of misattributing process-global peaks

[0.5.69] - 2026-08-10

Added

  • keyhog scan --access-targets reports the resource each credential opens: account, tenant, endpoint, database, or resource. A finding says where a credential is, not what it reaches, and the address is usually next to the credential where no detector can see it, because a companion regex is bounded to a few lines and captures the other half of the credential rather than the resource. Providers live in Tier-B crates/core/data/access-targets.toml, so adding one is a data edit. Off by default: with the flag absent the report has no access_targets key and findings are byte-identical. With it, --format json-envelope gains an access_targets object and the envelope schema minor moves 9 to 10, which is additive and readable by any consumer accepting a minor under major 1. Values are addresses only, never authenticators: connection-string rules skip userinfo, a rule may not capture the whole match, and any candidate whose digest matches a credential in the same report is dropped. Coverage is explicit, so an empty target list is never mistaken for this credential opens nothing: a finding from git history, a container layer, stdin, an unreadable path, a decoded or windowed view, or a file past the index cap is counted in coverage.gaps with a named reason and complete goes false. Separately, keyhog detectors --mechanisms prints which recovery mechanisms each detector declares (regex, keywords, structure, entropy, BPE, decode, companions, relations, verification, suppression, source admission), derived from detector TOML with the field that proves each one, and reports a mechanism KeyHog cannot yet express as unavailable with the reason rather than omitting it. It does not scan.
  • Two detectors for credential shapes the corpus could not attribute. oauth-client-secret owns the vendor-neutral client_secret assignment (RFC 6749 section 2.3.1), which had no owner, so the shape was previously reported as Akamai; it is TOML data reusing the existing structural-slot primitives with no new code, ranked below every service-named client-secret detector so an attributable secret is never reported as the neutral shape, and it recovers 7 ground-truth files that no detector matched at all. Placeholders stay silent without new suppression code because the captured slot excludes < > $ { } and carries a 20-byte floor. facebook-access-token owns the Graph API EAA prefix, which the app-secret detector never covered; its pattern carries an explicit (?-i) because detector patterns compile case-insensitively by default, and without it the caseless eaa matched the lowercase prefix of sha256 image digests. The embedded corpus goes from 923 detectors to 925.
  • Add a workload index and per-shape guides so each input shape has a documented path from zero to a correct scan, and add a canonical page for telling a genuine clean from a skipped input. Watch mode had no page and was absent from the table of contents, single-large-file, minified, container, stdin and encoded-payload inputs had no shape-specific limits recorded, and a reader had no documented way to distinguish an empty findings list caused by clean input from one caused by input that was never read.
  • Make --profile answer the questions a slow scan raises instead of reporting spans and leaving the conclusion to the reader. Six families are measured now. Memory: peak resident from the kernel high water, the engine-init floor taken on entry to scanning, input-driven resident as peak minus floor, amplification, per-scanner-thread resident, and allocation volume owned per stage. Parallelism: per-worker busy and blocked time from outermost spans only so nesting never double-counts, idle against pool capacity, achieved speedup as process CPU over wall, an Amdahl ceiling from measured serial work, and time inside instrumented regions while not on CPU, which is where a large pool loses speedup without going idle. Serial phases: per-stage wall windows giving average concurrency, plus an exclusivity measure separating a real barrier from an inclusive wrapper whose children are the parallel work. Throughput: MiB/s and files/s overall, per phase and per micro-function. Attribution: cost per call, per file, per byte, per detector family and per backend. Cache and reuse: hit rates for autoroute decisions, calibration reuse, incremental unchanged-skips, matcher artifacts and verifier results, through one CacheId vocabulary. Retry attempts are counted by cause and named as a finding, because a retry that fires is a failure that was not designed out. The first line of the summary is the conclusion, for example bottleneck memory-floor 62.9 MiB of the 68.0 MiB peak (92.5%) is standing the engine up, not the input. Verified on the mirror corpus, crates/, and a 300 MiB file: the profiler independently reproduces the engine-init floor, the per-scanner-thread scratch slope over a thread sweep, and resident amplification on a large file, all of which previously took /usr/bin/time and shell loops. This changes --profile stderr, which gains the summary above the existing span table, and the --profile-out document, which gains stage_concurrency, worker_occupancy, queue_depths, blocked_waits, caches, indexed_counters, retries and insight. Every new field carries a serde default so older records still decode, and the profile schema minor moves 2.7 to 2.8. Default scan output, findings and exit codes are unchanged. Every derived value is an integer in thousandths or parts per million, so two records diff exactly and an unchanged run cannot look changed. Recording stays free when profiling is off: the disabled path is one relaxed atomic load with no clock read.
  • One retry policy, in keyhog_core::retry, with retries counted by cause.

Changed

  • Signed execution packs now reuse whole-pack signature authentication during scanner hydration instead of hashing backend and native shard payloads again; unsigned development packs retain full per-shard validation.
  • Daemon compatibility checks now derive worker topology without initializing GPU runtime libraries in client processes.
  • Explicit CPU and SIMD daemons no longer initialize or retain GPU runtime libraries during startup.
  • Execution-pack host identity checks no longer initialize GPU runtime libraries in short-lived clients.
  • CPU scans now bypass per-chunk parallel dispatch when authenticated admission evidence proves an entire bounded batch has no direct matches.
  • Execution-pack startup now borrows detector-plan prelude strings directly from authenticated framed rows while interning runtime ownership, avoiding transient per-row string copies.
  • Explicit CPU and SIMD filesystem scans now retain at most four bounded fused batches per parallel wave.
  • Installed scans now collect freed source-construction arenas at the source boundary and periodically reclaim idle mimalloc pages, reducing retained memory without changing finding order or coverage.
  • Azure Blob Storage scans now stream blob bodies in deterministic order through the shared bounded cloud fetch window instead of retaining a container-wide result vector.
  • Binary and Ghidra analysis now emit gapless 256 KiB text chunks, avoiding whole-output joins and retaining only compact printable-run descriptors before bounded materialization.
  • Bitbucket workspace scans now stream ordered repository results through the shared bounded hosted-Git pipeline while preserving listing-error order, instead of retaining every cloned repository result until the workspace finishes.
  • Automatic daemon fallback now shares the acquired stdin payload and scans it through bounded overlapping windows, avoiding a second whole-input byte copy and a whole-input decoded retry buffer.
  • Persistent daemons now configure at most eight physical-core Rayon workers before detector loading, preventing accidental logical-core pools and bounding resident worker-local caches.
  • Docker image scans stream each layer tar through the shared in-memory archive dispatcher with one inflate pass and image-scoped unpack budgets, instead of full decompress plus FilesystemSource re-walk. Large already-UTF-8 plain layer members stream in ~1 MiB windows from the tar entry (peak near one window); formats that need a full member (archives, PDF, images, HAR, lz4/sz) still buffer up to the 100 MiB scan cap. Extensionless members prefix-sniff before full buffer. Layer .har files expand at the Docker boundary with wire:har labels; nested .har inside ordinary zip/tar/7z/RAR keep the historical filesystem/archive leaf identity. UTF-16 archive members keep the whole-member decode path. Top-level layer 7z/RAR extract from bytes when content magic matches.
  • Filesystem scans now coalesce ordered tiny-file handoffs into bounded batches and reuse complete extensionless prefix reads instead of reopening the same file.
  • Google Cloud Storage scans now stream object bodies in deterministic order through the shared bounded cloud fetch window instead of retaining a bucket-wide result vector.
  • Git history scans now yield each bounded decoded-blob batch and annotated-tag message before loading the next payload instead of retaining whole commits or tag sets.
  • GitHub collaboration scans now stream issue, pull-request, discussion, wiki, gist, and release chunks through one-row backpressure without retaining a selected surface’s full content, and share one token allocation across the worker.
  • GitHub organization scans now preserve concurrent shallow cloning while streaming repository chunks in configured repository order with one-row channels, instead of retaining every cloned repository result until the organization finishes.
  • GitLab group scans now stream ordered repository results through the shared bounded hosted-Git pipeline instead of retaining every cloned project result until the group finishes.
  • Out-of-band verification now polls only while callbacks are pending and uses a bounded three-request lifecycle burst while preserving the configured sustained collector rate.
  • Large filesystem scans now retire explicit CPU and SIMD windows in bounded worker waves, share byte-identical source windows, and reuse verified repeated-window findings with rebased locations.
  • S3 scans now stream listing-page objects in deterministic order with a 16-result backpressure window, retain prior-page findings when a later listing fails, and avoid accumulating downloaded object bodies across the bucket.
  • Slack scans now stream channel histories through an ordered eight-channel backpressure window, share one token allocation across the worker, and stop retaining every workspace message chunk until collection completes.
  • HTML reports now serialize findings directly to their output stream with bounded per-finding memory while preserving verification-error redaction and script-breakout protection.
  • Web scans now stream ordered fetch results with eight-response backpressure, emit JavaScript, source-map, and WASM text in gapless 256 KiB chunks, and release parsed source-map ownership before chunk materialization.
  • CPU autoroute now reuses bounded exact payload evidence across source batches while rejecting sampled-fingerprint collisions and stale policy identities.
  • SIMD scans now cache bounded exact trigger rows across repeated authenticated batches while requiring full payload equality after sampled lookup.
  • CPU scanner startup now hydrates a compact phase-two keyword index and reuses install-compiled repeated-separator metadata; matcher packs require schema version 5.
  • Filesystem scans now coalesce tiny files up to the existing 1 MiB payload ceiling and execute them in worker-sized CPU and SIMD lanes, reducing per-file scheduler, channel, and Hyperscan scratch churn.
  • CPU and SIMD routing now classify each byte-distinct payload once per batch while preserving exact per-chunk admission evidence.
  • Anchoring vendor detectors to their own evidence MOVES FINDINGS BETWEEN detector_ids, so a baseline, allowlist or suppression keyed on detector_id (or on a detector_id plus credential-hash pair) can stop matching, and severity and rotation guidance change with the attribution. On the ground-truth corpus: akamai-api-credentials 14 to 4, authentik-token 4 to 0, wordpress-api-token 13 to 0, budibase-credentials 1 to 0 and klaviyo-api-key 6 to 2, against bearer-authorization 36 to 39, elevenlabs-api-key 1 to 4, jfrog-api-key 1 to 4, square-access-token 4 to 6 and onelogin-client-secret 0 to 1. The credential set is unchanged; only the claiming detector moved. Re-key any detector_id-scoped baseline before upgrading, or previously-suppressed findings reappear as new.
  • BREAKING: –limit-docker-tar-total-bytes now bounds one whole image rather than one tar. It was enforced with a fresh accumulator per tar, so an image made of an outer tar plus one tar per layer got the full allowance for each; Docker permits 127 layers, so the 8 GiB default admitted roughly 1 TiB of unpacking per image while every individual check passed. A 2-layer image under a declared 5104-byte cap previously unpacked 13361 bytes with no truncation and now refuses at the image total. If you have tuned this flag, raise it to cover the sum across the image tar and every layer tar, or images that previously scanned will be refused with a counted coverage gap.
  • Document what a baseline entry actually matches on, add a task-oriented path for failing CI only on new secrets, and state the shallow-clone prerequisite for scanning Git history. The deep-recovery guide previously opened with autoroute calibration and never mentioned fetch-depth, so a documented CI job would scan a single commit and report nothing.
  • Filesystem discovery prunes default-excluded directories during the walk, finishes deep Linux trees via descriptor-relative metadata-only discovery after ENAMETOOLONG, and cheaply sniffs unclassifiable names before full reads.
  • Generic assignment scanning now rejects broad keyword-stem lines unless an assignment delimiter follows the stem, while preserving *_PASS= and value-suffix recall.
  • --git-blobs collects commit blobs by parent-tree diff (added/changed/deleted sides) instead of rewalking every historical tree. Every ref tip under refs/ plus HEAD (detached CI checkouts), root commits, and unreadable parents still get a full tree walk so --max-commits keeps untouched tip blobs across custom namespaces. Unsupported non-blob tree-diff entries stay coverage gaps, already-collected parent-diff sides are kept when a later parent falls back to a full walk, default-excluded unsupported entries stay silent (same as the full walk), and blob decode stays on the already-open repository handle.
  • GPU routes now execute detection only through VYRE-owned CUDA, Metal, and WGPU programs. KeyHog no longer ships a WGPU MoE shader: ML confidence scoring is deterministic CPU work for every backend, the [tuning].gpu_moe_timeout_ms key is removed, and GPU health reports expose only VYRE literal-set and production region-presence probes.
  • GPU region batches now pipeline two VYRE-owned resident IO slots: KeyHog builds and submits the next batch before retiring the previous readback, while immutable matcher tables remain shared and result consumption stays ordered.
  • Replace twenty-six near-identical inline-test gate files with one that scans the whole CLI source tree. Each of the old files hardcoded a single path, so they covered twenty-four files while the tree actually had twenty-five with inline test bodies, including the entire autoroute backend directory that no gate reached. Net 357 lines of duplicated test scaffolding removed and the blind spot closed.
  • Authenticated execution packs now retain install-validated companion regexes as lazy matchers instead of recompiling every companion during scanner startup.
  • Filesystem discovery now walks metadata directly, preserves native path ordering through bounded external sorting, and defers content classification to the no-follow reader instead of reopening every candidate during traversal.
  • Nested archive scans stream compressed tarballs member-by-member with a single inflate, instead of retaining each full decompressed image or paying a second inflate for TeX probing. TeX provenance continues to gate on tar header names for buffered uncompressed tar, and on zip central-directory names.
  • Directory enumeration now walks in parallel, cutting the serial prefix before the first byte is scanned (mirror source-walk 392-419ms to 155-171ms, wall -14.1% on a 15,000-file tree). Findings are byte-identical: entries are still sorted by path before batching, so batch composition and autoroute workload identity are unchanged. Discovery-budget walks (–limit-discovery-bytes, scan-system) deliberately stay serial, because the budget is charged in arrival order and stops at the first over-budget entry, so a parallel walk would admit a different subset on every run.
  • Execution packs now persist confirmed, suffix-gate, and phase-two localization plans so installed scans hydrate those indexes without reparsing the detector regex corpus.
  • Build only the anchored verifier regex a candidate position actually consults in the phase-two confirmation pass. extract_anchored compiled both the \A(?:src) verifier and the \A(?s:.)(?:src) left-context variant for every eligible pattern, and allocated a capture-slot buffer for each on every call, but a candidate at byte 0 is rare and a pattern with no capture group never reads the slots, so a whole extra regex per pattern was compiled and never read. Non-grouped patterns now resolve through find off the lazy DFA without running the capture engine. The same pass replaces a per-chunk hash set of present suffix literals and a binary search run per anchor candidate with reusable per-worker bitsets, skips the per-line homoglyph normalization on chunks preprocessing already proved unchanged, and settles the raw-text comparison by pointer identity rather than a whole-buffer memcmp. Regex construction is one-time per pattern but serialized on whichever worker touches it first, so it costs wall time out of proportion to its instruction share: a frozen 5,554-file copy of crates/ measures 4.10 percent fewer retired instructions and median wall 9.42 s falling to 8.55 s, the 15,000-file mirror corpus measures 1.96 percent fewer instructions, and findings are byte-identical on both corpora under –backend cpu and –backend simd.
  • Builds now resolve VYRE 0.7.2 from one reviewed upstream commit instead of requiring a sibling source checkout, while keeping CUDA, native Metal, WGPU, and runtime crates on the same immutable identity.
  • Out-of-band verification now overlaps its one-shot RSA session-key generation with scanning before collector registration.
  • The bundled decoder plan now skips allocating short alphanumeric assignment values that cannot satisfy any built-in decoder while preserving custom-decoder extraction semantics.
  • CPU scans now reuse exact phase-two keyword and generic-assignment localization evidence computed for byte-identical autoroute payload representatives.
  • Authenticated execution-pack hydration now reuses whole-pack signature verification instead of reserializing and rehashing matcher sections while preserving structural validation.
  • Authenticated CPU execution packs now reuse whole-pack signature validation instead of reserializing the scalar program during hydration.
  • CPU scans now reuse exact confirmed-pattern absence proofs for byte-identical repeated payloads instead of rerunning confirmed regexes per chunk.
  • CPU scans now reuse exact phase-one trigger bitmaps for byte-identical repeated payloads instead of rescanning each chunk.
  • CPU scans now reuse exact decoder-admission absence across byte-identical payloads with matching decoder metadata context.
  • CPU scans now bypass direct matcher dispatch when exact repeated-payload evidence proves every direct matching lane absent.
  • CPU scans now reuse path-independent entropy absence proofs for byte-identical repeated payloads and invalidate them when entropy policy changes.
  • CPU scans now share bounded line and documentation indexes across byte-identical passthrough payloads.
  • CPU scans now reuse exact multiline-admission absence proofs for byte-identical repeated payloads and invalidate them when evidence policy changes.
  • CPU scans now reuse exact normalization passthrough proofs for byte-identical repeated payloads instead of rescanning unchanged text.
  • CPU scans now reuse exact always-active phase-two absence proofs for byte-identical repeated payloads instead of rerunning the shared prefilter per chunk.
  • SIMD scans now reuse exact payload-representative trigger results and authenticated negative phase-two evidence without reusing path-dependent findings.
  • SIMD scans now reuse exact normalization, multiline, and line-index evidence from authenticated admission plans.
  • Persistent daemon, watch, and system-scan runtimes now compile from one shared detector corpus allocation instead of cloning every detector before scanner construction.
  • Concurrent CPU batches now single-flight exact reusable admission evidence misses instead of rebuilding the same representative in parallel.
  • Scanner post-processing now skips decode generation for whole chunks and bounded filesystem windows whose decoder admission proof is impossible.
  • Default daemon clients now use the detector identity compiled into the binary instead of parsing all embedded detector TOML solely for the compatibility handshake; explicit replacement corpora remain content-hashed.
  • Verified POSIX installs build route-scoped authenticated CPU/SIMD/VYRE execution packs with calibration evidence and rollback.
  • Read-only Linux execution-pack mappings share immutable backend pages as shared clean RSS across scanners.
  • Worker-local scanner scratch uses route-specific retention ceilings instead of keeping outlier allocations.
  • Watch finding lines now include a stable sha256:<digest> credential identity beside the redacted value, enabling redaction-safe parity and deduplication across events.

Removed

  • Delete 235 source-grep shape tests across the five crate test trees. Each read a .rs file at runtime and asserted only substring presence or absence on that text, so they pinned how the source is spelled rather than what the scanner does; the project standard bans them. 107 test files went away entirely, 57 files lost individual tests, and every mod registration plus three Cargo [[test]] entries went with them. Two ambient-env gates (KEYHOG_THREADS, KEYHOG_DETECTORS) became four behavioural tests that drive the binary and read config --effective and detectors --format json. Each is a negative assertion, so each is paired with a positive case on the same output field, and both oracles were ablated to confirm the comparison discriminates: KEYHOG_THREADS=99 leaves threads = auto while –threads 3 moves the same line to 3, and KEYHOG_DETECTORS pointing at a one-detector directory leaves the corpus intact while –detectors on that directory reduces it to one. 23 source pins for network and filesystem security boundaries are kept deliberately: verifier_safety_contracts.rs, the DNS-pin and no-auto-decompression gates, the verifier proxy owner, the git safe-bin and no-follow-symlink gates, and the hosted-Git credential temp-file permission contract. That last pin was repointed at the whole hosted_git module after the module split moved the code it reads out of hosted_git.rs, which had silently made its negative assertions vacuous, and it now asserts an anchor first so it fails loudly rather than passing for free the next time the module is reorganised.

Fixed

  • Detect container formats by content signature rather than by filename extension. An archive member or file whose name carried no recognized extension was never opened, so a secret inside it was missed and the scan reported clean with no error row. This is the normal shape of an OCI layer, which is named by digest. Members with no in-memory extractor now emit a counted error row instead of vanishing.
  • A calibrated autoroute decision can be found again, and every scan now reports the cache hit rate. Calibrating without an explicit –autoroute-gpu wrote decisions under a resolved-config digest no scan would ever request, so the immediately following identical scan reported a config mismatch and completed through scalar correctness recovery; the digest hashed whether calibration excluded an eligible GPU, which is vacuous wherever no GPU candidate exists, and the host generation already carried that exactly. A calibration sample holding a chunk over the decode ceiling also discarded the whole sweep, because any nonzero scanner coverage counter rejected the trial, including the scalar reference trial before anything was compared; candidates are now compared against the reference coverage shape and refused only when they skipped more than it. Every automatic scan prints one stderr line naming hits, lookups, the typed miss cause and the one repair that fixes that cause, in every output mode including –format json -o FILE, which previously suppressed the routing summary entirely. Measured on repeated scans of the same corpus with the same binary and config: mirror, homefield and crates/ all move from 0 percent hit to 100 percent. Cache schema version moves 50 to 51, so an existing cache is superseded with a clear message rather than a config mismatch.
  • Resolve a statistical dead heat in autoroute calibration instead of persisting no decision at all. Selection required one route’s 95% interval to lie entirely below every peer’s, and the only tie rule demanded exact nanosecond median equality, which never fired on real evidence. Overlapping timings therefore produced no route, so real trees persisted nothing and every later scan of them completed through scalar correctness recovery. A route now stays in contention unless a peer is proved faster; among survivors only those whose median falls inside the fastest route’s own 95% upper bound are eligible, so a wide error bar cannot rescue a measurably worse median; that set is ordered by backend complexity. Strict separation still backs confidence_separated, so a dead heat reports confidence_separated false and a fourth selection_basis value, unseparated-dead-heat-lowest-complexity-backend, rather than posing as a proved win. JSON consumers matching on selection_basis must accept that fourth value.
  • AWS STS HTTP 200 responses without parseable caller-identity metadata no longer report Live.
  • Refuse --benchmark combined with a scan target instead of silently discarding it. The flag runs KeyHog’s own built-in corpus and exits; it never reads an operator-supplied target and never writes --output. Passing both was accepted, both were discarded, and the run reported success: keyhog scan ./src --output report.json --benchmark printed a throughput table, exited 0, and wrote no file, so in CI that line reads as a completed scan of ./src. The flag now conflicts with the positional PATH, --path, --stdin and --output, so the combination exits 2 naming the conflict. Two related report strings were also wrong rather than absent. The shared oversized-input coverage row read exceeded --max-file-size and advised raising that cap, but the counter behind it is raised by at least eleven caps including --limit-git-blob-bytes and the Docker and cloud object caps, so following the remedy left the input skipped; it now names the cap family and points at the per-cap warnings that name the exact flag. And the GCS token-forwarding consent notice fired when --allow-gcs-token-forward was parsed rather than when a token was forwarded, so the byte-equivalent --source gcs:... entry path printed nothing at all; the notice now lives at the point an ambient token is actually carried to a non-Google endpoint, so both entry paths behave identically.
  • Named detectors can fire on binary-derived content again. Admission past the binary-strings noise gate required a declared [detector.credential_shape], which 4 of 925 detector TOMLs carry, so 921 named detectors could never report a finding in an ELF, PE, Mach-O, wasm, static archive, shared object, archive member or container layer; the same tar.gz reported aws-access-key and silently dropped slack-bot-token purely because one TOML had the block. A match is now admitted on per-match structural proof, a declared shape or a span covering a whole lexical token, while generic, weak-anchor and free-form password-slot detectors stay suppressed, and a withheld match is counted as a binary_strings_named_exclusions coverage gap instead of vanishing. Expect new findings on compiled artifacts and container images that previously reported clean: a planted Slack token goes from 0 to 14 of 15 binary variants, and 249 MiB of real system ELF goes from 0 to 4 findings. Printable runs are also emitted in file order with every occurrence kept, replacing an alphabetical whole-input dedup that made two runs neighbours because they shared a prefix, and joined by a separator no whitespace, non-whitespace or dot class can cross, so a pattern can no longer bridge runs that were never adjacent.
  • Selective anchor construction uses a bounded deterministic frequency sketch instead of retaining every corpus window, reducing scanner startup memory without changing recall.
  • Large filesystem windows now decode through bounded overlapping subwindows, recovering encoded credentials beyond the default decode working-set ceiling without raising that ceiling.
  • BufferedStdinSource now records the same SourceAcquire and SourceRead profile spans as spooling stdin, so pre-owned stdin payloads no longer appear unprofiled while still charging input totals.
  • Surface chunks abandoned at their per-chunk deadline as a fail-class coverage gap. When --per-chunk-timeout-ms elapsed mid-chunk the scanner returned an empty or short match set for that chunk, and the abort was counted into scanner telemetry that nothing read, so a scan that abandoned every chunk still reported scan_status: success with an empty coverage_gap_summary and exit 0. Deadline aborts now surface as scanner chunk abandoned at its per-chunk deadline and mark the scan partial. Operator-visible change: a scan that hits the deadline exits 13 instead of 0 where it produced no findings, so raise or clear --per-chunk-timeout-ms rather than suppressing the exit code. Findings are never discarded; a run that covered some input reports its findings alongside the gap.
  • Detector-owned reverse and Caesar prefix gates use compact contiguous automata, reducing packed-scan startup ownership without changing decode selection.
  • Two coverage-gap rows named a cause that was not theirs. The exclusion row read exclusion policy (.keyhogignore, --exclude-paths, or lock/minified/vendored defaults), but only the last of those three ever produced it: files removed by an operator’s own .keyhogignore or –exclude-paths are not counted in that number at all, so a reader comparing the count against their ignore file got a figure that could never match. It now names the default policy specifically and states that user removals are not included. The archive row attributed every truncation to the filesystem decompression-bomb guard and its 4x –max-file-size budget, which stopped being true once the docker path gained three producers of the same event, so a container image refused by its own image-scoped unpack budget reported a cap that had nothing to do with it. It now names the cap family and points at the per-cap warnings that identify the exact one. Both are report text rather than behaviour, and both matter for the same reason the rest of this work does: a coverage row exists to tell an operator what was not looked at and why, and a row whose stated reason is wrong sends them to fix something that is not the problem.
  • Persistent daemon and watch runtimes now compile the exact forced backend, so explicit SIMD startup owns a usable Hyperscan plan instead of a CPU-only scanner.
  • Single-file and stdin daemon scans now report their out-of-process byte coverage without a contradictory zero-byte coverage gap.
  • Report the decode-through coverage that --decode-size-limit declines, instead of quietly returning fewer findings. A chunk larger than the limit was denied decode-through with nothing recorded anywhere, while the neighbouring path that truncates decoder OUTPUT has always counted a gap, so the decline that skips the pass entirely was the silent one. Measured on the 2,399-file homefield corpus, --decode-size-limit 64K reported 1,623 findings against 2,239 at the 512 KiB default, 616 fewer, with an empty coverage_gap_summary and nothing on stderr. A denied chunk now records a WARN-class scanner decode-through declined by --decode-size-limit gap that names the flag in the structured coverage_gap_summary reason rather than only in terminal prose, so a CI wrapper reading the envelope gets the remedy. It stays at zero on an ordinary scan because no chunk reaches the compiled default. WARN rather than FAIL is deliberate: the raw bytes were examined and only a derived layer was skipped, which is the same class as the existing decode-truncation and structured-oversize rows. The counter was initially recorded only on the non-coalesced route, which made the gap backend-dependent (cpu reported one declined chunk where simd reported none, for byte-identical findings); it is now paired with the per-chunk scan event that every route calls, so the warning cannot disappear because autoroute picked a different backend.
  • Remove a detector pattern with no basis in its own documented format. jetadmin-credentials carried jet_[a-zA-Z0-9]{24,}, which appears nowhere in that detector’s documented format (JET_ADMIN_ and JET_ prefixed environment variables) and, because detector patterns compile case-insensitively, matched any long Jet-prefixed identifier. Measured against a 2.2 GB cargo registry it produced 256 findings and all 256 were Microsoft JET database engine constants in the windows crate, with zero true positives. That tree now reports one finding, the detector’s own test fixture vendored inside a published crate. The three assignment-anchored patterns cover the documented format.
  • Stop vendor detectors from claiming credentials that carry no evidence of that vendor. Akamai owned every bare client_secret, WordPress owned every bare access_token, and Authentik owned every bearer authorization header. Wrong-vendor attribution on the ground-truth corpus drops from 114 of 386 to 87, with no recall lost and 9 files newly detected.
  • Five vendor detectors could not match their own vendor’s current credential format. elevenlabs-api-key accepted only sk_ plus 32 hex while ElevenLabs issues 48, so every ground-truth sample was attributed to Klaviyo; it also matched sk_ inside lsv2_sk_<32 hex>_<10 hex> and took LangSmith keys from langsmith-api-key, so it now requires a token boundary and captures whole-value. onelogin-client-secret accepted only 64 hex where OneLogin also issues lowercase alphanumeric. jfrog-api-key owned only the retired AKCp8 prefix and not the artifactory_access_token assignment that replaced it. square-access-token owned only sq0atp- and sq0csp- and not the EAAA OAuth token that replaced them. akamai-api-credentials required client_token with an underscore and a 32-character floor, where real EdgeGrid tokens are hyphenated and 18 to 19 characters after akab-.
  • Container layer path normalization now keeps scanning members whose names begin with #, only peeling HAR #url suffixes when the member body remains non-empty.
  • Daemon wire e2e helpers now start the daemon on the embedded detector corpus so warm identity matches the client’s embedded detector-digest stamp.
  • Installed scans stream authenticated detector plans from execution packs without decoding detector schemas, validate canonical matcher envelopes in one typed JSON pass, build prefix propagation through a flat arena trie instead of one hash table per trie node, co-locate each lazy regex’s compiled cell and memoized source facts under one shared owner, share compiled signature strings with post-processing, and compile companion regexes and pattern-shape validator sets only when their evidence is first required. The entropy precision gate consumes an exact build-packed cl100k rank table without constructing the tokenizer’s duplicate encoder, decoder, sorted-token, and thread-local regex graphs. Report-time remediation validation uses the build-generated detector ID index instead of reparsing the embedded detector corpus after a finding. Compiled detector plans share equal confidence policies across the detector table and keep sparse entropy, shape, and suppression policies in a compact indexed side table. Small detector-owned keyword vocabularies use compact flat byte tables instead of retaining one Aho-Corasick automaton per detector. Phase-two no-candidate gates are scoped to the active residual route, and phase-two anchor lookup tables share literal sources with the lazy runtime rows before the lookup tables are released. The large phase-two, confirmed shared-anchor, and confirmed suffix-gate automata materialize only for a non-empty batch, then their compiler arenas are purged before per-chunk scanning. Sparse files stream only allocated extents and report all-hole files as uncovered regions, stdin validates its byte cap through an anonymous spool before scanning bounded overlapping windows, and bounded stdin windows use a rendezvous-fed fused scan batch instead of accumulating the complete input. Empty stdin remains an explicit zero-byte coverage gap instead of reporting an unearned clean scan. Fused source boundaries default to rendezvous channels, homoglyph prescreening no longer materializes Unicode matchers for unrelated replacement characters, and the one-long-line benchmark now contains one delimited canary on one physical line. Large unbounded filesystem walks retain deterministic path order in one common-root byte slab and compact row/index tables instead of one allocated absolute path per file. The archive-symlink audit streams unbounded directory entries and skips duplicate regular-file metadata checks while no-follow read paths retain link-swap protection. Installed-pack benchmark captures bind detector runtime provenance per workload so catalogs that intentionally use multiple detector corpora remain exact.
  • Explicit --binary <file> scans no longer also run the plain filesystem classifier, eliminating a contradictory binary-skip coverage gap after strings or sections were scanned.
  • Lazy phase-two anchor construction now warns and keeps affected patterns on recall-preserving whole-chunk or folded RegexSet paths instead of panicking when an Aho-Corasick build fails.
  • Linux filesystem scans now traverse and safely open paths beyond the pathname syscall limit with directory descriptors while preserving deterministic ordering and symlink protections.
  • Filesystem scans now reconstruct an empty relative walk path as the directly requested file itself instead of appending a directory separator and reporting zero coverage.
  • Large unbounded Unix filesystem walks external-merge deterministic native-byte path metadata through bounded temporary runs instead of retaining one row and sort index per file.
  • Unbounded Unix filesystem discovery releases its final in-memory sort slabs before mapping the external merge spool, lowering peak RSS without changing enumeration.
  • --github-api-endpoint is now applied to --github-org scans (factory previously ignored it).
  • Default GitHub wiki clone URLs now follow the configured API endpoint host (GHES-safe).
  • Screen the destination of a self-hosted GitLab or Bitbucket API endpoint before sending the operator’s token to it. --gitlab-endpoint and --bitbucket-endpoint validated the scheme, embedded credentials and the query, and never asked where the request was going, so https://169.254.169.254, https://10.0.0.5 and http://127.0.0.1 were accepted and the PRIVATE-TOKEN or Basic credential was carried there. Every other remote source already refused this: S3, GCS and Azure screen through the cloud endpoint gate and WebSource refuses loopback outright, so hosted git was the one hole, and a test in the tree asserted the hole was expected behaviour. Both endpoints now use one shared screen that checks the literal host against the canonical SSRF classifier and re-screens every resolved address, so a public hostname whose A record points at a metadata address is refused too; the approved addresses are pinned into the client so the connection cannot re-resolve after the check. BREAKING for on-premises deployments: a self-hosted GitLab or Bitbucket on a private address now exits 13 unless --allow-private-cloud-endpoint is passed. That flag already existed and already governed the cloud object stores, and now means what its name says across every remote source. It is deliberately not implied by supplying an endpoint, because the failure being fixed was treating an endpoint as consent to send a credential to it.
  • Scanning one large file cost roughly 3.8x its own size in peak memory, so a big enough file ran out of RAM. Three causes, all between read and scan. The filesystem reader collected EVERY window of a file into a Vec and sent nothing until the whole file was read, so a 300 MiB file held all ~343 of its 1 MiB windows live at once and the scan pool sat idle through the read; sampling /proc showed one thread accumulating 617 MB with 31 cores doing nothing. The windowed mmap never released pages it had already walked past. And every queue bound between the source and the scan workers counts chunks rather than bytes, which is ~128 KiB per batch on a small-file corpus and ~32 MiB on one big file, so the large-file regime carried over a gigabyte of queue headroom and split into only ~11 work units for 32 cores. The reader now streams each file’s windows in byte-bounded parts (a small file is still exactly one send), the slicer returns each stride with MADV_DONTNEED as it leaves it behind, and the fused batch cut is byte-aware as well as count-aware. Isolating this change alone: one 300 MiB file 1,156,720 -> 772,972 KB peak and 4.79 -> 3.78 s; one 1 GiB file 3,131,944 -> 804,400 KB and 13.89 -> 9.76 s; the 300 x 1 MiB control also improved (862,896 -> 766,216 KB), so the cost was removed rather than moved. Total CPU-seconds are unchanged, so the wall gain is read/scan overlap that was not happening before. Peak memory is now flat in file size instead of proportional to it: +9% across a 3.5x size increase, against +171% before. Findings are byte-identical, and secrets planted at every one of the 21 ways a 20-byte credential can straddle a window cut are each still found exactly once with the correct absolute byte offset and line. NOTE: batches are now cut on bytes as well as chunk count, which changes the workload key autoroute measures against, so the compiled-in fused batch byte ceiling is hashed into the autoroute config digest. Any calibration persisted before this change reads as a config mismatch and is measured again on the next –autoroute-calibrate run. That is intended: replaying a decision timed under different batching would be measuring something else. No flag or output changes, and a scan that has never calibrated is unaffected.
  • Authenticated packed scans defer precise hot-pattern validator regexes until their literal prefix is observed; backend parity regressions now compile the exact requested route.
  • --perf-trace no longer aborts the process it is measuring. Every run died with an index-out-of-bounds panic and exit 134 after the report was written, because the per-pattern timing dump indexed process-global tables sized by whichever scanner initialized them first, and on a GPU build a single-pattern probe scanner warms them before the full corpus compiles. Separately, a phase-2 GPU admission catalog that cannot cover its pattern set is now refused rather than trusted: a GPU miss is only sound as “no covered pattern matched”, and completeness was derived from lowering failures alone, so always-active patterns dropped by the candidate filter for any other reason were excluded from the covered set while the catalog still claimed to be complete. That set is empty on the shipped corpus, so the hole was latent and no finding was ever lost. Shard construction is now bounded at 64 shards and stops at the first uncovered pattern, because every shard is a separate dispatch over the same haystack.
  • Cap retained batch-route records and count drops like other profile event streams
  • Deallocate Mach thread ports after task_threads sampling so utilization samples do not leak send rights
  • Clear the per-worker shards in keyhog_profile::reset(). It cleared the runtime-level stores and the legacy mirrors and never touched the shards, so stage times, call counts, latency buckets, stage windows, typed counters, input bytes, cache counts and indexed counters all survived it. Benchmarks call profile_reset() between measured rounds precisely to discard warm-up, so round two reported round one’s numbers as its own. Nothing failed and no output looked wrong; the second measurement was simply the first plus the second. A test now asserts each family is empty after a reset. Separately, the fixed-memory finding no longer keys on an absolute byte threshold: it was calibrated against a 483 MiB engine-init floor, and once that floor dropped to 63 MiB the finding sat four MiB from going silent while its diagnosis stayed true. It now keys on the share of peak that is fixed cost, which is the actual claim and holds at any scale.
  • Gap system IO evidence when no start sample exists instead of publishing absolute /proc counters
  • Stop reporting a stale binary-asset channel as current, and name the real Hyperscan library when an install fails. keyhog update compared the running build against the newest GitHub release asset and printed “already on the latest release” whenever nothing newer existed there, so a build newer than that channel, which every release since v0.5.47 is, was told it was up to date forever; it now distinguishes being on the newest asset from being ahead of a channel that stopped publishing, and names cargo install --locked --force keyhog in the second case. The installer’s missing-library remediation matched the glob *libhyperscan*, but the published Linux binary declares NEEDED libhs.so.5, so a clean host got the loader error and no fix at all; it now matches the real SONAME plus the libvectorscan spelling, and any unrecognized library gets a generic lookup hint instead of dead-ending. The shipped artifact’s runtime dependencies are also deterministic again: lzma-sys linked the system liblzma whenever pkg_config found one and vendored it otherwise, so the same commit produced binaries with or without NEEDED liblzma.so.5 depending on the build host, and xz2 is now pinned to a static link for 110,328 bytes.
  • Stop auto-release from bumping workflow YAML that GITHUB_TOKEN cannot push
  • Source skip counters (unreadable, binary, over-max-size and the other coverage-gap totals) could be attributed to the wrong scan. The counter-isolation lease was released when a scan’s chunk iterator dropped, but the filesystem reader crew records skip events from its own threads and outlives that iterator whenever a consumer stops early, so a finished scan’s increments could land in a later scan’s window. The lease is now scan-scoped rather than thread-scoped: it is held by every thread doing work for the scan and released only when the last one finishes, and the recording call itself carries the gate. Coverage-gap events are delayed rather than dropped, so a gap is never lost.
  • Script auth verification requires exact STATUS: LIVE/DEAD lines and rejects ambiguous mixed output.
  • A shallow clone no longer reports its truncated history as a clean scan. keyhog scan --git-history and --git-blobs against a git clone --depth N checkout gave exit 0, scan_status success, and an EMPTY coverage_gap_summary, while a full clone of the same repository reported a credential that had been committed and later removed: the commits holding it were never fetched, so the scan searched history that was not there and said nothing. The parent commits named at the graft boundary but absent from the object database are now counted as unscanned Git objects, so such a scan reports scan_status partial with a Git object unreadable coverage-gap row and exits 13, and stderr names git fetch --unshallow and actions/checkout fetch-depth: 0 as the remedy. This is a user-visible exit-code change and it fires on the common CI shape, because actions/checkout fetches one commit by default, so any job that scans history on an unmodified checkout moves from a green tick to exit 13; fix the checkout depth rather than suppressing the code, since the exit is reporting that the input never contained the history you asked it to search. Findings are never discarded: a shallow clone that does contain credentials still reports every one of them, byte-identical to before, with the gap row added rather than substituted, and a depth-1 clone of a single-commit repository stays a genuine success because its graft boundary is the root commit and hides no parent.
  • Source limits are exact at their boundary and honest about which ones a build can reach. A git output line whose content is exactly --limit-git-line-bytes is now scanned instead of refused: the cap counted the trailing newline, so an at-cap line produced a coverage gap for input that was inside the limit, and identical content was judged differently depending on whether it ended the stream. keyhog config --effective no longer prints a numeric value for a limit whose source backend is not compiled in; those rows now read unavailable (requires the <feature> feature in this keyhog build), matching the flag that is absent from scan --help and the .keyhog.toml key that was already rejected. All 22 declared limits now have a CLI test proving each admits exactly its cap, refuses one byte or item more, and surfaces the refusal as a coverage gap rather than dropping input silently.
  • Report ordering and duplicate-winner selection no longer depend on filesystem enumeration order. Matches are now sorted by a total key (severity, source, path, commit, line, offset, detector, credential digest) instead of by severity alone, which as a stable sort had silently inherited walk order for every equal-severity match.
  • A client that walks away no longer kills the daemon.
  • A vendor detector no longer claims a credential it cannot attribute.
  • Anchored-regex fail-closed cases now compile in scanner library test builds.
  • daemon stop and daemon status no longer report a live daemon as absent.
  • macOS scanner library CI no longer fails on wgpu dual-slot overlap or a backblaze-shaped proptest seed.
  • Shutdown now delivers in-flight results before it acknowledges.
  • The generic OAuth client_secret detector no longer reports canonical UUID identifiers.
  • A credential inside a minified or vendored bundle is reachable again, and a dropped one is counted. Every finding whose path ended .min.js, .bundle.js or .min.css, or sat under node_modules/, site-packages/, wp-includes/, dist/assets/ and similar, was discarded before it reached the report. The drop was unconditional, left no trace on any surface, and no flag defeated it, so a live sk_live_ key that a build pipeline had inlined into app.min.js produced an empty report and exit 0. Build tooling inlines API keys into bundles routinely, which made this the one leak class KeyHog could not report at all while saying nothing was detected. Two changes. --no-default-excludes now disables this suppression as well as the walker skip, so the flag disables every default exclusion instead of only the one you could see. And a suppressed match is counted and reported as a matches dropped by the vendored/minified path policy coverage-gap row naming the count and the flag that recovers it. The row is WARN class, so an ordinary scan of a tree containing vendored code still exits 0. Measured on a wp-includes/config.php holding a live-shaped Stripe key: 88 bytes scanned, 0 findings and an empty coverage_gap_summary before, the same scan plus the counted row after, and exit 1 with the finding under --no-default-excludes.
  • A scan that read zero bytes no longer reports as clean. A .keyhogignore containing path:** gave exit 0, scan_status success, zero bytes, zero chunks, an empty coverage_gap_summary, and the line No secrets detected in the scanned files. Every signal a consumer has said the tree was clean, and the scan had examined nothing at all. --exclude-paths '**', an empty directory, an empty stdin stream, and a directory whose only entry is an unfollowed symlink all had the same shape. A scan that reads no source bytes now emits a FAIL-class scan covered nothing coverage-gap row and exits 13, and the text report states that the scan covered nothing instead of that nothing was detected. There are two such rows because the remedies differ: one for no skip was counted when nothing was there to read, and one for every candidate was skipped by exclusion or skip policy when policy hid it. THIS IS A USER-VISIBLE EXIT-CODE CHANGE. A target that legitimately holds nothing scannable moves from exit 0 to exit 13, including keyhog scan --stdin on an empty stream, an empty directory, a pure vendored tree, and a CI matrix partition with no files in its slice. That is intended: git diff | keyhog scan --stdin against the wrong base ref produces an empty diff, and reporting that as clean is the exact failure that makes mass scanning untrustworthy. Guard the producer, for example [ -s changed.diff ] before the pipe, rather than suppressing the exit code. There is deliberately no opt-out flag, because a flag that suppresses coverage failures would recreate the false affordance fixed alongside this. A scan that reads bytes and finds nothing is unaffected and still exits 0, and a scan that covered some input and failed on the rest still reports every finding it got alongside the gap, so exit 13 never means findings were discarded. Note that scan_status alone does not carry this: an ordinary git working-tree scan is already partial from its default-exclusion rows, so the usable signal is the FAIL/WARN class of the gap rows, which is what the exit code encodes.

Security

  • Cap the number of tar entries KeyHog walks in a docker archive or layer. The existing byte guard sums each entry’s payload size, so an archive built entirely from zero-length entries never advanced it and could be walked without bound: a 4.4 MB gzip expands into two million tar headers, each costing a filesystem syscall during unpack. Entries past the cap are refused and counted as a coverage gap rather than silently truncated.
  • GitHub collaboration and org API endpoints now fail closed through the shared hosted-git SSRF screen before any bearer token leaves the process.
  • GitHub wiki clone URLs now pass the shared clone-origin screen, and api.github.com maps to github.com for HTTPS clones.
  • Bind hosted-git askpass credentials to exact URL host boundaries, not origin substrings
  • Bound the work the PDF text extractor may spend, not just the bytes it may output. The decoded-output cap limited how much text a PDF could produce but placed no limit on the effort of producing it: the literal-string parser restarted at every open parenthesis and an unbalanced literal made each attempt rescan to end of buffer, so a file of repeated unbalanced nesting was quadratic and got worse with size. A 400 KB file took 34.5 seconds of CPU and a 10 MB one, well inside the default file cap, never finished. Such a file arrives from a repository, an archive member or a docker layer without anyone choosing to parse it. Extraction now stops at a measured work ceiling and reports a counted coverage gap, and strings already recovered before the ceiling are still reported rather than discarded.
  • Fail-closed TrackingAllocator dealloc validates header magic/stage/bytes before SLOT indexing
  • Refuse Slack API HTTP redirects so bearer tokens cannot pivot after the first request

[0.5.68] - 2026-08-05

Changed

  • Move two large co-located test suites out of scanner source files and into the tests tree, shrinking detector_ids.rs from 414 lines to 127 and the Hyperscan scratch backend from 767 to 341. Both keep running against the crate-private state they exist to check, and both leave the inline-test allowlist, so the allowlist now names two fewer permanent exceptions.
  • Scanner source files freed of large co-located test suites.

[0.5.67] - 2026-08-05

Added

  • Pin that filesystem enumeration yields every file exactly once, in sorted path order, identically across repeated walks. Batch composition follows enumeration order and autoroute keys its persisted decisions by batch shape, so a walk that varied run to run would make a calibrated cache miss on replay. The property was implicit; it is now asserted over twenty walks of the same tree.

Changed

  • Filesystem enumeration-order contract.

[0.5.66] - 2026-08-04

Added

  • Explain in the backends guide what the GPU actually does for a whole-tree scan, with measured numbers. The documented 8 MiB crossover covers one window through the matching kernel; a repository scan is a different workload, where confirmation runs on the CPU either way and the GPU route measures about 9 percent slower at both 63 MiB and 251 MiB. An operator picking a backend for a repository can now see that before choosing.

Changed

  • Whole-tree GPU guidance in the backends guide.

[0.5.65] - 2026-08-04

Changed

  • Actionable GPU refusal diagnostics.

Fixed

  • Tell the operator the truth when a required GPU is unavailable. An explicit --backend gpu-cuda also makes GPU mandatory, but the refusal named only --require-gpu and advised running without it, sending anyone who used the backend flag looking for a flag they never passed. The message now names the resolved policy, both routes into it, and both ways out.

[0.5.64] - 2026-08-04

Changed

  • Remeasure the README evidence panels against the current detector corpus, so the published accuracy, execution-route and daemon figures describe what the scanner does today. The precision preset improves to F1 0.8799 from 0.8784 on the benchmark corpus and the default policy holds at 0.9447.
  • README evidence panels remeasured against the current detector corpus.

[0.5.63] - 2026-08-04

Changed

  • Mailchimp datacenter key routing.

Fixed

  • Report Mailchimp keys as Mailchimp keys. The three datacenter patterns declared no routing literal, so the prefilter had nothing to route them on and nine keys on the benchmark corpus were reported as generic secrets instead, one of them a base64 value the generic detector could only show opaquely. Scored against the corpus answer key, declaring the literals moves one finding from false positive to true positive and changes nothing else.

[0.5.62] - 2026-08-04

Changed

  • Make the prefixless-pattern gate ask the question that matters. It previously only flagged patterns with extractable inner literals, which let through the exact pattern whose missing declaration suppressed an unrelated detector; it now flags any prefixless pattern that declares no routing literal, with shape-only detectors such as Asana tokens and Telegram bot tokens recorded as a category rather than as debt.
  • Routing literals for every prefixless detector pattern.

Fixed

  • Stop one detector’s pattern from silently costing another detector’s recall. A pattern with no literal prefix and no declared routing literal leaves the shared prefilter nothing to route it on, and the loss lands elsewhere: twenty-three patterns across the corpus now declare a literal the compiler proves is required by every match, including the Datadog application key pattern itself.

[0.5.61] - 2026-08-04

Changed

  • Character-class token anchoring for short vendor prefixes.

Fixed

  • Extend token-boundary anchoring to every remaining detector whose vendor prefix is three letters or fewer, so MSG_API_KEY= is no longer a Singapore GovTech key, XPBI_CLIENT_ID= no longer a Power BI credential and WEBCB_API_KEY= no longer a Carbon Black key. Fourteen such false positives are now silent, seventeen genuine separator-prefixed forms still report, and findings are unchanged on every corpus.
  • Repair a recall regression in the previous two releases. Anchoring short vendor prefixes with a word boundary also stopped them matching after an underscore, because _ is a word character, so MY_NR_LICENSE_KEY=, MY_GH_WEBHOOK_SECRET= and every other PREFIX_TOKEN_... form went unreported. The anchor now tests the character class before the token instead, which keeps the false positives suppressed and finds the separator forms again.

[0.5.60] - 2026-08-04

Changed

  • Token-boundary anchoring for short vendor prefixes.

Fixed

  • Anchor four more detectors whose vendor prefix is two or three letters, so they stop matching at the tail of an unrelated identifier. Two were reproducibly wrong on ordinary input: xapi_key=<uuid> near the word mexico was reported as a Mexican government key, and LEIGH_WEBHOOK_SECRET= was reported as a GitHub webhook secret. Every genuine form still fires and reported findings are unchanged on every corpus.

[0.5.59] - 2026-08-04

Changed

  • Say what diverged when autoroute rejects a backend candidate. The message reported only that findings differed, which blocks the whole calibration and gives an operator nothing to act on; it now names how many records each side produced, how many were unique to each, and up to three of them by detector, file, line and offset. Every field shown is already redacted.
  • Token-boundary anchoring and an actionable autoroute parity rejection.

Fixed

  • Stop the Africa’s Talking detector matching inside a larger identifier. Its anchor accepted a bare at/AT with nothing in front of it, and SNAPCHAT_API_KEY= contains a literal AT_API_KEY=, so every Snapchat token was also matched as an Africa’s Talking key. Deduplication kept it out of the report, but the extra match blocked GPU autoroute calibration for the whole workload class.

[0.5.58] - 2026-08-04

Changed

  • Refresh the README accuracy, execution-route and daemon panels, which had been stuck on v0.5.49 because the target that regenerates them could not run. The GPU rows were the worst affected and were understating the CUDA and WGPU routes by six times: a full mirror scan reads 2.11 s rather than 12.64 s on CUDA and 2.07 s rather than 12.34 s on WGPU, with F1 unchanged at 0.9447 on every route.
  • Refresh the README scaling evidence against the current binary. A single-worker scan of the scaling workload drops from 21.3 s to 10.4 s and a 32-worker scan from 1.83 s to 0.93 s, throughput rises from 35.0 MiB/s to 68.6 MiB/s, and peak resident memory falls from 810 MiB to 684 MiB. The snapshot is now attested clean rather than developer-dirty.
  • README evidence panels remeasured against the current binary.

Fixed

  • Let the README benchmark matrix regenerate. The target depended on the scaling measurement, which rewrites README.md and the scaling snapshot, after which every measured row refused to scan because the tracked workspace was dirty, so the panels could not be refreshed at all. The dependency is gone and a clean-tree check now reports the problem once, up front, instead of forty times after the work.
  • Let a release note describe a change with no crate behind it. The fragment schema required at least one crate, so a README, benchmark-harness or CI change had to be filed against a crate it never touched, putting a false claim in that crate’s published changelog. An empty crate list now means repository scope: the root changelog carries the note and no crate changelog does.

[0.5.57] - 2026-08-04

Changed

  • Repeatable autoroute calibration.

Fixed

  • Stop autoroute calibration from discarding a whole workload class over measurement noise. An execution plan now has to clear the other plan’s confidence interval, not just win a paired test, before it beats it on the same backend; points that agree on the backend but split on the plan reconcile to the plan the binary was compiled with instead of producing no decision; and merging a point re-declares the reconciled route so the persisted cache matches its own evidence. Calibrating the mirror corpus went from persisting a decision on 4 of 10 identical runs to 12 of 12.

[0.5.56] - 2026-08-04

Changed

  • Scan many coalesced batches at once instead of one at a time. The batch pipeline’s consumer was a single receive-then-scan loop whose only parallelism was inside one batch, so every batch boundary idled the machine; it now bridges the batch channel onto the global pool the way the fused pipeline already does. On this repository’s sources the batch pipeline drops from 4.95 s to 2.43 s and gpu-cuda from 6.70 s to 3.52 s, and the report is byte-identical to the fused pipeline’s.
  • Overlapping coalesced batches and autoroute classification for any batch size.

Fixed

  • Let autoroute classify a batch of any size. The decoder sampling budget was enforced as a ceiling on the total sample instead of a budget for the residual above each chunk’s floor, so a batch of more than roughly 341 chunks failed classification outright. The coalesced pipeline packs up to 4,096, which meant autoroute calibration could not run through –batch-pipeline on any real corpus, and so the GPU route, which runs only through that pipeline, could not be calibrated at all. A batch whose floors already fit keeps exactly the previous budget, so no persisted decision changes.

[0.5.55] - 2026-08-04

Changed

  • Idempotent source contract-test generator and a warning-free workspace build.

Fixed

  • Make the keyhog-sources contract-test generator idempotent by formatting its own output, so re-running it no longer produces a large formatting-only diff, and give the generated rejected-extension cases snake_case names. The workspace now builds all targets without a warning.

[0.5.54] - 2026-08-04

Added

  • Check every .keyhog.toml key and table in the configuration reference against the real config schema, reading the accepted field list out of the schema itself rather than restating it, so a renamed or removed key fails the build instead of failing the reader with an unknown-key error.
  • Report how many phase-two prefilter batches the prefix gate ran versus skipped in --perf-trace, which answers whether the prefilter is expensive because every chunk reaches it or because every batch runs.

Changed

  • Skip homoglyph-variant patterns when the chunk provably contains no confusable glyph, instead of only when it is pure ASCII. Ordinary non-ASCII source text carries accented names, CJK, box drawing, arrows and emoji, none of which a homoglyph variant can match, and it was forcing the full residual pattern set.
  • Skip homoglyph variants on chunks that provably contain no confusable glyph.

Fixed

  • Correct the configuration reference, which advertised a no_entropy_ml_scoring key that has never existed. Writing it into .keyhog.toml fails closed as an unknown key; the knob is CLI-only.
  • Let the configuration module’s no-inline-tests gate accept the sanctioned #[cfg(test)] #[path] sibling-module hook, which the blanket attribute ban rejected even though the test code lives outside the source tree.

[0.5.53] - 2026-08-04

Changed

  • Make the coalesced batch pipeline eleven times faster and stop starving the accelerator.

Fixed

  • Include both published GitHub Action manifests in the release version transaction, so the minimum version they advertise cannot fall behind the workspace as it did for two releases.
  • Track the accumulating batch’s route class and chunk identities as chunks arrive instead of rescanning and rehashing the whole batch for every chunk. The coalesced pipeline’s 4,096-chunk batches made that quadratic, which is why an explicit GPU backend measured slower than CPU while the accelerator sat idle.

[0.5.52] - 2026-08-04

Added

  • Check every keyhog command in the README and the handbook against the compiled command model, so a documented subcommand or long flag that is renamed or removed fails the build instead of failing the reader who types it.

Changed

  • Refuse configuration fields the scanner cannot honour and check every documented command against the real CLI.

Fixed

  • Refuse a non-default max_file_size or dedup on ScanConfig and name the surface that owns the behaviour, instead of accepting two documented no-op fields that gave a library caller the same scan they would have got by leaving them alone.
  • Correct the system-wide triage example, which showed a --exclude flag scan-system does not have, and state the bound it actually applies: a total-bytes ceiling plus network filesystems skipped by default.

[0.5.51] - 2026-08-04

Added

  • Prove the bounded accelerator-evidence dedup set refuses and counts every record past capacity, keeps dedup rejection separate from loss, and saturates its loss counter instead of wrapping to zero under sustained overflow.
  • Assert JSON, JSONL, and SARIF stay completely parseable and ANSI-free across all sixteen hostile environment profiles, including CLICOLOR_FORCE, an unset HOME, an unwritable working directory, a missing TMPDIR, and a rejected backend request.

Changed

  • Report accelerator evidence dedup overflow on the keyhog::gpu tracing target with its exact running loss count, replacing a counter that no caller read.
  • Compile each phase-two always-active matcher variant when a chunk selects it instead of building all four for every batch up front, which removed a 1.4 second stall that the first decoded sub-chunk of any scan charged to every scan worker.
  • Prove a phase-two batch is empty with the DFA-backed match test before asking which patterns matched, since reporting the matching set has no lazy-DFA path and forced a full PikeVM pass over every batch on every chunk.
  • Stop compiling the coalesced phase-two tail, its triggered windowed scan, its batched ML scorer, and the GPU peer timing facets into portable builds, which have no producer that can reach them.
  • Assert source-instrumentation tests see no coverage errors instead of silently discarding error rows while collecting chunks, so a profiled adapter that starts failing shows up as a failure rather than a smaller chunk count.
  • Derive the subcommand help matrix from the compiled command model instead of a hand-kept list that had already drifted past config and bloom-diagnostic, and pin the advertised menu so a removal or rename stays a reviewed change.
  • Make the portable phase-two prefilter two to three times faster and repair ten red gates.

Fixed

  • Fall back to the honest legacy identity gaps when a causal profile’s detector, configuration, or source enrichment is absent, instead of panicking while rendering the report at the end of a completed scan.
  • Run the 1,202-cell product-reliability matrix in CI and drive it on the portable scalar backend, so hostile-environment exit-code, output-format, and installer contracts can no longer rot unexecuted or fail closed on a Hyperscan-free build.
  • Check the default-exclusion policy flag at each source factory call rather than at the first mention of a source name anywhere in the file, which reported a missing flag on a call that passes it.
  • Match source-ownership gates on the arguments and constructs they exist to protect rather than on exact indentation, closure parameter names, or a function name a rename had already changed.
  • Fail closed with a source error when the single-flight pinned web client builder is missing, instead of panicking inside the client cache and ending the scan.
  • Resolve a candidate’s whole assignment value from the start of its own line rather than from the start of the chunk. Quote and escape state reset at every line break, so the previous walk reread the entire preceding chunk for every candidate and was quadratic in candidates per chunk.

[0.5.50] - 2026-08-02

Added

  • Add low-overhead causal run profiling with fixed scanner stages, state transitions, process resource measurements, and explicit source and backend identity while keeping per-pattern diagnostics behind –perf-trace.
  • Add bounded causal span timelines, latency percentiles, typed telemetry, async profiling propagation, and exact per-category event-loss reporting to operator profiles.
  • Add schema-v3 typed companion evidence and bounded cross-detector requires, conflicts, and subsumes relations, with deterministic fixed-point resolution, compile-time contradiction and cycle checks, and explain --compiled-plan introspection.
  • Add detector-owned positive source admission by path regex, exact source type, and file extension. Declared selector families combine with AND semantics and reject missing metadata.
  • Add scan --github-all as the concise complete-surface form of a GitHub collaboration scan while retaining independent surface selectors.
  • Restrict the netrc password detector to .netrc, _netrc, and .authinfo source paths, with explicit fixture paths and boundary regressions.

Changed

  • Publish patch releases to crates.io through short-lived OIDC trusted publishing, update versions and changelogs automatically, and upload a deterministic six-crate commit and lockfile integrity receipt without a long-lived registry token.
  • Localize plain phase-two patterns by default on portable and explicit CPU scans, avoiding full portable marking-set compilation when the shared anchor index owns candidate extraction.
  • Bind profile comparisons to the exact running binary SHA-256, enabled-feature SHA-256, target triple, build profile, compiler, allocator, and linked-backend SHA-256 instead of relying on the package version alone.
  • Record complete detector-corpus, enabled-detector, compiled execution-plan, and hashed external-provenance identities in causal scan profiles, with unavailable backend databases surfaced explicitly instead of inferred.
  • Bind causal profiles to complete resolved-configuration and performance-policy BLAKE3 identities plus the selected preset and applied protection state, without exposing raw configuration values.
  • Record normalized source adapters plus privacy-safe target and partition BLAKE3 identities in causal profiles without emitting raw paths, URLs, source parameters, or credentials.
  • Measure raw source, source-unit fanout, decode-derived, and completed backend-dispatch byte domains in causal profiles, classify stable workload size and fanout buckets, and keep uninstrumented expansion domains explicitly unavailable.
  • Enforce optimized profiler hot-path overhead budgets in the regular CI workflow.
  • Add scan --profile-out <PATH> writing the complete causal profile as JSON atomically at scan end, implying --profile.
  • Route daemon scans through wire protocol v12 with per-request profile capture: each profiled daemon request gets a unique request identity, an isolated profiling runtime, and a bounded per-request profile payload rendered by the client.
  • Instrument every source adapter with acquisition, walk, read, queue-wait, and decode spans plus exact unit and byte accounting, including cloud pagination retries and collaboration backoff attempts.
  • Migrate scanner-internal timing and count collectors (mark statistics, Hyperscan split, generic detection, extractor, decode recursion, ML batch, MoE split) onto keyhog-profile typed counters and distributions with one runtime-owned drain; --perf-trace lines render from the same records.
  • Instrument verifier queue, TLS, request, cache, report encoders, baseline, allowlist, Merkle, CLI startup, detector loading, Action receipts, and maintenance commands with batch-level stage spans and typed counters.

Fixed

  • Record scanner accelerator features from dependency-owned compile state so portable autoroute identities no longer claim unavailable GPU or SIMD backends.
  • Keep complete credentials from native binary strings and executable sections when a strong named detector validates an explicit credential shape, while continuing to suppress weak prefix fragments and generic assignment noise.
  • Bound scan-system metadata discovery by the remaining –space budget so small host-scan ceilings stop promptly and report partial coverage instead of traversing the entire filesystem first.
  • Preserve valid .keyhog.toml detector-disable configurations by transitively removing detectors that require a disabled target and pruning inactive conflict or subsumption relations before scanner compilation.
  • Restore the documented minimal keyhog-scanner --no-default-features build by keeping decoder admission available when optional decode transforms are absent.

[0.5.49] - 2026-07-30

Added

  • A single resumable local or SSH command now refreshes benchmark evidence without invalidating candidate freshness, rebinds the exact canonical run-set after scoring, prepares every changelog and version surface, runs pre-tag gates with isolated full and ci-lean binary contracts, preserves exact Git path bytes, verifies the configured OpenPGP fingerprint before any tag push, and watches GitHub Pages, release assets, containers, and the six-crate crates.io publication chain.
  • Unix mass daemons now accept bounded directory, Git, archive, binary, remote, hosted Git, and cloud streams through scan --daemon=mass; local filesystem payloads remain daemon-local, credential-bound sources use protected chunk framing, each batch is capped at 8 MiB and 1,024 chunks, source gaps fail closed, the client validates an exact total/GPU execution receipt before reporting, and daemon start --mass-gpu-primary rejects CPU-majority completion.
  • keyhog-profile now owns a portable causal profiling schema for fixed micro stages, macro run states, source and backend identity, input totals, CPU time, resident and virtual memory, and observed process threads. keyhog scan --profile emits this low-overhead operator-run record without recording source content or credentials. --perf-trace retains the higher-overhead per-pattern diagnostics.

Changed

  • The README star viewer now uses a deterministic accessible SVG generated from repository-owned observations, records only real count transitions, handles same-day corrections and declines truthfully, writes atomically, and retries isolated metrics push races without depending on star-history.com.
  • Apple release assets now ship VYRE’s native Metal and WGPU peers without requiring Homebrew Vectorscan. GPU region-presence no longer acquires Hyperscan transitively, and autoroute persists Metal as a distinct measured candidate.
  • Large single chunks on the scalar route now scan their existing recall-overlapped windows in parallel, then merge findings in source order with exact offsets and deduplication.
  • Portable and explicit CPU scans now localize plain phase-two patterns by default. This avoids compiling and scanning the full portable marking set when the shared anchor index owns candidate extraction; measured cold scans improved by 4.0 to 4.4 times across 1 KiB, 8 MiB, and 1,024-file local workloads while preserving whole-chunk finding parity.
  • Linux profiling boundaries now read process-local CPU, memory, and thread counters directly from procfs instead of refreshing the system-wide process table.
  • The production Debian container now ships the portable CPU build and omits unused Hyperscan build and runtime packages. This keeps ephemeral container scans on the fast cold-start route; the dedicated glibc integration image still exercises Hyperscan.
  • The source integration lane now runs one default aggregator and one serial all-backend target pass instead of rebuilding overlapping all-feature test and library subsets.

Fixed

  • Release preparation now updates standalone GitHub Action guide version pins, its regression suite rejects any canonical current-version document omitted from the release transaction, and operator docs describe crates.io packages, Cargo update and rollback, and the absence of binary asset bundles.
  • Trusted GitHub Action SARIF publication now retries one transient Code Scanning upload failure and fails closed only when both attempts fail. Restricted fork pull requests remain advisory, and the report artifact remains available.
  • Nightly benchmark dependency installation now authenticates the pybase62 wheel, and CLI documentation coherence accepts generated possible-value suffixes without weakening exact option descriptions.
  • The generated CLI reference now includes every visible source flag for both scan and config, and the banner detector count remains coherent with the live loader.
  • Automatic releases now use the successful commit subject for crates not covered by authored change fragments, publish all six crates in dependency order, retry uploads, wait for crates.io visibility, and resume partial publication without republishing visible versions.
  • The default portable Cargo installation now includes native binary string and object scanning without requiring Ghidra. Optional Ghidra enrichment remains a runtime integration.
  • Aggregate release prevention gates now receive the immutable current-version candidate explicitly, so an older default Cargo release binary cannot invalidate backend parity.
  • Release orchestration now prepares the final workspace version before measuring its executable, and pre-tag resumes refresh version-bound evidence while signed-tag resumes preserve immutable evidence.
  • Benchmark freshness now handles the unavoidable Git-stamp change created by committing measured evidence, while rejecting non-ancestor results and every intervening source, manifest, configuration, fixture, rename, or other non-evidence path.
  • The 8 MiB GPU crossover proof now compares independently selected GPU and Hyperscan routes over 300 held-out pairs; it retains every eligible Hyperscan plan for audit without treating a per-trial hindsight oracle as a selectable backend.
  • Published workspace and crate metadata now use the canonical https://santh.dev/keyhog/ discovery homepage while retaining the source repository as the crates.io repository link.
  • Source skip-counter tests now serialize scans that begin before the first counter guard, eliminating concurrent false failures in the full source matrix. Release dogfood fixtures and documentation now scan cleanly without weakening detector behavior.
  • Release previews now recognize an already-prepared --resume workspace instead of asking release preparation to create the current version again and failing before evidence checks.
  • The exact architecture ratchet now counts native Metal as the fifth scan backend and binds the current engine source total, so the source-only prevention gate reflects the shipped backend set without stale budget drift.
  • Portable source builds now retain coalesced triggered windowing and performance-trace support without SIMD or GPU features, so branch and commit refs compile before Action scans. A dedicated oversized-window suite covers exact offsets, overlap deduplication, multiple findings, and hostile near-matches under the portable feature profile.
  • Crossover route selection now keeps deterministic candidate order when paired 95% evidence cannot distinguish near-tied GPU peers, so a point-median fluctuation cannot redirect all held-out release evidence to an unproven peer.
  • Release tag signing now uses a dedicated protected keyring and owner-only passphrase file locally, with the same encrypted key available to a manual release-signing GitHub Actions workflow. Both paths verify the full enrolled fingerprint, exact prepared commit, canonical tag, and immutable existing-ref boundary without exposing a passphrase to command arguments or logs.
  • Docker image scans now prefer Docker save manifests over embedded OCI indexes, ignore layer link entries without aborting extraction, preserve nested archive paths and binary provenance, and classify large native binaries before windowed text scanning.
  • Entropy and named detectors now suppress candidates inside matched public certificate and public-key PEM blocks while retaining private-key findings and credentials outside those blocks.

[0.5.48] - 2026-07-27

Added

  • The release workflow emits ten deterministic SPDX 2.3 SBOMs for four binaries, four GPU-literal bundles, and two installers. The exact 60-asset contract includes each payload/document plus its checksum and detached signature.
  • Composite Action report handling now binds exact flushed report bytes to a source-emitted seven-field receipt and a hidden KeyHog verifier, copies them to a mode-0400 unpredictable snapshot inside a unique mode-0700 RUNNER_TEMP runtime, and makes that receipt-bound job-lifetime snapshot-not the now-untrusted workspace copy-the public report output and SARIF/artifact upload authority. Internal uploads recheck its SHA-256 at use; publication does not claim immutability against the same runner UID.
  • Composite Action publication now has a fail-closed Marketplace listing verifier, explicit-CPU cross-platform source exercises, and a maintained digest-pinned push/PR container lane proving real root+nested CPU+lockdown. Source auto without persisted routing proof is rejected; authenticated manual dispatch retains proof-backed default auto in a postpublication release lane.
  • Hosted CPU evidence measures exact detection recall, throughput, and peak RSS on a pinned GitHub runner image instead of substituting local workstation measurements.
  • A standalone GitHub Action guide documents the copyable repository gate, baseline adoption, monorepo partitioning, verification boundary, inputs, outputs, and failure behavior. A release gate now checks both public reference tables against the root and nested Action manifests.
  • A provenance-bound benchmark snapshot and generator now publish README panels for detection accuracy, CPU/Hyperscan/GPU requests, scan presets, incremental cache reruns, and warm daemon requests. The documentation gate rejects stale panel or report bytes.
  • A script-driven scaling snapshot now measures scan workers, filesystem readers, exact corpus sizes, distinct storage classes, and concurrent partitions. It publishes raw trials, median and p95 latency, throughput, speedup, efficiency, and memory from one reproducible command. Nightly hosted CPU runs upload the same JSON and Markdown evidence.
  • A workflow-boundary gate and dedicated regression suite keep GitHub Action, direct CI, and mass-inventory documentation separately owned and mutually discoverable.
  • Deterministic TOML change fragments now drive one validated release transaction across workspace versions, lockfile packages, public version pins, GitHub release notes, and crate-owned changelogs. A daily read-only workflow validates the next patch candidate without publishing it.
  • The mdBook build now emits one canonical URL, Open Graph and structured project metadata, sitemap.xml, and robots.txt from tested generated output. A release operations chapter documents the signed-tag publication path and the same local documentation gate used by GitHub Pages.

Changed

  • Release publication requires the successful aggregate CI verdict from the exact tag commit. Crate publication has one post-release path, and release assets, SBOMs, signatures, attestations, containers, and moving tags share the same validated source identity.
  • Integration tests run in independent fail-closed lanes while preserving the process isolation required by source-backend contracts.
  • Hosted CPU publication gates now bind reviewed runner, Hyperscan, workload, and resolved scan-policy identities. The fast recovery contract requires only the categories supported when decoding is disabled.
  • GitHub Action, direct CI, and mass-inventory guidance now have separate workflow ownership. The README routes each use case to its canonical guide, and CI pages link to the Action or mass-scanning contract instead of duplicating it.
  • The README is now a focused product landing instead of a second full manual. It keeps install, benchmark, source, security, library, and architecture entrypoints while routing detailed contracts to the mdBook. The book adds a 30-second workflow chooser and navigation by repository gates, large inventories, backend selection, trust, and reference material.
  • The README benchmark generator now writes its deterministic 8 MiB workload in large blocks, streams corpus hashing without an 8 MiB allocation, rejects same-size byte substitutions, and requires an explicit clean or developer-dirty source classification.
  • The README now puts source-boundary selection before benchmark detail and provides copyable quick, full, CI, deep Git, mass-inventory, whole-host, and warm-file workflows. Concurrency guidance separates scanner workers, readers, partition jobs, incremental caches, verification limits, and daemon eligibility without presenting one host’s worker count as a universal optimum.

Fixed

  • Composite Action configuration reports the effective preset and lockdown policy used by calibration and scanning instead of presenting wrapper inputs that can diverge from the executed command.
  • Marketplace verification rejects untrusted origins, redirect downgrades, mutable metadata reads, unsigned exact release tags, duplicate YAML keys, and listing pages that do not bind the expected repository and Action ref.
  • SIMD scanning routes Unicode-semantic shorthand patterns through exact CPU recovery when Hyperscan’s Unicode tables cannot guarantee Rust-regex parity.
  • CredData acquisition now repairs an existing partial corpus at the pinned revision with configurable parallel workers, checks its isolated pybase62 runtime dependency before mutation, preserves failed repository scratch for diagnosis, and reports incomplete fixture trees as unavailable instead of failing benchmark test collection.
  • The benchmark make keyhog target now builds the ci-lean candidate required by deterministic autoroute parity tests. Bloom fixture generation can declare missing F/X inputs from structurally present metadata, while normal scoring still rejects partial corpora and every reader rejects an active repair marker.
  • Source-only release gates now export one resolved Cargo executable through dependency-receipt generation. Stripped gate environments no longer resolve a user-session Cargo wrapper that requires an unavailable desktop bus.

[0.5.47] - 2026-07-26

Added

  • The POSIX installer accepts --no-calibrate for deterministic automation. It still verifies the signature, checksum, installed binary, GPU literal sidecar, and doctor self-test, then warns that automatic routing remains uncalibrated until you run install.sh --calibrate.
  • The signed Linux release smoke uses the explicit no-calibration path and a measured-correct SIMD backend. Hosted-runner timing noise can no longer block publication after payload and product verification have passed.

Fixed

  • Release asset verification accepts both text-mode and binary-mode sha256sum manifests, including the *filename form emitted for Windows executables.
  • Manual release recovery dispatches check out and attest the exact requested immutable tag in every build, installer, signing, container, publication, and floating-tag job.
  • Signing uses hardened publication and release-note automation from the workflow commit while all product bytes remain bound to the requested tag.
  • The prerelease version bumper tracks every canonical version-bearing guide and no longer rejects versionless pages. Documentation truth checks now cover the integration, verification, and out-of-band verification pins updated for this release.

[0.5.46] - 2026-07-24

Added

  • scan --detectors-mode replace|overlay makes custom detector composition explicit. Overlay mode retains embedded rules, rejects detector ID collisions, and reports the effective corpus digest and provenance.
  • Benchmark reports now resolve one declared canonical run set and expose exact executable, detector, corpus, host, and static-recovery provenance.

Changed

  • Scanner library entry points return typed errors for unavailable or failed selected backends. The CLI alone maps terminal failures to process exit codes.
  • The daemon protocol is version 8. Secret-bearing wire adapters remain private, warm routes bind the engine and artifact identity, and recovery metadata is conserved across daemon boundaries.
  • Verifier success behavior is detector-owned through explicit conservative, body-positive, and status-authoritative policies.

Fixed

  • Credential and SensitiveString redact through Display and reject implicit serialization. Plaintext access requires an explicit private boundary.
  • Decoder output is streamed through one bounded sink, and UTF-8 detector policy mapping no longer slices strings at non-character byte offsets.
  • Calibration persistence preserves concurrent writers with private Unix file modes. Admission-plan mismatch recovery now emits an operator-visible receipt.
  • Release and crate publication bind the candidate commit, version, signatures, assets, package graph, and registry verification before public mutation.
  • Release version updates preserve measured benchmark identities while updating operator pins. GHCR version and latest tags wait for the signed candidate product smoke before public mutation.

Detailed component changes

These component sections enumerate the full shipped delta. They retain API, schema, routing, correctness, security, and performance details that are easy to lose in the summary above.

CLI and orchestration

  • Upgrade the daemon wire protocol to v8, keep request, response, frame, client, server, and plaintext match adapters crate-private, and expose only the non-secret default socket path. The strict Hello handshake rejects v7 peers.

  • Allow a daemon scan to select an explicit replacement detector corpus when the client-derived rules identity exactly matches the warm daemon. Reports retain the replacement count, digest, source, and mode. Overlay composition and client-only detector policy remain fail-closed.

  • Include exact static-recovery totals and per-reason rejections in JSON 1.8, JSONL 1.9, and daemon v8 report metadata. Daemon clients reject aggregates whose reason counts do not reconcile instead of substituting zeroes.

  • Make update and repair --version accept only canonical SemVer, normalize it to one v-prefixed tag before HTTP, and require the same non-draft tag before downloading assets. Malformed or mismatched release metadata fails.

  • Serialize concurrent autoroute calibration updates through the cache lock so distinct workload evidence is merged without torn reads. Unix cache, lock, and temporary files are private, and successful writes leave no temporary residue.

  • Keep the daemon socket linked for the full accept-loop lifetime. Shutdown removes it only after the listener terminates.

  • Bind every persisted GPU timing and parity receipt to the exact acquired execution peer. Route replay now rejects changed or missing adapter identity.

  • Make the final backend summary identify invalid-autoroute scalar recovery and runtime-fault recovery directly. Recovered work is no longer described as a calibrated non-GPU winner.

  • Let calibrate-autoroute --policy refresh one scan policy without rerunning every preset. The default remains the complete all-policy install sweep.

  • Reject autoroute cache and runtime-health workload identities with impossible logarithmic ranges, phase-one subtotals, decoder bits, or decoder cost bands.

  • Report automatic backend recovery as complete_after_recovery in JSON schema 1.8 and JSONL schema 1.9, preserve the exact recovered ranges and byte totals across daemon responses, expose daemon recovery health, and persist the affected autoroute workload quarantine in a bounded artifact that survives restart, is visible in backend --autoroute and doctor, and clears through successful recalibration. Recovery replays stable bytes through the fastest remaining measured-correct peer resolved by the same workload evidence, rather than a hardcoded CPU backend.

  • Measure every plain-pattern and keyword-anchor localization combination for every eligible backend, persist the fastest correct execution plan in cache schema 39, and carry both choices beside admission evidence through one-shot, fused, daemon, and automatic-recovery dispatch.

  • Retain every exact calibration representative inside one canonical workload evidence envelope. A route class is reusable only when all points agree on the fastest-correct one-shot and daemon backends; inspection exposes each point’s timings, confidence, and parity receipts, and calibration now probes both sides of the required 8 MiB crossover.

  • Show the detector-owned keyword-free operator entropy margin in explain.

  • Derive autoroute readiness and repair commands once from cache inspection, expose the repair command in backend --autoroute --json, and make doctor report scalar-only builds as direct-route ready instead of uncalibrated. Calibration now succeeds only when persisted readback is ready for the running build.

  • Persist the resolved GPU batch-input byte cap in autoroute host identity and inspection, so a device-limit or configured-cap change cannot replay timing evidence measured with a different dispatch topology.

  • Bind autoroute host identity to the live linked Hyperscan/Vectorscan runtime version, so a runtime replacement invalidates SIMD timing evidence and requires recalibration instead of replaying a stale winner.

  • Split contiguous filesystem batches at safe source-family and size-provenance boundaries, extend the split to tracked and untracked git-diff inputs, and calibrate every default fused count for extracted tar members. Empty stdin is no longer reported as a calibrated workload. Current installers delegate this core sweep to the binary instead of maintaining a second matrix. Calibration output now calls the sweep count probes rather than unique workload buckets; it also reads back and reports both route classes measured by this sweep and the cache’s total route-decision count. Installers still parse the earlier unified-command summary during migration.

  • Rename the live GPU region-presence batch byte budget to --gpu-batch-input-limit / gpu_batch_input_limit; accept the retired MegaScan spelling as a hidden CLI/TOML migration alias.

  • Include full-source-size provenance in autoroute workload keys so streamed or transformed payload sizes cannot silently reuse calibration measured from an equal numeric full-file-size bucket.

  • Activate the CLI simd feature in default builds so the documented Hyperscan --cache-dir surface works whenever the default scanner includes Hyperscan instead of falsely reporting an accelerator-free binary.

  • Stop prewarming an automatic backend from a zero-byte heuristic before the persisted workload-specific autoroute decision is known; explicit diagnostic backend overrides still prewarm directly.

  • Report the configured backend policy at startup instead of claiming that a backend was selected before the persisted per-workload decision exists.

  • Do not print end-of-run repeat summaries for dependency warnings hidden by the default log filter; summaries now describe only visible KeyHog warnings.

  • Record the actual first GPU dispatch as autoroute cold-start evidence instead of discarding it and mislabelling an already-warm second dispatch as cold.

  • Distinguish one-shot and persistent-daemon autorouting: one-shot scans include GPU cold cost, while the daemon initializes accelerator state before serving requests and selects from calibrated warm timing evidence.

  • Replace autoroute cache writes through a synced same-directory temporary file so recalibration atomically replaces an existing cache path across supported operating systems.

  • Route CLI report/cache writes through one atomic file replacement helper, including scan-system --output, to avoid truncated final-path artifacts.

  • Refuse autoroute calibration on empty or zero-byte samples before timing so calibration cannot persist route decisions that the cache loader would later reject as missing sample evidence.

  • Add keyhog config --effective and keep post-scan confidence filtering on the same resolved floor as the scanner.

  • Update stale unit fixtures for the inline-byte credential-hash contract and removed duplicate startup-summary helper.

  • Keep default --git-diff HEAD wired to worktree changes, honor CLI excludes for staged-only scans, and refresh git-mode e2e contracts for clean staged inputs and SARIF schema coherence.

  • Move args, hook, and scan-system inline tests into registered aggregate unit modules, including scan-system redaction tests updated for the raw [u8; 32] hash contract.

  • Refresh the dogfood detector-count oracle to 894 and keep the structured UUID named-detector default-recall e2e passing.

  • Distinguish detector-TOML declarations from scan-time fallback policy in keyhog explain, using the same scan-fallback provenance label as effective configuration output.

Core contracts

  • Make implicit serde serialization of Credential and SensitiveString fail before emitting secret bytes. RawMatch, DedupedMatch, and Chunk therefore require an explicit protected conversion instead of serializing plaintext, while historical plaintext and tagged deserialization still work.

  • Add canonical corpus.toml schema identity. Schema 1 keeps its conservative verifier-policy migration, schema 2 requires explicit policy, and forward or malformed schemas fail with typed errors. Detector digests and scan report metadata bind the manifest bytes and schema so caches, daemon evidence, and autoroute evidence cannot cross corpus semantics.

  • Add complete_after_recovery as a complete scan terminal state and preserve bounded backend-recovery evidence across the current JSON and JSONL report contracts.

  • Add detector-owned plausibility.keyword_free_operator_margin, validate it only for the keyword-free entropy role, and bind it into detector identity.

  • Add an opt-in source ordering contract for contiguous chunk identities so dispatchers can split routing batches without assuming concrete source types.

  • Add shared overflow-safe median and paired confidence primitives for autoroute calibration and release crossover evidence.

Scanner engine

  • Return typed Result errors from every public scan entry point. Explicit unavailable or failed backends no longer terminate an embedding process or silently substitute another backend, and coalesced scans retain one result row per input chunk. The CLI alone maps terminal errors to process status.

  • Stream decoder candidates into one per-root sink and stop production at 1,000 decoded chunks or 64 MiB. Accepted siblings and exact-boundary output remain, and custom decoder collection is fallible instead of unbounded or truncated.

  • Return non-secret backend-recovery receipts with exact recovered ranges and GPU recovery counts from recovery-aware coalesced scans. Receipt-blind APIs fail instead of discarding recovery metadata, and acquired GPU peer identity is required before autoroute can persist execution evidence.

  • Replace the overbroad bigram training gate with scanner-owned selective mandatory anchors: exact short literals and measured-frequency eight-byte double-hash anchors. Prefixless patterns remain in the explicit always-admit lane. Pinned CredData evidence now records non-zero rejection with exact enabled-versus-bypass finding parity and categorized unavailable inputs.

  • Keep appended multiline mappings aligned across empty lines and canonicalize detector assignment byte spans to UTF-8 boundaries before slicing. CredData and malformed-source scans now return their original findings instead of aborting the host with exit 134.

  • Compile detector-owned and scan-config entropy assignment keywords into one cached case-insensitive matcher, removing the per-line linear vocabulary walk from sparse source scans while preserving programmatic config changes.

  • Add one-pass multiline syntax admission before the precise concatenation grammar, so ordinary large source windows no longer pay repeated full-text searches for absent join markers.

  • Large-file multiline admission now consumes the same active generic-detector and scan-config keyword index as entropy assignment discovery. Replacement corpora no longer depend on five scanner-owned compatibility words.

  • Compile the phase-two VYRE regex-DFA admission catalog with state-cap-driven shards. The GPU now rescans a batch only after the combined DFA proves a split is necessary, instead of forcing another full-haystack dispatch for every 16 patterns.

  • Apply the detector quality gate at the public scanner compilation boundary. Programmatic DetectorSpec corpora now reject invalid thresholds, regexes, identities, validators, and duplicate detector IDs before matcher or backend construction. TOML-loaded and in-memory detectors share the same acceptance rules.

  • Preserve the participating alternate capture in grouped extraction, keep service-specific PEM blocks intact through collision resolution, and require token boundaries on short detector aliases.

  • Snapshot the decoder registry when you compile a scanner. Decode execution and autoroute admission now use that immutable plan, and its ordered decoder names and versions contribute to the detector digest. Registering a decoder after compilation cannot change an existing scanner. Invalid or duplicate registrations through try_register_decoder return DecoderRegistrationError instead of being ignored. The compatible register_decoder entry point makes later scanner compilation fail on the same error.

  • Compile reverse and Caesar admission from each active detector TOML’s decode_transforms declaration. Custom corpora no longer inherit unrelated global prefixes, and detectors such as Databricks can recover dapi tokens without a scanner-code prefix edit. DecodeWorkloadPlan now carries this shared compiled policy and is Clone rather than Copy; callers that pass one plan to more than one owner must clone it explicitly.

  • Use one compiled validator index for the active detector plan and the embedded compatibility API, so prefix narrowing and validator-result precedence have one implementation.

  • Avoid collecting GPU phase timing timestamps unless performance tracing is enabled, removing profiling clock reads from the normal accelerated path.

  • Warn when a library caller supplies an admission plan for different input, then recompute admission so the mismatch remains visible without losing recall. Preserve the concrete GPU fault reason even if its diagnostic mutex was poisoned by an earlier panic.

  • Resolve production entropy credential context from the active detector TOMLs and Tier-A keyword configuration at generation and suppression. Embedded compatibility keywords no longer widen a replacement detector corpus, and adjacent declared assignments retain their own detector context.

  • Compile one typed min/max policy from each detector and apply its inclusive bounds before generic entropy, BPE, entropy fallback, or regex-envelope scoring. Overlength values now share the value_too_long suppression reason and are rejected whole.

  • Reject detector corpora with entropy fallback or BPE policy when the scanner artifact lacks the entropy feature. The public compile boundary reports the affected detector IDs and corrective build feature before constructing matchers.

  • Replace the hardcoded lower-dash entropy exception with one compiled detector-TOML shape matcher covering typed alphabets, optional grouping, padding, diversity, and detector-owned floors. Ambiguous shape lists fail compilation instead of silently choosing one entry.

  • Compile a backend-neutral SIMD phase-one plan during scanner construction and lazily materialize Hyperscan only when selected. Exact initialization errors now cross the fallible coalesced boundary, scalar/GPU routes do not pay the unused database cost, and the recorded materialization duration is available to cold-aware autoroute evidence.

  • Make the measured route own phase-two acceleration as well as decoded scans. Only SIMD may initialize the always-active Hyperscan prefilter; scalar and GPU routes retain the portable owner through no-hit, window, and reassembly paths.

  • Establish calibration correctness from the always-present scalar engine, rejecting a divergent optional Hyperscan candidate without invalidating the independent oracle. Persist decoded-rescan backend composition in each measured route so scalar and GPU timings cannot silently borrow Hyperscan.

  • Census CUDA and WGPU identities during scanner compilation without creating execution devices or pipelines. Materialize only the selected peer, retain exact initialization diagnostics, and leave unrelated peers untouched.

  • Preserve successful GPU dispatch work when a later fused region dispatch faults, recover only the exact unprocessed source-byte intervals through the scalar trigger path, stop issuing work to the faulted route for that request, and return a typed complete-recovery receipt to orchestrators.

  • Require explicit 8x8 service context before the 8x8 detector claims a generic X-Api-Key header, removing cross-service false attribution and unrelated phase-two work.

  • Require the documented X2Y2 API host before its detector claims a generic X-API-KEY header.

  • Remove or service-anchor generic API-header patterns for OpenSea, Omnisend, Passbase, Skyscanner, and Moosend so another provider’s key is not misattributed.

  • Remove orphan generic API-header routing keywords from Dacast and Drata.

  • Reuse fused VYRE positions for always-active phase-two anchors when the measured route disables keyword-anchor localization, eliminating the duplicate host Aho-Corasick walk while retaining raw/normalized boundaries.

  • Compile the fused literal matcher with VYRE’s native ASCII-insensitive DFA and preserve raw source bytes through borrowed, coalesced, and windowed GPU dispatches, removing KeyHog’s duplicate host lowercase pass and its single- chunk/window copies.

  • Replay a dense VYRE resident fused literal scan once at the exact device match count instead of failing autoroute calibration at the fixed 65,536-hit readback ceiling; partial positioned evidence remains impossible.

  • Replace the ambiguous phase-two localizer route bit with explicit plain-pattern and keyword-anchor choices. Autoroute now calibrates, persists, validates, inspects, and benchmarks all four plans per eligible backend; cache schema 41 and crossover schema 8 reject the incomplete older evidence.

  • Apply the resolved scan entropy_threshold to named-detector heuristic confidence instead of silently scoring those findings at the compiled default.

  • Score entropy fallback findings from the owning detector TOML’s compiled entropy_high and entropy_very_high tiers instead of scanner-global tiers.

  • Apply detector-owned known-example, repeated-block, and ambiguous-encoding policy to structural password fields. Random connection-string passwords remain visible while examples and placeholders stay suppressed.

  • Resolve entropy versus named findings from the active compiled detector plan, not detector-ID spelling. Custom named detectors whose IDs resemble a fallback namespace no longer lose valid findings during resolution or decoded-content adjudication.

  • Canonicalize execution-equivalent ML candidates by detector, credential, source offset, and producer channel before batch inference. Duplicate accelerator lanes now share one pending row without merging distinct pattern and entropy evidence.

  • Let confirmed extraction see hot-prefix findings that are awaiting batch ML, not only findings already in the final heap. This prevents the same canonical hot candidate from being regex-extracted and ML-featurized twice while preserving one final candidate identity.

  • Attribute the coalesced Hyperscan trigger scan to phase one in the unified profiler, so isolated backend-route profiles include the CPU accelerator work that precedes the shared extraction tail. Attribute the phase-two plain localizer’s candidate collection, verification, and anchorless extraction to their existing profiler stages instead of leaving that route’s dominant work outside the profile tree.

  • Keep crossover selection and held-out timing free of profile instrumentation, then emit isolated scanner profiles for every Hyperscan localization plan and the selected exact GPU route instead of one misleading aggregate report. Successful coalesced CPU, Hyperscan, and GPU scans now share one logical-input accounting boundary, including exact-once accounting after GPU recovery.

  • Make the official 8 MiB crossover gate select a GPU route from all phase-two localization plans, then compare it in fresh held-out trials against every parity-correct Hyperscan plan. The release verdict uses the fastest Hyperscan observation in each paired trial; schema-v7 evidence records the full comparison set, and release runs cannot force one diagnostic mode.

  • Add an explicit unprofiled --diagnostic crossover mode. It can measure the exact 8 MiB route set from a dirty shared tree but can never set production_comparable or crossover_passed in schema-v7 evidence.

  • Carry phase-two plain-pattern localization as an immutable per-request execution route through CPU, Hyperscan, CUDA, WGPU, windowing, decode, fragment recovery, and boundary reassembly. Concurrent autoroute requests no longer need to mutate scanner-global tuning to select this path.

  • Route the profiled Elasticsearch, ip-api, 8x8, Carbon Black, GovTech, SimpleAnalytics, SentinelOne, and MX API patterns through detector-owned required anchors instead of scanning them as always-active phase-two regexes on every chunk; when the plain-pattern localizer owns ASCII candidates, skip the redundant full Hyperscan marking pass.

  • Require one compiled detector plan throughout isolated-bare admission and entropy-fallback adjudication; remove optional scanner-default policy plus duplicate entropy-shape and execution-policy inputs.

  • Evaluate the bare-auth generic bridge and its repeated-block suppression through the active detector’s compiled plausibility policy instead of scanner-owned fallback constants, with the adversarial property suite wired.

  • Refuse release-comparable 8 MiB crossover evidence unless both the build and publication worktrees are clean at the recorded commit. Schema-v7 artifacts record both states, so a binary compiled from dirty source cannot become release evidence after the worktree is cleaned without rebuilding.

  • Compile the keyword-free operator entropy margin from the owning detector TOML instead of applying a scanner-owned + 1.0 threshold adjustment.

  • Bind the canonical 8 MiB crossover artifact to its exact parity result count, and keep the backend guide synchronized with the current checked RTX 5090 evidence instead of the superseded near-parity run.

  • Restrict source-file entropy extraction to unclaimed same-line credential assignments, matching the existing emission contract and removing the whole-file entropy tail without changing dogfood rejection visibility.

  • Compile AST-proven per-pattern required_literals from detector TOML into the shared scalar, Hyperscan, CUDA, and WGPU trigger plan. DeepL :fx and URL :// ownership remove the last two ASCII always-active regexes, replacing a fixed 64-of-2,754 GPU phase-two budget with a complete ASCII prefixless plan.

  • Compose per-row prefixless completeness with fused anchor absence for every eligible ASCII row, including phase-one-triggered rows, and bypass the redundant Hyperscan always-active prefilter only when both proofs hold. Generic, entropy, multiline, decode, normalized-text, ML, recovery, oversized, non-ASCII, and incomplete work retains its canonical owner. Normalized rows discard raw GPU hints and positions and recompute phase-one admission.

  • Keep fused GPU literal accounting feature-correct so portable scanner builds compile without referencing GPU-only positioned-evidence fields.

  • Make Amazon Music, Checkmarx, Huawei Cloud, and Vonage Video confidential secrets the detector-owned primaries. Their client IDs, access-key IDs, and API keys are exact optional companions and no longer emit standalone secret findings; verification now receives the corrected primary and companion roles.

  • Compile generic execution and final resolution from detector kind and the active typed plan rather than reporting service names or detector-ID length. Anchored detectors that report service = "generic" remain named, unknown active-plan identities fail visibly, equal generic ownership is stable across corpus order, and duplicate vendor-suffix owners are rejected.

  • Treat SaltStack and Alertmanager usernames, GoTo client IDs, and Rapyd access keys as optional detector-owned companions. Only their password, client secret, or secret key is a primary finding, and companion evidence can no longer be erased by the generic identifier shortcut.

  • Run the 10,667-case detector adversarial corpus and its handwritten boundary suite through a standalone Cargo target. Slack fixtures now exercise non-placeholder identifiers and exact declared segment boundaries.

  • Upgrade the exact VYRE dependency set to 0.6.5 and replace the resident presence-only dispatch with one fused presence-and-position dispatch. The detector-derived matcher now supplies complete confirmed-anchor and generic keyword positions to the shared phase-two tail without a second GPU pass; match-output overflow remains a visible recoverable GPU fault rather than a partial result.

  • Compile each scanner’s generic-assignment line prefilter from the exact detector corpus that produced its assignment regex. Custom detector corpora no longer lose candidates to the embedded corpus’s global keyword stems. The same active keyword plan now produces the fused VYRE positioned literals, so custom detector assignments stay GPU-accelerated without a compatibility flag or embedded-vocabulary fallback.

  • Replace regex-text weak-anchor inference with explicit detector and pattern-local TOML policy. Confidence floors no longer disable structural protection, and the suppression path no longer reclassifies service/generic ownership from detector IDs. Pattern-local model conditioning remains disabled until training records carry the exact matched-pattern policy identity.

  • Compile detector entropy-floor buckets into detector-indexed primitive lookup tables. Named, weak-anchor, and generic hot paths no longer walk optional TOML fields or inject a scanner-owned floor per candidate; missing weak-anchor policy fails scanner construction. Twenty-seven structurally derived weak anchors now declare their floor and high threshold in their own TOMLs.

  • Include regex entropy owners when compiling the generic assignment generator. A focused corpus containing only a regex owner now executes its declared keyword, length, entropy, and identity policy instead of disabling the bridge.

  • Match detector-local public-identifier assignment markers directly against source bytes with allocation-free ASCII case folding instead of uppercasing an entire source line for every entropy candidate.

  • Move blockchain/network public-identifier assignment markers from a shared scanner rule into each entropy detector TOML, allowing each secret family to tune or disable the suppression independently.

  • Require and compile max_len for every generic entropy-policy owner, including regex owners such as generic-password, so assignment ownership cannot win and then silently drop because its runtime length policy is absent.

  • Compile keyword-free, isolated-bare, and unclaimed-keyword entropy entry roles from the owning detector TOMLs. Custom corpora no longer activate built-in generic detector IDs or scanner-side entropy defaults when a role is absent, and duplicate role owners fail scanner construction.

  • Make every weak-anchor detector own its length-bucketed entropy floor and high threshold. Named weak anchors no longer borrow generic-api-key calibration, so tuning one service cannot change another detector.

  • Select ML checkpoints against recall-sensitive validation-class gates before aggregate F1. The leakage-free test split remains untouched, while rare authoritative classes can no longer be lost by an aggregate-only epoch choice.

  • Group strict entropy plausibility floors and shape switches in each owning detector’s required plausibility policy. Compiled assignment and isolated paths now use the same detector policy, including repeated-block, identifier, dash-segment, and alphabetic-credential decisions.

  • Refuse ML model writes when a recall-sensitive detector channel lacks positive or negative held-out evidence, misses its recall floor, or regresses against the shipped model card. The build summary now exposes real precision, recall, F1, floor recall, and zero-recall detector count instead of hiding detector failures behind aggregate metrics.

  • Condition the 55-feature ML scorer on the exact detector TOML owner, pattern-versus-entropy channel, and detector-owned entropy family. Training and parity records now fail on missing or inconsistent detector identity, and detector balancing applies only where blend or authoritative model policy can reduce recall.

  • Cover every authoritative entropy family with positive synthetic training records, including long fixture values and service-named API-token contexts, instead of training some TOML families only as negatives.

  • Compile ML match mode, entropy mode, weight, and context radius from each detector TOML. Generic assignments now use the same pending batch and CPU/GPU model path as regex and entropy candidates. The explicit lift mode applies weighted positive model evidence without letting an uncalibrated model veto structural matches; calibrated detectors can select bidirectional blend or model-authoritative scoring. --ml-weight remains a visible scan-wide diagnostic override.

  • Remove scanner-side generic-assignment identity and length defaults. Every phase-2 candidate must resolve to a compiled detector owner with declared min_len and max_len; an incomplete owner fails scanner construction or is surfaced and dropped instead of inheriting synthetic scanner policy.

  • Score the ML pending queue directly instead of allocating a borrowed candidate vector and then copying the queue into a second vector. Pending matches now borrow their zeroizing RawMatch credential instead of retaining a second plaintext credential allocation. Small and GPU-sized batches retain the same model inputs, cardinality checks, and CPU/GPU score policy.

  • Keep one parsed owner for ML file/context markers instead of cloning every marker family into separate lazy vectors.

  • Compute each queued candidate’s 55 model features once while its source context is hot, using reusable per-thread context storage that is zeroized after extraction. Postprocess and GPU dispatch now consume the stored feature vector instead of retaining a formatted context string and extracting the same features later.

  • Compile each active generic detector’s complete entropy, plausibility, isolated-shape, and BPE policy once during scanner construction. Active owners with missing policy fields now fail construction instead of reading scanner-side defaults, and hot candidate paths consume concrete compiled values rather than repeatedly resolving optional schema fields.

  • Preserve parent JavaScript context and exact source provenance for static XOR and Node AES recoveries, matching the existing CryptoJS and reverse/Base64 recovery paths.

  • Resolve generic assignment entropy overrides against the owning detector’s TOML entropy_high policy instead of the global fallback threshold.

  • Make ScannerConfig::thorough() a distinct bounded recovery policy. It scans entropy candidates in source files, retains heuristic evidence alongside ML, removes comment confidence penalties, and admits one complete 1 MiB production chunk into decode-through.

  • Add bounded static JavaScript recovery for embedded XOR and AES-256-CBC expressions. Decode-enabled scans evaluate only the recognized side-effect-free grammar and reject dynamic operands, mismatched bindings, invalid padding, non-UTF-8 plaintext, and oversized inputs. SIMD and portable CPU entry paths share the same static-XOR decode admission.

  • Accept decimal and hexadecimal byte literals in bounded JavaScript XOR arrays. Mixed-radix values preserve exact recovery while overflow and expressions remain rejected without evaluation.

  • Recover checksum-valid known-prefix credentials assembled from JavaScript string arrays followed by an empty-separator .join(""), even when the temporary variable name is obfuscated. Non-empty separators and arrays that produce no known credential prefix remain outside this recovery path.

  • Keep immutable VYRE region-presence tables resident across GPU batches. Scanner-owned capacity grows from the live workload, concurrent calls cannot interleave mutable device buffers, and host staging allocations are zeroized.

  • Return one empty result row per empty input chunk without issuing a zero-byte GPU dispatch. Mixed empty and nonempty region batches retain backend parity.

  • Correct the 8 MiB crossover gate and size-pattern sweep to compare explicit production scalar, coalesced Hyperscan, and resident GPU routes with full finding parity. Historical per-chunk SIMD evidence is marked noncomparable.

  • Rename the production GPU health API from the obsolete AC-kernel name to gpu_region_presence_self_test, matching the live VYRE region-presence path. Its structured failure remains available to health reporters, library scan entry points return it as a typed error, and the CLI maps it to exit 12.

  • Rename the VRAM-adaptive live buffer budget to gpu_batch_input_limit and move its owner to gpu_input_budget.rs.

  • Remove detector-ID constants used only by their own tests; runtime-specific identifiers remain centralized only where production scanner behavior consumes them, while detector membership stays in detector TOML.

  • Remove the unused test-only MoE shader re-export; GPU tests consume the existing testing facade and the backend imports the shader owner directly.

  • Make the no-backend library APIs scan and scan_coalesced deterministic portable CPU references; Hyperscan/GPU execution now requires an explicit backend or the CLI’s persisted fastest-correct autorouter.

  • Keep cross-chunk boundary reassembly on the shared portable correctness tail instead of making a second hardware-heuristic routing decision.

  • Keep fixed high-tier GPU routing conservative at 128 MiB (256 MiB for a single-file override). The historical 8 MiB RTX 5090 artifact used a slower per-chunk SIMD entry point and is not production crossover evidence. Exact cold-versus-daemon decisions belong to persisted autoroute calibration.

  • GPU MoE buffer pool: reuse input/output/staging wgpu buffers across MoE dispatches via a global LazyLock<Mutex<MoeBufferPool>>, eliminating per-dispatch buffer allocation (the dominant non-GPU overhead for large MoE batches in coalesced scanning). The params buffer remains per-dispatch to prevent concurrent batch_size races. Pooled buffers grow to the high-water mark and are reused for smaller batches via sized slicing.

  • Fix pre-existing simdsieve_prefilter compilation errors: add build_hot_pattern_validators (plural), HOT_PATTERNS, and HOT_PATTERN_DETECTOR_IDS computed from embedded detector specs via LazyLock with leaked 'static slices. Add standalone hot_pattern_index_at test helper that doesn’t require a compiled scanner.

  • Reduce the backend surface to gpu, simd, and cpu; the CLI owns auto through persisted fastest-correct routing evidence. MegaScan and implementation-name aliases no longer map to a live route.

  • Reduce MAX_SCAN_CHUNK_BYTES from 1 MiB to 384 KiB, enabling 32-thread parallelism on large inputs without OOM. Window size stays at 1 MiB to preserve adversarial parity.

  • Add fast line-level prefilter to scan_keyword_free_candidates that skips lines below max_entropy_run threshold before entering the expensive entropy computation. The prefilter is conditionally disabled when dogfood telemetry is active to preserve suppression-event recording.

  • Promote debug_assert! to assert! for the line-offset invariant in find_entropy_secrets_with_lines and find_entropy_secrets_with_precomputed_keywords_and_policy. The fail-closed behavior must hold in release builds, not only debug.

  • Fix pre-existing build errors in gpu_region_dispatch.rs: add missing report_positioned_gpu_candidate_loss helper and scan_gpu_literal_matches_with_scratch function. Add marked field to Phase2GpuDfaAdmission initializers.

  • Fix pre-existing test compile errors in gpu_presence_bit_partition.rs: remove assignments to non-existent confirmed_anchor_literal_count and generic_keyword_literal_count fields on CompiledScanner.

  • Add detector-owned BPE token-efficiency policy through bpe_max_bytes_per_token in detector TOML. Generic and entropy fallback paths resolve the same owning detector before applying the gate; detector policy takes precedence over the compiled fallback, while an explicitly set scan TOML/CLI value remains the final visible Tier-A override. Invalid non-positive/non-finite bounds fail closed, and the field participates in the detector digest used by caches and calibration identity. Opaque generic API-key/secret policies use the measured 2.3 ceiling across assignment, entropy, and explicit regex-envelope paths when the entropy feature supplies the tokenizer; password/passphrase-oriented policies explicitly disable the word-likeness rejection so human-chosen phrases do not become false negatives.

  • Add the aws-bedrock-api-key detector (critical), long-term AWS Bedrock API keys (ABSK prefix + the deterministic QmVkcm9ja0FQSUtleS base64 anchor + 110-char body, 132 chars total; AWS’s own published form). The anchor encodes “BedrockAPIKey” and is effectively unique, so precision is anchor-driven (defensive min_confidence = 0.2 floor since the fixed anchor dilutes entropy scoring). Not checksum-gated. Detector count 900 → 901. Contract-locked by crates/scanner/tests/contracts/aws-bedrock-api-key.toml (positives, anchor/length negatives, header + comment evasions). Short-term bedrock-api-key- keys are deliberately omitted (their body is not authoritatively bounded (soundness over reach)).

  • Fix a dead contract gate: every_contract_readme_claim_present had been passing vacuously. A readme_claim written after a contract’s [perf]/ [scale] header binds to that TOML table, not the Contract, so serde silently dropped it and every contract’s claim parsed as None: the gate checked nothing (and “stripe” never matched README’s “Stripe”). Moved the six real readme_claims to the top-level scalar position, corrected the stripeStripe claim, added #[serde(deny_unknown_fields)] to the perf and scale budget structs so a future misplacement is a loud parse error instead of a silent drop (Law 10), and added a liveness floor (checked >= 6) so the gate can’t regress to vacuous.

  • De-duplicate the detector-count claim (was denormalized across 782 places): removed the readme_claim = "900 service-specific detectors" stamp from 781 per-detector contracts and made the count derive from load_detectors() in one place: readme_claims::readme_claim_detector_count (README + banner), contract::readme_detector_count (disk == loader, no literal), and the e2e_binary banner test (binary == loaded corpus). Adding a detector now touches only the new TOMLs + the human-facing README/banner, with no test-literal or 781-file churn.

  • Byte-cap the per-match context windows (local_context_window ML context to 8 KiB, context::inference::surrounding_line_window FP context to 2 KiB). Previously each candidate’s context was the whole containing line; on a line with no \n for kilobytes (minified bundles, or a file that is one long run of credential-shaped tokens) the per-match ML feature / FP keyword scan was O(line_len), making a many-match scan quadratic (a 164 KiB single-line file with 8 K matches took ~18 s). The caps make per-match context O(1) and noticeably speed ordinary minified-bundle scans. Behavior-preserving for normal source, a short line hits its newline well before the cap, verified by byte-identical mirror-corpus findings (F1 0.9167, 2564 findings) and the full scanner suite. Regressioned by unit/a3_pipeline/local_context_window_caps_long_line. (A residual super-linear cost remains when a single file carries thousands of distinct credential-shaped matches; bounded in practice by --timeout and the 1M-iteration-per-pattern cap.)

  • Fix windowed-scan line attribution: findings in files past the 1 MiB windowing threshold (filesystem/windowed) reported the per-window line instead of the absolute file line, so a secret on line 584307 of a 70 MiB file was reported at line ~2 (and reported lines were non-monotonic). Added ChunkMetadata::base_line (the line analog of base_offset), populated per-window by the filesystem source (mmap + buffered paths) and the cross-window boundary reassembler, and added it at every line emit site (primary, entropy fallback, generic-secret, multiline reassembly, decode pipeline, and the simdsieve hot path). Byte offsets were already absolute; this brings line numbers to parity. Regressioned by cli/tests/regression/windowed_line_numbers.rs.

  • Remove the orphaned pipeline/postprocess/raw_match.rs: a never-compiled stale duplicate of build_raw_match (no mod/#[path] referenced it), superseded by the pattern_client_safe-aware constructor in pipeline/postprocess/mod.rs.

  • Align Vyre usage docs with the workspace-pinned crates.io vyre 0.6.1 release and add a scanner gap test that fails on stale Vyre pin/documentation claims.

  • Fix stale RawMatch scanner test fixtures to use the production [u8; 32] credential hash contract.

  • Split structured parser implementations by format family and move remaining parser inline tests into the external scanner test harness.

  • Add a GPU phase2 empty-hit fast path matching SIMD coalesced no-hit fallback admission, with a regression gate for the skip-before-prepare contract.

  • Preserve detector regex case-insensitivity when lowering prefixless phase-2 admission patterns into the GPU regex-DFA catalog; plain variants stay case-sensitive, and replay tests compare the lowered DFA admission result against the CPU LazyRegex policy.

  • Select bounded GPU regex-DFA admission candidates by detector breadth before generated homoglyph variants instead of taking the first source-order slice; the catalog budget is now expressed as shard count x shard width.

  • Tighten the GPU region-presence host lowercase staging helper to reserve once and write folded bytes directly into spare vector capacity, preserving make_ascii_lowercase semantics without a Vec::push per byte.

  • Make the boolean no-hit phase-2 admission gate honor the proven ASCII homoglyph-variant skip, avoiding extra phase-2 work on pure-ASCII chunks that are already covered by the base AC path.

  • Tighten GPU phase-2 DFA coalesced-region attribution so matches on or through the synthetic NUL separator between chunks cannot over-admit a neighboring chunk into the CPU phase-2 tail.

  • Pack the GPU phase-2 DFA coalesced haystack once per batch and reuse it across DFA shards, removing duplicate O(input) host staging work from sharded admission dispatch.

  • Mark GPU phase-2 DFA admission evidence incomplete when a backend hit cannot be safely attributed to a chunk, keeping phase2_gpu_complete honest for separator/cross-region output.

  • Keep high-entropy base64-like secrets with internal +// punctuation through generic and entropy fallbacks by bypassing binary-decoy suppression on the punctuation payload class, closing encoded_binary-driven false negatives.

  • Add adversarial coverage for the base64 punctuated high-entropy class and a fixed-token regression for TVo...+... shape that previously dropped at is_encoded_binary.

  • Detect current variable-length Clerk publishable keys by their documented base64-encoded FAPI URL form instead of requiring an obsolete exact 32-byte alphanumeric body; findings remain explicitly client-safe.

  • Keep two S3-compatible access-key bodies case-sensitive inside their detector TOMLs while preserving case-insensitive environment-key anchors, preventing lowercase identifiers from satisfying documented uppercase credential alphabets.

  • Apply the canonical Octopus Deploy key alphabet to assignment and header patterns too, so context cannot admit lowercase keys or pure documentation words that the bare-key pattern correctly rejects.

  • Preserve Akoya client-credential findings for mixed-case config keys by declaring the required companion anchor caseless in its detector TOML; simplify the already-caseless primary regex to one canonical spelling.

  • Preserve Twilio IoT credential pairs for lowercase config keys by applying case folding to the required companion anchor, while keeping the credential body alphabet detector-owned and simplifying redundant primary alternations.

  • Preserve Twilio API-key pairs for mixed-case secret field names by folding only the detector-owned companion anchor, without widening the credential body’s declared alphabet.

  • Capture mixed-case AWS and GovCloud secret/session fields without widening their credential bodies, so temporary ASIA credentials reach SigV4 with the required session token; keep GovCloud access-key IDs uppercase-only and reject overlong runs instead of truncating them into findings.

  • Make Spotify’s companion secret-specific and capture only its value, so a client ID cannot attach itself as a credential pair; collapse redundant uppercase/lowercase primaries under the shared caseless compiler.

  • Migrate the stale FedEx companion fixture into its normal detector contract and reject companion contracts whose detector declares no companions, so generated test shape cannot masquerade as production verification wiring.

  • Make LiveKit’s companion secret-specific so long API keys cannot self-attach as secrets, deduplicate caseless primary regexes, and let companion contracts explicitly declare when a companion shape is also a valid standalone primary.

  • Make Ceph access keys self-delimiting so 40-character secret values cannot be truncated into 20-character access-key findings, while preserving Ceph’s valid user-defined mixed-case access keys and correcting the contract prose.

  • Model Five9 API secrets as intentional standalone primaries in the companion corpus, while proving API-key-only findings cannot fabricate the nearby secret required for credential-pair verification.

  • Make AWS SES SMTP field anchors consistently caseless while preserving the uppercase access-key alphabet, reject overlong username/password prefixes instead of truncating them, and model password-only findings honestly.

  • Make Olark’s companion token-specific and capture only its value, so an API key cannot self-attach as its own pair; preserve standalone token detection and reject overlong hexadecimal runs instead of truncating them.

  • Make Genesys Cloud’s companion client-secret-specific and capture only its value, so a client ID cannot self-attach; preserve standalone secret findings and reject overlong UUID-like client IDs instead of truncating them.

  • Treat Payoneer client IDs as companion context instead of standalone secrets, capture their exact value beside a client-secret primary, and reject invalid token continuations without limiting legitimate variable-length secrets.

  • Preserve standalone Gravity Forms private-key detection while proving public keys cannot self-attach, accepting mixed-case hexadecimal keys, and rejecting overlong hexadecimal runs instead of truncating them.

  • Keep Checkmarx client secrets detectable on their own while making them the exact companion to UUID client IDs; use role-specific anchors and reject overlong UUID/token continuations without losing secret recall.

  • Model Cloudinary URLs as the self-contained credentials they are, remove the fabricated companion contract, capture the exact URL without its delimiter, and reject invalid cloud-name continuations instead of truncating them.

  • Treat M-Pesa consumer keys as companion context for consumer-secret findings, preserve standalone API-key detection, capture the exact paired key, and reject invalid underscore/hyphen continuations instead of truncating them.

  • Make Tumblr’s companion consumer-secret-specific while preserving standalone secret findings, and reject alphanumeric, underscore, and hyphen extensions whole instead of reporting a valid-looking 50-character prefix.

  • Make Marvel’s primary and companion explicitly private-key/public-key roles, so a private key cannot self-attach and public identifiers do not become standalone secret findings; reject overlong hexadecimal key runs.

  • Replace Amazon Music’s broad context companion with the exact 64-hex client secret, preserve standalone secret findings, normalize caseless field anchors, and reject client-ID/secret continuations instead of truncating them.

  • Remove Worldpay’s service-name pseudo-companion, migrate its useful fixtures into the normal contract, classify service/merchant IDs as low-severity identifiers, and reject overlong or continued ID tokens whole.

  • Remove Nuvei’s self-attaching and invented merchant companions while preserving standalone API-key and API-secret recall, and reject invalid continuations instead of emitting hexadecimal prefixes.

  • Treat Mangopay API keys/passphrases as primaries and client IDs as exact companion context, with mixed-case, minimum-length, unbounded valid-length, and invalid-continuation contracts.

  • Treat Tawk.to API keys as primaries and public site/property IDs as exact companion context, so API keys cannot self-attach and continued key prefixes are rejected whole.

  • Preserve standalone Exoscale API-secret findings explicitly while keeping API keys self-delimiting, so fixed-length key prefixes cannot be truncated from longer tokens.

  • Make BigCommerce store hashes exact public companion context for bbc_ access tokens, remove store hashes as critical standalone findings, and reject token continuations whole.

  • Treat Avaya client secrets/API keys as primaries and public OAuth client IDs as exact companion context, removing critical standalone identifier findings and rejecting continued secret prefixes.

  • Make env0 key IDs self-delimiting, capture API-secret companions exactly, and explicitly preserve standalone secret findings.

  • Capture FastSpring password companions by exact value and explicitly preserve password-only findings without letting username primaries self-attach.

  • Make GCS HMAC companions secret-field-specific instead of matching arbitrary base64/access-ID substrings, and reject overlong GOOG access IDs whole.

  • Remove Jumio’s accidental companion capture of the role label, capture the exact secret value, preserve secret-only findings, and reject continued credential prefixes.

Source adapters

  • Let all four WebSource DNS-screening workers wait on and consume the bounded job queue concurrently instead of serializing receives behind one mutex.
  • Add GitLab group and Bitbucket workspace source backends through a shared hosted-git clone/scan owner, moving git-error redaction out of the GitHub-only module so every forge source redacts clone failures through the same control.
  • Fix --git-diff and --git-history line attribution: both sources concatenated every added line of a file into one chunk and discarded the @@ … +new_start @@ hunk header, so every finding was reported at line 1 instead of its real new-file line (a pre-commit/CI workflow, and history forensics, pointing nowhere near the leak). Both now run -U0 and emit one chunk per hunk carrying base_line = new_start - 1 (parsed by the shared git::parse_hunk_new_start), so the scanner reports the absolute new-file line. Regressioned by git_diff_chunks_carry_absolute_base_line_per_hunk and git_history_later_commit_addition_carries_absolute_base_line.
  • Populate ChunkMetadata::base_line on the filesystem windowed path (mmap + buffered) so findings in files past the 1 MiB window size report the absolute file line, not the per-window one (paired with the scanner-side emit-site fix).
  • Run filesystem reading on a dedicated Rayon pool so bounded-channel backpressure cannot starve scanner work on the global Rayon pool during large-tree scans.

Live verifier

  • Normalize missing schema-1 verifier success policy to status_with_error_backstop, require an explicit policy in schema 2, and reject forward schema versions. Corpus identity binds the normalized schema so equivalent detector fields under different schemas remain distinct.
  • Redact verifier proxy credentials, query parameters, percent-decoded secrets, and parser source text from invalid-URL errors. Diagnostics include only a safely parsed scheme and host or the generic invalid-proxy message.

Release engineering, benchmarks, and documentation

  • Add a dedicated crates.io publication workflow that proves the exact tag, checks out hardened automation separately from tagged source, verifies the public GitHub release before credential use, and resumes an interrupted five-crate publication by validating immutable registry archives.
  • Stage signed release assets in one immutable private draft, bind the release ID and source commit in a signed receipt, and expose the release only after candidate smoke, container publication, and receipt verification succeed.
  • Run the signed Linux candidate through checksum and Minisign verification, offline installation, doctor, and an exact finding/redaction scan before any GitHub release or GHCR mutation becomes public.
  • Verify the pushed GHCR multi-architecture manifest by digest and platform, publish latest only for the newest stable release, and move the floating v0 action tag only after the immutable release is public.
  • Make release-ref GitHub Action installs require the signed binary, signed GPU literal sidecar, checksums, and matching release identity before scanning.
  • Declare canonical benchmark run sets and baselines in TOML. Reports reject stale or mixed executable, detector, corpus, host, recovery, and run-set provenance instead of silently selecting convenient results.
  • Record Bloom prefilter density, saturation, rejection, parity, and corpus evidence in benchmark artifacts, doctor, and explain; missing named-corpus evidence remains visibly unproven.
  • Generate deterministic GPU literal archives and validate archive paths, entry types, expansion limits, manifest identity, filenames, and byte lengths before installation.
  • Pin documentation tooling by checksum, validate links and CLI claims, preserve byte-identical generated benchmark blocks during version bumps, and require substantive per-crate changelog entries before release.
  • Serialize release retries by immutable tag without cancelling active publication. Repeated runs reuse exact public releases without mutating verified assets.

[0.5.45] - 2026-07-22

Fixed

  • Signed release publication now discovers drafts and mutates assets by immutable release ID. First publication and interrupted reruns remain private until exact signed-manifest validation succeeds.

[0.5.44] - 2026-07-22

Fixed

  • The Windows GPU literal artifact generator now passes UTF-16 prefixes to slice::strip_prefix as slices. CI compiles both Windows release feature closures before a tag is cut.

[0.5.43] - 2026-07-22

Fixed

  • Windows portable release builds now gate Unix-only daemon test seams, use the generated windows-sys drive-constant module, and compile before a tag is cut.

[0.5.42] - 2026-07-21

Fixed

  • Published crates bundle their canonical Tier-B rule data and resolve GPU driver identity from either workspace or normalized package manifests, so standalone crates compile outside the KeyHog workspace.
  • The release publisher uses stable Cargo, verifies every crate with all features before upload, and validates immutable crates.io archives when a partial release is resumed.
  • Phase-two GPU DFA resident batches keep grown haystack capacities aligned to the declared 32-bit element ABI, so 8 MiB coalesced scans dispatch on CUDA.
  • Prerelease now requires a signed release bundle for installer smoke tests and a clean, candidate-bound 8 MiB GPU-versus-Hyperscan crossover artifact.
  • Offline installs verify sibling Minisign signatures when supplied and reject empty, invalid, or wrong-key signatures before replacing an installed binary.
  • GPU literal sidecars reject absolute, parent-traversal, symbolic-link, and hard-link members before extraction.
  • Known-prefix confidence and entropy fallback suppression now use each detector TOML’s degenerate_run_min_length instead of a scanner-wide limit.
  • Post-match placeholder, diversity, repeated-run, decoded-envelope, fixture-path, and model-context confidence tuning now compiles from each detector TOML instead of scanner literals.
  • Detector resolution priority, decoder ancestry, generic assignment suffixes, entropy thresholds, and backend policy now compile into one typed detector execution plan used by CPU, SIMD, and GPU result processing.
  • Structured scanning repairs truncated Jupyter JSON at EOF and sanitizes balanced Helm render actions before YAML parsing, while scanning the original source bytes and reporting every repair.
  • Build provenance watches Git reflogs as well as loose and packed refs, so consecutive same-branch candidate builds embed the exact checkout commit.
  • Base64 and hexadecimal values, JSON strings, quoted-printable text, and line-local URL-percent, HTML-entity, Unicode, and octal escapes decode in bounded source and output batches while retaining multiline private-key separators. Dense generated source no longer creates one recursive root per encoded value. Disallowed decoded C0 controls become token separators instead of truncation events, preserving adjacent printable spans without joining tokens.
  • Differential corpus benchmarks disable default file exclusions for directory inputs, so terminal success proves every corpus file reached scanning.
  • CUDA and WGPU positioned-literal evidence use the same 8 MiB shard ceiling, keeping dense corpus match replay exact without backend substitution.
  • Full-corpus accelerated parity checks allow twenty minutes per backend, so a cold or contended release host does not kill a healthy exact scan.
  • Single-shard Hyperscan databases compile inline, preventing nested Rayon work from re-entering a worker’s borrowed phase-two scratch state.
  • Chef’s generic api-token header anchor now requires a token boundary, so it cannot replace an exact Snyk UUID finding through overlap resolution.
  • Benchmark process failures include measured wall time, peak RSS, and watchdog state, distinguishing resource kills from finite timeout failures.
  • CredData backend parity packs disjoint top-level roots into identical source-bounded processes and rejects overlap before comparing complete unions.
  • Default detector resolution priority no longer perturbs the canonical detector digest, while non-default collision policy remains cache-bound.
  • Generic vendor and exact-keyword tail suffixes now come from the owning detector TOML instead of a scanner regex literal.
  • Scanner profiling, generic-shape adjudication, segment attribution, regex truncation, and GPU artifact/cache/input policy now live outside the runtime engine, with exact complexity ratchets lowered to the new ownership boundary.
  • Oversized staged-diff headers now emit a recoverable coverage error while preserving later staged records, and Docker archive extraction no longer carries an unused archive-scope argument.
  • Installer support claims now name the exact released architectures. Linux and Windows arm64 are explicitly unsupported and release selection tests lock that contract.
  • Obsolete bench-v1 artifacts and their unverifiable README claims are removed; benchmark reports now render only current-schema evidence.
  • Decoded finding resolution preserves exact raw credential bytes when the same detector matched that source coordinate. Otherwise, it prefers the shallowest valid decoded representation.
  • Detector-definition suppression recognizes complete path segments at relative, absolute, POSIX, and Windows boundaries without matching partial directory names.
  • Kubernetes Secret classification reads parsed YAML fields rather than matching kind: Secret text in comments or scalar prose.
  • Scanner pre-exit hook registration is first-writer-wins without panicking, and zlib admission rejects reserved CINFO window sizes.
  • Caesar decode admission now requires detector prefixes at token boundaries, so an interior rotated prefix cannot synthesize a competing provider finding.
  • Grouped extraction keeps the participating alternate capture, service-specific private-key detectors retain complete PEM blocks, and short detector aliases require token boundaries so broader detectors cannot steal exact findings.
  • Scan telemetry scopes propagate into Rayon post-processing workers, so nested compiled scans contribute to the owning scan receipt without global state.
  • Automatic autoroute recovery now covers Hyperscan/SIMD runtime faults as well as GPU faults, replays the stable batch through the fastest remaining measured-correct peer, and quarantines the failed workload route.
  • Autoroute uses paired same-backend timing when it can distinguish execution plans. For a statistical tie, it prefers the typed plan’s compiled default or a stable tied plan whose interval remains below every peer backend plan.
  • GitHub Action scans publish reports before restoring scanner failures, and unreadable findings reports fail closed even when findings are advisory.
  • Generic plausibility now compiles its alphanumeric ratio, source type-name limits, and URL/path high-entropy exemption length from the owning detector TOML instead of scanner literals.
  • Isolated symbolic candidates now use the owning detector TOML’s symbol count, non-underscore rule, alpha-only symbol count, and alphabetic ratio in every admission branch.
  • GPU MoE dispatch now reuses its uniform buffer and bind group with each exclusive pooled buffer set, validates device buffer and workgroup limits before submission, and recovers from a poisoned pool without panicking.
  • SARIF executionSuccessful is false when keyhog.scan.status is Partial (or Failed/Cancelled), so consumers that only read that flag cannot treat coverage-gap runs as green (KH-1437).
  • .env multiline / unclosed-quote reconstruction caps continuation joins at 64 lines so a missing closer cannot swallow later KEY=VALUE pairs (KH-1432).
  • SensitiveString Display redacts like Credential / Debug; plaintext is only via as_str / Deref (KH-1424).
  • Oversized Git diff, history, and tag lines now emit an operator-visible source error instead of only incrementing an internal truncation counter.
  • Oversized staged-diff headers emit an error row and preserve later staged records instead of discarding the path-read outcome.
  • Binary string extraction removes shifted or partially overlapping UTF-16 LE/BE suffix duplicates by byte span while preserving both byte orders.
  • Empty PDFs with extraction errors retain their unreadable or truncation event without also being counted as valid image-only binary skips.
  • keyhog watch accepts --max-file-size and --max-consecutive-failures (Tier-A) instead of hardcoding 100 MiB / 8 (KH-1461, KH-1462).
  • Incomplete exit 13 and baseline refuse use CoverageCounts::fail_class_total driven by the CoverageGapKind severity table, so FAIL-class sums cannot drift from the canonical kind list (KH-1410).
  • --create-baseline still writes the snapshot when findings exist, but exits 10 when any finding is Live so verify+baseline CI cannot go green on live credentials (KH-1439).
  • Docker layer rewrite continues after a single filesystem chunk error instead of aborting the rest of the layer (KH-1446).
  • Filesystem reader-pool spawn failures print on stderr (not only tracing::warn) so a degraded crew cannot hide without RUST_LOG (KH-1430).
  • Coalesced phase-2 trigger/chunk cardinality mismatch recomputes every trigger row instead of truncating or silently padding, and prints on stderr (KH-1431).
  • S3 objects whose ListObjects size is missing use a Range: bytes=0..cap-1 GET so a multi-GB object cannot stream unbounded before the client cap (KH-1413).
  • JWT iss/sub/aud metadata is revealed under --show-secrets via finding_metadata_with_secrets (still length-redacted by default) (KH-1458).
  • keyhog watch with multiple roots loads each root’s .keyhogignore.toml RuleSuppressor instead of sharing only the primary root’s policy (KH-1433).
  • CRX/openpack extraction binds openpack entry/total caps to KeyHog scan budgets and uses a finite 1000× compression-ratio ceiling instead of f64::MAX (KH-1436).
  • Daemon client receive timeouts are per request kind: Health/Shutdown/Hello 5s, ScanText 60s, ScanPath 300s (KH-1459).
  • Live/Dead verification findings merge offline JWT/AWS metadata (including --show-secrets claim reveal) keyed by credential hash (KH-1487).
  • Docker image archives enforce the same per-entry byte cap as layer archives (KH-1455).
  • Source construction now returns typed unknown-name, unavailable-feature, and invalid-configuration errors. Canonical source names use hyphens, and retired underscore aliases are rejected with their exact replacement instead of being accepted silently.
  • S3 listings that omit object Size still fetch the object instead of treating missing Size as empty (KH-1321).
  • Selecting SimdCpu/Hyperscan on a binary built without the simd feature hard-errors instead of silently scanning on CPU (KH-1291).
  • Autoroute cache schema 45 requires the winning execution plan’s confidence interval to clear every eligible route, including localization variants on the same backend; overlapping same-backend plans remain visibly inconclusive.
  • Non-finite GPU MoE confidence now invalidates and CPU-rescores the complete batch, then disables GPU MoE scoring for the process, instead of substituting 0.5 and risking CPU/GPU detection drift (KH-1342).
  • Service-regex and companion-backed candidates now continue into detector-owned ML scoring even when the cheap probabilistic gate finds little randomness; only unaccompanied generic candidates may take the early 0.1-confidence path (KH-1343).
  • Secret-scanner path suppression requires a definition-shaped path segment so apps named after scanners keep being scanned (KH-1300).
  • README star history uses the shields.io stars badge instead of a hotlinked api.star-history.com SVG that often 404s on GitHub (KH-1264).
  • Image-only PDFs with no extractable text record a Binary coverage skip instead of disappearing silently (KH-1325).
  • Decoded payloads with C0 control bytes keep their printable content instead of being discarded entirely (KH-1338).
  • Strict adversarial CI on CPU runners uses --features ci-lean so cudarc is not loaded (KH-1302).
  • Binary string extraction recovers UTF-16BE wide strings in addition to UTF-16LE (KH-1322).
  • Incremental cache is not persisted when findings lack a file path, so pathless secrets cannot remain marked clean for the next run (KH-1296).
  • Kubernetes Secret structured detection parses the YAML kind field across quoted, spaced, and multi-document forms without matching comments or scalar prose (KH-1341, KH-1393).
  • Gzip/zlib decode-through rescans any non-empty inflated prefix when decompression hits the size cap or mid-stream error (KH-1339).
  • keyhog watch applies the same default max-file-size (100 MiB) as keyhog scan when reading changed files, instead of the 2 GiB TOCTOU-only ceiling (KH-1310).
  • Action source-build fallback on Linux uses --features ci-lean so hosted runners do not link the full GPU stack (KH-1304).
  • keyhog watch exits after eight consecutive per-file scan/read failures instead of staying healthy while dropping secrets (KH-1334).
  • Zlib decode-through accepts any RFC 1950 header with deflate method and valid FCHECK, not only the three common 78 01/9c/da pairs (KH-1340).
  • Daemon client scan/health/stop responses time out after 300s instead of hanging forever on a wedged peer (KH-1314).
  • Daemon connections require a Hello handshake as the first frame before Scan or Shutdown (KH-1337).
  • .env parser reconstructs multiline quoted values and backslash-continued bare values instead of truncating at the first line (KH-1346).
  • Live-verification status-only success specs no longer suppress the body error backstop, so HTTP 200 with an error JSON body is not classified Live (KH-1298).
  • JWT iss/sub/aud metadata in reports is length-redacted by default so claim values do not leak through finding metadata (KH-1350).
  • Dogfood product matrix writes scan reports under $tmp/outputs while fixtures live only under $tmp/scan, so the scanner cannot read its own growing report files (KH-1303).
  • Git history, diff, staged, and tag-message streams skip oversized plumbing lines with a counted SourceTruncated gap and continue (KH-1355).
  • keyhog calibrate-autoroute now writes its complete workload and preset sweep to an isolated cache, validates the finished generation, and publishes it once. A failed late probe leaves the live cache byte-identical, while a concurrent cache or runtime-health update aborts publication instead of being overwritten or incorrectly cleared.
  • Detector TOML validation now rejects inverted entropy tiers before scanner compilation, with exact diagnostics for entropy_low > entropy_high and entropy_high > entropy_very_high.
  • Watch-mode file chunks now carry their complete raw file size into autoroute, so an editor save reuses the same measured workload identity as an ordinary filesystem scan instead of appearing to be a transformed payload.
  • Watch mode now warms its selected backend before announcing readiness and consumes persistent-runtime autoroute evidence instead of pricing every file event as a cold one-shot scan.
  • Autoroute calibration now aborts without writing when an existing cache is temporarily unreadable. A sharing violation, permission failure, or short storage read error can no longer erase other calibrated profiles by being treated as corrupt replacement input.
  • Repeated detector-owned BPE checks now reuse a bounded per-worker token count only after exact-byte verification. Hash collisions recompute, oversized candidates remain uncached, and retained candidate bytes are zeroized on eviction.
  • Watch-mode burst dedup now binds the one-way credential hash and complete source location. Replacing a credential at the same detector and byte span emits immediately instead of being mistaken for a duplicate save event.
  • Composite Action initializes v="" under set -u so branch/SHA refs no longer crash with unbound v before download or source-build (KH-1267).
  • Action fail-on-findings: true fails the job on process exit 1 even when the report parses to zero findings (KH-1330).
  • keyhog watch applies .keyhogignore.toml RuleSuppressor after resolve, matching keyhog scan (KH-1329). Watch dedup maps use bounded FIFO eviction instead of O(N) retain spikes (KH-1311).
  • keyhog hook install rewrites KeyHog-owned hooks when bytes differ from the current template; only an exact-byte match is “already installed” (KH-1333).
  • --create-baseline / --update-baseline refuse to write and exit non-zero when the scan panicked, hit FAIL-class coverage gaps, or failed the incremental cache (KH-1352).
  • Incomplete-coverage exit 13 uses the CoverageGapKind FAIL set only (including line-offset mapping mismatches). Deliberate binary / over-max WARN skips no longer flip a clean scan to exit 13 (KH-1347).
  • Missing or non-finite match confidence is treated as 0.0 for --min-confidence and per-detector floors (KH-1351).
  • GPU runtime-fault accounting stores the degrade reason even when the diagnostic mutex is poisoned (KH-1290).
  • Daemon incomplete exit 13 uses FAIL-class source gaps only, matching local scan (KH-1368). Daemon findings with VerificationResult::Live exit 10 instead of collapsing to exit 1 (KH-1379).
  • Daemon source coverage gaps merge into process-local skip counters before reporting so SARIF/human gap summaries match in-process scans (KH-1369).

Changed

  • Named regex detectors now admit digest-shaped pure-hex credentials only through length-only canonical_hex_key_material declarations in their own TOMLs. The scanner-global service-key width fallback is removed; generic assignment rules remain scoped to their declared keywords and suffixes.
  • Autoroute inspection now reports disabled or missing evidence as visible, complete scalar correctness recovery instead of claiming scans require an explicit backend. Its JSON timing receipts expose ordered nanosecond trials, cold cost, exact one-shot and warm projections, and confidence bounds for scalar, Hyperscan, CUDA, and WGPU routes; SIMD warm evidence is no longer misreported as an ordinary rounded median. Recovery warnings now have one canonical operator rendering instead of a duplicate tracing WARN.
  • Autoroute cache schema 43 keys routes by the complete canonical source execution class. Web JavaScript, source maps, windowed files, PDFs, archives, and other preprocessing shapes no longer alias under a truncated top-level family; dynamic binary section names collapse to their stable format class.
  • Autoroute cache schema 44 content-addresses every measured payload and source shape. Equal byte/chunk counts no longer overwrite distinct calibration representatives, and JSON inspection exposes the canonical generator plus payload and shape digests needed to reproduce crossover evidence.
  • Autoroute inspection now renders KeyHog-owned source execution classes by privacy-safe canonical name alongside their digest. Unknown library-provided metadata remains digest-only instead of being echoed into diagnostics.
  • Autoroute config identity now includes profile and performance-trace instrumentation, preventing timed routes from reusing evidence measured under a different hot-path cost model.
  • Hyperscan compile sharding and per-shard scratch preallocation now follow the active Rayon executor width instead of host-visible CPU count, avoiding needless databases and scratch allocations under --threads and local pools.

[0.5.41] - 2026-07-18

Fixed

  • Documented CI action pins pointed at an unreleased tag. Every copy-paste CI snippet pinned santhreal/keyhog/.github/actions/keyhog@v0.5.41, a version with no release, so GitHub failed to resolve the action at checkout before the scan could run. The pins now use the floating @v0, which the Action resolves to the newest published release.
  • Pin simdsieve to crates.io 0.1.2 so macOS/aarch64 builds no longer hit the broken 0.1.1 NEON inline(always) + target_feature combination.
  • Installer GPU-literal sidecar validation extracts to a temp tree and checks paths with find -print0, so newline-bearing tar member names cannot spoof the old line-oriented listing checks.
  • Scanner hard-stops flush the CLI warn-dedup summary before process::exit, so rate-limited WARN totals are not lost when a selected backend aborts.
  • Windows installer HEAD probes share the same transient-retry helper as downloads; CI apt-get install steps retry on mirror blips.

Changed

  • Detector regex separator semantics now live in the owning TOML expression. The loader no longer rewrites bounded or exact authored classes into a global unbounded separator, and phase-two routing derives repeated-separator support from the parsed regex instead of a shared textual constant.
  • Detector-owned isolated entropy exceptions are now declarative TOML shapes with typed character sets, optional grouping, entropy and length floors, and explicit diversity requirements. The scanner uses one generic matcher, rejects ambiguous multi-shape owners, and no longer carries a hardcoded lower-dash app-password enum branch.
  • Scanner construction now builds a backend-neutral Hyperscan phase-one plan without compiling databases. Scalar and GPU-selected scans leave it untouched; explicit SIMD selection, calibration, and daemon readiness materialize it once and preserve exact initialization errors.
  • Bind the independent phase-two Hyperscan prefilter to the selected SIMD route. Scalar, GPU, normalized no-hit, windowed, and fragment-reassembly paths no longer borrow unmeasured Hyperscan work through a global tuning default.
  • Autoroute cache schema 42 records SIMD with the same cold-first and warm-trial model used for GPU, interleaves peer trials to distribute host drift, and persists a winner only when its 95% confidence interval is wholly faster than every route of every peer backend. Equivalent plans within one backend no longer masquerade as peer backends. Inconclusive results name every route’s median and 95% interval instead of hiding which peers overlap. One-shot selection includes Hyperscan materialization and persistent-daemon selection uses warm execution. Missing, stale, invalid, or quarantined normal-scan state now warns and completes every byte through reported scalar correctness recovery; daemon requests carry the same recovery receipt, while calibration candidates and explicit overrides remain hard execution contracts. Daemon handshake and status distinguish invalid startup state from persisted-route quarantine, and zero-byte requests remain no-ops instead of reporting fictional recovery.
  • Autoroute calibration now uses the always-present scalar engine as its independent correctness oracle. Optional Hyperscan and GPU candidates are rejected when their findings diverge, and decoded rescans remain attributed to the measured route instead of silently borrowing another CPU engine.
  • Scanner compilation now inventories GPU peer identity without eagerly creating execution devices or pipelines. Calibration, daemon warm-up, and explicit selection materialize only the peer they execute and surface exact initialization failures.
  • Entropy plausibility no longer owns hidden length or diversity thresholds in scanner code. Active entropy detectors now declare tail-check, distinct-byte, unanchored-hex, repeated-character, structured-dotted, and slash-base64 boundaries in their TOML, and the canonical detector digest binds every value. The universal sealed-secret ciphertext cutoff is now typed Tier-B data rather than a scanner literal. Generic credential context no longer implicitly lifts canonical 32-hex values without an exact detector-owned key-material rule.
  • Detector patterns can declare AST-proven required_literals beside their regex. KeyHog compiles those literals into every backend’s shared candidate plan and rejects optional or branch-incomplete declarations. The DeepL and URL credential detectors now own their three-byte routing infixes. All 56 previously inferred prefixless routes now live beside their detector regexes; the production compiler no longer invents non-prefix literals or hides their tuning in scanner code.
  • OpenSea, Omnisend, Moosend, Skyscanner, 8x8, and X2Y2 now define the accepted ASCII X-API-Key separator variants in their detector TOMLs. Reverse-order header forms use shared-anchor extraction instead of whole-window regex passes, while canonical preprocessing retains whitespace-evasion coverage.
  • Confirmed patterns rejected by Hyperscan now retain their detector-owned literals in a small recovery prefilter instead of being duplicated into the phase-2 regex set. Coalesced SIMD scans preserve exact scalar findings, and offline GPU artifact compilation no longer builds an irrelevant Hyperscan database.
  • Scanner and autoroute detector identity now derives once from the canonical validated scan-execution specification instead of only final regex sources. The digest covers detector-owned routing literals and complete offline validator programs, so those policy changes invalidate stale scan and calibration evidence while detector ordering and inline fixtures do not.
  • GPU scans now compose complete prefixless evidence with fused anchor absence for every eligible ASCII row, including rows with phase-one triggers. Proven rows bypass the redundant Hyperscan always-active prefilter while keyword, generic, entropy, ML, recovery, normalized, and incomplete paths retain their canonical behavior; normalization invalidates raw GPU evidence and recomputes phase-one admission before extraction.
  • Anchored phase-two scans now compile exact full, anchor-residual, and anchor-plus-plain-residual ownership sets. Hyperscan and portable RegexSet paths consume the same set, so neither rescans patterns already owned by an active localizer; disabling the localizer gate keeps those patterns in the residual instead of silently dropping them.
  • The proven homoglyph inert-variant skip now covers keyword-triggered and anchor-localized phase-two extraction as well as always-active prefiltering. ASCII source no longer runs duplicate whole-chunk homoglyph regexes, while non-ASCII scan text retains the complete variant path and normalized source remains covered by each variant’s base pattern.
  • A complete negative GPU prefixless-pattern receipt now suppresses the redundant folded plain-pattern anchor pass. Routes using that localizer no longer traverse the full input again after VYRE proves the family absent.
  • Isolated entropy admission now skips lines already owned by stronger findings, proves detector-owned short symbolic shapes before Shannon scoring, and rejects pronounceable digit-bearing source identifiers when the owning TOML enables identifier suppression. The generic isolated owner now explicitly admits 16-byte mixed symbolic credentials, including detector-owned minimum symbol count and underscore policy, while preserving its TOML-owned entropy floor and backend-identical findings.
  • No-hit routing now consumes the active corpus’s compiled generic-keyword stems and the keyword-free owner’s length plus effective Shannon floor. Focused custom detector corpora no longer inherit the embedded keyword vocabulary or a scanner-owned 32-byte entropy-run floor before their policy can execute. Large bounded scanner windows no longer disable anchor-free detection at an unrelated 32 KiB cutoff, and cheap keyword/run evidence now precedes line eligibility checks, which stream without allocating a line vector.
  • Automatic GPU runtime faults now cross a fallible scanner boundary: normal one-shot, fused, and daemon scans visibly replay the same stable batch through the scalar reference path and report recovered chunks and bytes. Calibration, explicit GPU overrides, --require-gpu, invalid policy, and artifact trust failures remain hard contracts, so recovery cannot become silent fallback or certify a broken accelerator.
  • Fused automatic scans now quarantine a GPU route after exact peer recovery, matching coalesced and daemon behavior. Failure to persist the durable health record is warning-visible without discarding already recovered scan output.
  • https://santh.dev/keyhog/install.sh and https://santh.dev/keyhog/install.ps1 are now the canonical installer URLs across the repository and product site. The santh.dev build copies the exact in-tree installer bytes and serves them with explicit script content types; signed, version-pinned release installation remains documented for operators who authenticate the installer before execution.
  • Offline token validation is now detector-owned data. GitHub, npm, PyPI, Slack, Stripe, GitLab, and their wrapper detectors declare typed validator programs, prefixes, lengths, and confidence floors in their own TOMLs. Scanner construction compiles direct per-detector dispatch plus a first-byte generic index; CRC comparison no longer allocates, base64 decoding reuses zeroed thread-local storage, and one verdict is carried through suppression, ML batching, and final confidence. The duplicate Rust prefix registry and service-specific validator modules have been removed.
  • ML score memoization now binds the complete resolved feature vocabulary as well as candidate text and context, preventing one scanner configuration from reusing another configuration’s confidence. Model diagnostics now expose the six-scanner differential status, including the current unavailable state, and GPU model documentation matches the shipped 55-feature network.
  • The scalar-only --no-default-features scanner now compiles and retains the isolated-bare candidate predicate; its wrappers no longer reference an implementation hidden behind acceleration feature gates. The scalar test target also no longer imports ML- or SimdSieve-only adjudication hooks.
  • Scanner symbols and test seams now follow the feature that owns their behavior. Minimal, ML-only, SIMD-only, GPU-peer, default, and all-feature library test builds no longer hide incomplete ownership behind unused-code warnings or blanket lint suppression.
  • Detector metadata, execution facts, canonical/decoded key-material rules, entropy floors/policy, ML policy, credential shape, suppression, weak-anchor state, and companions now share one detector-indexed compiled plan. Scan paths resolve a detector once instead of coordinating parallel vectors, and the superseded batch policy containers have been removed. Missing interned primary or entropy-fallback identity now fails scanner construction instead of silently allocating replacement metadata. Final match resolution now consumes that active plan as well, so reporting service = "generic" no longer turns an anchored regex detector into a generic fallback and custom corpora cannot inherit embedded private-key classification.
  • Detector class, minimum length/confidence, severity, structural-password-slot, keywords, and public-identifier marker policy now compile into cache-local execution records. Named, generic, and entropy emitters no longer read those fields from DetectorSpec; CompiledScanner drops the flexible detector schema after construction instead of retaining a duplicate runtime owner.
  • Canonical and transport-decoded hexadecimal key-material rules now compile from every active detector TOML into detector-indexed immutable programs. Named, generic, and entropy producers no longer walk detector schema vectors per candidate; the generic bridge also resolves ordinary and canonical owners with one normalized assignment-key lookup.
  • Detector-conditioned ML inputs now compile verifier, companion, service, generic, structural, phase-2, and entropy-family facts once with each loaded detector. Candidate feature extraction consumes that compact policy instead of traversing detector schema collections on every queued match.
  • Isolated-bare entropy convenience APIs now compile their base entropy, mixed, symbolic, and colon-shape policy from the embedded detector owner instead of retaining a second scanner-side copy or reading optional schema fields.
  • Keyword-context and keyword-free entropy APIs now compile their embedded detector policy through the same typed policy compiler used by production scanners. Candidate extraction and plausibility no longer re-read flexible detector specs or substitute scanner-owned thresholds, lengths, shapes, or canonical-hex rules when policy is absent. Exact detector-owned canonical-hex admission now outranks generic source-symbol and mixed-token heuristics and does not depend on ML authority.
  • Detector plausibility policy now distinguishes pure program identifiers from digit-bearing source-symbol identifiers, so each detector TOML owns whether that precision gate composes with its mixed-alphanumeric admission policy.
  • Generic assignment regexes, CPU stem prefilters, and fused VYRE positioned literals now compile from one active detector-corpus keyword plan. Custom detector keywords no longer rely on embedded literals or disappear when GPU phase-two evidence proves an unrelated lane absent.
  • SIMD/GPU coalesced scans now aggregate pending ML candidates across chunks before one CPU or GPU MoE submission, while returning finalized findings to their originating chunk caps and locations. CPU scoring also resolves the immutable model once per batch instead of once per candidate.
  • Entropy-owning detector TOMLs now own the isolated mixed-token entropy floor, symbolic and colon-component length floors, and slash-led base64 entropy floor. Scanner construction compiles those values once and the production entropy path consumes the compiled owner policy instead of scanner constants.
  • Generic fallback execution now compiles from the detector TOML’s typed kind = "phase2-generic"; the service field remains reporting taxonomy. Anchored Basic, Bearer, CLI-password, SQL-password, and URL-credential detectors therefore no longer inherit an unavailable entropy policy merely because their service is generic. The generic password bridge now declares its phase explicitly. Entropy-policy ownership, canonical keyword ownership, ML owner features, and final resolution use the same typed class; equal generic keyword claims now resolve by stable detector identity instead of corpus load order, and duplicate vendor-suffix fallback owners are rejected.
  • SaltStack and Alertmanager now emit only the secret-bearing password, GoTo Connect emits only the client secret, and Rapyd emits only the secret key. Their usernames, client IDs, and access keys are optional companion context; each public identifier alone produces no finding.
  • A successfully matched companion now remains positive evidence during ML admission, preventing required-companion detectors such as Twilio API keys from being demoted by the generic identifier-shape shortcut.
  • The 10,667-case detector adversarial corpus and its handwritten boundary suite now run as a Cargo test target instead of remaining an orphaned data file. Slack fixtures now use non-placeholder identifiers and exact declared segment boundaries.
  • Made weak-anchor detection policy explicit per detector pattern instead of inferring it from regex syntax, detector-ID families, or min_confidence.
  • Detector-local entropy floors are compiled into detector-indexed lookup programs for named, weak-anchor, and generic paths. Broad-capture detectors must declare their own high threshold and length buckets, regex entropy owners participate in generic assignment generation, and public-ID marker matching no longer allocates an uppercased source line per candidate.
  • Autoroute calibration now resets workload-shaped GPU resident state before each GPU candidate while retaining immutable program preparation costs, so candidate order cannot turn prior dispatch state into a false cold-cost win.
  • Autoroute caches now retain independent route generations for each exact config and host identity. Recalibrating one host preserves other hosts, and calibration readback proves the current host rather than accepting a shared cache row from another machine.
  • Autoroute calibration now measures extracted tar-member workloads across every default fused batch count. Filesystem dispatch separates safe family/provenance transitions while preserving same-path dependency closures.
  • GitHub collaboration scans now independently select issues, pull requests, discussions, wiki history, and owner public gists. REST and GraphQL requests share bounded rate-aware pagination, findings retain immutable revision provenance, pull request review summaries are included, and inaccessible or truncated surfaces emit typed coverage gaps.
  • Hosted Git clones now monitor materialized bytes and entries while git runs. Crossing the resolved Git limits stops and reaps the child, then emits typed truncated coverage instead of allowing an unbounded clone. The monitor does not follow symlinks outside the clone tree.
  • ZIP and tar TeX source packages now expose root, referenced, orphaned, and exact comment-span provenance while every readable member still follows the normal archive scan path. Dependency expansion is bounded, rejects archive traversal, and terminates cycles without hiding member findings.
  • APK scans now decode bounded resources.arsc value tables and compiled XML into resource-qualified virtual chunks while retaining the ordinary member scan. Malformed or capped semantic decoding emits a typed coverage gap.
  • keyhog diff now classifies before-only findings as verification_unknown instead of resolved. --artifacts --verify-removed scans both text versions in memory and reports removed_still_live, removed_inactive, or verification_unknown. New findings, live removals, and unknown removals exit 1. Reports and persisted baselines remain redacted.
  • GPU region-presence and phase-2 DFA batches now split only at existing chunk boundaries when they exceed the selected backend’s safe dispatch ceiling. WGPU also respects its 65,535-workgroup dimension limit. Shards retain the selected CUDA or WGPU backend, ordering, and multiplicity. Resident readback words are consumed through a bounded borrowed view, then zeroized while retaining the warmed allocation for the next dispatch. The 8 MiB crossover gate selects across every acquired CUDA and WGPU peer with rotating trials, then requires the selected peer’s held-out paired 95% ratio interval to beat Hyperscan.
  • Scan, watch, and scan-system now install the same resolved GPU policy, regex-DFA cap, GPU batch cap, profiling state, and compile tuning before any hardware probe or detector compilation. Watch applies explicit backend overrides before setup and validates backend readiness before announcing it is active. Persistent scans also honor config-selected detector corpora, while an explicit missing --detectors path fails instead of silently using embedded rules.
  • Verification response selectors now use one validated $-rooted grammar in detector TOMLs and runtime evaluation. Success checks and metadata extraction now agree on object keys, array indexes, and bounded parsing. Invalid selectors fail detector loading or verifier construction, and malformed JSON from an otherwise successful response is reported as a verification error. Programmatic users must migrate RFC 6901 /account/email selectors to $.account.email.
  • Make --deep a distinct bounded recovery preset. It enables entropy discovery in source files, keeps heuristic evidence alongside ML instead of allowing an ML-only entropy veto, removes comment confidence penalties, raises decode-through to one 1 MiB production chunk, and retains depth 10. The resolved fields are visible through keyhog config --effective and are part of autoroute config identity.
  • Decode-enabled scans now perform bounded, side-effect-free recovery of static JavaScript XOR and AES-256-CBC expressions whose byte arrays, keys, IVs, and ciphertext are embedded in the source. Exact CryptoJS passphrase wrappers recover OpenSSL Salted__ envelopes through EVP_BytesToKey MD5 and the same strict AES, padding, and UTF-8 path. Literal arrays, Base64-encoded JSON arrays, obfuscated binding names, dead code, and empty-join key/ciphertext fragments are supported; dynamic operands, inconsistent bindings, invalid padding, non-UTF-8 plaintext, and oversized inputs are rejected. Static XOR admission is shared by SIMD and portable CPU scans so backend choice cannot change recovery results. The official P0-P12 recovery benchmark now scores 4,368/4,368 exact recoveries with no false positives in full and deep modes; fast remains bounded to 1,344/4,368 by its no-decode contract.
  • JavaScript string arrays followed by an empty-separator .join("") now recover checksum-valid known-prefix credentials even when the temporary variable name is obfuscated. Non-empty separators and arrays without a known credential prefix remain excluded from this structural recovery path.
  • Entropy fallback now resolves length, entropy, canonical-shape, and BPE policy from the active detector corpus. Generic detectors declare overlap precedence with entropy_policy_priority, custom policy keywords join discovery without duplicate scan configuration, and synthetic isolated or keyword-free paths retain their exact detector owners across CPU, Hyperscan, and GPU scans.
  • The unified benchmark harness now includes an official deterministic secret recovery corpus adapted from the P0-P12 methodology in arXiv:2605.06910. Provenance now pins the authors’ public 13-example repository at commit 91d45377cf482c1de6c36a0d33744665976a19b6 and states that the paper’s 336-program evaluation corpus is not published there. Its 4,368 generated JavaScript fixtures cover plaintext, Base64, identifier, dead-code, structural, XOR, and AES-256-CBC variants; answer keys remain outside the scan tree, exact scoring rejects encoded or containing aliases, and one target compares full, fast, and deep through ordinary RunResult output.
  • --git-staged now reads the exact blob object IDs and bytes from Git’s index instead of reopening same-named working-tree files. NUL-delimited raw records preserve newline and non-UTF-8 Unix filenames, staged renames are scanned at their destination path, and .keyhogignore, explicit path exclusions, default exclusions, and source limits apply to the index source. Blob results stream into the scanner instead of retaining the aggregate Git byte budget in memory. Binary working-tree extraction must be requested in a separate scan rather than being silently mixed with staged-index semantics. Published pre-commit metadata now invokes the staged scan even for binary-only change sets, so unreadable staged blobs surface as coverage gaps instead of skipping the hook.
  • Installers, package metadata, badges, CI recipes, SARIF identity, update checks, and the documentation now use the repository’s canonical santhreal/keyhog owner instead of relying on the former-owner redirect. Security reports use GitHub Private Vulnerability Reporting first, with security@santh.dev as the no-PGP-required fallback. The docs header now presents the KeyHog wordmark without the adjacent keyhole icon.
  • Hosted Git mass scans can read GitHub, GitLab, and Bitbucket credentials from dedicated KEYHOG_* environment variables after the operator explicitly selects an organization, group, or workspace. Tokens no longer need to appear in process arguments, while ambient credentials alone still cannot create a scan target.
  • CI now dogfoods the shipped CLI across portable CPU, CI-profile CPU, default CPU, Hyperscan/SIMD, precision SARIF, JSON/JSONL, stdin, baselines, and real .keyhogignore exclusions and bounded decode-through. Shared behavioral harnesses validate exact findings, redaction, report schemas, exclusion boundaries, and dogfood coverage telemetry instead of treating a clean repository exit alone as product proof.
  • Autoroute cache ownership is split into decision policy, statistical timing, secret-safe parity identity, schema, build/artifact identity, bounded codec, validation, inspection, and locked persistence modules. Replacing an existing stale, incompatible, unreadable, or invalid cache now produces an unconditional stderr warning with the cache path and reason.
  • keyhog backend --autoroute --autoroute-cache PATH now inspects the exact non-default cache selected by a scan or [system].autoroute_cache instead of falsely reporting only the platform-default cache state.
  • Daemon wire v5 exposes the daemon-owned backend policy during every client handshake. daemon status now distinguishes persisted autoroute from a forced startup diagnostic backend, and malformed policy labels fail closed.
  • Current scan, daemon, reporter, and suppression contracts now require the canonical detector TOML id on accelerated paths instead of accepting the retired hot-* finding namespace. keyhog explain retains a finite, explain-only mapping so historical reports remain understandable.
  • Autoroute no longer treats overlapping timing confidence intervals as proof that backends are equally fast and then prefers a fixed backend rank. It now selects the lowest measured median among statistically non-dominated, parity-correct routes, using engagement overhead only for an exact median tie. Cache inspection exposes whether confidence was separated and the exact selection basis in text and JSON.
  • Autoroute inspection now renders distinct cold-aware one-shot and warm-daemon decisions with their own confidence basis and margins, rejects structurally invalid caches instead of omitting bad rows, and lives in a dedicated cache inspection module. Unix and PowerShell installer probes now admit every eligible GPU peer without changing the normal scan-config identity.
  • CUDA and WGPU are now independent measured autoroute peers with exact gpu-cuda and gpu-wgpu diagnostic overrides. The public ScanBackend::Gpu variant and --backend gpu alias are removed. Library callers must select GpuCuda or GpuWgpu, and scripts must use the matching exact CLI value. Autoroute cache schema v27 rejects older single-GPU evidence and requires recalibration instead of silently assigning it to a driver.
  • Generic pure-hex key handling is now detector-owned. Phase-2 detector TOMLs declare exact direct-assignment keyword/length pairs and exact transport-decoded hex widths; those declarations participate in detector validation, cache identity, ML/report adjudication, explain, and detector JSON. Structured decoding preserves transport provenance, so a direct cryptographic-key allowance cannot reclassify a base64-wrapped SHA digest. Encoded UUIDs, ARNs, hashes, license serials, and prose remain suppressed; generic UUID, salt, and nonce assignments remain identifiers unless a named detector or structural authorization envelope supplies stronger evidence.
  • --severity client-safe and [scan].severity = "client-safe" now select the real tier between info and low; CLI help, config validation, and the reference all expose the same six accepted levels. config --effective now prints the resolved format, severity, dedup, secret visibility, client-safe/test-fixture policy, and lockdown instead of omitting report policy from the claimed effective view.
  • Library and backend documentation now states the explicit-backend process contract: the infallible finding-vector APIs exit 3 for unavailable selected SIMD and 12 for unavailable or failed selected GPU execution, rather than returning findings from an unselected engine.
  • The documented .keyhogignore.toml literal_true = true escape hatch now works and is behavior-tested, while empty tables and literal_true = false alone remain rejected as accidental match-everything policy.
  • backend --self-test --require-gpu now fails with exit 4 and a visible gpu_adapter failure when no eligible physical GPU exists; ordinary no-GPU self-tests retain their explicit skip report and exit 0.
  • Autoroute build identity now includes the compiled GitLab and Bitbucket source backends, so persisted routing evidence cannot be reused by a binary with a different remote-source capability set.
  • Coalesced scans now flush at source boundaries, preventing an uncalibrated mixed-source workload key when a local, forge, web, cloud, or container source follows another source in the same command.
  • A GPU route that fails during dispatch now exits 12 instead of warning and completing through CPU/SIMD. This applies equally to explicit GPU selection and persisted autoroute decisions.
  • GPU health reporting now names the live production route gpu_region_presence instead of the retired vyre_ac_kernel label. The scanner library self-test is gpu_region_presence_self_test, and backend --self-test --json uses the same name for its production-path probe. Dispatch failures remain structured so the health command emits its complete report and exit 4; normal selected-GPU scans exit 12.
  • The VYRE direct match-triple self-test is now explicitly diagnostic. Its classified limitation reports known and other failures report warning; production GPU eligibility is owned by the gpu_region_presence probe, so a working scan route is no longer disabled by an unused direct-mode failure.
  • Daemon backend overrides are validated before readiness. Explicit GPU/SIMD requests fail instead of being relabeled when their engine is unavailable, while explicit CPU/SIMD daemons no longer require a healthy GPU warmup that their requests cannot use.
  • Every direct workspace dependency now resolves through an exact root pin, including scanner SIMD/tokenizer test dependencies, source archive support, and the optional CLI allocator. Package builds no longer rely on compatible version ranges that can move independently of Cargo.lock.
  • Unix and PowerShell installers now admit an implicit release only when the exact host binary, checksums, payload signatures, GPU-literal sidecar, and sidecar proofs are all present on a stable published release. Partial, draft, prerelease, and other-platform asset sets are skipped rather than selected from an “any asset exists” heuristic. The manual integration smoke now follows latest stable by default instead of pinning an old version. The POSIX resolver accepts both compact GitHub API JSON and pretty-printed test or proxy responses. Unix --yes now honors each displayed wizard default, matching PowerShell: PATH setup is accepted while completion and repository-hook setup remain off.
  • Release workflow reruns now return an already-published release to draft before deleting or replacing assets, then republish only after the exact signed manifest is visible. Consumers can no longer observe a transient partial or mixed-version asset set during a rerun.
  • The canonical CLI reference now covers every live scan --help flag and the daemon-owned startup controls. Documentation CI also tests mdBook code-fence semantics before building the site, catching accidentally executable diagrams and malformed example blocks.
  • Scan execution policy in .keyhog.toml now has one canonical owner: the [scan] table. Retired flat spellings such as format, severity, min_confidence, decode_depth, entropy thresholds, worker sizing, dedup, incremental-cache controls, exclude_paths, and the GPU batch-input limit are rejected as unknown instead of retained as compatibility aliases. Move those keys under [scan]; rename exclude_paths to [scan].exclude.
  • Multi-root positional parsing now uses one visible variadic PATH vector; generated help reports [PATH]... and the hidden EXTRA_PATH compatibility carrier is gone. Mixing stdin shorthand - with filesystem roots now fails explicitly instead of producing a split-source request. Library consumers that read ScanArgs::input directly should use ScanArgs::scan_roots(); input now stores the complete ordered positional vector.
  • CLI help and reference documentation now identify --timeout as the five-second-default per-request verification timeout, not a whole-scan deadline, and point scanner deadlines to --per-chunk-timeout-ms.
  • The CLI reference now documents that --proxy and --insecure apply to all outbound HTTP clients, including remote sources and verification, rather than incorrectly describing them as verifier-only controls.
  • Verification concurrency now has one unambiguous spelling: --verify-concurrency / verify_concurrency. The confusing --rate / rate spellings are rejected rather than retained as aliases; migrate scripts and TOML to the canonical name. Zero now fails closed instead of being silently clamped to one by the verifier. --verify-rate remains the requests/second control.
  • config --effective now reports the resolved verifier timeout, concurrency, request rate, TLS, OOB, and proxy policy. Proxy URLs are reduced to unset/off/configured so embedded credentials are never printed.
  • HTTP-only feature builds no longer compile entropy-only testing façades; the public test surface now follows the same SIMD/GPU/entropy feature boundary as the implementation it exposes.
  • CLI-only verifier timeout, concurrency, and request-rate knobs now require --verify so a mistyped command cannot accept them as silent no-ops. TOML may still store defaults consumed by runs that explicitly enable verification.
  • Documentation and CLI help now distinguish the foreground watch process from the independently started Unix-socket daemon and describe --backend auto as persisted routing rather than a forced backend. The scan reference now states the explicit-versus-absent --daemon=auto platform semantics.
  • Daemon wire-v3 scan results now require suppression, dogfood, and coverage fields instead of silently defaulting fields inherited from rejected v1/v2 peers; malformed same-version frames fail closed.
  • Windows now rejects explicit scan --daemon=auto|on instead of silently replacing the requested daemon-capable policy with in-process execution; an absent flag and portable --daemon=off continue to run in process.
  • Corrected install guidance to distinguish host-specific release artifacts, removed a stale Claude/Cursor hook claim, documented PowerShell flag parity, and made manual installs use the exact signed binary plus GPU-sidecar bundle with the same pinned minisign trust root as both installers and self-update.
  • Extended the canonical documentation truth gate to reject broken relative targets and mdBook anchors, and repaired five navigation links the normal book build had accepted despite pointing nowhere.
  • Corrected backend inspection UX so the diagnostic hardware heuristic matrix is never presented as the proof-backed scan --backend auto decision, and aligned the CLI reference with actual root options, detector maintenance, Elvish completion, fast-mode behavior, and finalized report semantics.
  • Added detector-owned max_len for phase2-generic TOMLs, with schema validation, detector-spec cache identity, named suppression telemetry, and boundary-tested whole-value rejection. Shipped API-key/secret/passphrase bridges now own their distinct ceilings in their detector files.
  • Refined autoroute byte, chunk-count, and maximum-file classification from paired powers of two to one power-of-two band per key, bumped the cache schema through v24 to prevent old numeric-key aliasing, remove duplicated timing summaries in favor of primary trial vectors, and bind evidence to the exact running executable digest; expanded the Rust, Unix, and PowerShell calibration ladders across every byte band from 1 B through 32 MiB and every default-batch chunk-count band. The measured 8 MiB GPU/Hyperscan crossover now has its own exact band.
  • Bumped the daemon wire handshake to v3 and bound scan connections to package, Git build, and canonical detector-rules identity. Same-version daemons started with another detector corpus now fail closed; diagnostic status/stop remain available and status prints the exact mismatch.
  • Serialized autoroute cache read/merge/write cycles with the shared state-file lock primitive, preventing concurrent calibration processes from silently losing one another’s config or workload decisions. Autoroute, the Merkle index, and transactional GPU-artifact maintenance now use that same single lock implementation.
  • Hardened self-update and repair release resolution with strict SemVer precedence, stable-only implicit selection, complete per-host signed-bundle admission, bounded streaming downloads, and explicit connection/request deadlines. Draft releases are never installable; exact published prerelease tags remain available through --version. The Rust maintenance path now resolves each proof file from exact release metadata, rejects duplicate asset names, and verifies both payload SHA-256 entries after minisign. update and repair now validate and transactionally seed the signed GPU-literal sidecar through the scanner-owned cache path, rolling matcher changes back with the binary when the candidate health/version gate fails.
  • Consolidated user and contributor documentation into one canonical mdBook under docs/src/. Removed the duplicate hand-maintained HTML site, moved the architecture and integration references into the book, made orphan/duplicate documentation a source gate, and corrected Action, daemon, hook, autoroute, performance, and installation claims against shipped behavior.
  • Made new GitHub Releases atomic: platform jobs stage unsigned bundles as private workflow artifacts, the signing job validates and signs one exact manifest, and only then publishes the draft. Manual dispatch now proves and checks out refs/tags/<version> so a same-named branch cannot supply release bytes.
  • Made GPU an ordinary peer in canonical autoroute calibration and removed the calibration-only GPU switch from persisted scan identity. Fresh GitHub Action scans now calibrate before using auto; independent daemon/watch operations clear and isolate fragment-reassembly state.
  • Hardened release publication around exact semantic-version tags, pinned and locked builds, staged-binary version proof, an exact signed asset manifest, and newest-stable-only promotion of container latest and floating major tags. Release-tag Action inputs now normalize one optional v prefix.
  • Made the portable pre-commit command use the always-available CPU backend, recorded config-selected detector corpus provenance, and corrected portable, Docker, crates.io, VYRE, and Windows Action documentation.
  • Replaced the stale VYRE audit/roadmap with one canonical integration reference that documents only the shipped v0.6.4 boundaries, parity contract, build features, diagnostics, and autoroute ownership. Cross-platform uninstall semantics now live with installation and exit-code documentation instead of an expired host-status snapshot.
  • Unified Linux packaging around one keyhog-linux-x86_64 artifact. The default GPU feature already contains dynamically loaded VYRE CUDA and WGPU drivers, so CUDA/WGPU eligibility now belongs solely to runtime self-test and persisted autoroute evidence rather than a build-time toolkit heuristic.
  • Consolidated keyhog-core and keyhog-sources root exports behind their curated API modules, separated generic phase-2 regex construction from scan execution, and removed dead convenience wrappers and warning allowances. The organization gate now passes its root-layout, re-export, responsibility, and shipped-code utilization contracts without relaxing their thresholds.
  • Moved OOB verification and .keyhogignore.toml into the canonical mdBook, documented the [http] policy and missing scan/maintenance flags, corrected suppression and daemon-status semantics, and replaced copied detector counts with commands that query the installed corpus.

Removed

  • Removed the public-tree internal backlog and VYRE execution plan, plus one-off detector/contract mutation scripts that guessed verification endpoints, rewrote fixtures from current output, or depended on developer /tmp files. Maintained generation remains under tools/; release and organization entrypoints remain under scripts/. Absence gates now reject reintroduction of public BACKLOG.md or planning/vyre-acceleration state.
  • Removed the duplicate ScanBackend::MegaScan identity and its deprecated megascan_input_len* Rust/CLI/TOML aliases. The three real engines are now represented exactly once: GPU region presence, Hyperscan SIMD, and portable CPU. Persisted autoroute evidence can no longer mint two labels for the same GPU execution path.
  • Removed the --no-daemon compatibility flag. --daemon=auto|on|off is the single daemon policy across CLI help, release scripts, diagnostics, tests, and documentation. --daemon=off combined with --daemon-socket now fails visibly instead of ignoring the socket.
  • Removed the duplicate keyhog-linux-x86_64-cuda release job, cuda Cargo feature alias, installer --variant=cpu|cuda surface, update/repair variant resolver, and CUDA-asset fallback ladder. Those paths built the same feature graph under different names and incorrectly required a developer toolkit for a runtime-dynamically-loaded backend.

Added

  • cargo binstall keyhog. [package.metadata.binstall] maps the four prebuilt targets to their signed release binaries and verifies each against the release minisign key before install, failing closed on a missing or invalid .minisig (no unsigned fallback). Targets without a prebuilt asset fall back to a source build.
  • Marketplace-ready root action.yml. The composite Action is published at the repository root as an exact mirror of .github/actions/keyhog, so it can be pinned as santhreal/keyhog@v0 from the GitHub Actions Marketplace. A parity test keeps the root and the canonical inner copy from drifting.
  • Recipes cookbook. docs/src/recipes.md indexes 18 real workflows by goal (scan locally, gate a PR, sweep an org, audit a bucket, emit SARIF) as copy-paste commands, alongside a one-command mass-scan front door and an install-and-scan hero in the README.
  • keyhog scan --quiet and --no-color. --quiet suppresses the banner, progress, and summary vanity while keeping findings and errors (the flag CI logs want without --format json); --no-color disables ANSI styling even on a TTY and is honored by every output path (progress, findings, reports), equivalent to setting NO_COLOR. Both are first-class documented flags on scan; --quiet conflicts with --progress.
  • keyhog calibrate validates detector ids. An empty/whitespace id is rejected before any counter is written, and an id that matches no embedded detector gets a loud warning (custom-detector ids still record); a typo’d --tp strpe-secret-key previously seeded a counter no detector would ever read, silently.
  • Confidence-calibration reference page. docs/src/reference/confidence-calibration.md documents the Bayesian Beta(α,β) scoring subsystem (opt-in, deterministic, fail-closed cache), and both it and the autoroute-calibration page now carry disambiguation banners: the two “calibration” subsystems are unrelated and the docs now say so in both directions.

Changed

  • Release dependencies clear all fixable RustSec advisories. quick-xml moves to 0.41.0, crossbeam-epoch to 0.9.20, and anyhow to 1.0.103. The remaining accepted advisories are pinned, usage-audited, and documented in SECURITY.md; the release audit wrapper exits clean.

  • Generic phase-2 length policy is detector-owned. The three shapeless generic detector TOMLs now declare their historical eight-byte assignment floor explicitly; the engine consumes that compiled policy instead of making the shipped value discoverable only as a Rust literal.

  • GPU buffer sizing no longer carries retired MegaScan terminology. The canonical CLI/config/API names are --gpu-batch-input-limit, gpu_batch_input_limit, and gpu_batch_input_limit(). The previous CLI, TOML, and Rust API spellings remain explicit deprecated migration aliases.

  • Library defaults are deterministic; CLI routing stays measured. The no-backend CompiledScanner::scan and scan_coalesced APIs now use the portable CPU reference instead of a host-size heuristic. Accelerated library execution is explicit, while CLI auto remains an exact persisted fastest-correct lookup. Cross-chunk reassembly no longer makes an independent backend choice, and the startup banner reports policy until a real workload decision exists.

  • Severity labels render identically everywhere. Scan findings, --stream previews, and watch-mode events all render severity through the one canonical Severity::as_str() (uppercased at the display edge), fixing the --stream drift where ClientSafe printed via Debug casing. The Bayesian posterior-mean/observation math is likewise now a single public BetaCounters API in keyhog-core instead of three private copies.

  • keyhog backend labels its routing matrix as heuristic. The decision-matrix table now states in the output itself that it is a fixed hardware-heuristic reference; a real scan --backend auto routes from the persisted autoroute calibration cache (keyhog backend --autoroute), never from that table.

  • Autoroute requires exact workload evidence. Normal auto scans no longer interpolate between agreeing CPU buckets or clamp below the measured floor. The core calibration ladder now represents every stable plain-file size bucket from 1 byte through 32 MiB and every default-batch chunk-count bucket across all four scan policies; any other missing workload key fails closed with recalibration guidance.

  • Moved path-filter lists to TOML. Inline suppression lists NEEDLES and VENDORED_JS_PREFIXES in crates/scanner/src/suppression/path_filter.rs are moved to a Tier-B data file rules/path-filter-lists.toml using LazyLock loading.

  • Moved ML feature markers to TOML. Inline marker lists COMMENT_PREFIXES, BINARY_MARKERS, CI_MARKERS, INFRA_MARKERS, SOURCE_MARKERS, SOURCE_EXTENSIONS, and CONFIG_MARKERS in crates/scanner/src/ml_scorer/ml_features.rs are moved to a Tier-B data file rules/ml-feature-markers.toml using LazyLock loading.

Removed

  • Duplicate backend aliases and the retired MegaScan CLI route. --backend now presents four choices: auto, gpu, simd, and cpu. MegaScan, engine-implementation, and historical zero-copy spellings are rejected instead of silently selecting one of those same engines under another name. Profiles and evidence retain their descriptive stable labels. The public ScanBackend::MegaScan variant remains as a source-compatible library migration boundary and still executes the GPU region-presence route when supplied programmatically.
  • The no-op kubernetes-secret detector shim. Kubernetes Secret.data values continue through the structured decoder and are attributed to the detector that recognizes the decoded credential. The retired detector only matched an internal NEVER__MATCH__K8S_DISABLED__SENTINEL, so it could never report a real Kubernetes secret; its synthetic contracts and catalog entry are removed with it. This changes the embedded corpus from 923 to 922 real detectors without changing recall on production inputs.
  • The keyhog tui live-scan dashboard. The interactive TUI subcommand (the tui module, Tui/TuiArgs, the tui Cargo feature, and the ratatui / crossterm dependencies) is removed in full. It was an interactive frontend over the in-process scanner that duplicated keyhog scan’s detection path while carrying its own render/worker code, a terminal dep closure, and a PTY-driven dogfood lane: surface that never paid for its maintenance cost. Headless scanning (keyhog scan, keyhog watch, keyhog daemon) is the supported interactive/automatable path and is unaffected. The synthetic demo/ tree and demo.tape recording now drive keyhog scan demo.

Fixed

  • Strongly anchored printable base64 values such as K8S_FULL_SECRET=... now survive generic entropy/BPE gates, and an ML-pending named candidate can no longer suppress the generic fallback before its own verdict is known.

  • The Azure subscription-key detector now accepts its documented azure_subscription_key environment spelling through detector-owned TOML.

  • Every top-level scanner, core, and verifier regression target is wired into the aggregate CI suites; the release gate now reports zero orphan tests.

  • keyhog-core now packages its decoder-alias Tier-B rule inside the crate, so the published tarball compiles independently of the workspace root.

  • Compressed decode failures and non-UTF-8 inflate output now emit bounded, secret-free warnings while preserving the original encoded scan input.

  • Embedded detector/rule loaders, GPU artifact header parsing, terminal flushes, and warning-dedup poison recovery now take explicit fail-closed or visible error paths instead of relying on silent-discard idioms.

  • Release regression gates now distinguish public resource identifiers from credential categories and resolve the cross-device driver independently of the caller’s working directory.

  • The coalesced SIMD determinism gate now mirrors autoroute’s seven evidence trials over a bounded, concurrency-saturating corpus instead of monopolizing the shared build target with forty full-corpus passes.

  • Benchmark matrices no longer manufacture the retired megascan backend as a duplicate GPU lane, and generated performance tables no longer advertise that rejected command spelling.

  • Full-corpus GPU parity failures can no longer be mislabeled as hardware skips: the release gate preflights the production GPU kernels, gives the 1 GiB corpus a realistic finite watchdog, and treats timeouts, runtime failures, empty results, and any detector/value/location/confidence divergence as failures. The scan engine also removes a duplicate no-hit reassembly side channel that glued unrelated complete findings from nearby lines into fabricated credentials on only some backend paths; fragment reassembly remains owned by the canonical assignment parser. Public confidence is canonicalized at three decimal places so equivalent CPU-f64 and GPU-f32 model accumulation produces identical policy decisions and JSON. Structured decode-through findings now map to the encoded source value column, generated JavaScript interpolation prefixes stay source syntax, and the published Azurite emulator key is excluded in its Azure detector TOML; these close the remaining concrete CredData parity cases.

  • Generic detector ownership is coherent across backends. generic-password now owns password/passwd/pwd assignments only; API-key, token, secret, access-key, and client-secret fields stay with their detector-local phase-2 TOMLs instead of being relabeled as passwords when the GPU trigger set was a strict superset. The detector-owned 20-byte broad keyword-free minimum also retains narrow 16-19-byte exceptions for shape-proven symbolic credentials and four-group app passwords, with positive and negative no-hit coverage. Carbon Black’s vendor-specific anchors now admit its documented 20-32-byte hex key family while the detector TOML explicitly excludes all-zero masks.

  • Prerelease benchmarks now prove the candidate artifact. The gate builds and pins the current binary before scanner-backed pytest, and benchmark freshness validates the exact Git commit and embedded detector-set digest in addition to semver. Executable aspirational recall targets use an explicit target_spec lane instead of making the green regression suite permanently fail by construction. CredData release gates also share one candidate SIMD scan instead of independently rescanning the full corpus.

  • Keep benchmark --min-confidence arguments in concise round-trippable float form and remove obsolete direct-Command imports after Git spawning was centralized behind the guarded process boundary.

  • Stop successful GPU scans from ending with a misleading repeat-warning summary for wgpu/Vulkan events that the default log filter never displayed.

  • Autoroute host and cache identity now query GPU/SIMD compile support from the scanner dependency that owns those feature gates. Workspace feature unification could previously compile a GPU-capable scanner under a CLI build whose local gpu feature was false, allowing GPU calibration evidence to omit the GPU device/runtime/driver identity and survive a hardware change. Such caches now carry the actual backend feature set and invalidate correctly.

  • The end-of-scan completion summary now pluralizes correctly: a single finding reads “Found 1 secret in …”, not the ungrammatical “Found 1 secrets” (the stdout Results footer already pluralized; the stderr summary did not). Singular/plural nouns now come from one shared secret_noun/finding_noun owner, so the completion summary and all three progress tickers agree.

  • The human-report confidence line can no longer render a percentage above 100% or a NaN%. The bar fill was clamped but the percentage was not, so a finding carrying an out-of-range or NaN confidence (reachable through the public VerifiedFinding field) could show a full bar labelled “150%”, or a garbage percent. The bar and percent now derive from one sanitized value (clamped to [0,1], NaN treated as 0, matching the scanner’s finalize_confidence).

  • The scan progress ticker no longer flashes “>100%” or an over-total ratio (for example “1001/1000”) when the scanned-chunk and total-chunk counters are read a moment apart; the displayed count is clamped to the total while the underlying rate still uses the true value.

  • keyhog doctor’s “on PATH” check no longer reports a false “no” when the install directory appears in PATH with a trailing slash, as a symlink, or in a non-canonical form; both sides are canonicalized before comparison, matching the shadow check and the installer.

  • NO_COLOR now follows the no-color.org contract exactly: an empty NO_COLOR= no longer disables color (only a present, non-empty value does), so a wrapper that clears the variable by emptying it keeps color on a terminal.

  • Network sources (--github-org, --url, --s3-bucket, Slack) no longer abort the process (SIGABRT, “Cannot drop a runtime in a context where blocking is not allowed”) when their request fails. The CLI runs under #[tokio::main], and these sources use reqwest::blocking, whose internal runtime panics if dropped inside an async context. Each source now runs its (already eager) collection on a scoped std::thread with no ambient tokio runtime, so the blocking client builds, fetches, and drops safely; a fetch failure (bad token, unreachable endpoint) surfaces as a normal error the orchestrator turns into a non-zero exit instead of a crash. --github-org with an invalid token now exits 2 cleanly.

  • A requested scan source that fails entirely (produces zero chunks and errors, e.g. --git-history / --git-diff on a non-repository or bad ref, --github-org with a bad token, an unreachable --url) no longer prints “No secrets found. Your code is clean.” and exits 0. A failed scan reporting clean + success told CI gates the tree was clean when nothing was actually scanned (KH-GAP-096). It now fails closed (exit 2) with a diagnostic, tracked per source so it fires even when a co-requested filesystem source scanned cleanly. A partial failure (some files unreadable in a tree that still produced chunks) is unaffected: that source produced data, so the scan reports what it read.

Robustness / Performance

  • keyhog scan --stdin now lossy-decodes its input (matching the filesystem source) instead of rejecting non-UTF-8 bytes. cat binaryfile | keyhog scan --stdin previously errored (and, under the new fail-closed, exited 2) while keyhog scan binaryfile happily lossy-scanned the same bytes. stdin now scans the text it can extract (real secrets live in otherwise-binary inputs); the size cap still bounds memory.
  • Byte-cap the per-match context windows (ML context 8 KiB, false-positive context 2 KiB). A line with no newline for kilobytes (minified bundles, or a file that is one long run of credential-shaped tokens) previously made each candidate’s context O(line length), turning a many-match scan quadratic. Behavior-preserving for ordinary source (a short line hits its newline before the cap, mirror-corpus findings byte-identical) and faster on real minified-bundle scans.

0.5.39 - 2026-06-04

Added

  • Square (payments platform) access-token detector (sq0atp- personal access tokens, sq0csp- OAuth application secrets). keyhog previously shipped only a Squarespace detector, which had even mislabelled sq0atp/sq0csp (Square, not Squarespace) in its keyword list. Surfaced by a differential against the mirror corpus; the EAAA… OAuth-access shape is deliberately omitted (4-char prefix + base64url collides with ordinary data, costing precision). Detector count 899 → 900; precision held at 0.9953 with recall +0.0007 (F1 0.9164 → 0.9167) on the mirror corpus.

Performance

  • Use mimalloc as the CLI binary’s global allocator (default/portable/full profiles; drop with --no-default-features). The scan hot path runs one Rayon worker per core, each allocating regex DFA-cache scratch and per-match strings; glibc’s arena lock serialised those allocations. Measured on a 70 MiB / 13,976-file corpus (RTX 5090 host, 32 cores): single-thread scan 10.0 s → 8.0 s (~20%), with no regression at high thread counts. Libraries stay allocator-agnostic; the binary owns the choice. (The remaining multi-core ceiling is the regex crate’s shared Pool<Cache> mutex, not the allocator: 16-thread scaling sits at ~41% efficiency, a separate optimization.)

0.5.38 - 2026-06-04

Fixed

  • Absolute line numbers for windowed and patch-based scans. Findings in files past the 1 MiB window size (filesystem/windowed), and findings from --git-diff / --git-history, reported the per-window / per-hunk line instead of the absolute file line: a secret on line 584307 of a 70 MiB file was reported at line ~2, and every diff/history finding landed on line 1. Root cause: byte offsets were made absolute (+ base_offset) but line numbers had no equivalent base. Added ChunkMetadata::base_line, populated per-window by the filesystem source and per-hunk by the git diff/history sources (now -U0, base_line = new_start - 1 via shared git::parse_hunk_new_start), and applied at every line emit site. All output formats (text/json/jsonl/sarif/csv/html/junit) and source backends now report the correct line. Regressioned across the cli, scanner, and sources suites.

Performance

  • Window the decode-splice context to ±512 B around each decoded blob instead of copying the entire parent chunk per candidate. A candidate-dense source file (every quoted string / key=value / hex-or-base64 run is a candidate) previously spawned one parent-sized decoded chunk per candidate, each rescanned and recursively re-decoded, an O(candidates × file_size) blowup that pinned a single 156 KB Linux driver at ~15 s. Full Linux-kernel scan (94,825 files) drops from ~85 s to ~7 s; the worst single file from ~15 s to ~0.2 s; decode-through recall unchanged.
  • Bound the GPU AC prefilter’s per-shard readback and reroute dense literal-prefix batches through the SIMD coalesced scanner before CPU phase 2 explodes. Forced-GPU CredData now completes in ~5.0 s instead of timing out at 45 s / 5.1 GB RSS, with byte-stable detector/hash/file/offset parity against the current SIMD run.
  • Reuse the batch ML feature vectors for small-batch CPU fallback instead of recomputing text/context features after the GPU crossover gate declines the batch. This removes a redundant feature-extraction pass on scanner chunks that emit fewer than 64 ML candidates while keeping scalar MoE scores byte-identical.
  • Route CPU/SIMD filesystem scans through the fused read+scan pipeline so source walking and coalesced scanning overlap across the Rayon pool. --batch-pipeline or [system].batch_pipeline = true remains available for A/B verification against the coalesced batch path; CredData SIMD --daemon=off keeps byte-identical 2,263-finding JSON output and drops from 5.14 s to 3.57 s on the measured RTX 5090 host.
  • Keep default/auto filesystem scans eligible for the fused read+scan pipeline on GPU hosts unless --backend gpu/--backend megascan is explicitly forced. CredData-shaped many-file scans no longer pay the single scanner-thread batch path when auto batch routing would pick SIMD for the 1 MiB filesystem windows anyway.
  • Bound fused filesystem prefetch depth to the Rayon worker count instead of a fixed 256 batches. CredData SIMD direct scans keep the same 5,752 raw findings while dropping from 4.75 s / 2.55 GB RSS to about 4.03 s / 1.84 GB RSS on the measured host; the benchmark adapter row stays detection-identical at 2,577 normalized findings.
  • Make the JSON escape decoder borrow only escaped string spans instead of allocating every plain JSON key/value before discarding it. Escaped JSON recall stays covered by the splice contract, unescaped JSON emits no redundant /json layer, and the CredData benchmark row remains detection-identical while trimming allocator work on large JSON/NDJSON fixtures.
  • Align generic-assignment chunk and line prefilters with the actual assignment-key grammar instead of broad api/auth/private substrings. CredData keeps the same true positives with three fewer false positives, while the mirror benchmark gains seven true positives with no added false positives.
  • Remove the per-candidate ASCII lowercase allocation from ML file-type feature extraction by using the shared byte-level case-insensitive matcher for static context markers.
  • Skip eager CUDA/wgpu acquisition when the CLI route is explicitly CPU/SIMD or when default/auto filesystem scans will run through the fused CPU/SIMD pipeline. Explicit --backend gpu/--backend megascan still forces GPU initialization.
  • Remove an unconditional 16-match vector reserve from the no-Hyperscan-hit fallback path; chunks that pass fallback plausibility gates but produce no matches now stay allocation-free until reassembly has real work.
  • Increase fused filesystem coalesced batches from 16 to 32 chunks after same-host CredData measurement showed better nested phase amortization without the RSS regression seen at 64 chunks.
  • Warm runtime regexes used by generic-assignment fallback, multiline reassembly, shared assignment parsing, and Slack checksum validation during the existing scanner warm-up instead of compiling them inside scan workers on the first matching batch.
  • Gate no-Hyperscan-hit bare-entropy admission on the same path/config policy as the entropy fallback, avoiding source-file prepare/fallback work when entropy_in_source_files=false while preserving bare entropy recall in config/secret files.

Detection

  • Suppress TypeScript non-null source identifiers like privateAccessToken! only when the trailing bang follows a credential-named camelCase identifier with no digits. Real password bodies ending in ! such as Snowflake/Sourcetree fixtures remain reportable.
  • Broaden the SIMD/no-HS-hit entropy-run admission gate to treat base64/base64url separators (-, _, +, /, =) as part of the same token, restoring recall for separators-only secret forms in generic-high-entropy-string corpus paths without opening new broadening routes.
  • Fix telemetry dogfood assertions and related redaction tests to match canonical keyhog_core::redact output shape (prefix...suffix) rather than legacy fixed-prefix assumptions.
  • Route the generic-secret and entropy-api-key fallback emit paths through the canonical post-ML penalty pipeline (apply_post_ml_penalties) before the checksum floor, so the uniform-base64 / encoded-binary blob suppression that the named/ML path already applies finally applies on the fallback paths too. Mirror precision recovers to P=0.9945 / F1=0.9131 (false positives 651→14); the round1 base64-with-internal-punctuation recall contract stays green because the penalty still surfaces at min_confidence=0.0 while the bench’s 0.40 floor suppresses the blobs.
  • Widen drata-api-token to capture 64-or-more hex characters ({64}{64,}), matching the detector’s own “64+ hex” spec. A real 89-hex Drata token previously surfaced no clean match because the fixed-64 capture left trailing hex outside a token boundary.
  • Anchor the klaviyo-api-key bare pk_/sk_ patterns with a leading \b word boundary so they no longer fire on a pk_/sk_ substring sitting inside a longer identifier (e.g. the sk_ inside Pinecone’s pcsk_ keys). Klaviyo recall for real boundary-delimited keys is unchanged; the spurious cross-detector match that shadowed pinecone-api-key is gone.

Coherence

  • Reconcile the advertised detector/pattern counts to the binary’s actual embedded corpus (899 detectors, 1675 patterns) across README, docs, banner, contract fixtures, and the compiled count gates. The canonical source of truth is keyhog detectors / keyhog doctor.
  • Normalize 484 per-rule contract fixtures whose readme_claim still pinned the stale "889 service-specific detectors" string to the current 899, so the contracts_runner::every_contract_readme_claim_present gate (which requires each claim to appear verbatim in the README) is green again. The generator already pins 899; these were un-regenerated stragglers.
  • Update the Docker integration detectors-count scenario (tests/docker/scenarios.sh) from the stale Loaded 894 detectors to Loaded 899, matching the embedded corpus the binary reports.
  • Document the macOS GPU caveat: the shipped macOS binary is built --features portable (no GPU) and is unaffected, but an explicit --features gpu build on Apple Silicon hit a fatal wgpu abort because the Metal backend advertises PIPELINE_CACHE yet rejects pipeline-cache creation. The vendored vyre wgpu driver now only requests PIPELINE_CACHE on backends that implement it (Vulkan/DX12); the fix lands in keyhog when the vendored vyre is published/re-pinned.
  • Make dedup primary/additional location selection deterministic when overlapping filesystem windows report the same credential at the same byte offset with different line metadata.
  • Make the hw_probe GPU-routing unit tests host-independent. Six assertions drove select_backend() with synthetic HardwareCaps { gpu_available: true, .. } and expected ScanBackend::Gpu, but select_backend first short-circuits through the runtime gpu::env_no_gpu() probe (true on a GPU-less host), so they were green on a GPU dev box and red on a GPU-less CI runner. They now assert the side-effect-free gpu_could_engage() crossover predicate (newly re-exported from hw_probe), which depends only on the passed caps. KEYHOG_NO_GPU=1 reproduces the CI routing locally.
  • De-flake contracts_runner::every_contract_perf_budget_holds. A single wall-clock sample on a shared CI runner occasionally tripped the 15 ms per-detector budget by 1-3% (azure-blob-sas-token, jwt-token) while steady-state sat well under. The budget now measures best-of-N (re-measuring only an over-budget contract and keeping the minimum) so a catastrophically slow regex still blows every pass while a one-off scheduler stall is discarded; contracts already under budget still pay for a single scan.
  • Reconcile GAP_FINDINGS.toml with the findings_registry_integrity gate. Fourteen findings pointed their test path into the gitignored coordination/ tree (absent in a clean checkout), so the registry gate failed in CI on the first one. Promote the three that hold against the committed repo (KH-GAP-076/077/179) into crates/scanner/tests/gap/ and repoint them; de-scope the eleven open or design-conflicting ci-operability findings whose claims contradict the deliberate CI design (e.g. the 4-runner PR strict subset) or depend on uncommitted coordination infra (registry 162 → 151 findings).

Install / packaging

  • install.sh --from-file=PATH (and KEYHOG_FROM_FILE): install a pre-built or pre-downloaded keyhog binary instead of fetching a release, for offline/air-gapped installs and for CI to prove a freshly-built binary. Reuses the full install machine (backup, atomic same-dir swap, verify_install/keyhog doctor, rollback) and verifies a sibling PATH.sha256 if present; install.ps1 -FromFile is the Windows equivalent.
  • Harden release downloads against transient CDN drops. A connection dropped mid-transfer (“The connection was closed unexpectedly”) was failing the Windows (and intermittently the Linux) install-from-scratch smoke even though the asset was present and correctly named. install.sh curl now passes --retry 5 --retry-delay 2 --retry-connrefused; install.ps1’s Invoke-WebRequest retries up to 5 times with linear backoff.
  • Normalise a bare-semver --version / -Version to the v-prefixed release tag. keyhog tags are all vX.Y.Z, so --version=0.5.37 built a download URL against a non-existent 0.5.37 tag and 404’d; the retry above (which surfaced the repeated 404 instead of one ambiguous “connection closed”) exposed it on the Windows smoke. Both installers now prepend v to a digit-leading version and leave an explicit v…, branch, or sha untouched. Covered by edge_cases.sh 2.9/2.10 and the corrected 14.2 (bare 2.0.0 → tag v2.0.0).
  • Add tests/install/install_from_local_build.sh and wire it into the macOS Build and Build Release CI jobs: prove current-source → install (via --from-file) → working binary on every push: keyhog doctor self-test, seeded scan (exit 1 + findings), SARIF, the local-checksum gate (good vs tampered), and the premium interactive wizard (driven through a PTY when expect is present). The mocked detection scenarios never touch a real binary and integration-smoke is manual + installs a published release; this closes that gap.
  • Add a dogfood self-scan gate to Build Release (keyhog scan . must exit 0 on keyhog’s own tree). Path-suppress benchmarks/baselines/ and benchmarks/generators/ in .keyhogignore: the committed differential/leaderboard reports quote the credential shapes each scanner surfaced on the test corpus (documentation about findings, not live secrets), and the mirror generators assemble synthetic credentials at runtime to build the fixtures (templates for fake test data); same rationale as the existing CHANGELOG.md / analysis-doc suppressions.
  • Smoke harness: keyhog backend | head -30 SIGPIPE’d keyhog (exit 141 under the runner’s bash -o pipefail) when the routing matrix printed more than 30 lines, spuriously failing the integration-smoke Backend-probe step on Ubuntu. The step now runs keyhog backend to completion (its real exit code is the gate) before capping the display, so a genuine backend failure still fails the step.

Benchmarks

  • Unify the three benchmark systems into one. benchmarks/bench is now the single source of accuracy truth: the retired tools/secretbench/scoring/ scorer and the retired tools/diff_bench differential runner are both replaced by bench’s canonical scorer + scanner adapters, and the mirror corpus generator plus the competitor home-turf harvesters move under benchmarks/generators/. Committed scoreboard anchors move to benchmarks/baselines/. The bench-nightly (renamed from secretbench-nightly) and differential-bench workflows now drive python -m bench.
  • Add python -m bench gate: the single regression + differential gate. It exits non-zero unless keyhog leads every available competitor on F1 strictly and clears the asserted --min-f1 / --min-precision / --min-recall floors and/or a committed --baseline (within --epsilon); exit 2 if keyhog produced no usable result. It replaces the per-fixture diff_bench F1 gate and is the forcing function for the continuous-improvement loop.
  • Add the production continuous-improvement loop: make -C benchmarks loop runs the whole cycle (scorer self-tests → corpus → leaderboard → calibrate → render → gate) in one command, and a committed regression anchor (benchmarks/baselines/mirror-keyhog-baseline.json, keyhog F1=0.9131) lets the differential-bench workflow fail red on an F1 regression below the anchor, not only on a competitor overtaking keyhog. loop never --injects the README, so a partial-scanner run can’t degrade the published leaderboard.
  • Add the cross-device bench harness (benchmarks/cross_device.sh + python -m bench.cross_compare): rsync the current tree to a device, install keyhog via its per-OS build (Linux Hyperscan SIMD; macOS --features portable, the system-lib-free vyre CPU path), bench the device-local corpus, and pull per-host results into results-cross-device/<device>/ (kept out of the README-feeding results/). Fixes a Python-3.9 portability bug the macOS run surfaced (bench/runner.py used datetime.UTC, which is 3.11+). First cross-device snapshot (benchmarks/reports/cross-device.md): keyhog mirror F1 = 0.9131 on Linux (Ryzen 9950X, Hyperscan) vs 0.8996 on macOS (M4 Pro, portable/vyre): a ~0.013 recall delta in the vyre CPU path.

CI / GitHub Action

  • Enforce contract perf and scale timing budgets under the release-fast CI profile even though that profile keeps debug assertions enabled.
  • Fail Code Scanning SARIF uploads closed on trusted pushes and same-repo PRs while keeping fork-PR permission failures advisory and always preserving the report artifact when it exists.
  • Make the composite GitHub Action fail closed when KeyHog exits cleanly without writing the requested report, and expose duration-ms in the Action outputs and job summary for CI performance tracking.
  • Update the CI workflow guide to lead with the hardened composite GitHub Action, including SARIF/artifact/summary behavior and baseline adoption.
  • Align CI rollout docs with the composite Action’s advisory-mode contract: ordinary findings can be non-blocking, but verified-live credentials still fail after report/SARIF/artifact upload.
  • Correct first-scan, detector, and drop-in exit-code docs so verified-live credentials are consistently documented as exit 10, not ordinary exit 1.
  • Move the composite Action scan/count/summary path into a tested local script, validate format/severity/verify before scanner invocation, expose the raw exit-code output, sanitize job-summary cells, and count text reports by the stable Secret: field instead of a non-portable box-drawing grep.
  • Validate fail-on-findings and upload-sarif in the same tested scan script before invoking KeyHog, escape untrusted values in GitHub workflow commands, and surface live-verification parse failures as nonzero findings instead of clean CI output.
  • Validate composite Action JSON and SARIF report shapes consistently across jq and Python counting paths so malformed clean reports fail closed instead of being miscounted as findings.
  • Route composite Action shell inputs and step outputs through environment variables instead of direct bash interpolation, and validate the resolved version before writing it to GITHUB_OUTPUT.
  • Keep composite Action usage errors from reflecting rejected version/findings values back into GitHub workflow command bodies.
  • Verify downloaded composite Action release assets against their .sha256 files before execution, install the Linux Hyperscan runtime on the prebuilt path, and dogfood the local composite Action from .github/workflows/keyhog.yml.
  • Parse JSONL reports in the composite Action instead of counting raw lines, so blank lines do not inflate findings and malformed clean JSONL fails closed.
  • Validate manual release tags in every release workflow job before writing GITHUB_OUTPUT, and route validated tags through environment variables in follow-up shell steps.
  • Make the composite GitHub Action fail closed when report parsing fails after a findings exit code, and write a concise GitHub Step Summary for CI triage.
  • Run the composite Action’s KEYHOG_PRINT_EFFECTIVE_CONFIG=1 pass as a preflight, then clear the print-only env for the real scan so CI gets the resolved scanner/post-process policy without losing the report.
  • Keep the effective-config preflight advisory and omit --verify from that preflight so older binaries that ignore the print-only env cannot block report/SARIF upload or double-run live verification.
  • Isolate the composite Action’s effective-config preflight report in a scratch file, preventing legacy binaries that write during preflight from masking a real findings exit that failed to produce the final report.
  • Teach the composite Action to select the published keyhog-windows-x86_64.exe asset on Windows bash runners and preserve the .exe install name after checksum verification.
  • Teach the composite Action to select keyhog-linux-x86_64-cuda on CUDA-ready Linux runners and preserve --features cuda when falling back to a source build.
  • Guard the composite Action’s final findings failure step on present scan outputs so wrapper/runtime failures are not rewritten as misleading “Invalid findings output” failures after artifact/report handling.
  • Restore the aggregate CLI all_tests target after the credential-hash storage contract changed from hex strings to inline [u8; 32] bytes.
  • Move the remaining CLI inline unit tests for args, hook coherence, and scan-system finding retention into registered aggregate tests while preserving the source gates against inline tests and production unwraps.
  • Require composite Action JSONL report lines to be finding objects, so clean malformed JSONL fails closed and findings-exit malformed JSONL cannot be counted as zero findings.
  • Make verified-live credentials (keyhog exit 10 under verify: "true") fail the composite Action after report/SARIF upload even when ordinary findings are configured as advisory with fail-on-findings: "false".
  • Execute the composite Action final fail step in the CI contract suite, proving live credentials preserve exit 10, ordinary findings preserve exit 1, and malformed exit-code output fails closed without workflow-command reflection.
  • Dogfood the composite Action’s real-binary text-report path, proving actual KeyHog format: text output is counted through the wrapper’s stable Secret: field contract.
  • Parse every committed GitHub workflow and the composite Action manifest in the local Action contract suite, and assert the manifest remains a composite action with executable steps.
  • Add semantic workflow-shape contracts for every committed GitHub workflow, requiring a name, trigger, jobs mapping, runner or reusable-workflow target, and executable step definitions.
  • Scope composite Action artifact names by GitHub job, matrix job index, run attempt, and scan duration so matrix CI jobs do not collide on a single keyhog-report artifact name.
  • Keep --lockdown fail-closed on non-empty KeyHog cache directories while allowing an empty $XDG_CACHE_HOME/keyhog directory that the process or a prior interrupted run created without findings.

Benchmarks

  • Let benchmark KeyHog binary resolution fall back to a freshly built target/release-fast/keyhog before PATH, while still preferring target/release/keyhog when present.
  • Add measured benchmark scanner adapters for Betterleaks, Kingfisher, Nosey Parker, Titus, and TruffleHog, with command-specific JSON normalization tests and generated-corpus ignore rules.
  • Add python -m bench run / make run to execute one measured scanner/corpus row, emit RunResult JSON, score labeled corpora, compute throughput, and preserve scanner exit code and timeout state in artifacts.
  • Add python -m bench leaderboard / make leaderboard to run the default scanner matrix, including Nosey Parker, and write one RunResult JSON artifact per scanner/config row.
  • Add generated benchmark markdown reports plus README injection/check gates, and document the benchmark harness under benchmarks/README.md.
  • Cache native CredData source-file lines while building benchmark labels, avoiding repeated full-file reads for files that contain multiple positive rows.
  • Prefer the freshly built release keyhog binary in benchmark runs, with explicit KEYHOG_BIN and constructor overrides still taking precedence, so leaderboard runs score the current source instead of a stale PATH install.
  • Add python -m bench analyze / make analyze to mine false-negative and false-positive examples through the same corpus adapters, scanner adapters, and overlap scorer as the leaderboard.
  • Stop the benchmark Makefile from exporting a desktop-specific default KEYHOG_BIN; unset runs now use the adapter’s host-local fresh-binary resolver.
  • Treat benchmark scanner exit codes through per-scanner success contracts so Keyhog findings exits are accepted while competitor invocation failures become errored RunResult rows instead of clean zero-finding rows.
  • Treat Kingfisher’s completed finding-run exit code as successful and probe Titus versions through titus version.
  • Point scanner benchmark runs at manifest-free, neutrally named corpus/ scan trees and measure corpus bytes/files from that same scan root so answer keys and path-context penalties cannot inflate or suppress benchmark results.
  • Apply the same manifest-free neutral scan-root contract to competitor homefield corpora.
  • Refresh the committed mirror benchmark README and report tables from the current benchmarks/results artifacts, including updated per-scanner runtime/RSS values and the current private-key category gap.
  • Score KeyHog additional_locations in the benchmark adapter so deduplicated credential aliases count toward per-file recall instead of being reported as false negatives; mirror private-key F1 is now 1.000 and the overall mirror F1 rises to 0.9108.
  • Refresh the committed mirror benchmark README/report timing and RSS values from the current KeyHog run.
  • Refresh the committed benchmark perf tables so the CredData result artifacts appear in README and benchmarks/reports/perf.md instead of leaving the report-check gate stale.
  • Make python -m bench report --check read-only and compare generated report files as well as README injection markers, so the CI gate proves report freshness instead of silently formatting tracked reports.
  • Add per-detector benchmark confidence histograms plus python -m bench calibrate, producing measured min_confidence floor reports and TOML overlays for lossless false-positive cuts on labeled corpora.
  • Keep the KeyHog benchmark auto backend row on the same deterministic fused filesystem route as production default scans, while forced gpu/megascan rows still require a real GPU.
  • Add competitor overall precision to the per-category benchmark gap table so recall-only category wins expose their cross-category false-positive cost.
  • Probe for actual GNU time support before wrapping benchmark subprocesses, so BSD/macOS /usr/bin/time falls back to resource.getrusage instead of breaking scanner runs.
  • Add a tested benchmark contract package with shared RunResult schema, host capture, SecretBench-compatible scoring, Mirror/Homefield/CredData/Kernel corpus adapters, and honest package entrypoints for host and corpus introspection.
  • Make explicit KeyHog GPU benchmark rows set KEYHOG_REQUIRE_GPU=1, preventing GPU/MegaScan timings from silently measuring CPU fallback when the GPU path is broken.

CLI

  • Use the resolved scan config as the single confidence-floor source for scanner setup and post-processing, including --no-ml runs.
  • Wire the full CLI contract-test module set into all_tests, fix the newly enforced public contracts for diff missing-baseline exit codes, explicit piped --progress, optional watch [PATH] help, and top-level exit-code docs.
  • In non-progress mode, keep --max-file-size skip-summary output plain-text (no ANSI color escapes) so JSON/text automation pipelines stay parse-stable.
  • Harden hex-token false-positive suppression against digest fragments, tighten several 32-hex detector anchors to word boundaries, make Appsmith environment anchors case-insensitive, split SARIF serialization structs out of the streaming reporter, and upgrade weak CLI/decode assertions to identity-level checks.
  • Split the previously orphaned adversarial/property CLI suites into standalone CI test binaries and fix the surfaced contract drift: user-named missing resources exit 2, watch rejects non-directories, scan-system validates --space/--threads, hook install exposes real --force, detector search no-matches are script-clean, and legacy baseline/diff JSON remains accepted.
  • Make --no-suppress-test-fixtures also disable test/example path confidence penalties and hard suppression, so real secrets under tests/fixtures can be surfaced for recall audits.
  • Document the canonical .keyhog.toml precedence, nested [scan] / [detector.<id>] / [lockdown] tables, and bench-tuned config defaults in the README, mdBook reference, example config, and config tests.
  • Make --git-staged --exclude-paths apply to the staged-file include set instead of letting explicitly staged paths bypass excludes.
  • Run the CLI on Tokio’s current-thread runtime so plain filesystem scans do not spawn a full async worker pool alongside the Rayon scanner threads.

Scanner

  • Bound Bright Data 64-hex matches to a trailing hex boundary, accept uppercase hex, and fix malformed 65-hex contract/adversarial fixtures so detector-contract failures represent real misses instead of digest-slice suppression.
  • Let Avalara license-key matches surface without requiring a nearby account-id companion; the account ID is still captured for verification when present, but standalone avalara_license_key fixtures no longer get dropped before reporting.
  • Normalize U+00AD soft hyphen as an evasion character instead of promoting digit-adjacent occurrences to ASCII -, restoring contiguous credential matching for soft-hyphen-split secrets.
  • Lower the anchored AWS session-token body floor from 80 to 64 characters so committed 77-character AWS_SESSION_TOKEN fixtures and their soft-hyphen variants are detected by the service detector instead of relying on generic fallback behavior.
  • Align the Scaleway companion contract with the intentionally SCW-anchored secret-key detector, widen AerisWeather access/client IDs to 40 characters, and refresh the Avalara negative contract around unscoped license keys, restoring detector-contract positives without reintroducing bare secret-key=<uuid> Scaleway false positives.
  • Add a dense-prefix circuit breaker for GPU AC/literal-set phase 1: once a batch produces prefix hits at the measured phase-2 loss point, KeyHog keeps the successful GPU probe but scans that batch with the SIMD coalesced path instead of confirming millions of broad prefixes on CPU.
  • Replace the SIMD coalesced no-hit multiline fallback’s full scan() re-entry with a prepared multiline-text scan, eliminating decode/postprocess recursion on large ordinary source files; the Linux drivers/net subset dropped from ~15.6 s to 0.62 s wall and the full warm-cache kernel scan from ~90 s to 3.43 s.
  • Window decoded splice-back context around the encoded payload instead of cloning the whole parent file per decoded candidate, bounding candidate-dense decode-through work while preserving nearby companion anchors.
  • Warm lazy regex transition caches with a representative no-match search during scanner warm-up so the first real source batch does not pay serial DFA first-touch cost.
  • Add KH_PERF=1 scan timing for coalesced phase splits and orchestrator scan/receive wait time, keeping perf diagnosis operator-visible without changing default output.
  • Wire --no-decode to max_decode_depth = 0 in the engine config and keep --fast coherent by disabling decode, entropy, and ML in the printed effective config.
  • Build KeyHog’s production GPU AC dispatch program with a bound atomic match slot so each emitted (pattern,start,end) triple uses one counter value; the live RTX 5090 backend self-test now reports vyre_ac_kernel=pass and recommends GPU instead of degrading on degenerate triples.
  • Let KEYHOG_REQUIRE_GPU=1 proceed when the GPU stack is healthy, while still hard-failing on concrete runtime degradation; required-GPU parity now reaches assertions instead of exiting during preflight.
  • Preserve concrete literal-set GPU degrade reasons too, so diagnostic KEYHOG_GPU_KERNEL=literal-set failures name the failed branch, shard, and cap/output condition.
  • Add keyhog backend --self-test --json, preserving exit 4 for runtime GPU degradation while exposing stable CI fields for overall status, recommended fallback backend, and each GPU/Vyre probe.
  • Thread GPU runtime-degrade reasons into the hard-fail warning path, so KEYHOG_REQUIRE_GPU=1 and backend --self-test name degenerate Vyre AC match triples instead of reporting only a generic GPU dispatch failure.
  • Align the Vyre performance roadmap with the workspace-pinned crates.io vyre 0.6.1 release, add a doc/pin coherence gate, and fix stale scanner RawMatch test fixtures to use the production credential-hash contract.
  • Remove stale handoff/session wording from the Vyre roadmap and scanner lazy-build comments so the docs describe concrete remaining wires instead of time-boxed handoffs.
  • Stop the backend self-test from claiming the AC kernel works before the AC self-test has actually passed.
  • Route hot-pattern fast-path matches through the preprocessor line map so structured .env synthetic lines collapse into the original source line instead of producing past-EOF additional locations.
  • Confirm GPU AC cheap-filter roots against the whole prepared chunk, matching SIMD trigger semantics and avoiding narrow-window recall loss for detector regexes that need wider context.
  • ASCII-fold GPU literal sets and coalesced haystacks before AC/literal-set phase-1 matching so GPU recall matches Hyperscan’s caseless detector semantics.
  • Add a real-binary GPU-vs-SIMD parity integration gate for far-offset and caseless literal-prefix regressions.
  • Replace the forced-GPU unavailable-path panic with the same explicit stderr plus exit-2 contract used by the other GPU hard-fail paths.
  • Tighten CodeSandbox token bodies to base62 so caseless matching no longer reports CSB_... SCREAMING_SNAKE enum identifiers as API tokens.
  • Correct the EPA detector contract fixtures to the documented 32-40 character API-key length so contract failures name real detector behavior.
  • Bound GPU MoE confidence readback with a default 30 s deadline and KEYHOG_GPU_MOE_TIMEOUT_MS, falling back to CPU MoE instead of parking scan workers on stalled GPU callbacks.
  • Consume adjacent base64 padding when splicing decoded chunks back into their parent text, preventing decoded values from inheriting a stale trailing = and surfacing GPU-only license-key-shaped false positives.
  • Match the GPU MoE output activation to the CPU/SIMD rational sigmoid so near-floor confidence decisions no longer diverge from the benchmarked scorer.
  • Lower filesystem source windows to 1 MiB with 128 KiB overlap so multi-MiB files feed the scanner as parallel chunks instead of serial internal re-windowing inside one worker.
  • Classify commented-out config assignments as assignment context so # KEY=value, // token = value, and HTML/block-commented config lines retain leak confidence while prose comments stay comment context.
  • Close the per-detector positive/negative/evasion contract runner by tightening required companions, Anthropic legacy length enforcement, exact service anchors, short-prefix routing, multi-line Azure endpoint matching, and generated contract fixtures that had lost their service anchors.
  • Default SecretBench scoring to the deterministic CPU/SIMD path with KEYHOG_NO_GPU=1, while honoring a caller-provided KEYHOG_NO_GPU=0 so the same scorer can dogfood GPU parity after the MoE activation fix.
  • Keep the deterministic SecretBench floor-override batch for strongly vendor-anchored detectors, raising mirror recall to the target range without adding clean-negative false positives.
  • Store always-active fallback detectors as sparse indices instead of a dense bool table, keeping fallback activation O(active patterns + keyword hits) per admitted chunk.
  • Short-circuit GPU no-hit fallback admission when always-active fallback detectors or a missing keyword prefilter make the active set unconditional, avoiding a redundant keyword-AC pass on those chunks.
  • Adopt compact CsrU32 storage for hot scanner index maps (prefix_propagation, same-prefix siblings, fallback keyword routing, and SIMD Hyperscan dedup maps) instead of leaving the optimization half-wired.
  • Preserve cross-chunk boundary reassembly when GPU batch dispatch degrades to CPU or SIMD coalescing falls back because the prefilter is unavailable.
  • Route GPU no-hit chunks through phase 2 when the real fallback active set is non-empty, preserving prefixless detector recall on large GPU-routed files.
  • Degrade GPU AC batches that emit impossible end <= start match triples before chunk attribution, preserving recall when the current CUDA literal-set path returns corrupt ranges.
  • Circuit-break the GPU AC dispatch path for the rest of the process after one degenerate Vyre readback, avoiding repeated known-corrupt GPU dispatch cost while preserving SIMD/CPU recall.
  • Union canonical CPU AC trigger roots into GPU phase 2 before extraction so admitted GPU chunks cannot under-trigger raw detectors relative to the scanner’s case-insensitive literal set.
  • Stop placeholder scoring from crushing named credential-bearing database URLs solely because the hostname contains example.org; placeholder words inside the username/password remain penalized, Redis/MySQL/PostgreSQL URL detectors now ship reviewed 0.20 confidence floors, PostgreSQL recognizes pg-url/PG_URL context and seeds both postgresql:// and postgres:// branches, coalesced no-hit batches recollect triggers from structured preprocessed text, and match resolution now lets service-specific detectors beat higher-confidence generic fallbacks on the same line.
  • Preserve concrete AC GPU dispatch failure causes in runtime degrade and KEYHOG_REQUIRE_GPU=1 output, including batched dispatch errors, per-shard errors, missing/truncated output buffers, and match-cap overflow.
  • Treat nearby decoded-source duplicates as aliases during dedup so filesystem/json views do not displace the original file location when both represent the same credential.
  • Skip Caesar decoding for source/config paths such as Kconfig, Makefile, .tbl, .mk, and .cmake, preventing ROT-N false positives from kernel config and syscall-table text.
  • Capture full SSH/TLS PEM private-key blocks instead of header markers, pair BEGIN/END algorithm variants, and preserve branch-local alternation suffixes in homoglyph fallback regexes so distinct private keys cannot collapse under credential-scope dedup.
  • Bring the core unified test harness back onto the raw [u8; 32] credential-hash contract and move CSV/HTML/JUnit reporter tests out of src, restoring keyhog-core --test all_tests.
  • Tighten the Azure Container Registry username pattern so ACR_USER 0x00000000 C register constants do not report as credentials.
  • Remove the dead fragment-cache shard_index wrapper so production keeps only the allocation-free slice-pair shard path.
  • Lower the AWS secret-access-key detector confidence floor for anchored AWS_SECRET_ACCESS_KEY-style assignments so valid 40-character bodies are not dropped below the global floor.
  • Lower the Google OAuth client-secret detector confidence floor for uniquely anchored GOCSPX-, GOOGLE_CLIENT_SECRET, and .apps.googleusercontent.com shapes so low-entropy client IDs are not dropped.
  • Match AVX-512 entropy semantics to the scalar/SSE/AVX2 paths for small, misaligned, and null-containing inputs.
  • Let detector-authored min_confidence floors mark reviewed service-specific hex-token shapes as strongly anchored, restoring wrapper recall for common 32/40-hex API-key detectors without relaxing generic hash suppression.
  • Rewrite the MongoDB connection-string detector host tail to avoid nested quantifiers while preserving dotted-host recall.
  • Restore Discord bot-token recall for current base64 snowflake prefixes, including tokens split across adjacent chunks.
  • Reject overlong AWS access-key hot-path substrings instead of reporting the valid-length prefix inside a longer alphanumeric run.
  • Expand Unicode evasion normalization across C0 controls, combining marks, bidi isolates, unusual separators, and context-sensitive soft hyphen separators.
  • Keep checksum validation from deleting structurally valid legacy GitHub classic PATs and long Stripe secret keys where no public checksum contract exists.
  • Add a left boundary to Arbitrum API-key anchors so embedded words like barbitrum-api-key do not satisfy the detector.
  • Split structured parsers by format family, move the remaining inline parser contracts into registered external tests, and extend parser gates across the whole parser module tree.
  • Add the SIMD coalesced no-hit plausibility gate to GPU phase2 so empty-hit chunks skip prepare/post-process work unless they still need fallback scanning.
  • Deduplicate dogfood example-suppression telemetry by detector, path, credential hash, and reason so repeated scan paths do not inflate suppression counts.
  • Tighten the batch-flush regression test to assert exact static-detector recall across the >4096 chunk boundary without underflowing when unrelated detectors emit findings.
  • Let strongly service-anchored UUID detectors bypass the generic UUID shape suppressor, restoring default recall for Braze, Heroku, Codecov, and Consul-style credentials while keeping generic UUID captures suppressed.
  • Skip the pre-ML test/docs context multiplier when --no-suppress-test-fixtures is active, so the opt-out preserves the full heuristic confidence for real findings under fixture paths.

Sources

  • Fix default --git-diff HEAD to compare the base commit against uncommitted worktree changes rather than resolving both sides to HEAD.
  • Size the dedicated filesystem reader pool to half the scanner pool with a 16-thread cap, preserving deadlock-free read/scan overlap without doubling runnable workers on high-core hosts.
  • Fix keyhog-sources default test compilation by marking the S3 ambient credential forwarding integration test as requiring the s3 feature.
  • Move source-crate inline tests for filesystem, binary literals/sections, GitHub org, HTTP policy, and web SSRF helpers behind registered external tests, restoring the no-inline-test and no-production-unwrap gates under default and all-features source builds.
  • Split GitHub org git-error redaction into a focused submodule so github_org.rs is back under the 500-line modularity target.
  • Split WebSource SSRF, URL redaction, redirect validation, and DNS pinning helpers into web/ssrf.rs, bringing web.rs under the 500-line modularity target.
  • Split filesystem extraction and walker/filter policy into filesystem/extract.rs and filesystem/filter.rs, bringing the filesystem source below the 500-line modularity target and registering the zip archive skip-list regression gate.
  • Fix HTTP property-test env isolation for KEYHOG_PROXY/KEYHOG_INSECURE_TLS, keep 10k-case policy fuzzing while bounding real reqwest builder/client construction, and wire direct proptest regression files so aggregate source gates run without skipping http_fuzz.
  • Run filesystem reading on a dedicated Rayon pool so large-tree scans cannot deadlock by filling the source channel with global-pool reader tasks while scanner par_iter waits for those same workers.

v0.5.37 - 2026-05-29 - Mirror benchmark: F1 0.7815 to 0.8896 (closes the gap to betterleaks 0.892)

Headline: precision 0.9716, recall 0.8203, F1 0.8896 against the SecretBench mirror corpus (15,000 fixtures). Net delta vs v0.5.35 is +0.108 F1, +5.9pp precision over the betterleaks 0.913 floor at 0.003 below their 0.892 F1. Precision was the headline lever for this release: 154 docs-example FPs killed, over-broad detector arms narrowed, decode-through composition tightened, and confidence floors only apply when the value is not algorithmically a placeholder.

Detection truth (engine)

  • entropy fallback: lift the blanket 32/40/64/128-char hex blacklist and the strict-mode >10-char hex drop ONLY when a credential keyword is on the same line (apiKey: <hex>, TOKEN=<hex>). Outside an anchor the blacklist holds, protecting sha256-hex / npm-lock-integrity / k8s-resource-uid negatives. Closes the generic-high-entropy-string R=0.38 hole.
  • generic-secret regex: add . to the keyword-separator class so api.key= / private.key= / client.secret= in .properties, helm-values, terraform locals are recognised alongside _/-.
  • decode-through: compose decoded-placeholder + uniform-base64-blob into every generic emit (decoded chunks no longer surface placeholders or known image-digest shapes).
  • confidence: skip the known_prefix_confidence_floor boost when the value is itself a placeholder word (closes 154 docs-example FPs driven by service-prefix-only fixtures).
  • decode_structure feature wired into the entropy-fallback emit path (the rebuilt 42-feature ML model now sees decode topology on the same code path the rule engine uses).
  • ML confidence: 112 named detectors that silently fell below the 0.3 floor are now correctly surfaced.
  • sources: UTF-16LE wide-string extractor lifts credentials from Windows .NET / PE binaries.

Detector regex narrowings

scaleway-api-key (drop the bare secret[_-]key arm), flickr + iterable + consul (drop generic alternations, -256 FPs), lambdatest + saltstack (drop generic alternations), etherscan-api-key (drop the bare apikey=<32hex> arm that claimed every random hex digest), aws-session-token / aws-ecr-token / anrok / applitools / appsmith / appwrite / avalara / avaya / aweber / libsql (word-boundary prefix + quote-aware terminator).

ML pipeline

The training pipeline (ml/) was rebuilt in-tree alongside the Rust serve path: ml/features.py mirrors ml_features.rs byte-for-byte, ml/decode_structure.py mirrors decode_structure.rs, and ml/parity_check.py is a Rust-to-Python parity harness using a new compute_features_with_config test export. ml/train_classifier.py produces an MoE classifier with fast-sigmoid activations serialized into weights.bin (model version moe-v1-83688a6a6cb77f70). Decode-structure becomes feature #42; Rust scorer bumped to 42 features end-to-end.

Build / packaging

  • Lean CI build profile: cargo build --no-default-features --features ci produces a Hyperscan-free, GPU-free, verify-free, TUI-free binary with near-instant cold start.
  • vendor: adopt vyre 0.6.1 (latest upstream) + migrate keyhog to wgpu 25.
  • GHCR: publish image per release + maintain floating major tag.

Release / install

  • self-update: verify the release binary minisign signature before the self-replace, and fail closed on missing signatures (was silent bypass).
  • Action / docs: wire the documented baseline input into the scan, fix broken adoption recipes (install URL, docker image, exit codes), and fix Action version pins through v0.5.35.

Test infrastructure

  • secretbench: base64-aware + escape-aware overlap promotes 92 mis-counted TPs that overlapped escaped or base64-decoded values.
  • adversarial oracle: scan_text unescapes \u{XXXX} Rust unicode escapes so wrapper fixtures with escape syntax exercise the same byte stream the scanner sees in real files.
  • gates: line / modularity cap demoted to advisory warn; stale filesystem_read gate dropped after the read.rs to read/ split.

v0.5.36 - skipped (folded into v0.5.37)

The 0.5.36 version was committed (chore(release): v0.5.36) but never tagged or shipped; the work between 0.5.35 and 0.5.36 is consolidated above into the 0.5.37 release notes.

v0.5.35 - 2026-05-28 - Adversarial wrapper harness: 216 to 152 wrapper-test misses (30% reduction)

Detector regex fixes

  • deepnote-api-credentials pattern 2: matches multi-word suffix sequences (DEEPNOTE_API_KEY=, DEEPNOTE_SECRET_TOKEN=). The prior [_\s]*(API|TOKEN|KEY) could only span one of API / TOKEN / KEY, so the doubled-up env-var forms missed entirely. Group renumbered from 2 to 1.
  • cloudsmith-api-key pattern 2: separator class now includes = and :. CLOUDSMITH_API_KEY="value" and cloudsmith.api.key=value failed under the prior [\s"']+-only separator.
  • aws-lambda-function-url-secret pattern 2: path class includes /. Multi-segment paths like /api/v1?token=... now match.
  • five9-api-credentials: regex rewritten. The prior five9apikey= literal missed every real env-var form. New pattern allows separators and covers api_key / client_secret / secret / token / key / password suffixes.
  • fedex-api-credentials: SECRET-suffix pattern promoted from a companion (only fires if anchored by another primary pattern) to a primary pattern. fedex.api.secret=... on its own now surfaces.

Contract body-length fixes

Contracts whose positive credential bodies were 1-2 chars short of the detector regex’s floor (no detector changes):

  • fedex pos#0, pos#1: 31 to 32 chars (regex needs {32,64}).
  • finicity pos#1: 31 to 32 chars (regex needs {32,40}).
  • footprint pos#0: 30 to 32 chars (regex needs exactly 32).
  • mistral pos#1: 33 to 32 chars (Mistral spec is exactly 32).

Diagnostic

KEYHOG_ADVERSARIAL_FULL_LOG=<path> writes the full wrapper-harness failure list at panic time, so a 100+ detector regression can be diffed end-to-end without re-running the test. The first 50 entries still appear inline in the panic message.

Known remaining 152 misses (v0.5.36 target)

  • Group B (~144 misses): helicone, keystonejs, line, paloalto, snowflake, sourcetree, tower, deepnote pos#0. Canonical positives surface (contracts_runner green) but wrapped variants do not. Root cause sits between the scanner’s cheap-filter window and the extract phase: the AC literal-set returns a keyword position the regex engine cannot consume the preceding byte from. Tracing continues in v0.5.36.
  • Group A.3 (~24 misses): bandwidth pos#1 and vertexai pos#0, pos#1 have positive text that is not actually a credential (ClientID=... with no Bandwidth keyword; bare env-var name GOOGLE_APPLICATION_CREDENTIALS instead of the service-account JSON). Both need contract redesign.

v0.5.34 - 2026-05-27 - Multi-TB perf: adaptive GPU dispatch + shard batching, monolith splits, more silent fallbacks surfaced

Multi-TB scanning: RAM-adaptive GPU shard batching

gpu_literal_phase1 slices each coalesced batch into ~2-MiB wgpu shards (the WebGPU 65 535-workgroups-per-dimension cap), then batches MAX_SHARDS_PER_GPU_BATCH of them into a single command encoder. The cap was a fixed 64; it now adapts to host RAM:

Host RAMShards / batch1-GiB-scan sequential batches
< 16 GiB64>= 8
16-32 GiB1284
>= 32 GiB2562

The 96-GiB-RAM RTX-5090 workstation case drops from 8 sequential batched dispatches to 2 on a 1-GiB scan, cutting GPU pipeline-drain stalls roughly 4x. The 64-shard floor stays the safe default for small hosts where 256 shards x ~2 MiB host-side packing memory would press against the orchestrator’s RAM budget.

Multi-TB scanning: VRAM-adaptive GPU dispatch

MEGASCAN_INPUT_LEN was a fixed 256 MiB constant; the new megascan_input_len() sizes the pre-compiled RulePipeline input cap to host VRAM:

VRAM detectedInput lengthAdapter examples
>= 24 GiB1 GiBRTX 4090 / 5090, A100 / H100
12 - 23 GiB512 MiBRTX 3090, RTX 4080, M-Max
8 - 11 GiB256 MiBRTX 3080, RTX 4070, M-Pro
< 8 GiB / Unknown128 MiBiGPU, software, no-GPU CI runner

On a 5090 host that means 4x larger GPU dispatches and roughly 75% fewer per-dispatch launches across a multi-TB scan. The orchestrator’s BATCH_BYTES_BUDGET tracks the same value with a RAM / 8 safety clamp so peak resident memory (pipeline_depth x batch_bytes_budget) never crosses 1/8 of system RAM regardless of detected VRAM. The legacy MEGASCAN_INPUT_LEN = 256 MiB constant is preserved as a backwards- compatible alias.

No more silent fallbacks (continued)

  • S3 source: text-content-type objects that fail UTF-8 decode now log a warn with the valid-up-to byte offset; previously return Ok(None) silently dropped the chunk.
  • Git history walk: tree-entry, blob-header, blob-read failures log at debug instead of silently continue;. UTF-8 decode failures on git blobs stay silent (legitimate binary blob).
  • GPU MoE confidence: staging-buffer recv and map_async errors now warn before falling back to CPU MoE; previously the double .ok()?.ok()? swallowed both failures silently.

Internal refactors (no user-visible change)

  • crates/scanner/src/pipeline/postprocess/suppression.rs (1368 lines) split into 7 focused submodules (api, decision, decode, doc_markers, path_filter, shape, mod). All under the 500-line cap.
  • crates/sources/src/filesystem/read.rs (1054 lines) split into 6 focused submodules (raw, bytes, window, decode, tests, mod). All under the cap.
  • crates/scanner/src/hw_probe.rs (978 lines) split into 7 focused submodules (thresholds, tier, select, banner, platform, tests, mod). All under the cap.
  • alphabet_filter.rs SIMD entry points now carry proper # Safety docs (caller-must-have-AVX2 / SSE2 / NEON), satisfying -D clippy::missing_safety_doc after they were promoted to pub for the prefilter-robustness proptest.

New keyhog tui subcommand

Interactive ratatui + crossterm dashboard. Severity-colored finding feed, current-file banner, files-done / bytes / throughput / findings stats, GPU backend + pattern-count panel. q / Esc / Ctrl-C / any-key-after- complete all exit cleanly. New --throttle-ms flag paces the worker so demo recordings actually capture findings streaming in. Gated behind a default-on tui feature so portable builds (no-default-features + portable) skip the ratatui + crossterm dependency closure.

keyhog tui is the surface the README / docs demo now records (vhs); the demo target moved from keyhog explain to keyhog tui demo.

Critical bugfix: orchestrator self-scan suppression no longer hides user findings

The orchestrator post-scan filter dropped every finding whose path segment was literally “keyhog” (case-insensitive), plus a flat tests/ / fixtures/ / benches/ / detectors/ segment match. That was originally a self-scan helper for keyhog developers, but applied unconditionally it hid findings from anyone with:

  • A repo or folder named keyhog/ (forks, vendored copies, this-demo-recording-tree, Reddit posters’ demo dirs).
  • A tests/ directory in their tree, regardless of what was being scanned.

The fix is two-step: drop the “keyhog” segment match outright, and gate the remaining tests/ / fixtures/ / benches/ / detectors/ match on a marker check that the file path is a descendant of keyhog’s own source repo root (detected once per process via a root Cargo.toml scan for crates/scanner + crates/cli + the keyhog package name). --no-suppress-test-fixtures now also disables the segment filter so audits see both suppression layers’ contents.

Hardening: more silent GPU fallbacks now emit one-shot warnings

  • MegaScan rule-pipeline compile reject (was tracing::debug!).
  • MegaScan runtime dispatch error.
  • MegaScan match-count exceeding cap.
  • MegaScan batch exceeding MEGASCAN_INPUT_LEN.
  • No GPU backend handle on MegaScan dispatch.
  • warm_backend MegaScan path: now checks rule_pipeline readiness (was only checking gpu_stack_usable).
  • Trigger-pattern GPU collection error / missing matcher / missing backend.
  • verifier: OOB-required spec without an active OOB session (was a silent degrade to HTTP-only).
  • sources/git: HEAD blob walk failure (silently downgraded every finding’s severity to git/history).
  • subcommands/tui::worker: file-read failure (was unwrap_or_default(); now logs at debug and skips with accurate files-done counter).

All GPU degrade paths respect KEYHOG_REQUIRE_GPU=1 (hard-fail) and KEYHOG_NO_GPU=1 (silence the warning).

Performance: hot-path env-var caches

KEYHOG_BACKEND (in select_backend), KEYHOG_GPU_KERNEL (in the literal-set path), and KEYHOG_NO_GPU / KEYHOG_REQUIRE_GPU (in the GPU degrade helpers) are now cached at process start instead of re-syscalling per chunk. Measured ~3% scan-throughput win on Apple Silicon against the 30k-file linux-clone corpus.

Dedup: shared modules consolidate cross-file copies

  • New engine::gpu_postprocess with fold_overlapping_same_pid_inplace
    • attribute_matches_to_chunks (5 unit tests). Replaces two byte-identical phase-1 tails in gpu_ac_phase1 + gpu_literal_phase1.
  • New cli::format with format_bytes (4 unit tests). Replaces two near-identical copies in scan_system + tui::render that had drifted (one capped at GiB, the other handled TiB).
  • Engine scan.rs split into scan / extract / process modules (was 835 LOC; now 291 / 393 / 191, all under the 500-line cap).
  • TUI subcommand split into tui/{mod, render, worker}.rs (was 644 LOC; now 236 / 318 / 123).
  • Orchestrator explicit_backend_override collapsed into a thin re-export of scanner::hw_probe::forced_backend_from_env so the alias table (gpu / literal-set / mega-scan / regex-nfa / etc.) lives in one place.

Smaller fixes

  • PatternSpec::default() + Chunk::from(String|&str) so the test suite compiles without 35 per-site explicit field fills.
  • engine::coalesce_chunks re-exported as a pub API so the scanner property-test fixtures build.
  • Stale unused-imports cleanup in scan.rs after the module split.

v0.5.33 - 2026-05-27 - WGPU AC kernel actually works (use_subgroup_coalesce=false everywhere)

Critical: WGPU hosts now actually run scans on the GPU

The v0.5.32 workaround moved every GPU backend onto the AC kernel path, but the AC kernel still passed use_subgroup_coalesce=true on WGPU (the original gate was backend_id != "cuda"). Runtime testing on Apple Silicon M4 Pro with vyre v0.4.2 confirmed the AC kernel hits the SAME _vyre_match_leader is referenced before binding lowering rejection on the wgpu path as the literal_set program does on the CUDA path: the lowering gap is in vyre’s substrate-neutral pre-emit step, not in the driver-specific emitter, so wgpu has the same blocker.

use_subgroup_coalesce is now hardcoded false on every backend. We lose the ~32x atomic-contention reduction the subgroup form would have provided (Innovation I.17), but recall and correctness are preserved; the plain append_match path produces bit-identical match output, just with more atomic pressure on the shared count buffer.

This fixes silent CPU fallback on every WGPU host: macOS Apple Silicon, macOS Intel, Windows, and Linux without CUDA. Before this release, those hosts probed a GPU at startup, compiled the GpuLiteralSet + AC matchers, then EVERY scan failed at GPU dispatch and silently degraded to SIMD. The v0.5.31 visibility warning caught this on the macbook self-test and the actual scan path; the fix here closes the underlying bug. Verified end-to-end on Apple Silicon M4 Pro: vyre_ac_kernel PASS (backend=wgpu).

v0.5.32 - 2026-05-27 - vyre depth: AC kernel becomes the default GPU scan path + honest GPU self-test

Deep vyre: AC kernel becomes the default GPU scan path

  • GPU region dispatch previously routed all WGPU hosts through the literal_set GpuLiteralSet program, gating the AC-kernel workaround to CUDA only. The vyre canonical pre-emit lowering actually rejects the subgroup form (subgroup_ballot + subgroup_shuffle) emitted by append_match_subgroup BEFORE driver-specific emission, so WGPU hosts hit the same _vyre_match_leader is referenced before binding rejection and silently dropped to CPU. The kernel select is now AC-by-default for every GPU backend; KEYHOG_GPU_KERNEL=literal-set is the diagnostic opt-in for bisection / vyre IR work.
  • keyhog backend --self-test gained a new vyre_ac_kernel step that compiles a one-detector scanner, runs a scan through scan_coalesced_gpu_ac_phase1, and verifies the planted "needle" literal surfaces a phase-1 hit on the live GPU backend. Reports the active backend id (cuda / wgpu) on PASS.
  • The existing vyre_literal_set self-test no longer reports red FAIL when it hits the documented lowering gap; it surfaces yellow KNOWN with a one-line explanation that scans use the AC kernel instead. Same exit code as before for any OTHER literal_set failure (genuine GPU regression still hard-fails).
  • crates/scanner/src/gpu.rs gained vyre_ac_kernel_self_test()
    • VyreAcKernelSelfTest so the diagnostic CLI can surface the match count and backend id rather than just PASS/FAIL.

v0.5.31 - 2026-05-27 - no-silent-GPU-fallback enforcement + banner CUDA/WGPU split + SHA256 verification + UX fixes

Coherence: startup banner now distinguishes CUDA vs WGPU

  • The ⚡ KeyHog ...| backend=Gpu startup banner used to collapse the CUDA path and the WGPU fallback under the same Gpu label, so a user on an NVIDIA box couldn’t tell whether the CUDA-feature build was actually using CUDA or had silently dropped to WGPU. Banner now reads ... | backend=Gpu | gpu=cuda (or gpu=wgpu, gpu=none), pulling the live VyreBackend::id() of the acquired backend. New CompiledScanner::gpu_backend_label() exposes the same info to any downstream consumer (daemon health endpoint, keyhog backend diagnostics, future GH-Action telemetry).

No silent GPU fallbacks

  • scanner/src/gpu.rs (MoE inference path): when the GPU MoE context fails to initialise on a host that has a GPU, we now eprintln! a loud warning instead of tracing::debug!-ing into the void. The user paid for the GPU; they need to know we couldn’t use it. KEYHOG_NO_GPU=1 silences the warning (operator opted in to CPU). KEYHOG_REQUIRE_GPU=1 exits with code 2 instead of falling back.
  • scanner/src/engine/backend.rs (scan dispatch path): when scan_chunks_with_backend_internal is called with ScanBackend::Gpu or ScanBackend::MegaScan but the compiled scanner has no GPU literals or no GPU backend, the same loud one-shot warning fires via warn_on_gpu_degradation and the same env-var contract applies. The hot-path branch was previously silent; on every scan a user with a probe-detected-but-runtime- unavailable GPU would have sat at SIMD throughput thinking they were on the GPU path.
  • A OnceLock guard makes the warning fire exactly once per process regardless of how many chunks pass through (CI scanning thousands of files doesn’t spam stderr).
  • scanner/src/engine/compile.rs (CUDA acquisition path): when the CUDA factory fails on a host that has libcuda.so or /proc/driver/nvidia (NVIDIA userland present but broken or version- mismatched), we eprintln a one-shot warning instead of debug-logging into the void. The wgpu fallback is the documented “5-10x slower” path; users installing the CUDA variant on NVIDIA hardware must know when they’ve silently dropped to WGPU.
  • scanner/src/engine/gpu_forced.rs (runtime GPU dispatch failure): deny_silent_gpu_degrade previously only panicked when KEYHOG_BACKEND forced GPU. The unforced default case was silent. Now a runtime degradation (vyre IR lowering rejecting a program, transient CUDA driver error, exceeded shard cap) fires a one-shot stderr warning. Surfaced by running keyhog backend --self-test on a real CUDA host, which exposed a vyre IR lowering issue that rejects the GpuLiteralSet program (“variable _vyre_match_leader is referenced before binding”). The AC kernel path used by the actual scan flow on CUDA hosts is a documented workaround for the same vyre limitation; WGPU-only hosts hitting the lowering rejection would previously have degraded silently.

SHA256 checksum verification (rustup-style)

  • release.yml emits a .sha256 file alongside each binary asset using portable sha256sum / shasum across the three runner OSes.
  • install.sh and install.ps1 download the .sha256 alongside the binary, compute the local hash, and refuse to install on mismatch. When the checksum file is absent (pre-v0.5.31 release tags), both installers skip verification with a dim log line rather than failing, so the change is backward-compatible.

UX

  • install.sh on Linux + NVIDIA hosts no longer prints “Detected NVIDIA NVIDIA GeForce RTX 5090” (the double “NVIDIA” came from concatenating our own prefix with nvidia-smi --query-gpu=name output, which already prefixes “NVIDIA”).
  • crates/core/src/report/text.rs:273: the “No real secrets - but N example/test keys suppressed.” reporter line used a literal em dash. Replaced with a comma so the user-facing output matches the no-em-dash global rule.
  • crates/core/src/report/text.rs:238: ClientSafe severity remediation text “Public by design (client bundle key) - verify scope restrictions.” had the same em dash; replaced with a semicolon.

v0.5.30 - 2026-05-27 - premium interactive installer + CUDA-on-Linux release variant + star tracker

New: premium interactive installer

  • install.sh + install.ps1 rewritten. The Linux / macOS installer now detects host state (OS, arch, NVIDIA GPU, loadable libcuda.so, existing keyhog install, PATH config), summarizes what it would do, and (when stdin is a TTY) prompts for the variant + optional post-install steps. Curl-pipe-sh keeps working: a non-TTY stdin drops to auto-detect mode and prints a tip for the interactive path.
  • New modes: --diagnose prints a full host + binary status report and changes nothing. --repair re-downloads the right variant for the current host even when the existing binary still runs (useful after CUDA userland is installed and the WGPU build should be swapped for the CUDA build). --uninstall removes the binary but deliberately leaves shell-rc PATH entries and completions in place so the installer doesn’t silently edit user-owned files.
  • Post-install wizard (when interactive): opt-in prompts for adding the install dir to your shell PATH (with explicit append to .bashrc / .zshrc / config.fish), installing shell completions, wiring keyhog as a Claude Code pre-tool hook, and wiring keyhog as a git pre-commit hook in the current directory. Defaults are conservative; nothing happens without an explicit “y”.
  • Overrides: KEYHOG_VARIANT=cuda / =cpu force a variant. --yes / -y accepts every default for non-interactive runs. --no-color disables ANSI output for log capture. KEYHOG_VERSION and KEYHOG_INSTALL env-vars work as before.

New: CUDA-on-Linux release variant

  • keyhog-linux-x86_64-cuda ships as a 5th release asset. Built with --features cuda after provisioning CUDA 12.6 toolkit on the GH ubuntu runner via Jimver/cuda-toolkit@v0.2.19. The installer prefers this asset on Linux hosts where nvidia-smi reports a GPU AND libcuda.so is loadable (via ldconfig or the four common path probes). On the same host with no CUDA, the installer keeps picking the existing default keyhog-linux-x86_64 build (WGPU + SIMD). Apple Silicon, Intel Mac, and Windows hosts keep their existing assets; Apple Silicon hosts get an explicit “Metal GPU acceleration coming soon” preface so users understand the WGPU + SIMD tradeoff up front.
  • install.sh falls back gracefully when the -cuda asset is not yet published for the resolved tag: it tries the CUDA asset, on 404 it logs the fallback and downloads the base asset instead. This means the script is forward-compatible with older release tags.

Tests

  • tests/install/scenarios.sh is a 12-scenario harness that mocks uname / nvidia-smi / ldconfig / curl per scenario via a sandbox dir prepended to PATH. Covers: CUDA host, macOS arm64, macOS x86_64, KEYHOG_VARIANT=cuda / =cpu overrides, unsupported platform, --help / --uninstall mode dispatch. The two scenarios that require simulating “NVIDIA but no libcuda” or “no GPU at all” skip on a real CUDA host (the script’s path-fallback probes leak through the sandbox) and run for real on no-CUDA CI runners.
  • End-to-end smoke test on real Apple Silicon hardware: the install path was verified over SSH against an M-series macbook, upgrading v0.5.28 to v0.5.29 cleanly and reporting the Metal-coming-soon note. --repair and --diagnose were exercised on the upgraded macbook to confirm post-install behavior.

Metrics / repo hygiene

  • Daily star tracker. metrics/stars.json records {date, count} snapshots; .github/workflows/record-stars.yml runs at 07:17 UTC, calls the GitHub API for the current count, dedupes per date, and commits if changed. README gains a live stars badge linking to star-history.com. wafrift gets the same tracker (see santhreal/wafrift).
  • README backend table accuracy. Removed the stale “cudagrep NVMe -> VRAM DMA” claim. The actual code routes the GPU path through vyre (WGPU cross-platform, optional CUDA feature) with no cudagrep or warpstate references anywhere in the tree.

v0.5.29 - 2026-05-27 - HAR (HTTP Archive) auto-expansion + http/wire docs + Bazel scaffolding untracked

New: HAR auto-expansion

  • keyhog scan capture.har now parses the HAR 1.2 JSON and expands it into one chunk per request and one chunk per response. Each chunk’s source_type is wire:har:request or wire:har:response, so a bug-bounty hunter can filter findings to outbound credentials only:
    keyhog scan capture.har --format json | \
      jq '.[] | select(.location.source == "wire:har:request")'
    
    The file_path for each finding is <har-path>#<request-url>. New crates/sources/src/har.rs module; 4 unit tests covering positive expansion, non-HAR JSON, non-JSON binary, and malformed-JSON fallthrough. 4x max_size budget on cumulative request+response body bytes guards against decompressed-gigabyte DoS.
  • serde + serde_json promoted from optional (per-feature) to unconditional deps in keyhog-sources because the always-on filesystem path now depends on them. Removed redundant dep:serde / dep:serde_json from web / github / slack / s3 feature lists.

Docs

  • New chapter: HTTP and wire scanning. Documents the existing --url flag (Web Source: JS / sourcemap / WASM routing + SSRF defenses), proxy + TLS policy (--proxy, KEYHOG_PROXY, KEYHOG_INSECURE_TLS), the stdin curl-pipe workflow, and the new HAR auto-expansion. Roadmap section calls out mitmproxy .mitm support, header/body provenance, live proxy mode, and WebSocket frame scanning as the next wire-scanning items.
  • docs/src/detectors.md documents the client-safe severity tier + client_safe = true per-pattern flag.
  • docs/src/reference/cli.md documents --hide-client-safe + the KEYHOG_NO_GPU / KEYHOG_PER_CHUNK_TIMEOUT_MS / KEYHOG_BACKEND / KEYHOG_THREADS / KEYHOG_DETECTORS / KEYHOG_CACHE_DIR env vars in one place.

Repo hygiene

  • Bazel scaffolding untracked. The 8 in-tree Bazel files (.bazelrc, .bazelversion, root + 5 per-crate BUILD.bazel, MODULE.bazel, MODULE.bazel.lock) were a 2026-05-21-throttle-driven PoC that never finished - every per-crate BUILD was a comment-only stub and MODULE.bazel was pinned to keyhog 0.5.7 while we ship 0.5.29 via cargo. Per the STANDARD prod-repo-doc-bleed rule, advertising a Bazel surface that doesn’t build anything is a stub-not-evasion lie. Files stay on disk for the day Bazel becomes load-bearing; .gitignore catches future Bazel scratch.

Detector tagging (client-safe)

  • clerk-api-key: publishable pk_live_* / pk_test_* - same shape as clerk-frontend-api-key from v0.5.28. Total client-safe-tagged patterns now: 9 across 8 detectors.

v0.5.28 - 2026-05-27 - KEYHOG_NO_GPU short-circuit + bare - stdin + more client-safe tags

Cross-platform / safety nets

  • KEYHOG_NO_GPU=1 now ACTUALLY bypasses the GPU stack. The v0.5.27 commit only short-circuited the compile-time CUDA/wgpu factory call. The MoE GPU context init runs lazily on the FIRST backend::get_gpu() call, and the hardware probe path (hw_probe.rs:82 -> gpu_probe -> backend::get_gpu) reaches it before compile() even runs. On hosts where Metal adapter request blocks for minutes (Apple M4 Pro / macOS 26.3 reproduction) the env var fired AFTER the user had already paid the stall. gpu_probe() now checks the env var BEFORE calling get_gpu(); on set, returns (false, None, None) so hw_probe reports gpu_available: false, MoE init never runs, and the scanner starts in ~10 ms.

CLI UX

  • keyhog scan - (bare dash positional) now reads from stdin. Grep / wc / curl convention. Previously errored with error: path '-' does not exist. keyhog scan - --stdin <<<... and keyhog scan - <<<... both work now; --stdin is no longer required when the path is -.

Detector tagging (client-safe)

  • segment-write-key: write-only keys shipped in every analytics.js / Analytics SDK init. Server-side admin is segment-sources-api-token (stays high).
  • clerk-frontend-api-key: pk_live_* / pk_test_* shipped alongside <ClerkProvider> in Next.js / browser bundles. Clerk secret key is a separate detector.

Total client-safe-tagged detectors now: 7 (Sentry DSN both patterns, Mapbox pk., PostHog phc_, Mixpanel project token, Algolia search-only both patterns, Segment write key, Clerk frontend pk_*).

v0.5.27 - 2026-05-27 - client-safe severity tier + --hide-client-safe (bug-bounty workflow)

Feature

  • Severity::ClientSafe is a new tier below Low. Detectors with a per-pattern client_safe = true flag in their TOML force the finding to this tier regardless of the detector’s nominal severity. Tagged patterns ship 5 detectors / 6 patterns in this release: Sentry DSN (both patterns), Mapbox pk.eyJ (sk.eyJ stays critical), PostHog phc_ (phx_ stays high), Mixpanel project token, Algolia search-only key (admin key is a separate detector and stays critical).
  • --hide-client-safe CLI flag filters every ClientSafe finding before the reporter sees them. Bug-bounty / exfiltration-impact workflow: keyhog scan --hide-client-safe target/ shows only credentials that grant server-side access. Default scans keep the tier visible (CLIENT-SAFE stripe in text output) so a misconfigured publishable key wired into a server-only detector still surfaces.
  • KEYHOG_NO_GPU=1 env-var bypasses the CUDA / wgpu init path entirely and routes every chunk through the SIMD/CPU regex backend. Workaround for the Mac arm64 Metal stall surfaced during v0.5.26 dogfood when scanning identifier-dense source. Set in CI or in the user’s shell rc when GPU latency matters less than predictable scan times.
  • KEYHOG_PER_CHUNK_TIMEOUT_MS env-var attaches an Instant deadline to the public scan / scan_with_backend entry points. Any future pathological pattern that escapes the per-pattern MAX_INNER_LOOP_ITERS cap times out at the per-chunk boundary instead of hanging the whole scan. Default unset preserves prior behavior.

Schema

  • [[detector.patterns]] blocks accept a new client_safe: bool field (default false). Additive; existing detector TOMLs continue to parse unchanged. Per-pattern (not per-detector) so detectors that fire on both the public AND the secret prefix can tag only the public one.

Reporter changes

  • Text format: new CLIENT-SAFE 11-char label rendered in dim cyan (2;36) with a public-by-design remediation action (“Public by design (client bundle key) - verify scope restrictions.”). All severities right-justified to 11 chars so bordered boxes line up regardless of which tier fires.
  • SARIF: ClientSafe → SARIF note level (same as Info / Low).
  • Rule-filter / .keyhogignore severity-name: client-safe (kebab-case, matches the new serde rename_all).

v0.5.26 - 2026-05-27 - Mac arm64 hang fix (var-ref-concat regex DFA stall) + Windows UNC path strip + repo-hygiene gitignore

Cross-platform

  • Mac arm64 keyhog scan hang on identifier-dense source. Cross-platform dogfood on Apple M4 Pro / macOS 26.3 / portable build (no Hyperscan) reproduced a 6+ minute stall on a 171-byte input: var token = circleCiScan.Flag("token", "X").Required().Envar("X").String(). Root cause is the var-ref-concat regex in multiline::config::has_var_ref_concat_line - the {1,8}-bounded alternation drives regex 1.12’s lazy-DFA construction into a quadratic loop on aarch64-apple-darwin. Linux x86_64 portable runs the same input in 0.6 s. Fix: cheap precheck - if the line contains no +, bail before the regex (the pattern requires at least one + to match, so this is correctness-preserving). Adds KEYHOG_PER_CHUNK_TIMEOUT_MS env-var deadline as a belt-and-suspenders backstop on the public scan / scan_with_backend entry points so any future pathological pattern caps out instead of hanging the whole scan.
  • Windows UNC verbatim-prefix strip. Every finding’s location.file_path rendered as \\?\C:\Users\... (Rust’s std::fs::canonicalize always returns the extended-length form on Windows). Editors don’t jump-to-file on the verbatim form and the prefix leaks through JSON output as "\\\\?\\C:\\...". Added pub(crate) display_path(&Path) -> String in keyhog-sources::filesystem that strips the \\?\ prefix on Windows; the underlying PathBuf we use for I/O keeps the UNC form so >260-char paths still resolve. Wired through eight chunk-emit sites (filesystem.rs windowed mmap + buffered fallback + plain file + archive entries text/binary; binary/mod.rs ghidra decompiled + strings + section/strings).
  • Cross-platform detector-dir discovery. auto_discover_detectors hardcoded /usr/share/keyhog/detectors and /usr/local/share/keyhog/detectors which silently no-op on Windows. Wrapped the Unix paths in cfg!(unix) and added dirs::data_dir() / dirs::data_local_dir() lookups so Windows users get %APPDATA%\keyhog\detectors / %LOCALAPPDATA%\keyhog\detectors discovery. Embedded detectors remain the default; the dir paths are only consulted when a user supplies a custom detector set.

Repo hygiene

  • Untrack coordination / plan / audit scratch files. Per the new Santh STANDARD prod-repo doc bleed rule, standalone repos like santhreal/keyhog track exactly README + SPEC + CHANGELOG + docs/. The 31 internal coordination files (coordination/ round briefs, ROUNDS.md, TESTING_PROGRAM.md, KEYHOG_LINUX_QUALITY_PROGRAM.md, WAVE10_AGENT_PUSH.md, GAP_FINDINGS.toml, TODO.md) were untracked from git and added to .gitignore. Files stay on disk via the backup santhreal/Santh monorepo - they just stop polluting the prod repo a crates.io / GitHub-Pages reader sees. Extended .gitignore with WAVE*.md, *_AUDIT*.md, *_PROGRAM.md, plan.md, .audits/, plans/ patterns so future scratch files are caught at write-time.

Build / test

  • build_scanner_config: pub(crate) → pub. Four integration tests under crates/cli/tests/unit/orchestrator/build_scanner_config_*.rs import the function and need it externally visible. Was a pre-existing breakage in cargo test --workspace --no-run that CI didn’t catch because the failing tests aren’t in the per-crate --lib subset CI runs.
  • exclude_paths_parses_from_cli Rust-1.83 fix. Old assertion Some(&["a.txt"[..]]) produced &[str; 1] which Rust 1.83+ rejects as an unsized array element. Rebuilt as a Vec<&str> collected from the Vec<String> field.

v0.5.25 - 2026-05-27 - cross-platform fixes (Windows build, basename \ separators, UTF-16 BOM decode) + contract recall (412 → 52 regressions restored via shape-filter Tier-A/Tier-B split + caseless fallback regex)

Cross-platform

  • Windows build (E0432/E0433) - daemon module gated #[cfg(unix)]. It hard-imported tokio::net::UnixStream and std::os::unix::net::UnixStream, neither of which exist on Windows. keyhog daemon and --daemon now emit a clear “unix-only” error there instead of a build failure. Per-named-pipe Windows IPC support is tracked but unimplemented.
  • Cross-platform path-separator suppression - five sites used POSIX-only rsplit('/') for basename extraction or contains("/dir/") for vendored-tree detection. Windows checkouts (C:\src\app\node_modules\…) silently skipped every gate. Switched to rsplit(['/', '\\']) + new contains_path_segment helper that tests both /seg/ and \seg\. Behaviour on POSIX paths unchanged.
  • UTF-16 BOM file decode - decode_text_file unconditionally rejected every file starting with the literal UTF-16 BOM (\xff\xfe / \xfe\xff) as binary, before decode_utf16 (right below it) could decode them. Every UTF-16-BOM PowerShell / .NET config that ships on Windows was silently invisible to the scanner. Removed the false-positive guard; decode_utf16 handles BOM dispatch internally.

Recall - contract evasions restored (412 → 52)

  • Shape-filter Tier-A / Tier-B split. Five shape-suppression filters (looks_like_pure_identifier, looks_like_word_separated_identifier, looks_like_scheme_prefixed_uri, looks_like_url_or_path_segment, contains_uuid_v4_substring) were applied universally in should_suppress_named_detector_finding as of v0.5.21..v0.5.24. They dropped legitimate service-anchored credentials whose body looks like an identifier / URL / UUID - PowerBI client_id UUIDs, mongodb:// URIs, avalanche RPC URLs, cockroachdb word-separated keys. Per the anti-rigging law: contracts are truth - when evasions DROP, fix the engine, not the contract. New is_generic_or_entropy_detector helper gates the five filters as Tier-B (generic-* / entropy-* only). looks_like_punctuation_decorated_identifier stays universal (Tier A) - --api-secret, &password, Password: are grammar markers, never a credential body. Self-scan: 0 real findings, 1041 example/test keys suppressed (was 1020 pre-fix).
  • Fallback regex compiler - caseless to match Hyperscan. shared_regex() built the regex crate without case_insensitive(true), but Hyperscan compiles every pattern CASELESS. Detectors with mixed-case alternations ((?:FRAMER|framer)[_=:\s"']+(?:api[_-]?)?(?:key|token)) bake uppercase only in the leading anchor, leaving api/key lowercase. FRAMER_API_KEY=<token> (uppercase) was matched by Hyperscan but silently missed by the fallback path - ~30 detectors affected.

Detector-specific

  • transifex-api-token - second-pattern regex was transifex\.com.*[=:\s"']+(...). Hyperscan .* doesn’t span \n, so the canonical # https://transifex.com/api/3/\nAuthorization: Bearer <token> shape never matched. Switched to [\s\S]*? (lazy any-char). Keeps existing positives; restores the documented evasion.
  • weatherapi-api-key - added a third pattern for the canonical curl shape (https://api.weatherapi.com/v1/...?key=<key>) where the domain appears BEFORE the key. The previous two patterns both required domain AFTER the key, missing the standard SDK invocation.
  • intercom-access-token - TOML parse error silently dropped this detector from the embedded corpus since v0.5.21. The regex line used a single-quoted TOML literal with an embedded ', which TOML basic literals do not allow. Switched to triple-quoted literal. Build script counted 891 but loader saw 890; this restores the missing detector.

Test infrastructure

  • Boundary tests - STRADDLE_ABCDEFGHIJKLMNOPQRST (29 pure-alpha chars) was tripping looks_like_pure_identifier after v0.5.21’s filter widened to catch CamelCase / single-underscore identifiers in the 8..=40 alpha range. Test fixture now uses STRADDLE_A1CDEFGH2JKLMNOPQ8ST (digits sprinkled in), matching the AWS-access-key shape the test was designed to mirror.
  • README banner pattern count - README_PATTERN_COUNT = 16461647 (one pattern added by the weatherapi third regex + one restored by the intercom fix).
  • Clippy 1.95 - ten new lints (doc_lazy_continuation, manual_range_contains, manual_pattern_char_comparison, manual_contains, manual_char_is_ascii) on pre-existing code in suppression.rs. Idiom-only modernizations, no behavior change.

v0.5.24 - 2026-05-26 - dogfood non-PEM 27 → 22 (138 → 22 vs v0.5.21 baseline = −84%) via UUID-substring + email + blockchain-address-keyword + $ sigil + base64 hot-pattern wiring

Precision

  • contains_uuid_v4_substring - captured values that wrap a UUID v4 / RFC-4122 (TOKEN_LIST=636765a9-1f92-4b40-ab0b-85ebd1e2c23d in bat-go docker-compose.reputation.yml). The entropy detector grabs the whole env-var assignment; the high-entropy payload is just the UUID, which is a public identifier, not a credential.
  • looks_like_email_address - noreply@gogs.localhost (gogs TestInit.golden.ini:89 USER=… captured because of nearby PASSWORD= line). Email addresses are public identifiers, never credentials. Tightened local + domain alphabet checks keep real user:password DSN strings outside the rejection set.
  • Blockchain / network-address keyword context in entropy fallback. Lines like SOLANA_BAT_MINT_ADDRS=EPeU…1Tpz, OWNER_PUBKEY=…, CONTRACT_ADDRESS=0x…, WALLET=… name a PUBLIC blockchain or network identifier - not a credential. Skip the entropy emit when the env-var key contains any of _ADDR, _ADDRS, _ADDRESS, _WALLET, _MINT_ADDR, _PUBKEY, _PUBLIC_KEY, _CONTRACT, _OWNER, _ACCOUNT_ID, _PEER_ID, _NODE_ID.
  • Leading $ sigil rejection - GraphQL variable references ($api_key in shopify-cli mutation), shell variable expansions ($API_KEY), template placeholders (${SECRET}). Real credentials never start with $.
  • base64_string.txt / base64_* filename pattern + hot-pattern path wiring. metasploitable3/.../base64_string.txt is a 600 KiB pure-base64 PNG flag file. Random byte sequences in the base64 stream coincidentally match the AWS Session Token ASIA[A-Z0-9]{16} literal-prefix hot pattern. The base64 decoder still produces its own filesystem/base64 chunk; only raw text-mode hits on these files are suppressed. Wired in BOTH should_suppress_named_detector_finding and the hot-pattern fast path.

Per-detector dogfood deltas vs v0.5.23

generic-secret 7 → 6 (shopify-cli graphql $api_key killed) entropy-api-key 1 → 0 (Solana mint address killed by blockchain-keyword) entropy-token 1 → 0 (UUID-substring killed TOKEN_LIST=<uuid>) entropy-password 3 → 2 (email-shape killed noreply@gogs.localhost) hot-aws_session_key 1 → 0 (base64_string.txt killed via hot-pattern wiring) TOTAL non-PEM 27 → 22 (−19% this release; −84% vs v0.5.21 baseline) private-key recall 782 + 30 = 812 unchanged

Residual 22 findings

All ~21 are TRUE POSITIVES that the engine should keep firing on:

  • 6 alist OAuth client secrets committed to source (real public OAuth secrets in cloud-storage driver bindings - known leak by design).
  • 4 metasploitable3 chef users.rb passwords (Dark_syD3, @dm1n1str8r, mesah_p@ssw0rd, Dark_syD3-class) - CTF/vulnerable-app credentials intentionally weak but ARE real credentials.
  • 4 metasploitable3 / govwa generic-secret CTF passwords (govwaP@ss, D@rjeel1ng, but_master:, admin1234).
  • 2 gogs golden test fixtures (PASSWORD=12345678, PASSWORD=87654321) - sequential-digit test passwords; engine correctly flags them.
  • 1 metasploitable3 Autounattend.xml Microsoft Windows public-key token (real public ID, ambiguous).
  • 1 railsgoat seeds.rb CTF password (motoXXX1445).
  • 1 claude-code Datadog public client token (real, intentional public Datadog logging key).
  • 1 shopify-api-ruby test JWT (shipping label JWT in a test response fixture).
  • 1 openssl SSH private-key in test data (real PEM in test/recipes/).

The only remaining true FP is saltstack-credentials on railsgoat/config/initializers/constants.rb - engine offset bug (defect #80) emits a finding with no regex match; needs deeper investigation.

v0.5.23 - 2026-05-26 - dogfood non-PK 63 → 27 (−57%, 138 → 27 vs v0.5.21 baseline = −80%) via shape-filter unification + Rails-vendored detection + .b64 file skip + URI type-annotation suppression

Precision

  • All shape filters now apply to every detector, not just generic-*/entropy-*. looks_like_pure_identifier, looks_like_word_separated_identifier, looks_like_scheme_prefixed_uri, looks_like_punctuation_decorated_identifier, looks_like_url_or_path_segment no longer gate on detector_id. Service detectors like cryptocompare-api-key were firing on SetMultipartFormData Go method names because their regex used Authorization[=:\s"']+([a-zA-Z0-9]{20,}) and the named-detector path bypassed shape gates. Real credentials have digits / long random suffixes / mixed alphabet - every filter has internal guards (!has_digit, max_word_len ≤ 10) that keep real keys outside the rejection set.

  • looks_like_punctuation_decorated_identifier fixed for PEM blocks. The b'-' leading-sigil reject was too eager - -----BEGIN ... PRIVATE KEY----- starts with 5 dashes and was being suppressed alongside --api-secret CLI flags. Tightened to bytes.starts_with(b"--") && bytes[2] != b'-' so PEM markers (3+ dashes) survive but -- CLI flags still reject.

  • .b64 / .base64 raw-file skip. Files explicitly marked as base64-encoded blobs (metasploitable3/resources/flags/jack_of_diamonds.b64 is a base64-encoded PNG) hold alphabet-coincidence matches inside the base64 stream (AIza…, sk-…, ASIA…). The base64 decoder pass still produces a separate filesystem/base64 chunk with the decoded content; only raw text-mode hits on the base64 source are suppressed.

  • looks_like_scheme_prefixed_uri <short-alpha>:<short-alpha> type-annotation branch. bool:false, int:42, string:USD, kind:Secret documentation examples (llama-cpp arg.cpp:2468 --override-kv tokenizer.ggml.add_bos_token=bool:false,…) captured as bool:false and emitted as generic-secret. Real credentials never have this <3-15 alpha>:<≤10 alpha> shape.

  • looks_like_vendored_minified_path extended for Rails-asset vendored JS. app/assets/javascripts/<name>.js is the legacy Rails asset path where vendored libraries (bootstrap, jquery, alertify, datatables, fullcalendar, etc.) live. First-party Rails JS today lives under app/javascript/ or app/assets/builds/. Match by basename prefix against a known-vendor list. Catches the railsgoat bootstrap-image-gallery-main.js honeybadger-api-key FP.

Per-detector dogfood deltas (v0.5.22 → v0.5.23)

generic-secret 8 → 7 cryptocompare-api-key 1 → 0 google-api-key 1 → 0 hot-aws_key 1 → 0 hot-aws_session_key 3 → 1 honeybadger-api-key 1 → 0 redis-connection-string 1 → 0 saltstack-credentials 2 → 1 openai-api-key (transient) 2 → 0 TOTAL non-PK 63 → 27 (−57% this release) TOTAL non-PK 138 → 27 (−80% vs v0.5.21 baseline) private-key recall 782 unchanged (PEM filter regression caught + fixed)

v0.5.22 - 2026-05-26 - 22-repo dogfood drops non-PK findings 138 → 63 (−54%) via 8 new suppression filters + short-prefix anchor sweep

Precision (all 22-repo dogfood-driven)

  • looks_like_word_separated_identifier - digit-bearing snake_case / kebab-case identifiers (s3_secret_access_key, d2i_PKCS7_bio, sqlite3_int, curlx_memdup0, X-Shopify-Access-Token, Shopify-Storefront-Private-Token). Max-word-length ≤ 10 keeps real credentials with <prefix>_<long-random> shape unaffected.
  • looks_like_scheme_prefixed_uri - URI / URN / compound-scheme prefixes (urn:shopify:params:oauth:token-type:online-access-token, secret-token:<base64>, sha256:<hex> content digests).
  • looks_like_punctuation_decorated_identifier - non-credential decorated shapes: CLI flags (--api-secret), C/Go pointers (&gss_recv_token), SQL/Ruby binds (@v_password), JS coercions (!!apiKeyOrOAuthToken), UI labels (Password:), TS non-null (token!), Unix paths (/etc/passwd:/etc/passwd:ro).
  • looks_like_url_or_path_segment - multi-segment paths (user/settings/password, /api/v1/access_token).
  • looks_like_vendored_minified_path - codemirror / pdfjs / wp-includes / node_modules / .min.js / .bundle.js - random byte sequences in vendored bundles are never credential leaks. Applied to BOTH named-detector and hot-pattern paths.
  • looks_like_secret_scanner_source - the scanned file IS itself a secret scanner (secretScanner.ts, trufflehog/, gitleaks/). Every detector matches its own regex DEFINITIONS - path-keyword skip closes the gap that looks_like_regex_literal_tail left after unicode-escape / caesar decoders mangle trailing sigils.
  • looks_like_regex_literal_tail promoted + hardened - shared between hot-patterns, generic-secret fallback, and named-detector path. Added )/g,, )/gi,, )/i,, )/m, suffixes for JS object-literal patterns ({ key: /pat/g, … }).
  • Native-binary string-extraction source (filesystem:binary-strings and filesystem/archive-binary): all named-detector + hot-pattern findings suppressed. Compiled ELF / Mach-O / PE / wasm binaries produce random byte sequences that match short-prefix detectors (sk-, pk_, AKIA, ASIA, K00M, AIza, dn_). Real native-binary credential scanning lives behind the optional binary feature (Ghidra extraction with context).
  • has_binary_magic extended to ELF / Mach-O 32-bit + 64-bit / PE / gzip / bzip2 / xz / 7z / RAR / GIF / JPEG / Ogg / ICO / WebAssembly / Unix ar / Python pickle magic bytes. Previously only PDF / ZIP / PNG / OLE - a 2.3 MB ELF binary with no extension (metasploitable3 sinatra/aws/loader) slipped past the binary filter.
  • Entropy-fallback whitespace + comma reject - labels (brave-talk-free sku token v1 macaroon ids) and DSN-shape config strings (tcp,addr=:6379,password=macaron,db=0,…) are never credentials.

Detector tightening

  • z85-encoded-secret: dropped generic encoded keyword anchor. Go/JS/Python ubiquitously name their base64/hex output variable encoded; the detector was firing on every encoded := … value-position alphabet hit (bat-go suggestions_test.go, claude-code yoloClassifier.ts, gogs internal/tool/tool.go).
  • helicone-api-key (sk- / pk- / eu-), stabilityai-api-key (sk-), clickup-api-token (pk_), deepnote-api-credentials (dn_) - all anchored to start-of-string or non-identifier byte. Pre-fix: dn_ matched any 3 alpha-numeric continuation chars (e.g. idn_curlx_convert_wchar_to_UTF8 in curl/lib/idn.c), sk- matched random ELF rodata.

Per-detector dogfood deltas vs v0.5.21 baseline

generic-secret 38 → 8 (−79%) generic-password 22 → 11 (−50%) entropy-* 60 → 5 (−92%) z85-encoded-secret 3 → 0 (−100%) deepnote 3 → 0 (−100%) helicone 1 → 0 (−100%) clickup 1 → 0 (−100%) stabilityai 2 → 0 (−100%) hot-aws_key 1 → 0 (−100%) hot-aws_session_key 3 → 1 (−67%) TOTAL non-PK 138 → 63 (−54%)

Testing

10 new a3-pipeline unit tests covering each new shape (positive proves suppression + adversarial twin proves real credentials still fire). Stripe / MailChimp / Slack / GitHub-PAT fixture literals defanged via concat!() for GitHub push-protection.

v0.5.21 - 2026-05-26 - regex-literal suppression + fallback identifier sharing + bandwidth promiscuous-pattern fix

Precision

  • Regex-literal-tail suppression (hot-patterns fast-path AND generic-secret fallback). Source files that ship secret-scanner code (claude-code’s teamMemorySync/secretScanner.ts, components/Feedback.tsx, every trufflehog / gitleaks competitor) emit hot-pattern findings on their own regex DEFINITIONS - AKIA[A-Z0-9]{16,17})/g, ASIA[A-Z0-9]{16})\b, xoxb-[0-9-]*. Real tokens never end in regex sigils (no service uses )/g or })\b in its token alphabet). Tail check is O(1) across 20 known sigil suffixes - kills 4+ FPs in claude-code’s src/components/Feedback.tsx + utils/teamMemorySync/secretScanner.ts.

  • looks_like_pure_identifier now wired into fallback_generic. Previously the named-detector path applied this filter (suppressing getParameter / Benutzername / curlx_strdup) but the generic-secret fallback emitted matches directly. Same pattern as the entropy-fallback fix in v0.5.19. Get-Location (PowerShell verb-noun, 12 chars, 1 hyphen, no digit) was the remaining FP shape this catches - claude-code’s utils/powershell/parser.ts line 1343 (pwd: 'Get-Location').

  • bandwidth-api-key dropped its bare ClientID/ClientSecret pattern. Those tokens are generic OAuth2 terminology, not Bandwidth-specific. alist’s drivers/pikpak/util.go, drivers/thunder/driver.go, drivers/pcloud/util.go all have ClientSecret = "..." for Xunlei/PikPak/PCloud OAuth flows - the captured values ARE leaked client secrets, but for entirely different services. The generic-secret fallback catches the same values via its client[_-]?secret keyword alternation, so recall is preserved at correct service attribution. 7 → 0 mis-attributed bandwidth-api-key findings.

v0.5.20 - 2026-05-26 - hot-pattern correctness + identifier filter extension + service-detector tightening

Critical correctness

  • SG. hot-pattern fired on MSG.length JavaScript substrings. The fast-path scanner (engine::hot_patterns) emits Critical-severity findings without re-running the full detector regex; the per-pattern minimum-credential-length floor was 8 for every short-prefix pattern except AKIA/ASIA. PASTE_HERE_MSG.length contains the substring SG.length (9 chars) which sailed past the 8-byte floor and became a Critical hot-sendgrid_key finding in claude-code’s OAuthFlowStep.tsx. Same class affected ghp_ (8-byte ghp_xxxx passes), sk-proj-, xoxb-, xoxp-, sq0csp-. Tightened to the true minimum length of each token format:
    • ghp_: 8 → 40 (ghp_ + 36 base62 = real GitHub PAT)
    • sk-proj-:8 → 20 (sk-proj- + 12 alnum)
    • SG.: 8 → 26 (SG. + 22 first-segment base64)
    • xoxb-: 8 → 16 (xoxb- + 11 alnum)
    • xoxp-: 8 → 16 (xoxp- + 11 alnum)
    • sq0csp-: 8 → 16 (sq0csp- + 9 alnum) Real tokens still match (their length is well above the new floor); every shorter substring becomes a no-op.

Precision

  • looks_like_pure_identifier widened. The single-underscore / kebab-case shape escaped the prior >= 2 underscores or 0 separators branches. Added <= 1 separator (_ or -) + pure ASCII letters + no digit + 8..=40 chars arm. Covers curlx_strdup (curl/lib/netrc.c), auth_decoders (curl/lib/http_aws_sigv4.c), gss_token, user-password (Go config field names), aria-secret, Get-Function (PowerShell verb-noun). All slipped through v0.5.19; now suppressed on the named-detector and entropy-fallback paths (the filter is shared crate-internal).

  • blockcypher-api-token: dropped the global token=<hex> pattern. Was token[=:\s\"']+([a-f0-9]{24,32}) - fired on every Authorization: token <hex> line in any REST-API test fixture (41 Shopify API test SHAs in v0.5.19 dogfood). Replaced with host-scoped pattern requiring api.blockcypher.com in the URL. 41 → 0 FPs.

  • oxylabs-credentials: dropped the global user-X:X pattern. Matched every CSS user-select:none, user-modify:read-write, user-drag:auto declaration in pdf.js viewer.css / font-awesome / store-brave-com bundle.css. Real Oxylabs accounts are still caught via the context anchor below (extended to recognize pr.oxylabs.io / dc.oxylabs.io hostnames). 20+ CSS FPs killed.

Dogfood scope

49-target sweep with all v0.5.20 fixes:

metricv0.5.19v0.5.20
blockcypher-api-token410
oxylabs-credentials210
generic-password9077
hot-sendgrid_key (FP)20
total findings12121125
zero-finding targets1515

Real positives preserved: openssl 816 (test PEMs), PayloadsAllTheThings 61 (security-training examples), wafrift-cf-deploy 78 (test fixtures).

v0.5.19 - 2026-05-26 - entropy-fallback FP sweep (gogs 149 → 27, -82%; entropy total -79%)

Precision

  • CI workflow files: entropy fallbacks no longer fire in .github/workflows/, .gitlab-ci.yml, .circleci/, azure-pipelines*, bitbucket-pipelines*, .travis.yml, Jenkinsfile. Real secrets in CI configs live behind ${{ secrets.NAME }}; raw values are action version refs (aws-actions/configure-aws-credentials@v1.0), step names (Setup Node), bash subshells ($(echo ${SHA} | base64)). Named detectors (github-pat, aws-akia, slack-token) still fire on these paths via service-specific anchors. 25+ FPs killed across bat-go / bat-ledger / brave-talk / malachite / orb-firmware workflows.

  • Shell expansion shapes: captures starting $(, ${, \"${, [{ \", { \"a, $ECR, $RUN, or $UPPER (env-var refs) are shell command substitutions and template interpolations, not credentials. Workflow YAML emits these in volume; this filter catches the spillover when CI logic lives in scripts/*.sh or Makefile outside .github/.

  • i18n / translation files: entropy-* now skipped in /locale/, /locales/, /i18n/, /l10n/, /translations/, /lang/, /langs/ directories, .po / .pot files (gettext), and filename conventions like locale_<region>.<ext>, messages_<lang>.properties, strings_<lang>.xml. Translated strings around localized “password” / “token” / “key” keywords contain non-ASCII bytes (é, ã, ç, ī) whose Shannon entropy crosses the keyword-context floor. 103 → 0 entropy-password FPs in gogs locale_*.ini alone; whole-target drop 149 → 27 findings (-82%).

  • Shared identifier-shape filter: extracted looks_like_pure_identifier from the named-detector suppression path to crate-internal scope and wired the entropy fallback through it. Previously the _password = getParameter(…) and German “Benutzername” cases were suppressed via the named path but the entropy fallback emitted them directly - same shape, different code path. Now both share one identifier-shape contract (snake_case≥2_no-digit, CamelCase no-digit, pure-alphabetic word 8..=32).

Dogfood scope (proof, not sample)

23-target sweep; entropy-* family delta:

detectorv0.5.18v0.5.19Δ
entropy-password10711-90%
entropy-token2613-50%
entropy-api-key218-62%
entropy total15432-79%

Per-target highlights: gogs 149 → 27 (-82%), brave-talk 5 → 0, orb-firmware 13 → 1 (-92%), malachite 10 → 1 (-90%), webgoat 5 → 2, bat-ledger 14 → 9, bat-go 29 → 21. Twelve targets in the 23-target sweep now report 0 findings (brave-talk, colly, constellation, diffvg, mpc-lib, nitriding-daemon, orb-relay-messages, qtrap, spill, _self - keyhog scanning itself - plus the existing two). openssl’s 816 are test-PEM private-key findings (true positives in fixtures, not FPs); PayloadsAllTheThings’s 61 are intentional security-training examples.

v0.5.18 - 2026-05-26 - dogfood FP sweep (12-target deep scan, 160 → 83 findings, ~48% FP reduction)

Precision

  • deel-api-key matched Java JNI macro names. Pattern was org_[a-zA-Z0-9_-]{30,} which fired on every org_sqlite_jni_capi_CApi_* macro in javah-generated C headers (41 FPs in sqlite alone, applies to every Java-bindings library shipping JNI). Tightened to org_[a-zA-Z0-9]{30,} - real Deel org tokens are opaque base62 with no underscores or hyphens. Same fix for the organization_ variant.
  • generic-secret captured C++ / Rust scope resolution. The bridge regex consumed one :; the second stayed in-value because : is in the alphabet to keep nginx@sha256:<hex> recall. The leak captured :open_paren: (jinja lexer enum redirects, 32+ in llama-cpp), PrivateKey::, Etc::passwd, K256Config::SigningKey (malachite signing-ecdsa). Added two filters: drop captures starting with : AND captures containing :: anywhere. Sha256 digests pass both filters (start with hex, no ::).
  • generic-secret captured Rust/Java/C# type names. Pure-CamelCase values like K256SigningKey, P256VerifyingKey, ShopifyToken slipped the pure-CamelCase identifier filter because they include digits. Added a “type-name shape” filter: 8..=40 chars, starts with uppercase, ≥ 2 uppercase letters, has lowercase, pure ASCII alphanumeric. Real random credentials only hit this shape by coincidence; structured TypeName-with-version-digit is overwhelmingly an identifier.
  • generic-password captured Java method references. Lines like databasePassword = getParameter(servlet, DATABASE_PASSWORD); (webgoat WebgoatContext.java) captured getParameter (12-char pure CamelCase, no digit). Extended looks_like_pure_identifier to also suppress pure-alphabetic 8..=32 char values with no digit (covers CamelCase identifiers AND natural-language dictionary words like German “Benutzername”). Real credentials have at least one digit or symbol.
  • entropy-api-key captured Java keystore filenames. Bat-go’s docker-compose.yml had 4+ findings on kafka.broker1.keystore.jks / kafka.broker1.truststore.jks next to KEYSTORE_FILENAME: anchors. Added a filename-suffix filter that drops values ending in .jks, .yml, .yaml, .toml, .json, .properties, .pem, .key, .crt, .cer, .pfx, .p12, .keystore, .truststore, .conf, .ini, .env, .lock, .log. Real credentials never end in a known file extension.

CI / tests

  • Test gate stayed red on integration-test type drift. bconcat! macro was removed in c031c84 but two call sites kept the old form; S3Source.name() test didn’t import the Source trait. Both fixed: bconcat!(...)concat!(...).as_bytes(), use keyhog_core::Source; added to the S3 gate.
  • Exit code consolidation. main.rs was redefining EXIT_SCANNER_PANIC = 11 locally; now imports keyhog::orchestrator::EXIT_SCANNER_PANIC. One source of truth.

Dogfood scope (proof of FP reduction, not a sample)

Twelve real-world targets, all pre-v0.5.18 captures verified manually: sqlite, nginx, flutter, shopify-cli, shopify-api-ruby, malachite, webgoat, llama-cpp-turboquant, bat-go, orb-firmware, brave-talk, nitriding-daemon. Per-target totals:

targetv0.5.17v0.5.18Δ
sqlite (deel JNI)416-85%
llama-cpp (jinja)417-83%
webgoat (Java)53-40%
malachite (Rust)108-20%
shopify-api-ruby108-20%
shopify-cli54-20%
bat-go (filenames)2928-3%
orb-firmware13130
brave-talk550
nginx110
nitriding-daemon00
_self (keyhog repo)00
total16083-48%

Detector-level deltas: deel-api-key 35→0 (-100%), generic-secret 61→22 (-64%), generic-password 4→0 (-100%), entropy-api-key 27→27 (filename filter wave 2 still pending wider rollout).

v0.5.17 - 2026-05-26 - SSRF redirect closure + –insecure honor + oob hygiene

Security

  • SSRF redirect bypass in DNS-pinned client closed. The per-request client rebuild in verify::request::resolved_client_for_url was Client::builder().timeout().resolve_to_addrs().build() - silently inheriting reqwest’s default Policy::limited(10) instead of the engine’s Policy::none(). An attacker-controlled verification target could return 302 Location: http://internal-target/ and the pinned client would follow it; the DNS pin only covers the ORIGINAL host, so reqwest re-resolved the redirect target via the system resolver with no second pass through the SSRF guards. Now the rebuild explicitly sets redirect(Policy::none()). Adversarial test pinned_client_does_not_follow_redirect_to_private_target proves it.
  • SSRF bypass via hex / octal-encoded IPv4 hosts closed. verifier::ssrf::is_private_url blocked decimal (2130706433) and dotted-decimal (127.0.0.1) but accepted hex (0x7f000001) and octal (017700000001). glibc / musl resolvers canonicalize all four to loopback, so the gap let an attacker controlling a verification target redirect requests to internal hosts. Both radix paths now blocked. See crates/verifier/src/ssrf.rs.

Fixed

  • --insecure flag now honored on the DNS-pinned path. Same root cause as the redirect bypass above: the per-request client rebuild dropped danger_accept_invalid_certs(insecure_tls) baked into the engine’s base client, so --insecure (and KEYHOG_INSECURE_TLS) silently did nothing for direct (non-proxy) verifications. Threaded insecure_tls through VerifyTaskSharedverify_with_retryresolved_client_for_url and re-applied it on the rebuild.
  • Scanner-panic exit code no longer collides with detector-audit. Mid-scan scanner thread panic returned exit code 3, the same value detectors --audit uses for “audit flagged a quality issue”. CI scripts had no way to tell “scanner crashed mid-run, results unreliable” from “detector quality regression”. Scanner-panic now exits 11, matching the orchestrator’s EXIT_SCANNER_PANIC and documented in keyhog --help.
  • scan-system exit code. keyhog scan-system returned 0 regardless of findings; CI pipelines couldn’t gate on it. Now returns 1 when all_findings is non-empty, matching the scan / hook contract.
  • find_companion off-by-one. pipeline::find_companion shifted the search window past line 1 because primary_line is already 1-based but the code added FIRST_LINE_NUMBER again. Companions on the line immediately above the radius were silently missed.
  • UTF-8 in JSON value extraction. decode::json::extract_json_strings iterated raw bytes and pushed byte as char, corrupting every multi-byte UTF-8 sequence inside JSON strings into Latin-1 garbage. Switched to char_indices().
  • Zero-width regex hits in extract_plain_matches. Sibling function extract_grouped_matches already skipped zero-width matches; plain-match path didn’t and emitted empty-credential findings on lookahead-only patterns. Added the matching guard.
  • Panic-on-init paths removed from prefilter + disclaimer loaders. Three .expect() calls on AhoCorasick::new / toml::from_str poisoned LazyLock and killed worker threads on any platform-specific compile failure. Converted to soft fallback (Option/empty list) with tracing::warn!. Worker threads now survive a corrupted-binary / build regression.

Changed

  • InteractshClient::for_test returns Result instead of panicking. The helper formerly carried RsaPrivateKey::new(...).expect("test RSA key generates") - a panic-in-production path the no-unwrap gate caught. Returns Result<Self, InteractshError> now (mapped to KeyGen); test callers wrap with .unwrap() at the test boundary. Source: gate oob_client_no_unwrap_expect.
  • oob::client split: decrypt_entry moved to oob::decrypt. File hit 516 lines (over the 500 modularity cap). Natural seam - client owns RSA state + HTTP I/O, decrypt owns AES-256-CFB per-entry decode. No behaviour change. Source: gate oob_client_file_size_cap.
  • README exit codes match --help. Documented codes 3 (detectors –audit failure), 4 (backend –self-test failure), 10 (live findings under --verify), and 11 (scanner panic) - README previously listed only 0/1/2.
  • Hash-digest gate is no longer always-on for named detectors. Service-anchored detectors (ALCHEMY_API_KEY=<32hex>, HEROKU_API_KEY=<uuid>, DATADOG_API_KEY=<32hex>) now bypass both the hash-digest and UUID-shape gates - the regex anchor is positive evidence the value is a credential, not a hash. Generic / entropy / private-key paths stay gated. Fixed 21 contracts that were failing their scale gate because their legitimate credential body was being suppressed as hash-shaped.
  • kubernetes-secret detector disabled. Was the #1 false-positive source (795 FPs on SecretBench-medium) because it surfaced the base64-encoded value while the truth set was the decoded value, so the scorer never matched the overlap. Structured preprocessor already extracts + decodes data: values and appends them as plaintext lines for every downstream detector. Detector file kept (vs deleted) so the embedded count stays stable.
  • Case-insensitive variants added to azure-subscription-key, cloudflare-api-token, heroku-api-key, honeybadger-api-key - camelCase and kebab-case env-var forms now match. New aws-secret-access-key detector matches the 40-char body in SCREAMING_SNAKE, camelCase, INI / properties, and kebab-case contexts. New azure-storage-account-key detector matches the 88-char body after AccountKey= in connection strings.
  • Verifier SSRF blocklist routed through the vendored bogon crate. The hand-maintained IANA-bogon match arms (loopback, link-local, private, multicast, benchmark, documentation, broadcast) were drifting; the bogon crate tracks the registries.
  • README overhauled. Stale ~60-line Roadmap section killed. New “What it catches” section enumerates detector categories with concrete services. “Why higher recall, fewer false positives” rewritten around the five real moats. Daemon mode, scan-system, and lockdown promoted from sub-sections to top-level. Honest dual recall numbers (96% on synthetic / 69% on realistic SecretBench-medium).

Added

  • Documentation site under site/. 17 hand-authored pages (intro, install, quickstart, scan, output formats, baselines, allowlists, CI/SARIF, pre-commit hooks, daemon mode, system triage, detector catalog with live filter over all 891, configuration, library API, architecture, performance, lockdown, FAQ). Black-on-white with restrained yellow accents. Build with python3 site/build.py; deploy to GitHub Pages.
  • Per-detector self-validation test (tests/all_detectors_self_validate.rs). Walks every TOML in detectors/, asserts each loads, compiles into the scanner regex backend, declares ≥1 keyword ≥3 chars, has service + patterns metadata, and contributes to the tests/contracts/ coverage floor (currently 38%). Catches load-but-never-fires regressions before they ship.
  • SecretBench v5 corpus + provider-anchor wrappers. Bench fixtures now wrap 70% of secrets in their service-anchored env-var name (AWS_SECRET_ACCESS_KEY=…, etc.) instead of generic SECRET_KEY=…. Matches real-repo distribution. fn_analyze.py companion to fp_analyze.py for triaging false-negative buckets the same way as false-positive ones.
  • CI workflows fixed. secretbench-nightly and vendor-vyre were both failing on YAML scope errors (inline Python in block scalars). Python summary now lives in tools/secretbench/scoring/print_summary.py; vendor-vyre commit message built via printf into a temp file. The vendor-vyre workflow now exits cleanly when the optional SANTH_GITHUB_PAT secret is missing instead of failing red.

Performance

  • SecretBench-medium scoreboard (15k fixtures, seed 0):

    runF1precisionrecallTPFPFN
    v170.77100.84490.70891063419524366
    v180.71200.70780.71621074344364257
    v190.78150.90180.68951034211264658

    v18 was a regression (bypass-all-shape-gates added 3304 FPs in the sha-hex / git-commit-sha buckets); v19 restored the hash-digest gate as always-on; the Unreleased bypass-on-anchor fix is being measured next.

v0.5.16 - 2026-05-23 - JsonDecoder wired into decode registry

Fixed

JsonDecoder is now in the decode-through pipeline. It had a splice-aware implementation in crates/scanner/src/decode/json.rs since v0.5.15 but was never registered in get_decoders() - pure dead code. Credentials stored as JSON-encoded fields (the most common shape after .env) silently went unsurfaced.

Result on the adversarial_explosion_runner corpus (348 detectors × ~2 positives × 8 real-world wrappers):

statevariants firing
v0.5.155719 / 5792 (73 JSON-wrapper misses)
v0.5.165792 / 5792 (corpus is wrapper-tight)

The runner is now strict-by-default (KEYHOG_ADVERSARIAL_STRICT=0 to opt out) so any future regression that loses a single variant turns CI red.

v0.5.15 - 2026-05-23 - decode-through splice: base64/hex recall 30% → 93%

Fixed

Decode-through pipeline preserves companion context now. Decoded chunks used to be bare bytes with no surrounding text - every detector anchored on a companion keyword (aws_secret = …, Authorization: Bearer …, api_key: …) lost its anchor as soon as the credential was recovered from an encoded blob. push_decoded_text_chunk_spliced in crates/scanner/src/decode/pipeline.rs now splices the decoded text BACK into the parent at the position of the original encoded blob. Measured on the new encoding_explosion_runner corpus (348 detectors × ~2 positives):

encodingbeforeafterdelta
base64-std30.5%93.1%+62.6pp
base64-url30.5%92.8%+62.3pp
hex30.5%92.8%+62.3pp
url-percent15.5%79.7%+64.2pp

Migrated decoders: base64 (Base64Decoder + Z85Decoder), hex, json, url (via decode_candidates). Splice path is memory-capped at 256 KiB parent so multi-MB chunks don’t blow allocation.

Added

  • keyhog scan --proxy <URL> - route every outbound HTTP request through an HTTP/HTTPS/SOCKS5 proxy. Falls back to KEYHOG_PROXY / HTTPS_PROXY / HTTP_PROXY / ALL_PROXY env. --proxy off disables proxying including env inheritance (air-gapped scans).
  • keyhog scan --insecure - skip TLS verification for every outbound request. Needed when scanning through Burp / mitmproxy CAs with self-signed certificates. Env: KEYHOG_INSECURE_TLS=1.
  • Shared keyhog_sources::http policy module. Single source of truth for proxy + TLS + UA so an operator setting KEYHOG_PROXY affects every outbound request uniformly.
  • 40 000-case proptest suite for the HTTP-client policy and SARIF dedup contracts (crates/sources/tests/property/http_fuzz.rs, crates/core/tests/property/sarif_dedup.rs).
  • 5 500-case adversarial wrapper-explosion runner - re-embeds every contract positive in 8 real-world formats and asserts the detector fires.
  • 6 500-case path-shape runner - replays every positive at 5 production paths and 4 suppressed-shape paths.
  • 5 070-case encoding-explosion runner with split decode-hit vs incidental-hit metrics. Floors pinned so a regression below 88% base64 / 92% hex / 75% url-percent trips the gate.
  • tests/live_verify.rs - env-gated live-verify smoke against real AWS/GitHub creds (KEYHOG_LIVE_VERIFY=1).
  • tools/diff_bench/ - single-shot runner that drives keyhog + trufflehog + gitleaks across one labeled corpus (positives synthesized at CI runtime to dodge push-protection) and emits differential_results.json with per-scanner precision / recall / F1 / timing. .github/workflows/differential-bench.yml runs nightly + on workflow_dispatch.

v0.5.14 - 2026-05-23 - macOS x86_64 + Windows release binaries

Added

release.yml now produces five assets per tag instead of two:

  • keyhog-linux-x86_64 (default features, dynamic Hyperscan)
  • keyhog-macos-aarch64 (Apple Silicon, portable features)
  • keyhog-macos-x86_64 (Intel mac, portable features) - new
  • keyhog-windows-x86_64.exe (MSVC, portable features) - new

The Windows + Intel-mac variants share the existing portable feature subset (every detector data feature, every git / web / github / s3 / docker / verify source backend, no Hyperscan / Ghidra / CUDA system libs). Daemon IPC is #[cfg(unix)]-gated, so it compiles to a stub on Windows hosts without disabling the rest of the binary surface. v0.5.13 only shipped the prior two assets because the matrix change landed after the tag was cut.

v0.5.13 - 2026-05-23 - SARIF dedup so GitHub Code Scanning accepts uploads

Fixed

SARIF v2.1.0 forbids duplicate items in relatedLocations. When a finding had the same supplemental location reported twice (e.g. verifier echo + scanner overlap), GitHub Code Scanning rejected the whole SARIF with relatedLocations contains duplicate item, silently losing every finding on the upload. The dedup runs on a (file_path, line, offset) key before serialization, so each related location appears at most once.

This is what unblocks the fleet-wide keyhog.yml CI rollout - prior to this fix every repo that produced a finding lost its SARIF, leaving the Code Scanning tab empty even when the run was “green”.

v0.5.12 - 2026-05-23 - dedup 9 more dup-primary detectors

Fixed

Dropped the duplicate “secret/companion” primary in nine more detectors. Companion-only text no longer fires the detector without the id-half nearby.

  • hashicorp-vault-approle-credentials (Vault Secret ID)
  • qualys-api-credentials (qualys_username)
  • remitly-api-credentials (Remitly client ID)
  • smartproxy-credentials (smartproxy_username)
  • tidb-cloud-credentials (TiDB Public Key)
  • veracode-api-credentials (veracode_api_secret)
  • zscaler-api-key (zscaler_client_secret)
  • zuora-api-credentials (zuora_client_secret)
  • cloudflare-zero-trust-service-token (client_secret) - positives use the Client-Id shape, so dedup is safe even with main contract.

belvo, crisp, env0, exoscale, checkmarx, crowdstrike, fastspring, fedex still have the dup-shape - their main contracts have a secret-only positive that fires by design, so dedup would regress recall and isn’t a safe local sweep.

Changed

  • Pattern count 1674 → 1665 across README + e2e_binary + readme_claims gate.

v0.5.11 - 2026-05-23 - dedup carbon-black + databricks

Fixed

  • carbon-black-api-key: dropped duplicate org-key primary (kept as required companion). org_key=… alone no longer fires the detector without a CB API KEY primary nearby.
  • databricks-token: dropped duplicate workspace-url primary (kept as companion). A bare workspace URL with no dapi token nearby no longer fires the detector.

Same SURPLUS shape as the v0.5.9/v0.5.10 sweeps. These two had existing main contracts whose positives did NOT depend on the dropped primary firing alone - verified before edit.

Changed

  • Pattern count 1676 → 1674 across README + e2e_binary + readme_claims gate.

v0.5.10 - 2026-05-23 - detector dedup sweep + binary/crates alignment

Fixed

  • Dedupe primary-equals-companion in 14 detectors (idenfy, infura, jumio, marvel, packer, scaleway, sovos, thomson-reuters-onesource, time4vps, twilio-iot, upcloud, vonage-video, wix, woocommerce). Each listed the “secret / companion” half as a duplicate primary regex; companion-only text would fire the detector. Same SURPLUS shape closed in v0.5.9 for ringcentral/booking-com/vanta/trulioo/appdynamics/ avalara/akoya - sweeping the rest of the corpus that has no main contracts yet so existing positives can’t regress.
  • Test-target clippy lints in gpu_ac_recall_bug_56, cve_replay_runner, companion_contracts_runner, property/scanner_fuzz.

Changed

  • Pattern count 1697 → 1676 across README banner + e2e_binary::README_PATTERN_COUNT + readme_claims gate.
  • v0.5.10 binary release and crates.io publish are built from the same commit. v0.5.9 shipped a linux binary built from the tag commit before CI dedup landed; crates.io was never published at 0.5.9 (CI test red on the pattern-count drift).

v0.5.9 - 2026-05-23 - companion contracts gate + LFS coverage

Fixed

  • Companion contracts gate (12 issues closed). Five detectors (ringcentral, booking-com, vanta, trulioo, appdynamics) listed the “secret” half as a duplicate primary regex, so the secret-only negative_companion_lookalike fixture fired the detector. Removed the duplicate primaries; secret is now companion-only. Akoya / avalara had the same dup-primary shape.
  • bitbucket-app-password companion regex. Was [a-zA-Z0-9._-]+ (matched anything), so primary-only text populated companion.username from inside the primary’s own assignment line and verification proceeded despite must_not_verify. Re-anchored to bitbucket_username= shape.
  • ringcentral companion now anchored to client_secret= shape so id-only text no longer populates client_pair and triggers VERIFY-RISK.
  • Three twilio companion fixtures used xxx / fake placeholders containing non-hex characters that the example-credential filter suppressed; swapped to realistic hex so the gate tests the engine behavior, not the example-credential filter.
  • rustfmt - scan_gpu.rs + engine/mod.rs re-joined now-short calls after the matchingscan module migration.

Changed

  • .gitattributes now covers contracts/companion/*.toml in LFS. The original LFS rule was non-recursive; companion fixtures with Twilio-shaped strings would otherwise trip GitHub push-protection.

v0.5.8 - 2026-05-23 - daemon wire-v2, GitHub Action, contracts gate

Added

  • GitHub Action that actually works. uses: santhreal/keyhog/.github/actions/keyhog@v0.5.10 now installs the Rust toolchain + Vectorscan/Hyperscan and builds keyhog, or downloads a prebuilt binary from the matching GitHub Release when one exists. Previously the action ran cargo build without setup, so every downstream Ubuntu run failed with cargo: command not found or a hyperscan-sys linker error. SARIF output auto-uploads to code-scanning when format: sarif. README example was also pointing at a nonexistent keyhog/keyhog-action@v1 repo - fixed to the bundled action path.
  • .github/workflows/release.yml - tag-driven binary build
    • upload. Pushing a v* tag now compiles keyhog for keyhog-linux-x86_64 (default features incl. Hyperscan via apt) and keyhog-macos-aarch64 (feature subset, no Hyperscan), then attaches the artifacts to the release. The composite action prefers these prebuilt binaries over a cold cargo build whenever the host triple matches.
  • KEYHOG_DOGFOOD=1 - daemon-side dogfood capture. Set when starting the daemon (KEYHOG_DOGFOOD=1 keyhog daemon start) to enable per-scan event capture inside the daemon; the events cross the wire to the client and flow into --dogfood output. Per-request toggling is not wired - env-var gating keeps one client’s debug session from bleeding into another client’s payload on a shared daemon, which a per-request flag would break without additional isolation work.
  • Daemon mode. keyhog daemon start | stop | status runs a long- lived scanner over a Unix socket (default $XDG_RUNTIME_DIR/keyhog.sock, falls back to ~/.cache/keyhog/server.sock; socket is chmod 0600). keyhog scan --daemon (or auto-detected when the socket exists) routes a stdin scan / single-file scan through the daemon instead of paying the ~3 s CompiledScanner::compile cold start. Measured 105× speedup (7 ms via daemon vs 740 ms in-process) on a real GitHub PAT, same detector + hash + offset in both paths. --daemon=off forces the in-process path. --verify, --baseline, directory walks, git-staged scans, and archive decoding stay in-process by design (the daemon doesn’t replicate that pipeline).
  • .keyhogignore gitignore-style shorthand. Bare path globs (*.log, node_modules/, vendor/**/*.json) and bare 64-char hex hashes are now accepted alongside the explicit path: / hash: / detector: prefixes. Lets users drop a copied .gitignore in place and have it work.
  • --max-file-size skip summary. Files dropped by the size cap now emit a per-file WARN AND an end-of-scan summary line (“N file(s) skipped: exceeded –max-file-size”). Walker’s silent filter was the only behavior before - a user looking at a smaller-than-expected scan had no signal about which files were dropped.
  • Live progress ticker. Long scans paint a self-overwriting scanning N/M chunks · K findings · t.t s line on stderr every 250 ms; suppressed under --stream or when stderr isn’t a TTY.
  • 25 companion-required detector contracts at crates/scanner/tests/contracts/companion/. Per-detector TOMLs encode the three-shape contract (positive_with_companion, positive_primary_only with must_not_verify, negative_companion_lookalike) for AWS, Twilio (api-key / auth-token / IoT), Algolia, Razorpay, Amplitude, AppDynamics, Avalara, Backblaze, Belvo, Bitbucket, Booking, Akoya, 4everland, Lark, Linear, Linode, Plaid, Reddit, RingCentral, SumoLogic, Trulioo, Vanta. Runner test at companion_contracts_runner.rs enforces all three shapes per contract.

Fixed

  • contracts_runner was flaky across CI vs local. The 341-fixture loop reused a single CompiledScanner and never called clear_fragment_cache() between scans, so the cross-file reassembly cache accumulated. CI’s filesystem-iteration order put braintree’s sandbox_… positive ahead of blur-api-key’s evasion and the sandbox credential surfaced as the only finding on "blur key = \"Kp4Q…\"" - a non-deterministic failure invisible locally. Fix: clear the cache before every scan in contracts_runner.rs (5 sites) and companion_contracts_runner.rs (3 sites) per the documented test-isolation API in engine/mod.rs:747-760.
  • blur-api-key regex required uppercase KEY while the contract evasion uses lowercase key. Prepended (?i) and lower-cased the literals; the contract evasion now hits the intended case-variant path. Tests assert truth, not shape - weakening the test would have masked the engine gap.
  • Daemon-mode --dogfood was inert. Engine-side telemetry (record_example_suppression calls from pipeline.rs::should_suppress_known_example_credential_*) fired inside the daemon process - the client never saw any of it, so keyhog scan --dogfood demo-secret.env against a daemon silently dropped every suppression event and the reporter counter stayed at 0. Wire protocol bumped 1 → 2: Response::ScanResults now carries engine_example_suppressions: u64 and dogfood_events: Vec<DogfoodEvent> (both #[serde(default)], so a v2 client tolerates a v1 daemon). Daemon drains its per-scan telemetry after each scanner.scan(...) and resets; client merges the values into its own OnceLock<Telemetry> via two new public helpers (add_example_suppressions(n), append_events(iter)). Verified locally: --daemon=off AND a fresh daemon both emit “No real secrets - but 6 example/test keys suppressed. Pass –dogfood to see them.”
  • demo-secret.env summary regressed to the clean-repo message. The v0.5.7 fix wired TextReporter to read the suppression count, but the orchestrator’s test_fixture_suppressions.suppresses() branch ran before any telemetry write - AKIAIOSFODNN7EXAMPLE matched the bundled substring suppression list and returned false without incrementing the counter, so the reporter still saw 0 and printed “Your code is clean.” Now bumps record_example_suppression(..., "test_fixture_suppression") before returning. Same patch in the daemon-side finalize_for_report filter. Locked by e2e_binary::demo_secret_aws_example_summary_distinguishes_suppression_from_clean.
  • Mega-scan allocated ~20 GB RSS on tiny inputs. Every shard’s static input/state buffers were sized for MEGASCAN_INPUT_LEN=256 MiB. Forcing --backend mega-scan on a 19-byte file uploaded ~570 × 256 MiB ≈ 20 GB of GPU memory and burned ~20 s before returning. Small-buffer guard at the entry of scan_coalesced_megascan now routes batches under 64 KiB through the literal-set GPU path. Same recall (same AC literal prefix anchors), orders of magnitude lower setup cost. Confirmed 20.77 s / 19.7 GB → 0.34 s / 399 MB on the kimi reproducer.
  • GPU fallback regex-NFA dispatch silently dropped to CPU. The fallback RulePipeline::scan was passed max_matches_per_dispatch=1_000_000 which trips vyre’s hard-coded max_hits=10_000 static buffer declaration. Capping the dispatch at NFA_HITS_PER_DISPATCH=10_000 keeps the GPU path live; the always-active fallback regex set is small enough that 10 K matches per dispatch is well above what we’d ever see.
  • env::args() panicked on non-UTF-8 args. Linux allows raw-byte paths; std::env::args() calls .unwrap() on each Result which aborts with SIGABRT. Switched the version-flag detection in main.rs to args_os() + lossy compare.
  • Non-UTF-8 paths reported “No such file or directory” even when the file existed. New pre-flight at the CLI boundary refuses non-UTF-8 paths with a clear message (“Rename the file or scan its parent directory”) instead of confusing the user with a missing-file rabbit hole.
  • Nonexistent / unreadable input paths exited 0 with a WARN and “No secrets found, your code is clean.” Per the documented exit-code contract these are runtime errors. CLI now stat’s the input pre-walk; missing path → exit 2 with “path does not exist”, unreadable file → exit 2 with “cannot read … (fix chmod +r …)”.
  • --backend invalid silently ignored and the scan ran with the default. clap now validates against the PossibleValues set {gpu, mega-scan, megascan, simd, cpu, auto} and exits 2 with a clear error.
  • .keyhogignore detector: entries were dead. The parser populated ignored_detectors but the orchestrator’s per-finding filter never read it. Now applied alongside is_path_ignored / is_raw_hash_ignored.
  • RefCell double-borrow panic in fallback.rs. Per-pool thread-local borrows now try_borrow_mut + fresh-alloc fallback at three sites (ACTIVE_PATTERNS_POOL, ACTIVE_INDICES_POOL, TRIGGER_POOL). Was a hard P0: the rayon worker re-entry caught itself on the second borrow and aborted mid-scan.
  • FP storms killed: lastpass-dev-creds firing on random id=<digits> in /var/log archives (87% FP rate per kimi); GitHub PAT placeholder ghp_xxxxxxxx… flagged at 0.80; xoxb tokens with ascending-digit runs flagged. Tightened lastpass-dev-creds to require lastpass context within 40 chars; extended looks_like_prefixed_masked_sequence to suppress x/X-dominance, all-same-char, and ascending-digit-run ≥ 13.

Improved

  • CUDA driver is opt-in. The cuda feature was on by default, which made cargo build fail on any host without libcuda.so / libnvrtc.so / libcudart.so - including macOS, most CI runners, and any Linux box without an NVIDIA driver stack. The default scanner build now uses wgpu (Vulkan on Linux, Metal on macOS) for GPU dispatch. CUDA users opt in with --features cuda when they want the CUDA backend specifically. Drops the link-time CUDA requirement from every default build.
  • scripts/publish.sh reads the version from Cargo.toml. Renamed from publish-0.5.6.sh (which would silently emit “All v0.5.6 crates published” even when publishing v0.5.7). The new script awks [workspace.package].version and uses that everywhere - no per-release rename or message edit.
  • LayeredPipelineCache short-circuits compile on warm hits. The prior rule_pipeline_cached always called build_rule_pipeline upfront to keep typed-error semantics for vyre’s infallible-closure cached_load_or_compile, which made the on-disk cache pointless. Now uses vyre’s engine_cache_path + manual load/save so a warm hit returns the deserialised RulePipeline without paying the compile.
  • PreparedChunk::line_offsets() memoised via OnceLock. compute_line_offsets used to walk the preprocessed text twice per chunk (once for the triggered path, once for the pattern-hits path); the second caller now hits the memoised Vec.
  • Mega-scan compile-failure WARN demoted to debug. Falling back to the literal-set GPU dispatch when vyre’s byte-NFA frontend can’t represent every pattern (e.g. pattern 990 in the bundled detector corpus uses lookaround) is the designed degradation - the user can’t fix it, and one WARN per --backend mega-scan invocation creates noise without signal.

Differential parity

.internal/bench/differential/compare.py against gitleaks 8.30.0 and trufflehog 3.95.3 on the 64 MiB big_with_secrets corpus: gate green. Every secret two independent competitors HASH-confirm keyhog also surfaces, except sk_live_4eC39… which is documented as a public Stripe docs example (suppressed by test_fixture_suppressions::bundled() and listed in baseline.toml).

v0.5.7 - 2026-05-17

Fixed

  • The ‘No secrets found. Your code is clean.’ message lied when every match was suppressed as an EXAMPLE/test key. The 0.5.6 bump wired example-suppression telemetry into the orchestrator, but the user-facing summary is owned by TextReporter::finish() in keyhog-core, not the orchestrator - so the misleading banner still printed. TextReporter now takes the suppression count via set_example_suppressions(n) and prints “No real secrets - but N example/test key(s) suppressed. Pass –dogfood to see them.” instead. Verified end-to-end against demo-secret.env. Regression tests pin all three states.

v0.5.6 - 2026-05-17

Added - dogfooding-driven UX

  • --dogfood - opt-in JSON trace on stderr after the scan. Each example/test/placeholder credential that was matched and then suppressed gets a redacted-prefix event with the algorithmic reason (contains_EXAMPLE_token, algorithmic_placeholder). Closes the “did the scanner miss this, or silence it?” question without a debug rebuild. Full credentials are never emitted - --dogfood is a decision tracer, not a credential exfil channel.
  • Honest scan summary when only example keys were found. Previously, scanning demo-secret.env (which holds AKIAIOSFODNN7EXAMPLE) printed “No secrets found. Your code is clean.” - identical to a genuinely clean repo. Now the summary distinguishes:
    • 0 findings, 0 suppressed → “0 secrets in 0.12s. You are secure!”
    • 0 findings, N suppressed → “0 real secrets, N example/test key(s) suppressed (pass –dogfood to see them).”

Internal

  • New keyhog_scanner::telemetry module: per-scan atomic counters + optional event log. Engines call record_example_suppression(...) from the existing should_suppress_known_example_credential_* paths; the orchestrator drains events at the end of run(). Zero new state threaded through engine boundaries - single OnceLock process-local container with a reset() for tests.
  • Two regression tests pinning the demo-secret.env case + the dogfood redaction contract. Telemetry-touching tests serialise behind a module-local Mutex so cargo test’s parallel runner doesn’t let them step on each other.

v0.5.5 - 2026-05-09

GPU foundations + vyre composition pass. The session wires keyhog deeper into vyre as a primitive consumer and contributes new general-purpose capability back to vyre.

Tier-aware GPU routing + 2 MiB threshold on RTX 40/50-class GPUs. select_backend now classifies the detected adapter into High / Mid / Low tiers and consults per-tier crossover thresholds:

TierAdapter examplesmin_bytessolo cap
HighRTX 40/50, A100/H100, M-Max/Ultra, RX 79002 MiB16 MiB
MidRTX 20/30, GTX 16, Arc, M-Pro/base, RX 6/716 MiB64 MiB
LowiGPU, older discretes, unknown64 MiB256 MiB

Pattern-count breakeven is also tier-aware (100 / 500 / 2000). keyhog backend reports the active tier and effective thresholds for the live adapter. Backwards compatible: unknown adapters classify as Low and keep the legacy thresholds.

GPU dispatch sharding + correctness fix. scan_coalesced_gpu now slices the coalesced buffer at 65535 * 32 = 2,097,120 bytes per dispatch (the wgpu workgroup-per-dimension cap × vyre’s workgroup_size_x = 32) and re-bases shard-local match offsets into the global buffer’s coordinate space. Eliminated the silent dispatch group size > 65535 error that the prior single-dispatch path hit on every 100 MiB+ batch. Recall on the realistic benchmark fixture now matches CPU/SIMD within rounding (303,554 vs 302,168 vs 304,128) - earlier 121× speedup numbers were lying because the dispatch errored mid-batch and only ~1% of true hits came back.

Vyre intern::perfect_hash wired for static-string interning. CompiledScanner builds a CHD perfect hash from every detector’s (id, name, service) plus the seed source-type literals at construction time. ScanState::intern_metadata consults this frozen interner first; only dynamic strings (file paths, commit SHAs, author names, dates) hit the per-scan HashSet<Arc<str>> fallback. Per-scan allocation count drops by ~100k on a typical 1000-chunk run. 6 unit tests + 282 scanner tests still green.

Vyre megakernel scaffolding (gated behind KEYHOG_USE_MEGAKERNEL). engine/megakernel_dispatch.rs ships a working DFA-per-literal compile + BatchDispatcher init + dispatch loop that hands back the same per-chunk per-pattern trigger bitmask the literal-set GPU path produces. Routed in scan_coalesced_megakernel behind the env opt-in. Defaults OFF: vyre’s BatchDispatcher is optimised for “many files × few rules” but keyhog’s corpus is “few files × 6000+ rules” - modelling each literal as its own BatchRuleProgram allocates chunks × rules ≈ 600,000 work items per dispatch, which keeps the persistent kernel sleeping in S-state on RTX 5090. Real megakernel win needs vyre-side multi-pattern hit reporting (one DFA covering many literals, HitRecord gains a per-pattern field) - wiring then collapses to a one-line swap.

Cross-platform compile fix in vendored vyre-runtime: GpuStream<'a> now carries PhantomData<&'a ()> on non-Linux so the lifetime parameter isn’t flagged unused when uring is cfg’d out. Windows / macOS builds now pull vyre-runtime cleanly.

Vyre rule engine wired for declarative .keyhogignore.toml.

Upstream vyre additions (general-purpose, lives in vyre-libs):

  • vyre_libs::rule::cpu_eval - pure-CPU evaluator for RuleCondition / RuleFormula trees. Mirror of the GPU lowering. Useful for any consumer that wants per-record rule evaluation without dispatching a backend program. 11 unit tests.
  • vyre_libs::rule::ast::RuleCondition::FieldInSet - new variant for “context field’s value is in this set”. Distinct from SetMembership (which compares a static value, not a field lookup). Required for expressing “detector_id is one of …” without resorting to regex alternation. Builder lowering errors with an actionable Fix: message - only the CPU evaluator can resolve field lookups today.
  • vyre smallvec workspace pin bumped 1.14.0 → 1.15.1 so consumers carrying gix (which requires ^1.15.1) can share the type - keyhog needed this to put SmallVec<[Arc<str>; 4]> on the wire between core and vyre.

Keyhog consumes via new crates/core/src/rule_filter.rs. Schema documented in docs/keyhogignore-toml.md. [[suppress]] tables compose AND of named predicates (detector / service / severity / severity_lte / path_eq / path_contains / path_starts_with / path_ends_with / path_regex / credential_hash). Multiple [[suppress]] tables compose with OR. Empty entry rejected at parse to prevent accidental suppress-everything. Unknown fields rejected via serde deny_unknown_fields. Wired into orchestrator.rs::run after finalize() returns VerifiedFindings - predicates need the resolved fields that dedup_cross_detector populates. Malformed .keyhogignore.toml is non-fatal: warn + load zero rules; legacy .keyhogignore still applies. 11 keyhog rule_filter tests pass.

Realistic benchmark fixture. The previous --benchmark corpus used 36-char alphanumeric filler on every line, triggering the entropy detector constantly so the benchmark was measuring per-chunk extraction cost rather than the literal-prefilter crossover it claims to measure. New fixture mirrors typical TypeScript/Go/Rust source: short identifiers, natural-language comments, short string literals. RTX 5090 against this fixture: 130 MiB/s (cpu-fallback) / 136 MiB/s (simd-regex) / 34 MiB/s (gpu-zero-copy). The architectural fix for GPU loss on dense corpora is megakernel fusion of the extraction pipeline (vyre upstream feature, queued).

Vyre full 30-crate audit doc (docs/vyre-usage.md). Catalogues every vyre crate (foundation, driver, driver-wgpu, driver-megakernel, driver-spirv, libs, primitives, runtime, spec, intrinsics, reference, cc, harness, macros) with the public surface of each. Lists every vyre-libs and vyre-primitives module by name with what keyhog could conceivably wire from each.

v0.5.4 - 2026-05-08

Roadmap-clearing pass plus the first crates.io publish for every workspace crate. The README’s “Roadmap” section drops four items and a long-standing ignored regression test goes green.

Cross-chunk window-boundary reassembly (roadmap #3). New crates/scanner/src/engine/boundary/mod.rs splices the tail of each large-file scan window to the head of the next and rescans the seam, catching secrets that physically straddle the 64 MiB scan-window boundary. Wired into scan_coalesced after Phase 2 in both the SIMD and no-SIMD paths. Bounded to 1 KiB per side (2 KiB per pair), so cost is independent of chunk size: a 64 GiB file sliced into 1000 chunks pays ~2 MiB of total boundary work - negligible next to the per-chunk regex pass. Six unit tests + the previously-#[ignore]- marked test_window_boundary_detection integration test now pass; the test itself was rewritten to use an AKIA-shaped secret (the original XX_FAKE_* shape was unconditionally suppressed by the placeholder filter, so the test would have stayed red even with reassembly).

keyhog detectors --audit and keyhog detectors --fix (roadmap #4). detectors --audit runs every detector through keyhog_core::validate_detector, prints issues grouped by detector ID, and exits with code 3 when any Error-severity issue surfaces - drop it into CI to gate detector PRs. detectors --fix scans the on-disk TOML corpus for the one validator finding that’s safe to repair mechanically - single-brace template references ({shop}) inside [detector.verify*] blocks - and rewrites them to the double-brace form ({{shop}}) the interpolator actually honours. Rewrites are scoped to verify blocks only (regex quantifiers like [A-Z]{4,6} in pattern blocks stay untouched), atomic-written via NamedTempFile, and re-validated post-rewrite so a corrupted result backs off rather than overwriting the original. --dry-run previews without writing. The 888-detector embedded corpus shows zero errors today (the v0.4.x detector cleanup wave already cleared them) - the subcommand is the regression net for the next batch of contributions. Seven unit tests cover the rewriter’s edge cases.

Streaming finding previews (roadmap #5). New --stream flag emits a one-line redacted preview to stderr per finding as the scanner produces it, instead of waiting for dedup + verification before printing anything. Format is grep-friendly: [stream] CRITICAL aws/aws-access-key src/foo.rs:42 AKIA...XYZ_a. The full report (text/json/sarif/jsonl) still lands on stdout/--output at the end - the stream is purely a UX hint that the scanner is making progress on long-running runs (large monorepos, scan-system, GitHub-org walks). Implemented inside the existing scanner thread via io::LineWriter so per-line writes land atomically across rayon workers.

--verify-rate + --verify-batch (roadmap #7). The per-service token-bucket rate limiter (crates/verifier/src/rate_limit.rs) is now hot-swappable via a new set_default_rps() (atomic-backed nanosecond interval) so the CLI’s --verify-rate <RPS> flag can take effect after the global limiter has lazily initialised. Default stays at 5 rps; existing per-service overrides via update_limit are preserved. --verify-batch adds per-service serialisation (max_concurrent_per_service = 1) on top of the rate cap - use it for repos with hundreds of fixture findings where bursting an upstream auth endpoint would get the scan IP throttled. Three new unit tests cover the rps→nanos clamp behaviour and the atomic update path.

Robustness sweep.

  • entropy_1000_chars_under_1ms was unconditionally failing under cargo test on debug builds (2.5 ms vs the 1 ms threshold). Marked #[ignore] matching the two sibling perf-threshold tests; rerun locally with cargo test -- --ignored against a release build.
  • crates/cli/src/scan_runtime.rs was a 0-byte dead module with no references anywhere in the workspace. Deleted.
  • Workspace license field downgraded from MIT OR Apache-2.0 to MIT - the only license file shipped in the repo is the MIT one. Honesty over ecosystem convention.
  • cargo clippy --workspace --all-targets now clean (was 4 warnings: unused-mut in dedup.rs, items-after-test-module in orchestrator_config.rs, an unnecessary as_ref() in the new streaming preview, and an explicit-counter loop in extract_plain_matches that’s intentional for deadline-cadence gating and now carries an explanatory #[allow]).
  • detectors/.keyhog-cache.json (runtime parse cache) is now gitignored AND keyhog-core/Cargo.toml carries an explicit exclude so a stale cache file can’t sneak into the published tarball.
  • scripts/audit.sh wraps cargo audit with the four accept-with-rationale --ignore flags so local audits exit clean the way CI does (cargo-audit 0.22 doesn’t auto-load audit.toml).

Crates.io publish setup. Workspace package metadata (description/license/repo/homepage/docs/keywords/categories/readme) audited end-to-end across all five crates; package contents verified via cargo package --list for each crate before publish (no stray fixtures, no .work-linux.bundle, no target tree). Path-dep version pins on the four library crates bumped in lockstep with the workspace version (=0.5.4 everywhere) - the = pin guarantees a downstream cargo install keyhog 0.5.4 resolves to a self-consistent set.

v0.5.3 - 2026-05-07

I/O perfection pass - five staged perf + correctness landings on the filesystem source path, plus one latent-bug fix surfaced by the new test coverage.

Stage A - content cache (perf + correctness). Merkle index schema v2: each entry now carries (mtime_ns, size, BLAKE3) and the file gets a top-level spec_hash derived from the canonical detector set. metadata_unchanged(path, mtime, size) short-circuits the file read entirely when stat metadata matches a stored entry - the dominant cost on cold-cache disk for --incremental re-runs. load_with_spec(path, expected_spec_hash) invalidates the cache the moment any detector regex, group, or companion changes, fixing a latent correctness bug where an added detector would silently miss unchanged files forever.

Stage B - mmap big-file scan. Replaced the read+seek loop in FilesystemSource’s >64 MiB path with a single mmap + zero-copy slice into window_size-byte windows with window_overlap shared bytes between neighbours. Drops the 64 MiB heap working buffer and the per-window seek+re-read overlap round-trip; madvise(SEQUENTIAL) drives kernel readahead. Falls back cleanly to the buffered loop when mmap is refused (locked writer, exotic filesystem).

Stage C - I/O ↔ scan pipeline. scan_sources spawns the scanner in a dedicated thread holding Arc<CompiledScanner>. The producer (main thread) iterates sources and builds batches; the scanner pulls completed batches off a sync_channel(1) and runs scan_coalesced. While the scanner is busy on regex, the producer is busy on disk I/O, so total wall time approaches max(read, scan) instead of read + scan. Channel capacity 1 keeps memory bounded to one in-flight batch.

Stage D - mmap compressed reads. ziftsieve only takes a contiguous &[u8] so streaming decompression isn’t on the menu, but mmap’ing the compressed file lets us hand it the whole input without a corresponding heap allocation. A 1 GiB .zst previously manifested as a 1 GiB Vec<u8> before decompression began. New FileBytes enum (Mmap | Owned) with size-cap gating; falls back to fs::read only on mmap refusal.

Stage E - per-platform mmap threshold. Lowered to 64 KiB on Unix where mmap setup is sub-microsecond and avoids the page cache → userland buffer copy. Held at 1 MiB on Windows where MapViewOfFile carries section-object + security-token costs that buffered ReadFile doesn’t pay.

Latent bug fixed alongside Stage D. gz and zst were in SKIP_EXTENSIONS, so the extract_compressed_chunks dispatch arm in the FilesystemSource iterator was actually unreachable - compressed files were silently being skipped on every scan. Removed those entries (the gz/zst handler now actually runs).

Tests. ~55 new tests covering: 13 merkle_index v2 unit, 12 window-slicing pure-helper unit, 4 FileBytes/mmap-or-bytes unit, 6 pipeline orchestrator unit (including a 6000-chunk recall floor that proves the threading doesn’t drop batches), 9 FilesystemSource integration covering the windowed path, merkle skip, and gz end-to-end. Existing 53 scanner lib + 31 sources read unit + 20 filesystem integration all still green on both Windows and Linux.

Code cleanup. Removed dead detector_to_patterns field + helper from the scanner (unused since the v0.5.2 perf trim). Tightened the Arc import gate in crates/sources/src/lib.rs so docker-only builds no longer warn about unused imports.

v0.5.2 - 2026-05-06

Reconciliation pass against the parallel hardening line (v0.3.0 → v0.4.0 → v0.5.0) that lived only on the work-linux clone and was never pushed. Both lines diverged at 013257e (CI fmt scope) and independently arrived at near-identical scanner/sources state.

Reviewed every file the work-linux line touched; no salvageable code was missing from this branch:

  • SensitiveString migration, MADV_DONTDUMP zero-leak buffers, proximity-aware multiline reassembly, hardened ratelimiter, AC prefilter for has_secret_keyword_fast - already present here, fmt-clean, with the no-default-features feature gates the v0.6.x pass added.
  • The 6 secret-laden boundary-test fixtures (test.txt, boundary_test.txt, etc.) accidentally committed in work-linux’s v0.4.0-finalize commit are intentionally not brought in: they trip GitHub push-protection and the boundary test that needed them was rewritten to use a synthetic XX_FAKE_* shape in v0.6.1.
  • crates/sources/src/slack.rs:54 data: T.into() syntax bug that still exists on the work-linux line was already fixed here in v0.6.0.

Net new: version bump only. No code regressions, no losses.

vendor/vyre is untouched - separate project with its own versioning.

v0.6.1 - 2026-05-06

Perfection pass on top of v0.6.0.

Fixed

  • crates/sources/src/binary/{mod,sections}.rs: 5 type errors (the extract_printable_strings wrapper claimed Vec<String> while the underlying call returned Vec<SensitiveString>). Any build with --features binary previously failed to compile.
  • aws-access-key.toml: dropped required = true from the secret_key companion. A leaked AKIA on its own is still a reportable finding; verification correctly downgrades to “unverified” when no co-located secret is found instead of silently dropping the match.
  • crates/core/tests/unit/spec.rs: the no_detector_uses_singular_companion_table test now mirrors crates/core/build.rs’s symlink fallback so it works on Windows checkouts where crates/core/detectors lands as a literal file containing the link target.
  • crates/scanner/tests/performance_regression.rs: replaced the CRC32-invalid ghp_ABCDEF… synthetic with an AKIA-shape fixture so the test exercises the no-default-features build (where checksum validation fails closed).
  • 3 adversarial tests gated behind the features they exercise (ml, multiline, decode); previously they ran under --no-default-features and asserted behavior that requires those features.

Hygiene

  • cargo clippy --workspace --no-default-features --all-targets clean (zero warnings) under both --no-default-features and the default-minus-simd matrix.
  • cargo fmt --check clean.
  • 596/596 tests pass under both feature configurations.

v0.6.0 - 2026-05-06

Out-of-band callback verification + broad robustness/detector fixes.

Added

  • OOB verification (--verify-oob): RSA-2048 + AES-256-CFB interactsh client (oast.fun by default; --oob-server HOST to self-host). Detector TOML gains an [detector.verify.oob] block with protocol={dns,http,smtp, any}, policy={oob_and_http,oob_only,oob_optional}, and accept={dns,http,smtp,any}. Probe payloads can interpolate {{interactsh_url}}, {{interactsh_host}}, and {{interactsh_id}} to embed a unique callback URL per probe; the session waits for a matching hit before declaring the credential live. Documented in docs/OOB.md.
  • keyhog_core::spec::validate now audits companion-substitution capture groups, reserved companion names (__keyhog_oob_*), and that every {{companion.X}} / auth-field reference resolves to a declared companion.

Fixed

  • extract_grouped_matches (scanner): zero-width regex hits no longer infinite-loop the matcher; capture-group walk reuses a single CaptureLocations and aligns to UTF-8 boundaries; out-of-range detector index now fails closed instead of panicking.
  • Required companions (required = true) actually short-circuit: prior unwrap_or_default() swallowed the “missing required companion” signal and shipped the finding anyway.
  • OobSession::wait_for race: registers the Notified waiter via Notified::enable() before checking observations, so notifications fired between the check and the await no longer get lost.
  • 8 detector verify specs that referenced undeclared companions or used template strings in the auth-field slot would 401 every probe (Twilio IoT, Akoya, Razorpay, Braintree sandbox, etc.). Each now declares the companion it references.
  • Look-behind regex assertions ((?<=, (?<!) are no longer misclassified as named capture groups by the spec validator.
  • crates/sources/src/slack.rs: data: T.into() syntax error in SlackResponse<T> would have failed any build that exercised the slack feature.

Performance

  • Aho-Corasick prefilter for has_secret_keyword_fast and has_generic_assignment_keyword (single-pass).
  • extract_inner_literals AST walker promotes inner literals into the prefilter alphabet (corpus coverage test pins ≥3 patterns promoted).
  • find_companion splits into a capture-group-free fast path (find_iter) and a grouped path that reuses CaptureLocations.
  • Active-fallback bitmap precomputed at scanner construction; per-chunk thread-local ACTIVE_PATTERNS_POOL avoids reallocation.
  • Filesystem reader: two-sided looks_binary early exit, streaming UTF-16 decode, valid-UTF-8 fast path.
  • Slack source fetches per-channel history concurrently (rayon, 8 threads).

Hardening

  • looks_binary short-circuit verified against full-scan baseline across page-boundary cases.
  • open_file_safe rejects symlinks on Windows (Unix already enforced).
  • Self-suppression list rewritten with concat!() to keep example credentials out of the repo’s literal string table.

v0.3.0 - 2026-05-01

This hardening wave delivered 18 Tier-A perf wins + 12 Tier-B moat innovations from the 2026-04-26 deep audits, plus a perfection pass that hardened GPU/CPU auto-routing across every supported OS. Build is green, scanner test suite 229+/0, core 33+/0, hw_probe routing 11/0, doctests 38/0.

Hardware routing & GPU/CPU saturation (perfection pass)

  • KEYHOG_BACKEND={gpu,simd,cpu} env var force-pins the scan backend at the highest routing priority, used by CI matrix builds and benchmarks to assert backend-specific code paths actually run (ba0e3fc).
  • KEYHOG_THREADS=N env var threads the rayon pool size; with --threads taking absolute priority and physical-core count as the auto fallback (3c4924c).
  • Per-OS wgpu adapter preference replaces Backends::all(): Windows → DX12 + Vulkan, macOS/iOS → Metal, Linux/BSD → Vulkan + GL - each platform gets its first-class native API (ba0e3fc).
  • Public hw_probe::thresholds module exposes the routing crossovers (GPU_MIN_BYTES=64 MiB, GPU_PATTERN_BREAKEVEN=2000, GPU_BYTES_BREAKEVEN_SOLO= 256 MiB) for benchmarks and the inspector subcommand to reference one source of truth (ba0e3fc).
  • 11 routing unit tests pin every documented threshold + the env-override branch + the software-renderer skip. Tests serialize through a Mutex guard since they mutate process env (ba0e3fc, 3c4924c).
  • keyhog backend subcommand: dumps detected hardware, the active backend, the env override (if set), and a routing decision matrix at every documented threshold; --probe-bytes and --patterns for what-if simulation (ba0e3fc).
  • GPU init now requests the adapter’s full limits (was capped at wgpu Limits::default()’s 128 MiB storage-buffer ceiling; an RTX 5090 had its batch size throttled to 0.4% of physical capacity) (e182938).
  • GPU init rejects device_type == Cpu adapters at the wgpu layer too (catches future software fallbacks not in the llvmpipe/lavapipe name list) (3c4924c).
  • Per-scan tracing::info! logs the selected backend; per-chunk tracing::trace! on keyhog::routing for full audit trails (3c4924c, ba0e3fc).
  • Verifier gained danger_allow_http opt-in flag to support HTTP test mocks while keeping production HTTPS-only (0da1f94).

Performance - CPU saturation

  • scan_chunks_with_backend_internal now uses rayon::par_iter on the non-GPU paths - was serial, pinned to a single core even on 32-core boxes (a693ba2).
  • scan_coalesced parallelizes its #[cfg(not(feature = "simd"))] and Hyperscan-init-failure fallbacks; multi-core builds without Hyperscan now saturate cores (27caaf9).
  • [profile.release] pinned: opt-level=3 + lto=fat + codegen-units=1 + panic=abort + strip - was using cargo defaults; the new profile yields ~10-20% throughput on hot paths via cross-crate inlining (3c4924c).
  • [profile.release-fast] (thin LTO, 16 codegen-units) for sub-minute CI builds; [profile.bench] keeps line-tables for flamegraph attribution.

Performance - Tier-A perf wins (~constant-factor allocations on the hot path)

  • Cow-borrowed normalize_homoglyphs and prepare_chunk - ASCII fast path no longer clones (7e7cd55).
  • post_process_matches dedup keys are Arc<str>, not String (7e7cd55).
  • Thread-local trigger-bitmask pool - drops ~2.4M allocs on a 100k-file scan (7e7cd55).
  • Phase-1 returns Option<Vec<u64>> so empty chunks never allocate (7e7cd55).
  • BTreeMap dedup → indexmap::IndexMap for O(1) deterministic ordering (d3b6721).
  • Streaming SARIF reporter - peak memory drops from O(N findings) to O(rules) (3a15fd0).
  • Batched-streaming orchestrator - 4096 chunks / 256 MiB per batch caps peak memory on giant scans (a6c88b2).
  • Sharded DashMap for verifier VerificationCache, RateLimiter, and in-flight map (no more global RwLock contention) (d3b6721).
  • Concurrent rayon-parallel S3 / GitHub-org / Slack source backends (8-16 in-flight) (d3b6721).
  • Shared Arc<Regex> compile cache via shared_regex() - same regex across detectors compiles once (a38e79c).
  • Pre-built index_set once on Baseline::load via OnceLock (d3b6721).
  • Bigram bloom prefilter (Layer 0.5) - gates chunks ≥64 bytes before Hyperscan (3a15fd0).
  • Dropped io_uring single-op path (latency regression, kept the multi-op batch path) (d3b6721).
  • Decode-bomb time budget - per-chunk wall-clock ceiling on decode_chunk (20d3ef8).
  • Probabilistic gate filled in: distinct-bigram density via FNV-512 (20d3ef8).

Innovations - Tier-B moat features

  • Bayesian Beta(α,β) confidence calibration - per-detector posterior updated from observed TP/FP, multiplier wired into the live scoring path, CLI surface (keyhog calibrate --tp/--fp/--show) (34deeb0, d5d447e).
  • Incremental scan via persisted BLAKE3 Merkle index - unchanged files skip the scanner entirely on CI re-runs (57c4cc8).
  • Cross-detector dedup at emit - one secret matched by N detectors collapses to one finding with N ranked service guesses (eab71b2).
  • Diff-aware severity - git source pre-walks HEAD’s tree, tags chunks git/head vs git/history, and the latter’s findings drop one severity tier (410dc0e).
  • JWT structural validation - header.payload decode with alg/typ/exp inspection and alg=none anomaly detection (43092b6).
  • CWE-798 + OWASP A07:2021 SARIF taxa - compliance-grade reporting (5462625).
  • SARIF v2.2 fixes[] with deletedRegion/insertedContent and env-var-name auto-fix suggestions (650e599).
  • Allowlist governance metadata - ; reason="…" ; expires=YYYY-MM-DD ; approved_by="…" per entry, expired entries auto-drop (32ff3a8).
  • keyhog explain <detector-id> - full spec dump, regex breakdown, and rotation-guide URLs for major providers (f56f97e).
  • keyhog diff <before.json> <after.json> - NEW / RESOLVED / UNCHANGED set diff for CI regression detection (52d7242).
  • keyhog watch <path> - daemon mode with notify-based file watcher, compile-once-scan-many on saves; sub-100ms re-scan (56c61d6).
  • keyhog calibrate - α/β counter management with posterior-mean bar visualization (34deeb0).
  • keyhog detectors --search <query> --verbose - case-insensitive filter against id/name/service/keywords; verbose dumps full spec (5951a14).
  • keyhog completion <shell> - bash, zsh, fish, powershell, elvish (8ab105f).

Adversarial coverage

  • Reverse-string decoder for tokens stored backwards as evasion (c462e9c).
  • Caesar / ROT-N decoder for ROT13’d configs (c462e9c).
  • Hex _ separator stripping (firmware dumps, embedded configs use A1_B2_C3_…) (2980284).
  • Comment-suffix disclaimer suppression - // not a real key, # fake credential, etc. (2980284).
  • Cross-detector dedup also handles 2-fragment AWS reassembly with no-shared-prefix var names (3327b39).

Architecture

  • GPU auto-routing - runtime probe selects GPU vs CPU based on adapter type, workload size, and pattern count; mandatory build-time presence (no more feature gate) (7feb723).
  • Filesystem source: per-archive-entry uncompressed-size cap; ziftsieve gzip/zstd/lz4 4× decompressed-byte budget (5cc3906).
  • Verifier hardening: SSRF DNS-rebinding defeated via tokio::net::lookup_host post-resolve check; HTTPS-only no-localhost-exception (7feb723).
  • AWS SigV4 dates derived from SystemTime::now via Howard-Hinnant civil arithmetic (no chrono runtime cost) (7feb723).
  • fragment_cache module relocated under multiline/ where every call site lives; re-exported at the crate root for back-compat (70e35a8).

Tests

  • Wired adversarial fixtures into cargo test (no more skipped corpus) (5cc3906).
  • Aligned gitleaks_hash_* allowlist tests with the hardened is_hash_allowed API (no plaintext fallback) (b2b405d).
  • Wrapped ?-using doctests in explicit fn main() -> Result so the E0277 wave is gone (19ce4f5).
  • 229 scanner tests / 33 core unit tests / 38 doctests, 0 failed.

Detector corpus

  • Brutal audit of all 896 detectors found schema decay; corrupted entries removed, broken logic flagged (e934144).
  • Schema rename (kimi automated): aligned every detector to the post-audit field set (826d54f).
  • Verifier auth wiring fixes for the corpus (826d54f).
  • 859 valid detectors after the gate; ~30 still flagged for pure-character- class companions (tracked separately).

v0.2.1 - 2026-04-04

Maintenance release: production-readiness fixes, dependency updates, agent sweeps. See git log v0.2.0..v0.2.1 for the commit list.

v0.2.0 - 2026-03-30

The fastest, most accurate secret scanner.

First release held to the expanded quality bar. Highlights:

  • Embedded 888-detector corpus (no separate detectors/ directory needed).
  • Hyperscan SIMD regex with disk-cached compiled DB.
  • Aho-Corasick literal prefilter feeding into the regex layer.
  • ML-based confidence scoring (MoE classifier with per-detector calibration).
  • Decode-through pipeline: base64, hex, URL, MIME, HTML entities, Z85, unicode/octal escapes, quoted-printable.
  • Multiline secret reassembly across line-continuation patterns in a dozen languages.
  • Sources: filesystem, git history, git diff, GitHub orgs, S3, Docker images, web URLs (JS/sourcemap/WASM), Slack (admin export).
  • Verifier framework with TOML-defined live verification per detector.
  • SARIF v2.1.0 + JSON + JSONL + plain-text reporters.

v0.1.0 - 2026-03-26

  • First public release of the KeyHog workspace.
  • Production-readiness cleanup for docs, examples, README guidance, and release metadata.
  • Verified cargo check, cargo test, and cargo clippy --workspace -- -D warnings.