KeyHog
A secret scanner. Built in Rust. Made to be fast on big repos, careful with your time on small ones, and quiet about findings that aren’t actually credentials.
$ keyhog scan .
K E Y H O G
───────────
v0.5.41 · secret scanner · 922 detectors
by santh
┌ CRITICAL ─── Stripe Secret Key
│ Secret: sk_l...p7dc
│ Location: src/config/staging.env:14
│ Confidence: ■■■■■■ 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
Walks files - your working tree, your git history, a docker image, GitHub/GitLab/Bitbucket repository collections, an S3, GCS, or Azure Blob bucket, a list of URLs - and reports leaked credentials. Every finding has:
- a detector that fired (
stripe-secret-key,aws-access-key, …) - a location (file, line, offset, optionally commit hash and author)
- an entropy score + confidence
- an optional live verification result if you pass
--verify
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> selects an explicit replacement
corpus, so detector policy can change without changing scanner code and without
a hidden merge with embedded rules.
What it doesn’t do
- No telemetry. Findings stay local. The scanner never phones home.
- No agent. A daemon mode exists for IDE-save and stdin/single-file fast-path scans on Unix, but it’s opt-in and stays on your machine.
- 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:
-
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. -
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.
-
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.
Get going
# Linux / macOS
curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
# Windows (PowerShell)
iwr https://raw.githubusercontent.com/santhreal/keyhog/main/install.ps1 -useb | iex
Then:
keyhog scan .
The Install page has package-manager, build-from-source, and offline-install paths. The Your first scan page walks through what the output means and where to go from there.
Where things live
- Source: github.com/santhreal/keyhog
- Issues: github.com/santhreal/keyhog/issues
- Releases: github.com/santhreal/keyhog/releases
- Security: use GitHub private vulnerability reporting first. If the form is unavailable, email
security@santh.dev; PGP is not required. See the security policy.
License: MIT.
Install
The quickest paths first. Each installs the canonical release artifact for your supported host; platform feature differences are explicit below.
One-liner: Linux / macOS
curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
Drops a binary in ~/.local/bin/keyhog. The installer detects the platform and
existing install before downloading and tells you the chosen asset. Linux
x86_64 has one accelerator-capable binary: Hyperscan plus VYRE’s CUDA and WGPU
drivers. CUDA/NVRTC use dynamic loading, so no build-time toolkit is required
and the same artifact runs on GPU and CPU-only hosts. Backend probing and
persisted autoroute evidence, not installer variants, decide execution. macOS and
Windows assets use the portable no-system-library build without Hyperscan or GPU
drivers.
Interactive mode (recommended for first install)
curl ... | sh is fast but skips the wizard because stdin is a pipe.
For shell completions and optional hook setup:
curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh \
-o keyhog-install.sh
sh keyhog-install.sh
The interactive installer shows you:
- The host it detected (OS, arch, GPU, libcuda state).
- The binary it would install (with the GPU note).
- Any existing keyhog install it found.
- Whether
~/.local/binis on yourPATH.
Then it prompts (default in brackets):
- Add
~/.local/binto your shellPATH?[Y/n] - Install shell completions for bash / zsh / fish?
[y/N] - Wire keyhog as a git pre-commit hook in this dir?
[y/N]
The displayed default is authoritative: PATH setup defaults to yes, while
completion and repository-hook setup default to no. --yes accepts those
defaults without prompting. There is no shipped Claude Code / Cursor agent-hook
prompt or keyhog hook install --agent <name> flag; installer variants are not
part of the current release contract.
One-liner: Windows
PowerShell 5+ (ships with Windows 10/11):
iwr https://raw.githubusercontent.com/santhreal/keyhog/main/install.ps1 -useb | iex
Drops the binary in %LOCALAPPDATA%\keyhog\bin\keyhog.exe. Detects
your GPU for diagnostics; the Windows installer ships the portable
no-system-library build, with no Hyperscan, WGPU, or CUDA asset in the
current release.
For the interactive flow:
iwr https://raw.githubusercontent.com/santhreal/keyhog/main/install.ps1 `
-OutFile keyhog-install.ps1
.\keyhog-install.ps1
Heads up. The Unix daemon mode is unavailable on Windows (it relies on Unix-domain sockets).
keyhog scan,keyhog detectors,keyhog watch,keyhog hook, etc. all work the same. Thedaemonsubcommand and explicit--daemon=auto|onemit an explicit “unix-only” error so nothing silently regresses.--daemon=offremains a valid portable declaration of in-process scanning.
Installer overrides
| Env var / flag | Effect |
|---|---|
KEYHOG_VERSION=v0.5.41 (or --version=v0.5.41) | Pin a specific release tag. With no pin, the installer admits only the newest stable release with this host’s complete signed bundle; it probes the latest redirect first, then checks recent releases when that proof is incomplete. |
--install-dir=... | Install into a different directory. |
GITHUB_TOKEN=... | Optional auth for the fallback GitHub releases API lookup. The normal latest-asset path does not need it. |
--yes / -y | Accept the displayed defaults without prompting: PATH setup yes, optional completion and repository hook no. |
--no-color | Disable ANSI colors (e.g. for log capture). |
--from-file=/path/to/asset | Offline / air-gapped install from a pre-downloaded complete host bundle. The installer requires sibling .sha256 files unless --insecure accepts missing checksum proof; verify the downloaded .minisig files manually as shown below before invoking the local path. |
--calibrate | Re-run the installer’s visible autoroute calibration sweep against the already-installed binary, without replacing that binary. |
--insecure | Emergency-only: proceed when signature/checksum proof is missing. A present-but-wrong signature or checksum is always fatal, --insecure or not. |
The table uses Unix spellings. The PowerShell equivalents are -Version,
-InstallDir, -Yes, -NoColor, -FromFile, -Calibrate, and -Insecure;
environment variables keep the same names. PowerShell also exposes the matching
-Diagnose, -Repair, and -Uninstall modes.
Download integrity
Every downloaded asset is verified before it replaces anything: a minisign signature check against the pinned release public key, then a SHA-256 checksum, for both the binary and the GPU literal sidecar (which is also hardened against path traversal and symlink escapes). Verification runs on the freshly downloaded file in a temporary location, so a binary that fails either check is deleted and never installed.
Verification fails closed by default. If the signature or checksum cannot be
obtained or does not verify, the install aborts rather than proceed with an
unverified binary. Passing --insecure (-Insecure on Windows) is the only way
to accept an unverified binary, and it is intended for emergency or local
diagnostics, not routine installs.
The binary swap itself is recoverable: the previous binary is backed up before the new one is moved into place and restored automatically if the new binary fails its post-install self-test, so a failed or interrupted install leaves a working binary behind.
Release publication uses the same exact manifest: each platform binary, its SHA-256 file, the GPU-literal sidecar and checksum, plus detached minisign signatures for both payloads. Matrix builds stage those files as private CI artifacts. New releases and published-release reruns remain private while the asset set is mutated; only the exact signed manifest is made visible.
keyhog update and keyhog repair use strict semantic-version precedence.
Their implicit latest-release lookup ignores drafts and prereleases and skips
any release that lacks the complete signed binary and GPU-literal bundle for
the current host. Use --version <TAG> to request an exact published tag,
including a prerelease. Release metadata, payloads, and signatures have bounded
downloads and connection/request deadlines; an oversized or stalled response
fails without changing the installed binary.
The maintenance commands validate the signed sidecar’s archive paths, entry types, expansion limits, manifest version, binary-version binding, filenames, and byte lengths before changing local state. Matcher files are installed under the scanner-owned cache path while the candidate binary is health-checked. A failed artifact install or candidate check restores both the previous binary and every replaced matcher; concurrent maintenance uses a visible cache lock.
Post-install calibration
The installer does not report success immediately after copying the binary. It
runs keyhog doctor, then visibly measures every candidate enabled for each
calibrated configuration: scalar CPU and Hyperscan/SIMD where present, plus
every eligible hardware GPU. It covers the workload classes and scan-policy
presets it can materialize on that host. The resulting
decisions are written to the same per-user autoroute cache normal scans read. If
a required calibration probe fails, the install fails rather than leave
--backend auto pretending to be usable. Source-specific probes that require
an unavailable external tool, such as Git or a running Docker daemon, are named
as unavailable; install the tool and rerun install.sh --calibrate or
install.ps1 -Calibrate before relying on that source class.
Calibration is identity-bound to the KeyHog binary/build, detector and routing
rules, resolved scan configuration, host/backend capabilities, source class,
and workload bucket. An absent, stale, malformed, or incomplete decision is
therefore not permission to choose a convenient backend. An automatic scan
fails closed with exit 2 and an actionable recalibration message. Inspect the
current state without changing it with:
keyhog backend --autoroute
keyhog backend --autoroute --json
Use keyhog calibrate-autoroute for KeyHog’s self-contained filesystem/stdin
workload sweep. Use the installer --calibrate mode when you also want its
environment-backed Git, URL, and container probes. An explicit
--backend cpu|simd|gpu bypasses the autoroute decision table for that scan;
it is a diagnostic or benchmark override, not a repair for missing evidence.
Runtime GPU controls
| Control | Effect |
|---|---|
keyhog scan --no-gpu | Disable GPU initialization for this resolved scan configuration. Automatic routing still requires persisted calibration for that configuration; use an explicit CPU/SIMD backend only for diagnostics. |
keyhog scan --require-gpu | Hard-fail (exit 12) when GPU is unavailable before scanning or a selected GPU dispatch fails at runtime. This is a diagnostic/CI assertion, separate from autoroute. Autoroute itself is not a fallback hierarchy: it selects the fastest measured-correct backend from all eligible candidates. |
.keyhog.toml [system].gpu = "off" | Persist the CPU/SIMD-only policy for a repository. Use "required" for self-hosted GPU runners where a GPU regression must fail closed. |
keyhog scan --backend gpu|simd|cpu | Force a specific live scan engine regardless of autoroute. Diagnostic and benchmark override only; it does not prove autoroute correctness. A selected GPU route that cannot complete dispatch exits 12 without CPU/SIMD substitution. |
The GitHub Action calibrates the actual runner and admits only usable physical
accelerators. On self-hosted GPU runners, --require-gpu or
[system].gpu = "required" turns accelerator availability into an explicit
fail-closed requirement; it does not choose GPU over a faster calibrated peer.
Daemon policy after installation
Installation and calibration do not start a daemon. On Unix, run and manage the optional warm scanner explicitly:
keyhog daemon start
keyhog daemon status
keyhog daemon stop
daemon start is a foreground long-lived process; run it under the service
manager or terminal lifecycle you intend to own. It announces readiness only
after compiling the scanner and validating its selected backend.
For keyhog scan, an omitted daemon flag means --daemon=auto. Auto uses a
reachable, compatible daemon only for a request it can reproduce exactly
(currently one stdin stream or one regular file). With no socket it runs in
process directly. If a reachable daemon fails its handshake or request, KeyHog
prints the failure and retries the same eligible scan in process. Directory,
Git/remote, baseline, verification, explicit backend/GPU, and unsupported
policy combinations stay in process without weakening their semantics.
Bare --daemon means --daemon=on: the daemon is required, so a missing,
stale, incompatible, or incapable daemon is an exit-2 error and no in-process
substitution occurs. --daemon=off always runs in process. A daemon left alive
across a binary, build, or detector-corpus change is visible as stale in
keyhog daemon status; scan handshakes refuse it until it is restarted. Windows
has no daemon transport: an omitted flag and --daemon=off run in process,
while explicit --daemon=auto, --daemon=on, and daemon subcommands fail
loudly. A connected-but-stale daemon status prints the identity mismatch but
still exits 0, because the status request itself succeeded; the strict scan
route remains rejected. See Daemon and warm scans for
request eligibility, socket trust, warm-GPU routing, and timeout details.
Repair, diagnose, uninstall
sh keyhog-install.sh --diagnose # print host + binary state, change nothing
sh keyhog-install.sh --repair # re-download the right asset for this host
sh keyhog-install.sh --uninstall # remove the binary + installer-owned shell wiring
--diagnose is the first thing to run if something looks off: it
reports CPU arch, OS, GPU + libcuda state, the currently-installed
binary (path + version), whether the install dir is on PATH, and
the asset the installer would download for the latest release tag.
--repair re-downloads the asset matching your current platform even if
the existing binary still runs. The unified Linux binary probes CUDA and WGPU
at runtime, so installing a GPU or CUDA userland does not require replacing it
with a different artifact.
--uninstall removes the binary, asks an installed keyhog uninstall --yes
to surface/clean persisted state first when that subcommand is available,
then removes only the shell artifacts the installer owns: its marked PATH
block and the known bash/zsh/fish completion files.
On Unix, the running binary can unlink itself. Windows does not allow a running
.exe to delete itself, so direct keyhog uninstall --yes exits 2 and prints
the exact executable path to remove after the process exits. The PowerShell
installer performs that outer-process cleanup for the normal uninstall flow.
Direct binary download
If you do not trust pipe-to-shell, download and inspect the installer first, or obtain the complete host bundle from the releases page.
| Platform | Asset name |
|---|---|
| Linux x86_64 (default) | keyhog-linux-x86_64 |
| macOS x86_64 (Intel) | keyhog-macos-x86_64 |
| macOS aarch64 (Apple) | keyhog-macos-aarch64 |
| Windows x86_64 | keyhog-windows-x86_64.exe |
For an asset named <asset>, the complete host bundle is:
<asset>,<asset>.sha256, and<asset>.minisig;<asset>.gpu-literals.tar.gz, its.sha256, and its.minisig.
Verify both payload signatures with minisign and both SHA-256 files before installing. The offline installer path then performs its own checksum checks, safe sidecar extraction, atomic replacement, health check, and rollback:
ASSET=/absolute/path/to/keyhog-linux-x86_64
KEYHOG_MINISIGN_PUBLIC_KEY='RWTPnJ/p6xVJ3TJIxr+ZVHMD/MTHWZhsdE38Go/oD3DYBoi4bePR55go'
minisign -Vm "$ASSET" -P "$KEYHOG_MINISIGN_PUBLIC_KEY"
minisign -Vm "$ASSET.gpu-literals.tar.gz" -P "$KEYHOG_MINISIGN_PUBLIC_KEY"
if command -v sha256sum >/dev/null 2>&1; then
(cd "$(dirname "$ASSET")" && sha256sum -c "$(basename "$ASSET").sha256")
(cd "$(dirname "$ASSET")" && sha256sum -c "$(basename "$ASSET").gpu-literals.tar.gz.sha256")
else
(cd "$(dirname "$ASSET")" && shasum -a 256 -c "$(basename "$ASSET").sha256")
(cd "$(dirname "$ASSET")" && shasum -a 256 -c "$(basename "$ASSET").gpu-literals.tar.gz.sha256")
fi
sh keyhog-install.sh --from-file="$ASSET"
On Windows, use
./keyhog-install.ps1 -FromFile C:\absolute\path\to\keyhog-windows-x86_64.exe.
Verify each payload’s .minisig first and keep each .sha256 sibling beside
its payload. Do not install only the binary and silently omit the release-bound
GPU literal sidecar.
Build from source
You’ll want this if you’re contributing or running a feature combination the prebuilt binaries don’t cover (e.g. Ghidra binary extraction).
git clone https://github.com/santhreal/keyhog
cd keyhog
cargo build --release -p keyhog
./target/release/keyhog --version
The default feature set requires Hyperscan / Vectorscan:
- Debian / Ubuntu:
sudo apt install libhyperscan-dev pkg-config - macOS: not available via Homebrew. Build with
--no-default-features --features portableto skip Hyperscan and use the pure-Rust path. - Windows: build with
--no-default-features --features portable.
The default Linux build includes the dynamically loaded CUDA and WGPU backends:
cargo build --release -p keyhog
CUDA is attempted only when its runtime libraries and a compatible device are
present. WGPU is an independent candidate. Missing accelerator libraries do not
prevent the binary from starting; keyhog backend --self-test --json reports
the exact runtime state and autoroute calibration determines eligibility.
The portable feature is what the official Windows + macOS release
binaries are built with: same scanner, no native dependency, ~5%
slower on big inputs.
crates.io
KeyHog consumes the published VYRE runtime crates from crates.io through exact
workspace pins. The repository does not carry a vendor/ source tree.
Verify the install
keyhog --version
keyhog detectors | head # smoke-test the embedded detector corpus
keyhog scan README.md # scan a single file; exit 0 = clean
If keyhog --version reports a recent release and keyhog detectors
lists hundreds of detectors, you’re set. Move on to
Your first scan.
You can also run the installer in diagnostic mode at any time to print a full status report:
sh keyhog-install.sh --diagnose
Your first scan
You have the binary on your PATH. Now:
keyhog scan .
That walks the current directory, hands every file through the scanner, and prints findings. The exit code carries the verdict:
| Exit code | Meaning |
|---|---|
0 | Scan finished, no findings |
1 | Findings present, none confirmed live |
2 | User error - bad config, bad path, unsupported flag |
3 | System error - local I/O or detector-corpus audit failure |
10 | Live credential confirmed under --verify |
11 | Scanner thread panicked; re-run before trusting results |
12 | Selected or required GPU became unavailable |
13 | Requested source failed or coverage incomplete |
So a CI step that should fail the build when a credential leaks is just:
keyhog scan .
No grep, no jq, no exit-code arithmetic. Findings exit non-zero, so
the build goes red; with --verify, live credentials use exit 10.
What you get out of it
By default, output is human-readable:
$ keyhog scan .
K E Y H O G
───────────
v0.5.41 · secret scanner · 922 detectors
by santh
⚡ 16 cores | GPU: NVIDIA GeForce RTX 5090 | SIMD: AVX-512 | Hyperscan | 922 detectors (6061 patterns) io_uring | backend=simd-regex | gpu=none
┌ CRITICAL ─── Stripe Secret Key
│ Secret: sk_l...p7dc
│ Location: src/config/staging.env:14
│ Confidence: ■■■■■■ 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 banner (on stderr, only when it is a terminal) tells you the binary
version and detector count. With --progress, the capability line also
shows the current host’s CPU/GPU labels, scanner engine, compiled pattern
count, selected backend, and GPU engagement result. Each finding renders
as a severity-colored box: header severity + detector, then Secret:
(redacted to its first and last few characters), Location:, a
Confidence: bar, and an Action:/Docs: remediation hint. The
Results footer joins the counts with · and lists the numbered next
steps.
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
Each finding is a JSON object with these fields, every one always present (consumers like SARIF converters and CI gates rely on the schema being stable):
{
"detector_id": "stripe-secret-key",
"detector_name": "Stripe Secret Key",
"service": "stripe",
"severity": "critical",
"credential_redacted": "sk_l...p7dc",
"credential_hash": "sha256-hex",
"location": {
"source": "filesystem",
"file_path": "src/config/staging.env",
"line": 14,
"offset": 12,
"commit": null,
"author": null,
"date": null
},
"verification": "skipped",
"metadata": {},
"additional_locations": [],
"confidence": 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 it 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
Common patterns the default walk already skips include .git/,
node_modules/, __pycache__/, vendored/build output, minified assets, and
editor backup files. The canonical behavior and opt-out are documented under
path suppressions.
Going further
Once the basic scan works:
- Output formats - JSON, SARIF, plain text.
- Verification -
--verifymakes API calls to confirm credentials are live; a dead credential is downgraded one severity tier (critical→high, …), 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 nine values: text (default),
json, jsonl, 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.
--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/staging.env:14
│ Confidence: ■■■■■■ 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), a
Confidence: 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
Stable-schema JSON array. Every finding has every documented field
present. See Your first scan for the
schema. The confidence field is canonicalized to three decimal places before
the reporting-floor decision, so equivalent CPU and GPU scans serialize the
same value.
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 sarif
- Static Analysis Results Interchange Format. GitHub Code Scanning, GitLab Security Dashboard, and most IDE security plugins consume this.
keyhog scan . --format sarif > keyhog-results.sarif
Upload to GitHub:
# .github/workflows/secrets.yml
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: keyhog-results.sarif
Findings show up in the Security → Code scanning tab with the detector ID as the rule, file path + line as the location, and the redacted credential as the message.
--format github-annotations
GitHub Actions workflow commands - 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,
and confidence when available. The plaintext credential is not emitted.
--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.
--format jsonl
Newline-delimited JSON - one finding per line, no outer array. Better
than --format json for streaming consumers that want to start
processing before the scan finishes:
keyhog scan /huge/monorepo --format jsonl \
| while read line; do
echo "$line" | jq -r '.location.file_path'
done
Combining with --verify
--verify calls each detector’s verification endpoint to confirm the
credential is live. Live credentials keep their severity; dead ones get
downgraded one tier. The output format doesn’t change - the
verification field of each finding becomes "live" or "dead"
instead of "skipped". (The JSON value is the lowercase
VerificationResult variant - "live", "dead", "revoked",
"rate_limited", "unverifiable", "skipped", or an {"error": "..."}
object - not the verified-live/verified-dead labels the text
reporter prints.)
keyhog scan . --verify --format json \
| jq '.[] | select(.verification == "live")'
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, jsonl, sarif, csv, github-annotations, gitlab-sast,
junit) carry findings only, 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/jsonl/sarif/csv/github-annotations/gitlab-sast instead. The
animated 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).
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.
- New contributor? Read Repository layout → The pipeline → the “where do I find X?” table.
- Touching detection? The detector format is data, not code; see the detector reference.
- Touching the scan engine? Its own header doc is the deepest map:
crates/scanner/src/engine/mod.rs(“# The one flow” + “# Where each method lives”). - Choosing between a one-shot scan, a large repository scan, the daemon, and
watch? Start with Execution surfaces, then read the daemon workflow and autoroute reference.
Repository layout
Every top-level directory, one line each. Code is Rust under crates/;
everything else is data, tooling, docs, or eval harness.
| Dir | Role |
|---|---|
crates/ | Rust workspace: runtime code only (five 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 is the foundation and depends 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 ───────────────────────────┐
│ binary, subcommands, daemon, watch, baselines, installer │
└───────┬───────────────┬───────────────┬───────────────────┘
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ │
│ scanner │ │ sources │ │
│ detection │ │ inputs │ │
└──┬─────┬──┘ └──┬─────┬──┘ │
│ │ │ │ │
│ └────┬────┘ │ │
│ ┌───▼────┐ │ │
│ │verifier│ │ │
│ │ live │ │ │
│ └───┬────┘ │ │
└──────────┼──────────┴─────────────┘
┌───▼───┐
│ core │ types · detector registry · report · dedup
└───────┘ · allowlists · incremental (merkle) cache
| Crate | Owns | Start reading at |
|---|---|---|
core | Embedded 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/ |
scanner | The 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/engine/mod.rs (the flow), adjudicate/, pipeline/, lib.rs |
sources | Where bytes come from: filesystem, git (staged/diff/history), stdin, Docker, S3, GCS, Azure Blob, GitHub-org, web, HAR, strings, binary. | crates/sources/src/lib.rs |
verifier | Turning 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 |
cli | The 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 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.
- 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,gitlab_group.rs,bitbucket_workspace.rs,hosted_git/,web/,har.rs,strings.rs,binary/). - 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 batched literal region-presence route (engine/gpu_region_dispatch.rs). It produces one “which detectors may match here” bitmap per chunk. The fast prefilters (simdsieve,bigram_bloom,alphabet_filter,prefix_trie) live atcrates/scanner/src/; detector-to-matcher construction lives inengine/compile.rs,compiler.rs, andcompiler/. - 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/scan.rs). Decode-through (base64/hex/url/unicode/json) runs here and recurses:decode/. - Finish raw matches: scanner-owned suppression, confidence, and
cross-chunk seam reassembly run in
engine/scan_postprocess/,engine/process.rs, andengine/boundary.rs. Confidence + ML scoring live inconfidence/,ml_scorer.rs, andml_scorer/; context inference lives incontext/. 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. - Verify (optional): for detectors with a
[detector.verify]endpoint, turn a candidate into verified-live, behind SSRF/bogon/rate guards.verifier/. - 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, andcrates/core/src/report/own these steps.
The accelerated batch path is two-phase and coalesced: files with no
phase-one hit stop before extraction, while full extraction runs only on hits.
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.
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.
| Workload | Execution surface | Routing and ownership |
|---|---|---|
| One in-process scan | keyhog scan ... --daemon=off | Full orchestrator; persisted one-shot autoroute evidence or an explicit diagnostic --backend. |
| Large tree, multiple inputs, Git, cloud, container, binary, or live verification | In-process orchestrator | Fused or coalesced batches; the daemon is not eligible even when it is running. |
| Repeated eligible stdin or single-file scans on Unix | keyhog daemon start, then keyhog scan ... | Client checks request eligibility and peer identity; the ready daemon uses warm-runtime autoroute evidence. |
| Continuous local directory monitoring | keyhog watch | Foreground watcher with its own compiled scanner; 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:
| Boundary | Owner |
|---|---|
| Candidate measurement and cross-backend parity probes | backend/calibration.rs |
| One-shot and warm-daemon route decision policy | backend/evidence.rs |
| Statistical trial evidence and confidence intervals | backend/evidence/timing.rs |
| Secret-safe, complete finding identity used for parity | backend/evidence/match_identity.rs |
| Workload identity and bucketing | backend/workload.rs |
| Host and accelerator identity | backend/host.rs |
| Cache schema, exact artifact/build identity, bounded codec, validation, inspection, and locked persistence | the 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.
Finding identity and dedup
There is one identity contract with stage-specific keys, not interchangeable “same finding” guesses:
| Stage | Owner | Key | Why |
|---|---|---|---|
| Window overlap and raw collector | crates/scanner/src/engine/windowed_support.rs::record_window_match; crates/scanner/src/scanner_config.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 helper | crates/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 scope | crates/core/src/dedup.rs::dedup_matches | DedupScope::Credential: (detector_id, credential); DedupScope::File: (detector_id, credential, source + file_path + commit); DedupScope::None: no grouping | This 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 collapse | crates/core/src/dedup.rs::dedup_cross_detector | (credential_hash, primary_file_path) after dedup_matches | One 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 cleanup | crates/core/src/report/sarif.rs | (file_path, line, offset) within one reported finding | Output 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.
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 42-feature MoE, gate on held-out F1 plus
aggregate, per-class, and per-detector real 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.
Where do I find X?
| I want to… | Go to |
|---|---|
| Add/edit a detector | detectors/<name>.toml (data; see CONTRIBUTING.md for the schema) |
| Understand the scan flow at method level | crates/scanner/src/engine/mod.rs header |
| Change how confidence is scored | crates/scanner/src/confidence/, ml_scorer.rs |
| Add a suppression gate / change what counts as a non-secret | the one gate list public_noncredential_shape; see “Match adjudication” above (never inline a looks_like_* call in an emission path) |
| Retrain / improve the ML model | ml/retrain_loop.sh (+ ml/README.md) |
| Add an input source | crates/sources/src/ |
| Add live verification for a detector | [detector.verify] in the TOML + crates/verifier/src/verify/ |
| Change output format / exit codes | crates/cli/src/format.rs, reporting.rs |
| Add a benchmark / change the gate | benchmarks/bench/ |
| Verify a perf or detection claim | benchmarks/ (the README numbers regenerate from here) |
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:
| Mechanism | Role | Can create a candidate? |
|---|---|---|
| Service-anchored detector regex | Matches a vendor or credential-specific shape from detector TOML | Yes |
| Companion patterns | Requires related fields or fragments near a primary match | Confirms an existing candidate |
| Structured and multiline extraction | Reassembles assignments and strings that syntax splits across lines or nodes | Yes |
| Decode-through transforms | Scans supported encoded or transformed representations while preserving source attribution | Yes |
| Generic assignment bridge | Extracts values beside credential-role keys when no vendor shape exists | Yes |
| Shannon entropy | Measures byte-distribution uncertainty for opaque generic values | Yes, on the entropy-discovery path |
| BPE token efficiency | Rejects language-like values that compress into common subword tokens when the owning detector enables it | No; precision gate |
| Shape, placeholder, path, and context policy | Rejects examples, references, prose, identifiers, and context-specific noise | No; precision gates |
| Checksums and structural validators | Proves or rejects formats that carry intrinsic validity bits or grammar | Adjusts acceptance/confidence |
| On-device MoE scoring | Scores ambiguous candidates using local features; never sends content away | Adjusts confidence |
| Live verification | Optionally asks the owning service whether a surviving credential is active | Adds 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, shape/context policy, and confidence then confirm, reject, or score them. Verification runs only after a candidate survives detection and reporting policy.
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 implemented score: Shannon entropy and BPE token efficiency are the two
separate signals documented here.
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.
| Detector TOML field | If increased / enabled | If decreased / disabled |
|---|---|---|
entropy_low | Requires more Shannon entropy for keyword-anchored generic values; fewer low-randomness passwords/tokens survive | Admits more values when the assignment key supplies evidence; shape, BPE, context, and confidence gates still apply |
entropy_high | Tightens keyword-independent generic admission | Admits more opaque candidates without strong assignment context |
entropy_very_high | Tightens isolated, anchor-free token admission | Expands the no-keyword search and therefore its false-positive surface |
entropy_floor | A higher applicable length-bucket floor suppresses more low-entropy candidates for that detector | A lower floor preserves more human-chosen or structured credentials |
mixed_alnum_floor | Rejects more identifier-like alphanumeric runs | Preserves more low-randomness mixed-alphanumeric values |
bpe_max_bytes_per_token | A higher ceiling is looser: fewer compressible/word-like candidates are rejected | A lower ceiling is stricter: more language-like values are rejected, with corresponding recall risk |
bpe_enabled = false | Not applicable | Skips token-efficiency rejection for detectors such as human-chosen passwords |
decoded_hex_key_material_lengths | Adds only the declared pure-hex widths after transport decoding | Omitted widths remain decoded-digest negatives |
canonical_hex_key_material | Adds only the declared pure-hex lengths under the declared assignment keys | Omitted keyword/length pairs remain digest-shaped negatives |
min_len / keyword_free_min_len | Longer values are required; short false positives fall, but short real credentials can also fall | Shorter credential shapes become eligible |
max_len (phase-2 generic) | Longer assignment values remain eligible; increase only when the credential contract permits them | Long assignment values are rejected rather than truncated into an apparently valid finding |
allowlist_paths, allowlist_values, stopwords | Adds detector-specific path, value-regex, or literal exclusions | Removing an exclusion makes that detector consider the matching path/value again; it does not affect other detectors |
min_confidence | Raises this detector’s reporting floor | Lowers this detector’s reporting floor; an operator override can still replace it |
weak_anchor | Keeps generic shape/entropy gates active for a service detector whose captured value collides with generic identifiers | Trusts the service anchor without the weak-anchor policy; use only when the pattern itself proves the credential shape |
structural_password_slot | Applies password-slot placeholder policy to a free-form value captured from a syntactic credential slot | Leaves that detector outside the structural-password family |
private_key_block | Makes the detector’s enclosing key block suppress less-specific findings nested inside it | Treats the match as an ordinary, non-enclosing finding |
[detector.credential_shape] | Declares exact prefix/length/shape constraints that a captured credential must satisfy | Omitting it leaves that detector without an additional credential-shape constraint |
Resolution rules
These settings do not all use one generic “last value wins” rule:
- BPE ceiling: the compiled fallback is
2.2UTF-8 bytes percl100k_basetoken. The owning detector’sbpe_max_bytes_per_tokenreplaces that fallback. An explicitly supplied[scan].entropy_bpe_max_bytes_per_tokenor--entropy-bpe-max-bytes-per-tokenreplaces every BPE-enabled entropy/generic detector ceiling; the CLI wins over the config file.bpe_enabled = falsestill disables the gate for that detector. - Confidence floor: the scan floor defaults to
0.40. A detector TOMLmin_confidencereplaces the scan floor for that detector, and an operator[detector.<id>].min_confidencereplaces the detector-declared floor. Under--precision, the resolved global and per-detector floors are clamped to at least0.85; neither source can weaken the precision preset. - Entropy policy: omitted detector fields use
4.5(entropy_high),3.0(entropy_low),5.8(entropy_very_high), and4.0(mixed_alnum_floor). Detector TOML values replace those individual fallbacks. The scan-wideentropy_thresholdis 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. These rules preserve the different evidence carried by an assignment key, an isolated opaque token, and an unanchored generic value.
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.
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 keyword/length contract
supplies the discriminator. 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. Explicit CLI values take precedence over config-file values. An
explicit scan-wide BPE ceiling takes precedence over detector-local BPE ceilings
so a benchmark can compare one bound consistently. entropy_threshold can
tighten a detector’s high band but does not silently replace its lower
detector-owned keyword band. A detector’s min_confidence replaces the global
reporting floor for that detector, and [detector.<id>] min_confidence is the
operator override for that one ID. For production detector tuning, put the
stable value in the owning detector TOML and prove it with that detector’s
positive, negative, evasion, backend-parity, and corpus contracts.
Settings, hardware, and result parity
Hardware changes execution, not detection policy. CPU, SIMD/Hyperscan, and GPU routes consume the same resolved detector/config digest. Autoroute admits a candidate only when its canonical detection identities match the reference: chunk membership, detector id/name/service/severity, hashed credential value, stored credential hash, hashed companion names and values, source, file, line, byte offset, commit, author, date, entropy, confidence, and multiplicity. Confidence, suppression, verification, deduplication, and output formatting then run through the shared policy tail. Missing or stale exact evidence is an error; calibration never relaxes a detector to make a backend look faster.
| Change | Finding-set effect | Routing/calibration effect |
|---|---|---|
| Different CPU, GPU, driver, or accelerator availability | None for the same resolved detector/config and input; a parity mismatch rejects that route | Host/device/runtime identity changes, so old autoroute evidence is not reusable |
| Different detector TOML, thresholds, allowlists, or enabled detectors | May change candidates, suppressions, confidence, and final findings | Detector/config digest changes; recalibration is required |
--fast, --deep, or --precision | Changes the resolved feature and confidence policy, so results may differ by design | Each preset has a distinct config identity and calibration coverage |
| Explicit `–backend cpu | simd | gpu` |
| Input size, chunk count, source family, decode density, or full-source-size availability | The input itself can change findings; backend choice must not | Selects a different exact workload key, including whether each source family’s size bucket came from full-source or payload evidence |
| One-shot process versus ready daemon | None: runtime lifetime cannot change detector policy or canonical matches | The same timing record derives a cold-aware one-shot route and a warm persistent-daemon route; the winners may differ |
Configuration Presets
--fast(orScannerConfig::fast()): Disables high-FP generic entropy checks, ML, and deep decoding (max_decode_depth = 0). Maximizes throughput.--deep(orScannerConfig::thorough()): Admits unanchored generic high-entropy strings, enabling deep decoding (max_decode_depth = 10), ML scoring, and entropy sweeps. Maximizes recall.--precision(orScannerConfig::high_precision()): Setsmin_confidenceto0.85(HIGH_PRECISION_MIN_CONFIDENCE), keeps ML enabled, limits decoding depth (max_decode_depth = 1), and disables high-FP generic entropy checks. Maximizes precision.
Strict Backend Parity
KeyHog supports three search backends: pure Rust CPU, SIMD/Hyperscan
(simd-regex), and GPU/VYRE region presence. Portable builds retain the
pure-Rust trigger path without Hyperscan. keyhog calibrate-autoroute measures
every eligible backend for the host/config/workload key and rejects candidates
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 an error; automatic routing never silently substitutes another backend.
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:
-
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.
-
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.
-
Backend trigger pass. The
simd-regexbackend compiles the detector corpus into Hyperscan databases when thesimdfeature is present;cpu-fallbackuses the pure-Rust trigger path. One pass returns which detector IDs have a candidate match.GPU-capable builds add VYRE’s region-presence literal-set backend. There is no universal model-name or byte threshold at which KeyHog silently switches to it.
--backend autorequires 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 withregex+group+ optionaldescription(required for service-anchored detectors; optional structured-envelope anchors forphase2-generic) - optional
companions(e.g. AWS access key needs the secret key nearby) - optional
verifyblock - 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 the measured 2.3 ceiling; password/passphrase policies setbpe_enabled = falsebecause 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 generictoken=<uuid>remains an identifier; anAuthorization: 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.
- Universal:
- 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.
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
| Filter | What it catches |
|---|---|
| Known example fixtures | Stripe docs key, AWS docs key, RFC 7519 JWT |
pure_identifier | getParameter, Benutzername, auth_decoders |
word_separated_identifier | s3_secret_access_key (function name) |
scheme_prefixed_uri | urn:foo:bar (URI literal, not creds) |
url_or_path_segment | /api/v1/users/123 (REST path) |
contains_uuid_v4_substring | TOKEN_LIST=636765a9-… (UUID identifier) |
punctuation_decorated_identifier | --api-secret, &password, Password: |
| Vendored-minified-path | node_modules/jquery-3.6.0.min.js |
| CI workflow path | .github/workflows/ci.yml - ${{ secrets.X }} |
| i18n translation path | locale/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"
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.patterns]]
regex = 'rk_live_[a-zA-Z0-9]{24,}'
description = "Stripe live restricted key"
[[detector.patterns]]
regex = 'rk_test_[a-zA-Z0-9]{24,}'
description = "Stripe test restricted key"
[detector.verify]
method = "GET"
url = "https://api.stripe.com/v1/charges?limit=1"
[detector.verify.auth]
type = "basic"
username = "match"
password = ""
[detector.verify.success]
status = 200
That’s the whole contract for one service. Every other detector follows the same shape.
Each shipped detector also owns a canonical positive/negative truth pair:
[[detector.tests]]
test_positive = "STRIPE_SECRET_KEY=sk_live_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789aBcD"
test_negative = "sk_live_short"
These are executable production-path fixtures, not documentation examples. The positive must surface that exact detector id and the negative must leave that detector silent. Keeping the pair beside the detector’s patterns and policy makes a TOML change reviewable and independently tunable without hunting through a second registry. 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 regex detector contract. "phase2-generic" selects a scanner-owned
generic discovery mechanism whose policy remains in this TOML; it may have no
patterns, but must declare the mechanism-specific fields validation requires.
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 literal prefix or inner literal can trigger from that literal even
when no declared keyword occurs; patterns with neither run in the ungated
phase-2 path and emit a load warning. kind = "phase2-generic" detectors require
keywords because their assignment/context bridge is the candidate source.
detector.patterns[] - one or more regexes. Each carries:
regex- the pattern. Every regex is compiledcase_insensitive, so it matches both cases without explicit alternation. To make a single pattern case-SENSITIVE (AWSAKIAis uppercase; some GCP/Snowflake ids are lowercase), prefix its regex with the inline flag(?-i)in the TOML - no schema field needed.group- which capture group is the credential.0= whole match,1= first captured group, etc.description- what shape this captures (env var, header, URL, …).client_safe- optional bool, defaultfalse. Whentrue, any match against this pattern collapses toSeverity::ClientSaferegardless of the detector’s nominal severity. Use for patterns that capture keys the vendor expects to ship in client bundles (Sentry DSN, Stripepk_*, 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[] - optional nearby values described by name, regex,
within_lines, and required (default false). Optional companions enrich the
finding and can strengthen confidence or verification. Only a companion marked
required = true gates the primary finding when absent.
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"
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).
If a detector leaves one of these fields unset, the typed compiled fallback for that mechanism applies. When set, the detector value overrides that fallback. An explicitly supplied scan-wide override may have final authority where the field’s documented precedence says so (for example, the BPE ceiling).
The available per-detector tuning fields are:
Entropy Thresholds
entropy_high(float, optional): Per-detector high-entropy threshold (bits/byte) for keyword-independent detection. Falls back toHIGH_ENTROPY_THRESHOLD(4.5) if unset.entropy_low(float, optional): Per-detector keyword-context (low) entropy threshold. Falls back toLOW_ENTROPY_THRESHOLD(3.0) if unset.entropy_very_high(float, optional): Per-detector very-high entropy threshold for keyword-free or isolated tokens. Falls back toVERY_HIGH_ENTROPY_THRESHOLD(5.8) if unset.mixed_alnum_floor(float, optional): Per-detector mixed alpha-numeric token entropy floor. Falls back toMIXED_ALNUM_TOKEN_THRESHOLD(4.0) if unset.entropy_floor(array of tables, optional): Length-bucketed low-entropy suppression floor mapping maximum lengths to minimum entropy scores. If absent, generic adjudication uses its compiled 3.5-bit fallback where this gate applies.max_len(integer, optional): Inclusive maximum length for this bucket.floor(float): Shannon entropy floor.
BPE token efficiency
bpe_enabled(bool, optional): Detector-local token-efficiency switch. Omission inherits the enabled default. Setfalsefor families such as human-chosen passwords where word-like values are legitimate; the scanner then skips BPE tokenization for that detector. Do not combinefalsewith abpe_max_bytes_per_tokenceiling; detector validation rejects the conflict.bpe_max_bytes_per_token(float, optional): Per-detectorcl100k_baseUTF-8-bytes-per-token ceiling. Values above the ceiling are efficiently tokenized, word-like candidates and 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_tokenor CLI flag is the final Tier-A override for all eligible detectors. Lower ceilings favor precision and higher ceilings favor recall. This is the token-efficiency mechanism popularized by BetterLeaks, not another Shannon entropy calculation: it measures language-model subword compressibility. A generic detector may use it as the main precision discriminator by choosing a permissive detector-local entropy floor and a measured BPE ceiling, or compose both gates when byte-distribution and language-likeness each reject different noise. It is not a candidate generator: the detector’s regex or phase-2 assignment/entropy discovery path must first produce a candidate. BetterLeaks’ public documentation calls this Token Efficiency, not BPD; KeyHog usesbpe_...field names to keep that distinction explicit.
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.tomldeclares[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 directsecret_key=<64hex>policy cannot silently reclassify a base64-wrapped digest.canonical_hex_key_material(array of tables, optional;kind = "phase2-generic"only): Declares exact pure-hex character counts and assignment keys that this detector may treat as key material instead of a digest. Each table has non-emptylengthsandkeywordsarrays; every listed keyword must also appear in the detector’s top-levelkeywords. Matching ignores assignment-key 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.tomladmits 64-hex only for its explicit cryptographic roles such assigning_key,encryption_key, andhmac_secret, whilegeneric-secret.tomlownsprivate_keyandsigning_secret. Neither turns a broadapi_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. With ML enabled, an exact keyword/length match preserves the detector-derived heuristic floor; authoritative ML veto remains reserved for unowned entropy candidates.
The fields live beside the detector’s other top-level phase-2 policy, not in a scan-wide suppression table. For example:
decoded_hex_key_material_lengths = [32, 48]
canonical_hex_key_material = [
{ lengths = [32, 48], keywords = ["api_key", "encryption_key"] },
{ lengths = [64], keywords = ["encryption_key"] },
]
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, optional): Per-detector minimum length for an anchor-free (keyword-free or isolated) candidate. Falls back toKEYWORD_FREE_MIN_LEN(20) if unset.min_len(integer, optional): Per-detector minimum candidate length for any candidate this detector emits. Falls back to no detector-specific floor beyond the path-wide default if unset.max_len(integer, optional;kind = "phase2-generic"only): Inclusive maximum byte length for one generic assignment value. The candidate generator is compiled from the largest ceiling in the loaded detector corpus, then the owning detector rejects an overlength value whole; it never reports a truncated prefix. It must be at least the generic path minimum of 8 and no smaller thanmin_len. An omitted value uses the typed 128-byte compiled fallback. Keep this in the owning generic detector TOML so API keys, passphrases, and generic payloads can use different ceilings. Regex-backed patterns keep their own explicit 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.
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, defaultfalse): 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, defaultfalse): The service context is useful but the captured value still collides with broad hex/base64/identifier shapes. Generic shape and randomness safeguards remain active for that detector.private_key_block(bool, defaultfalse): 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.[detector.credential_shape](table, optional): A fail-closed byte-shape contract. It can declareexact_length,prefix,body_min_length, andbody_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 detectors
keyhog detectors # human-readable list, grouped by service
keyhog detectors --format json # one JSON array of detector objects
keyhog detectors --format json | jq length
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")'
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
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
Put custom detector TOMLs in an explicit corpus directory:
# my-detectors/my-internal-token.toml
[detector]
id = "acme-internal-token"
name = "ACME internal API token"
service = "acme-internal"
severity = "high"
keywords = ["ACME_API_TOKEN", "acme_internal_"]
[[detector.patterns]]
regex = "acme_internal_[a-zA-Z0-9]{32}"
group = 0
Then name that corpus on every operator path that should use it:
keyhog detectors --detectors my-detectors --audit
keyhog scan . --detectors my-detectors
--detectors selects the directory as the complete active corpus; it does not
silently merge the directory with embedded detectors. Copy any built-in TOMLs
you still want into the directory. A named path that is missing, is not a
directory, contains no detectors, or contains invalid TOML fails closed instead
of substituting the embedded corpus.
Disabling specific detectors
Turn off a detector by id in .keyhog.toml:
[detector.aws-access-key]
enabled = false
[detector.generic-secret]
enabled = false
Detector ids are the detector_id field in --format json/jsonl output, or
the left column of keyhog detectors. Accelerated literal slots remain owned by
the same canonical TOML detector id; there is no separate hot-* detector to
disable. Disabled detectors are dropped before the corpus compiles (zero scan
cost). If an id matches nothing in the loaded corpus, KeyHog warns rather than
silently ignoring it.
Running only a chosen subset
To run a curated set instead of the full corpus, point --detectors at a
directory holding only the TOMLs you want:
mkdir my-detectors
cp detectors/stripe-secret-key.toml detectors/aws-*.toml my-detectors/
keyhog scan . --detectors my-detectors/
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 thechunk.metadata.commitfield 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 - the short version
- Find a real example of the credential format (vendor docs, leaked public sample, source).
- Write the regex. Test it against the example, against a similar non-credential (“looks like, isn’t”), and against an attacker-rotated form.
- Add to
detectors/<service>-<thing>.toml-id,keywords,patterns, optionallyverify. - Add a contract file at
crates/scanner/tests/contracts/<id>.tomlwith at least:- 2 positives (env-var form, quoted form)
- 2 negatives (placeholder, EXAMPLE marker)
- 2 evasions (the actual deployed credential shape from production)
- Run
cargo test -p keyhog-scanner --test contracts_runner- must pass for your detector to ship.
That’s it. The contracts gate enforces that every shipped detector catches what it claims to catch.
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, confidence, suppression, verification, or output ordering.
The backend choices
| Backend | What it does | Typical cost profile |
|---|---|---|
cpu (cpu-fallback) | Pure-Rust literal and regex execution | Portable and cheap to start; useful when native accelerators are unavailable. |
simd (simd-regex) | Hyperscan/Vectorscan trigger matching plus the shared extraction and policy pipeline | Fast CPU throughput after compiled databases are loaded; the calibration reference for accelerated builds. |
gpu (gpu-region-presence) | VYRE GPU region-presence matching feeding the shared confirmation pipeline | Higher initialization/dispatch cost; can win on large or persistent workloads. |
auto | Exact lookup in a persisted, parity-checked calibration table | Default. It is a selector over all eligible engines, not a fallback order. |
--backend is an explicit diagnostic or benchmark override. It bypasses
autoroute; it does not prove that the chosen engine is fastest for the input.
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 have infallible finding-vector return types, so
selection is a hard process contract. Unavailable selected SIMD terminates with
exit 3; unavailable or failed selected GPU execution terminates with exit
12. They never return findings from another backend. warm_backend probes
startup eligibility in-band, but a process that must contain a later driver or
dispatch failure should run the CLI as a subprocess. The no-backend portable
CPU methods do not acquire an accelerator.
What “same results” means
Calibration compares the complete redacted RawMatch identity: chunk index;
detector id, name, service, and severity; hashes of the actual credential,
stored credential hash, and companion names/values; source, file, line, offset,
commit, author, and date; entropy and confidence. 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. Plain credentials and companion values never enter parity logs.
Normal automatic scans do not benchmark or silently replace a rejected backend.
Among parity-correct candidates, routing uses representative measured medians, never a lucky fastest trial. A fully separated 95% confidence interval is the strongest result. Overlapping intervals are disclosed as inconclusive rather than mislabeled as proof of equal performance; KeyHog then selects the lowest measured median among the non-dominated candidates, using engagement overhead only for an exact median tie. Autoroute inspection prints this selection basis.
This parity contract runs before the common suppression, verification, deduplication, and reporter stages, but already proves every raw field those stages consume. The same detector TOML corpus and resolved configuration digest identify every route.
Why size alone is insufficient
Two inputs with the same byte count can have different winners. Autoroute also keys evidence by one-power-of-two logarithmic ranges for bytes, chunk count, and largest source size, plus a jitter-resistant decode-density range, detector/pattern shape, source family, resolved configuration, build features, and host identity. It does not interpolate from a neighbouring range key; a measured key nevertheless covers the values grouped into that range.
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 checked RTX 5090 production-window baseline compares the GPU path with the real parallel Hyperscan path over one 8 MiB source split into 1 MiB windows with 128 KiB overlap. It verifies sorted full-match parity, rejects GPU runtime faults, excludes one warmup, and aggregates five process medians. The recorded medians are 31.4524 ms for GPU and 35.0860 ms for Hyperscan: GPU is about 1.12× faster in that warm workload.
That evidence proves the warm 8 MiB crossover on the recorded host; it does not
claim that a cold one-shot process, another GPU/driver, dense-match corpus, or
different detector/config digest has the same winner. Autoroute calibration is
what converts hardware- and workload-specific measurements into an exact local
decision. The reproducible metadata lives at
benchmarks/baselines/gpu_8mib_crossover_rtx5090_2026-07-10.toml in the source tree.
When automatic routing refuses to scan
Missing, stale, malformed, or incomplete evidence is an invalid automatic
route. KeyHog exits with a configuration error and prints the missing workload
identity plus the calibration command. Run keyhog calibrate-autoroute for the
core ladder or the installer calibration for source-specific probes. Use an
explicit backend only when you intentionally want a diagnostic override.
After selection, the backend remains a hard execution contract. If a selected
GPU route fails during runtime dispatch, KeyHog exits 12; it does not complete
that scan through an unselected CPU or SIMD backend.
For cache identity, inspection commands, calibration coverage, and recovery, see Autoroute calibration.
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
| Workflow | Command |
|---|---|
| Scan a public JS bundle | keyhog scan --url https://app.example.com/static/main.js |
| Scan every URL in a list | keyhog scan --url $(cat urls.txt) |
| Scan a source-map exposed by Webpack | keyhog scan --url https://app.example.com/static/main.js.map |
| Scan a HAR export from DevTools | keyhog scan capture.har (see HAR auto-expansion) |
| Scan a single curl response | curl -s https://api/... | keyhog scan --stdin |
| Scan a saved Burp / mitmproxy capture | keyhog scan dump.txt (treats as text - no protocol parsing) |
| Route every fetch through Burp | keyhog scan --url https://... --proxy http://burp:8080 --insecure |
| Scan in an air-gapped network | keyhog scan --url https://... --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, eachsourcesContent[i]becomes its own chunk tagged with the original filename. This is how a Webpack build withdevtool: '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 thebinaryfeature).- 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.
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 isn’t a CLI flag - it’s hardcoded so a user can’t accidentally
turn an --url invocation into a metadata-service IAM exfil.
Proxy and TLS
Everything outbound - --url, --github-org, --gitlab-group,
--bitbucket-workspace, --s3-bucket, --gcs-bucket,
--azure-container-url, --verify’s API calls - runs through one HTTP client builder.
Policy:
| Source | Effect |
|---|---|
--proxy http://burp:8080 | Explicit. Routes all KeyHog HTTP traffic through the proxy. |
--proxy off | Disable proxying entirely, including any TOML proxy. |
.keyhog.toml proxy | Used when no CLI proxy flag is set. |
| Proxy environment variables | Ignored; shell/CI state cannot silently reroute secret-bearing traffic. |
--insecure | Accept any TLS cert (self-signed Burp CA, etc.). |
| TLS environment toggles | Ignored; use --insecure or TOML explicitly. |
Order: explicit flag -> .keyhog.toml -> compiled default (no proxy,
strict TLS). There is no environment fallback for proxy or TLS policy.
User-Agent: keyhog/<version> is always set so you can grep your
proxy logs for keyhog traffic without guessing.
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:
| Chunk | source_type | What it contains |
|---|---|---|
| Request | wire:har:request | <METHOD> <URL>, every request header, query string, POST body. |
| Response | wire: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.
keyhog scan capture.har --format json | \
jq '.[] | select(.location.source == "wire:har:request")'
filters down to outbound credentials only - the bug-bounty
“what did I send” view. Swap request for response to see what
the upstream reflected back at you.
A HAR that fails to parse (truncated export from a crashed browser) falls through to plain text scanning so credentials still surface; the file isn’t silently dropped.
Defenses:
- 4×
--max-file-sizebudget on cumulative request+response body bytes. Defeats a malicious HAR that decompresses to gigabytes. - The cheap pre-sniff (
{"log"+"entries"in the first 2 KiB) bails before invoking the JSON parser on a 200 MiB blob that obviously isn’t HAR.
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).
Headers, bodies, URL params - where the secret sits
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 Bearer ghp_… in an HTTP header is
therefore detected by the same detector policy as a "token": "ghp_…" in a
JSON body or a ?token=ghp_… in the URL.
For an HTTP capture this is usually what you want - the location column in the finding gives the byte offset within the capture, and the surrounding context (line ±2) is enough to tell whether it was a header or a body.
Unsupported behavior:
- Parse the HTTP wire format and emit
header:Authorizationvsbody:json:$.tokenprovenance fields. - Attach field-level provenance such as
header:Authorization,body, orqueryto a finding. HAR findings do distinguish the request and response sides throughsource_type.
Unsupported Wire Features
The wire-scanning surface is intentionally narrow. These features are not part of the shipped HTTP-wire contract:
-
mitmproxy
.mitmflow-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. -
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 | queryto each finding, so the JSON consumer cannot filterwire_location == "header:Authorization"for the highest-signal subset (intentional auth tokens vs accidental body leaks vs URL-logged secrets). -
Live proxy mode. KeyHog does not ship
keyhog proxy --listen :8080or an inline HTTP proxy that scans flows while forwarding them. -
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.
Suppressions
A suppression is a filter that drops a candidate match before it becomes a reported finding. KeyHog has two kinds:
- Operator surfaces: things you configure to silence findings you have reviewed and accepted (allowlists, inline directives, per-detector floors, baselines). This page is the single map of all of them.
- Always-on shape/path heuristics: built-in precision filters that drop shapes that are universally not credentials. You cannot turn these off; they are summarised at the bottom and detailed in How detection works.
There is no
.keyhog.toml [suppress]table. Older docs showed a[suppress] hashes = […] / paths = […] / detectors = […]block. It never existed. Current.keyhog.tomlparsing 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.
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.
| # | Surface | Keyed on | Stage | Opt-out / scope |
|---|---|---|---|---|
| 1 | [detector.<id>] enabled = false (Tier-A compiled + Tier-B .keyhog.toml) | detector id | raw match | per-detector |
| 2 | Bundled test-fixtures.toml | exact / substring of the credential value | raw match | --no-suppress-test-fixtures |
| 3 | Self-scan test-data paths (keyhog repo only) | detectors/ tests/ fixtures/ benches/ segment | raw match | --no-suppress-test-fixtures; only inside keyhog’s own tree |
| 4 | .keyhogignore: path: | path glob | raw match | file |
| 5 | .keyhogignore: hash: / bare hash | SHA-256 of value | raw match | file |
| 6 | .keyhogignore: detector: | detector id | raw match | file |
| 7 | [detector.<id>] min_confidence / --min-confidence | confidence score | raw match | floor |
| 8 | --severity | severity rank | raw match | floor |
| 9 | Inline keyhog:ignore (and aliases) | the line itself | raw match | in-source |
| 10 | .keyhogignore.toml [[suppress]] rules | composable predicate | resolved finding | file |
| 11 | --hide-client-safe | client-safe tier | resolved finding | flag |
| 12 | Baseline (--baseline / --update-baseline) | finding identity | resolved finding | flag |
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.
Operator surfaces
.keyhogignore: line-based allowlist (opt-in, project-scoped)
A .keyhogignore at your scan root, one rule per line. It accepts explicit
hash:, detector:, and path: rules. A bare 64-hex line is a credential hash;
other bare entries are path globs, so ordinary .gitignore entries work.
# Ignore a specific credential by SHA-256 of the captured value (64 hex chars).
hash:5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
# A bare 64-hex line is also read as a hash (the jq-append workflow below).
5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
# Ignore every finding from one detector.
detector:generic-password
# Ignore files by glob.
path:fixtures/**
path:docs/example_*.env
# A bare glob with no prefix is a path rule (gitignore-style).
node_modules/
*.min.js
Hashes are bare 64-character hex (no sha256: prefix). Generate the exact line
to append from an existing run:
keyhog scan . --format jsonl | jq -r '"hash:" + .credential_hash' >> .keyhogignore
The hash: prefix is recommended for readability but optional for an exact
64-hex digest. .credential_hash is already the SHA-256 hex the rule expects.
Governance metadata (optional) trails an entry after ;:
hash:5e88…42d8 ; reason="published OAuth client_id" ; expires=2026-12-31 ; approved_by="secops"
An entry whose expires date is in the past is dropped at load time with a
fail-closed operator error, so short-lived approvals force a deliberate renewal.
The require_reason / require_approved_by / max_expires_days governance
flags under [allowlist] in .keyhog.toml are enforced before any suppression
is active; missing required metadata or an overlong expiry stops the scan.
.keyhogignore.toml: declarative rule allowlist (opt-in, composable)
When a single glob/hash/detector line is too blunt, a .keyhogignore.toml
alongside it gives composable [[suppress]] rules. Fields within one table AND
together; separate tables OR together. Full schema and field list:
.keyhogignore.toml reference.
# Drop aws-access-key findings under any tests directory.
[[suppress]]
detector = "aws-access-key"
path_contains = "/tests/"
# Drop low-or-lower stripe findings on one fixture file.
[[suppress]]
service = "stripe"
severity_lte = "low"
path_eq = "fixtures/stripe.yml"
# Drop one credential everywhere (mirrors a .keyhogignore hash: line).
[[suppress]]
credential_hash = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
A [[suppress]] table with no conditions is rejected (it would silently drop
every finding); write literal_true = true if you truly mean “drop all”. A
malformed present .keyhogignore.toml is a policy failure and stops the scan
instead of being treated as an empty suppressor.
Inline directives: suppress at the source line
Put a directive in a comment on the finding’s line, or the line directly above
it. Recognised forms: keyhog:ignore, keyhog:allow, gitleaks:allow,
betterleaks:allow (the last two ease migration). Comment markers understood:
//, #, --, /*, <!--.
API_KEY = "AKIA…EXAMPLE" # keyhog:ignore
Scope it to one detector so an unrelated finding on the same line still fires:
const token = "…"; // keyhog:ignore detector=stripe-secret-key
With no detector= token the directive suppresses every finding on that line.
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.
Confidence and severity floors
--min-confidence <f>(or[scan].min_confidence) drops findings below a score. A per-detector[detector.<id>].min_confidencetakes precedence for that detector.--severity <level>drops findings below a severity rank.--hide-client-safedrops the client-safe tier (public-by-design keys).
Baselines: suppress what already existed
Record the current findings, then on later runs report only new ones:
keyhog scan . --create-baseline .keyhog-baseline.json # snapshot, report nothing
keyhog scan . --baseline .keyhog-baseline.json # report only new findings
keyhog scan . --update-baseline .keyhog-baseline.json # report new AND fold them in
Always-on heuristics (cannot opt out)
Shape-based
List-independent heuristics about credential shape that are universally true.
| Filter | Drops 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. The shipped generic API-key
detector admits 32/48-hex for strong key roles 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 and signing_secret.
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
Specific directories produce findings that are almost always not credentials. KeyHog ships a small, high-precision path policy:
| Path pattern | Why |
|---|---|
node_modules/, vendor/, bower_components/, jspm_packages/, site-packages/ | Vendored third-party code; minified bytes coincide with secret prefixes |
wp-content/plugins/, wp-content/themes/, wp-includes/ | WordPress vendored trees |
app/assets/javascripts/bootstrap*.js, …/jquery*.js, etc. | Rails legacy asset path, vendored JS |
*.min.js, *.bundle.js, *.min.css | Minified bundles |
.github/workflows/, .gitlab-ci.yml, .circleci/, Jenkinsfile, .travis.yml, azure-pipelines*, bitbucket-pipelines* | CI config; ${{ secrets.X }} is syntactic |
locale/, locales/, i18n/, l10n/, translations/, lang/, langs/, *.po, *.pot | i18n files; translated password/token words are not credentials |
Paths containing secretscanner, secret-scanner, trufflehog, gitleaks, detect-secrets | The file IS a secret scanner; its regex literals shouldn’t fire on itself |
These are not configurable: their precision is high enough that making them opt-in would only make the scanner louder. If one suppresses a path you care about, that is a bug worth reporting.
Not a suppression surface:
[lockdown] require = truein.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. Likewiseaudit.tomlis 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 a single JSON object to stderr (separate from the
findings report on stdout): {"dogfood": {"example_suppressions_total": N, "events": [...]}}. Capture stderr to inspect it:
keyhog scan . --dogfood 2>&1 >/dev/null | jq '.dogfood.events[]'
2>&1 >/dev/null sends the dogfood object (stderr) to jq while discarding
the normal report (stdout). --dogfood is independent of --format, so the
report format does not matter here.
Each event carries the suppressor name (test_fixture_suppression,
pure_identifier_no_digit, vendored_minified_path, …), the path, the redacted
credential, and the rule that fired: the answer to “is the scanner being too
aggressive on my code?”.
Adding a suppression for an FP cluster
If you find a cluster of 5+ FPs that share a shape, file an issue with:
- The detector that fired.
- A sanitized example (replace the captured value with
[REDACTED]). - 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.
Verification
keyhog scan --verify makes an HTTP call to each detector’s
documented verification endpoint with the captured credential.
The response tells you if the credential is live.
--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 Confidence:
line in parentheses: (LIVE) for an active credential, (dead) for
one the provider rejected, (revoked), (limited) (rate-limited), or
(error). A dead or revoked credential is downgraded one severity
tier (see the table below), so its box header drops accordingly
(CRITICAL → HIGH).
┌ CRITICAL ─── Stripe Secret Key
│ Secret: sk_l...p7dc
│ Location: src/config/staging.env:14
│ Confidence: ■■■■■■ 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
│ Confidence: ■■■■■■ 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.
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, default200)- optional
success.body_contains(substring the response body must contain)
The verifier:
- Renders the URL with the credential substituted in
- Builds the auth header / query param as specified
- Sends the request
- Compares the response status (and optionally body) to the success criteria
If the criteria match: live. If not: dead. If the provider says
the credential was explicitly disabled: revoked. If it returns a
rate-limit error (e.g. HTTP 429): rate_limited. If the request times
out or DNS fails: an error (treated as unverified, severity
unchanged).
Severity shift on verification
The verdict is the lowercase VerificationResult variant (the JSON
value; the text reporter prints the same word upper/lower-cased in the
Confidence: line’s (...) suffix).
| Verification result | Severity action |
|---|---|
live | Unchanged (it really is what it claims to be) |
dead | Downgrade one tier (critical -> high, high -> medium, …) |
revoked | Downgrade one tier (same as dead) |
rate_limited | Unchanged, treated as unverified |
error | Unchanged, 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>– route verification through an explicit HTTP or SOCKS proxy. The same scan-wide flag also routes remote-source HTTP clients. Useful in corp networks and interception labs. When unset, no proxy is used; ambientHTTPS_PROXY/HTTP_PROXY/ALL_PROXY/NO_PROXYvariables are ignored so shell or CI state cannot silently reroute secret-bearing verifier traffic. Use--proxy offto force a direct connection when TOML configured a proxy.--insecure– accept self-signed certificates in verification and remote-source HTTP clients. ONLY use against endpoints you control. The default is strict TLS verification, and no environment variable can disable certificate verification.
The verifier never follows redirects (SSRF defense – a 302 to a private IP could otherwise leak the credential to an internal service). If a vendor’s auth endpoint returns 302 to follow into the API, that endpoint’s verify block in the detector TOML is wrong; report a bug.
Outbound destinations are filtered at the client level:
- 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.254AWS/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.
Out-of-band callbacks
--verify-oob enables callback-style verification for detectors that
need an external collector. If the collector handshake fails, keyhog
prints a stderr warning naming the --verify-oob server and the
handshake error. Detectors that require OOB verification then report
verification errors, while detectors with normal HTTP verification
continue through their usual path.
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 only sends findings that meet the verifier confidence floor
to external services. Findings below that floor still appear in every
output format, but their verification field stays skipped. When
that happens, keyhog prints a stderr warning naming how many findings
were skipped and the verifier confidence floor that caused it, so a
partial verification pass cannot look complete.
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 are:
- Format-only detectors (private keys, certificates, JWTs) where the credential itself has provable structure but no service to call.
- Services without a known low-impact verification endpoint (some internal APIs, deprecated services).
For these, --verify is a no-op. The verification field of the
finding stays skipped.
What you can’t do
--verifydoes NOT POST data. Every verification call is either a GET or a benign read-only endpoint (e.g.GET /me,GET /charges?limit=1).- The verifier does NOT cache results across runs. Each
keyhog scan --verifymakes 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.
Pre-commit hook
The point of a pre-commit hook is to stop credentials from ever landing in your repo’s history. It runs locally, fast enough to feel synchronous, and blocks the commit if a finding shows up.
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.
If your repo uses pre-commit instead of
raw git hooks, add the following to .pre-commit-config.yaml:
repos:
- repo: https://github.com/santhreal/keyhog
rev: v0.5.41
hooks:
- id: keyhog
stages: [pre-commit]
Then pre-commit install once, and it runs on every commit.
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 commitwould 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/staging.env:14
│ Confidence: ■■■■■■ 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 is just exec keyhog scan --fast --git-staged --backend cpu, so
this is the ordinary scan report over the staged blobs. Exit code is 1,
so git aborts the commit and your work-in-progress stays in the index. Your
options:
- Remove the credential from the file,
git addthe fix, and commit again. - Replace it with a placeholder and load the real value from the environment at runtime.
- If it is a false positive, add its hash to
.keyhogignore(see below) or a narrowly scoped predicate rule in.keyhogignore.toml, with the reason and ownership recorded 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):
- Add its hash to
.keyhogignoreashash:+ the bare 64-character SHA-256 hex digest (nosha256:prefix; that spelling is baseline-file-only):hash:5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 - Commit the suppression file alongside the credential.
- The next commit sees the hash and skips it.
This way the next contributor doesn’t have to learn the trick.
Performance
If a pre-commit scan feels slow:
keyhog daemon start(unix only). The daemon holds the compiled scanner in memory for editor-save or hook glue that scans stdin or one regular file. The default staged-file hook uses the in-process orchestrator because git source expansion, baseline policy, and verification are not daemon work.--fastselects the documented reduced-cost scan policy. Keep the full scan in CI so decoded, entropy, and deeper paths remain covered.
Uninstall
keyhog hook uninstall
Removes the KeyHog .git/hooks/pre-commit file if it carries the
generated KeyHog marker. If you hand-edited the hook,
keyhog hook uninstall refuses to touch it - clean it up by hand.
For the pre-commit framework, delete the keyhog stanza from
.pre-commit-config.yaml and run pre-commit clean.
CI integration
Add KeyHog in two stages: make findings visible with a durable report, then turn new findings into a merge gate. The recipes below keep scanning, enforcement, and report retention explicit so a missing upload or unsupported source cannot look like a clean run.
| Workflow | Recommended scan | Why |
|---|---|---|
| Developer commit | keyhog hook install | Fast staged-file feedback before push. |
| Pull request | Working tree, baseline enabled | Blocks newly introduced credentials. |
| Main branch | Full reachable Git history | Finds secrets already merged into history. |
| Release | History plus explicit live verification | Prevents publishing with a confirmed live credential. |
| Large scheduled inventory | Partitioned repository/cloud scopes | Keeps ownership, coverage, and artifacts independently retryable. |
GitHub Actions
# .github/workflows/secrets.yml
name: secrets
on:
push:
branches: [main]
pull_request:
jobs:
keyhog:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # scan full history, not just HEAD
- uses: santhreal/keyhog/.github/actions/keyhog@v0.5.41
with:
path: .
severity: high
format: sarif
The composite action installs KeyHog, writes a SARIF report, uploads it to Security -> Code scanning, attaches the report as a workflow artifact, and prints a job summary with the finding count, raw exit code, and scan duration.
When upload-sarif: 'true', SARIF upload is fail-closed on trusted pushes
and same-repo pull requests. Fork pull requests often lack
security-events: write; in that case the upload step is advisory and the
downloadable SARIF artifact remains available for review. Trusted upload
failures also keep the report artifact, so the failed job remains
diagnosable.
fail-on-findings: 'false' makes ordinary findings advisory after the
report/SARIF/artifact are written. A --verify scan that confirms a live
credential still fails the action with KeyHog exit code 10.
Self-hosted GPU runners can add keyhog backend --self-test --json
before the scan. On an eligible GPU host, the JSON includes ok, status, exit_code,
recommended_backend, and records for moe_kernel, the diagnostic
vyre_literal_set, and the production gpu_region_presence route. Exit 4
means the binary is present but a required GPU capability or the production
route failed; fail the GPU
lane or intentionally start a separate explicit SIMD/CPU lane. A selected GPU
scan never changes backend inside the failed route. A runner without an
eligible physical GPU instead returns one gpu_adapter probe with status
skip and exits 0; add --require-gpu when absence must fail the lane.
To adopt on a repo that already has known findings, generate and commit a baseline once, then wire it into the action:
keyhog scan . --create-baseline .keyhog-baseline.json
git add .keyhog-baseline.json && git commit -m 'chore: keyhog baseline'
- uses: santhreal/keyhog/.github/actions/keyhog@v0.5.41
with:
baseline: .keyhog-baseline.json
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
.keyhogignoreaspath:rules. Keep a short comment explaining each exclusion; broad globs can hide real coverage. - Put finding-specific exceptions in
.keyhogignoreor.keyhogignore.toml, preferably with reason, expiry, and approval metadata. - Commit a baseline when introducing KeyHog to an existing repository. Do not regenerate it automatically in CI; review baseline changes like code.
- 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.
For a monorepo, keep one root policy when ownership is shared. When teams need independent gates, run explicit subdirectory jobs with their own reports and baselines; do not hide one team’s paths behind another team’s ignore file.
GitLab CI
# .gitlab-ci.yml
keyhog:
stage: test
image: ubuntu:24.04
before_script:
- apt-get update -qq && apt-get install -y curl libhyperscan-dev
- curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
script:
# Exits non-zero on findings, which fails the job and gates the MR.
- ~/.local/bin/keyhog scan . --format sarif --output keyhog.sarif
artifacts:
when: always # keep the report even when the scan fails the job
paths:
- keyhog.sarif
The job’s exit status gates the merge request (keyhog exits non-zero on
findings) and the SARIF is kept as a downloadable artifact. Note: GitLab’s
artifacts:reports:sast expects GitLab’s own SAST JSON schema, not SARIF,
so to surface findings in the MR security dashboard you must convert the SARIF
to that format (e.g. a SARIF-to-GitLab-SAST converter step) - pointing
reports:sast directly at a SARIF file does not work.
CircleCI
# .circleci/config.yml
version: 2.1
jobs:
keyhog:
docker:
- image: cimg/base:stable
steps:
- checkout
- run:
name: Install keyhog
command: |
curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
echo 'export PATH="$HOME/.local/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 / generic shell
# .drone.yml
pipeline:
keyhog:
image: alpine:3.20
commands:
- apk add --no-cache curl
- curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
- $HOME/.local/bin/keyhog scan .
Same pattern works in Jenkins, Buildkite, Woodpecker, Concourse, or any CI that can run a shell. The two lines are the install command and the scan command.
Pinning a version
The install scripts pull the latest release by default. For reproducible CI, pin a specific version:
curl -fsSL ...install.sh | KEYHOG_VERSION=v0.5.41 sh
Update the pin via a Renovate / Dependabot config or just bump it by hand when a new release lands.
Scan history once per release, not per PR
A full git-history scan is the right thing to run on main post-merge
and on release tags, but it’s overkill for every PR. A typical setup:
| Trigger | Scan | Purpose |
|---|---|---|
| Pull request | keyhog scan . (working tree) | Fast feedback over proposed files |
| Push to main | keyhog scan --git-history . | Cover reachable repository history |
| Release tag | keyhog scan --git-history . --verify | Add 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
For many repositories or remote collections, make each organization, group,
bucket, or repository partition its own retryable job and retain one
machine-readable report per partition. Keep hosted-Git credentials out of the
process list by injecting KEYHOG_GITHUB_TOKEN, KEYHOG_GITLAB_TOKEN, or
KEYHOG_BITBUCKET_USERNAME plus KEYHOG_BITBUCKET_TOKEN through the CI secret
store. Then select the scope explicitly:
keyhog scan --github-org acme --precision --format jsonl --output acme.jsonl
keyhog scan --gitlab-group platform --precision --format jsonl --output platform.jsonl
keyhog scan --s3-bucket audit-archive --s3-prefix production/ \
--precision --format jsonl --output audit-archive.jsonl
Use the source limits from the CLI reference to define the intended coverage boundary. Reaching one is an incomplete-source result, not a clean scan; size the limit deliberately or split the inventory into more jobs.
Start in report-only mode, review coverage gaps separately from findings, then enable enforcement once baselines and exclusions are owned. Runtime and route choice vary with detector policy, source shape, cache state, host CPU/GPU, and network limits. Calibrate autoroute on the actual worker class; do not copy a routing cache between machines or force GPU/CPU based only on input size.
For long-lived workers, the daemon workflow avoids repeated startup work. Ephemeral hosted CI should normally use the ordinary process path unless the job performs enough scans to amortize daemon startup and explicitly checks daemon compatibility.
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 mode:
fail-on-findings: 'false'keeps unverified findings from blocking a PR, but verified-live credentials still fail after uploads so the report is preserved and the merge stays blocked. - Shallow clones:
actions/checkoutdefaults tofetch-depth: 1, which only fetches HEAD. A--git-historyscan against a shallow clone sees zero commits. Setfetch-depth: 0if you want history. - 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.
Integration recipes
Task-oriented recipes for running KeyHog locally, in hooks, and in CI. Install
the release with the verified installer, which also records the
host’s autoroute evidence. 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. An explicit --backend cpu in the
lightweight local-hook recipes below deliberately avoids machine-local routing
state.
For the full contract behind a command, use the focused reference instead of treating a copied snippet as a second specification:
| Task | Start here |
|---|---|
| Protect local commits | keyhog hook install |
| Gate a pull request | CI integration |
| Scan a large tree or choose a policy | Detection settings and hardware |
| Suppress an accepted finding | Suppressions |
| Interpret a failure | Exit codes |
If you only need one section, jump to:
- Pre-commit hook (git) - block secrets before they’re committed
- Pre-push hook (git) - block secrets before they leave the laptop
- pre-commit framework -
pre-commitPython tool - Husky / lefthook - JavaScript ecosystem hooks
- GitHub Actions - PR + push CI
- GitLab CI
- CircleCI
- Drone CI
- BuildKite
- Docker / Docker Compose
- Jenkins
- As a library (Rust)
- Embedded in another CLI
- SARIF for GitHub Advanced Security
- Slack / Discord / webhook alerts
- Allowlists and baselines
- Exit codes
Pre-commit hook (Git)
The maintained path is one command:
keyhog hook install
It installs a KeyHog-owned .git/hooks/pre-commit and refuses to overwrite an
unrelated hook unless you explicitly pass --force. See the
pre-commit guide for ownership, uninstall, and staged-content
semantics.
If another hook manager owns the file, invoke the same canonical staged scan:
keyhog scan --fast --git-staged --backend cpu
--backend cpu makes this small local check independent of autoroute state. The
hook scans the Git index, not unstaged working-tree changes. Review and remove a
real secret; suppress an accepted result through .keyhogignore or
.keyhogignore.toml, never an invented .keyhog.toml [suppress] table.
Pre-push hook (Git)
Pre-commit is the strongest gate. Pre-push catches secrets that landed
in earlier commits but were never pushed. Drop into .git/hooks/pre-push:
#!/usr/bin/env bash
set -euo pipefail
# Scan everything between the remote's HEAD and the local branch tip.
remote_sha="$(git ls-remote origin HEAD | awk '{print $1}')"
keyhog scan --git-diff "$remote_sha" \
--backend cpu
This compact hook compares the checked-out branch with the remote’s default branch. Repositories that push several refs or use a different integration base should enforce the exact ref range in CI, where the server supplies authoritative base and head revisions. KeyHog’s nonzero status is left intact so operational errors cannot be mislabeled as findings.
pre-commit framework
For projects that use the pre-commit Python
tool, add this to .pre-commit-config.yaml:
repos:
- repo: https://github.com/santhreal/keyhog
rev: v0.5.41
hooks:
- id: keyhog
Then run pre-commit install once. KeyHog’s repository-owned hook definition
supplies the canonical staged command; do not restate entry, filename, or
backend behavior in the consuming repository.
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"
GitHub Actions
Recommended: composite action (3 lines + a baseline)
The most concise integration. Drop this file at
.github/workflows/keyhog.yml and that is the whole PR:
name: keyhog
on: [push, pull_request]
permissions: { contents: read, security-events: write }
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: santhreal/keyhog/.github/actions/keyhog@v0.5.41
with:
path: .
severity: high
format: sarif
baseline: .keyhog-baseline.json # optional, see below
The Action downloads the platform release asset for release refs, verifies its
checksum, visibly calibrates every eligible backend when backend is omitted,
and uploads SARIF. Branch and SHA refs build the checked-out Action source.
Runtime and download size vary by release, host, cache warmth, and repository;
the job summary records the measured duration.
Release tags and explicit version: inputs require the matching published
asset and checksum and fail closed if either is unavailable. Only branch/SHA
Action refs may build from source, so a tagged workflow cannot silently execute
different code from the requested release.
Use fail-on-findings: 'false' when you want ordinary findings to be
advisory during rollout. If you also set verify: 'true', any
verified-live credential still fails the job with exit code 10 after
the SARIF report and workflow artifact are uploaded.
Adopt without breaking an existing repo. If your tree already contains findings keyhog would flag, generate a baseline once, commit it, and the action will only fail on NEW secrets going forward:
keyhog scan --create-baseline .keyhog-baseline.json
git add .keyhog-baseline.json && git commit -m 'chore: keyhog baseline'
Manual installation
If you want the install step explicit, use the verified installer:
name: keyhog
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
security-events: write
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for --git-diff / --git-history
- name: Install keyhog
run: |
curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Scan working tree
id: keyhog
continue-on-error: true
run: keyhog scan . --severity high --format sarif -o keyhog.sarif
- name: Upload SARIF
if: always() && hashFiles('keyhog.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: keyhog.sarif
- name: Enforce scan result
if: steps.keyhog.outcome == 'failure'
run: exit 1
continue-on-error applies only to the scan step so SARIF can upload. The final
step restores the failing job outcome for findings and operational errors; it
does not convert an incomplete scan into success.
Scan only changed files in a PR (faster)
- name: Scan PR diff
if: github.event_name == 'pull_request'
run: keyhog scan --git-diff origin/${{ github.base_ref }} --severity high
GitLab CI
.gitlab-ci.yml:
keyhog:
stage: test
image: ubuntu:24.04
before_script:
- apt-get update -qq && apt-get install -y -qq curl ca-certificates
- curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
- export PATH="$HOME/.local/bin:$PATH"
script:
- keyhog scan . --severity high --format gitlab-sast -o keyhog.json
artifacts:
when: always
paths:
- keyhog.json
reports:
sast: keyhog.json
allow_failure: false
CircleCI
.circleci/config.yml:
version: 2.1
jobs:
keyhog:
docker:
- image: cimg/base:stable
steps:
- checkout
- run:
name: Install keyhog
command: |
curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV"
- run:
name: Scan working tree
command: keyhog scan . --severity high --format json -o keyhog.json
- store_artifacts:
path: keyhog.json
when: always
workflows:
ci:
jobs:
- keyhog
Drone CI
.drone.yml:
kind: pipeline
name: keyhog
steps:
- name: scan
image: ubuntu:24.04
commands:
- apt-get update -qq && apt-get install -y -qq curl ca-certificates
- curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
- export PATH="$HOME/.local/bin:$PATH"
- keyhog scan . --severity high --format json -o keyhog.json
BuildKite
.buildkite/pipeline.yml:
steps:
- label: ":mag: keyhog secret scan"
command: |
curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
keyhog scan . --severity high --format json -o keyhog.json
artifact_paths:
- "keyhog.json"
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
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
Jenkins
Declarative pipeline (Jenkinsfile):
pipeline {
agent any
stages {
stage('keyhog') {
steps {
sh '''
curl -fsSL https://raw.githubusercontent.com/santhreal/keyhog/main/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
keyhog scan . --severity high --format json -o keyhog.json
'''
}
post {
always {
archiveArtifacts artifacts: 'keyhog.json', allowEmptyArchive: true
}
}
}
}
}
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};
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()
},
};
for m in scanner.scan(&chunk) {
println!(
"{}:{} (detector {})",
m.location.file_path.as_deref().unwrap_or("<memory>"),
m.location.line.unwrap_or(0),
m.detector_id
);
}
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
are infallible at the type level and therefore enforce selection as a process
contract: missing selected SIMD exits 3, while unavailable or failed selected
GPU execution exits 12. They do not substitute another engine. Probe startup
eligibility with warm_backend; isolate the CLI in a subprocess when the host
application must survive a later accelerator runtime failure.
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", "--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 finding: serde_json::Value = serde_json::from_slice(line)?;
// ... do whatever
}
Or invoke the scan subcommand directly from a wrapper script:
keyhog scan /path/to/project --format jsonl --min-confidence 0.4
SARIF for GitHub Advanced Security
keyhog scan . --format sarif -o keyhog.sarif
Then upload to GitHub Code Scanning (see the CI integration guide). KeyHog tags every finding with CWE-798 (Use of Hard-coded Credentials) and the OWASP A07:2021 (Identification and Authentication Failures) category, so they surface in the right dashboards out of the box.
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 --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 '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.
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
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
# Write the final findings-only JSONL report to a file
keyhog scan . --format jsonl --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 How detection works
and Backends and routing before changing policy or forcing an
engine.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Exit 12 with a selected-GPU diagnostic | Required, explicit, or autoroute-selected GPU execution could not start or complete | Run keyhog backend --self-test, recalibrate autoroute after fixing the GPU stack, or select another backend explicitly; KeyHog never substitutes CPU/SIMD inside the failed route |
| Findings count drops vs prior run | Baseline, detector corpus, scan policy, or .keyhog.toml changed | Compare the effective config, detector digest, baseline, and input scope from both runs |
| Pre-commit hook is slow | Scanning the whole repo on every commit | Use --git-staged not scan . |
| SARIF report is too large for the consumer | The selected scope produced more findings than the consumer accepts | Narrow the scanned source, use a reviewed baseline, or choose an explicit severity policy; do not hide an incomplete upload |
| Detection misses a known token | Detector absent from the loaded corpus / --fast disabled decode recursion or entropy discovery | Re-run with the embedded corpus and --deep; file an issue if it still misses |
Daemon and warm scans
The Unix daemon keeps one compiled scanner alive for repeated small scans. It is useful for editor saves, hooks, and other workflows where compiling the detector corpus costs more than scanning one file. It is not a second scanner implementation and it does not replace the full scan orchestrator.
Use it for repeated eligible stdin or single-file requests. Use the
in-process orchestrator for repository-wide and multi-source work:
# Repeated small scans: start once, then allow the default daemon=auto policy.
keyhog daemon start
keyhog scan --stdin < changed-file.txt
# Large trees and multi-source scans always use the full in-process path.
keyhog scan --daemon=off /large/repository
A running daemon is passive until an eligible client connects. KeyHog never starts it implicitly. Conversely, a running daemon does not capture every scan: directories, multiple inputs, and policy the protocol cannot represent remain in process.
keyhog watch is separate: it is a foreground filesystem-event loop with its
own compiled scanner, not a daemon client or daemon-managed process. Starting a
watcher does not create the Unix socket, and daemon status does not report it.
Use watch for continuous directory monitoring; use scan --daemon for an
eligible stdin or single-file request sent to the separately started service.
Start, confirm readiness, and stop
keyhog daemon start
keyhog daemon status
keyhog daemon stop
daemon start first prints that compilation is in progress, then prints a
separate readiness line after scanner initialization, backend validation, and
socket binding complete. Only the readiness line means the daemon can accept
requests. daemon status checks the existing process; it does not start one.
The default socket is $XDG_RUNTIME_DIR/keyhog.sock when
XDG_RUNTIME_DIR is set. Otherwise KeyHog uses the OS user cache directory:
~/.cache/keyhog/server.sock on Linux or
~/Library/Caches/keyhog/server.sock on macOS. If the OS cache directory is
unavailable (for example, a container without HOME), the fallback is the OS
temporary directory plus keyhog/server.sock. Use matching
daemon start --socket <PATH> and scan --daemon-socket <PATH> options for
another location. The transport is a user-only Unix-domain socket. Windows has
no daemon transport: it rejects daemon
commands and explicit --daemon=auto|on, while an absent flag or
--daemon=off uses the in-process scanner. On Unix, an absent flag has the
documented auto behavior and can use a compatible daemon at the selected
socket.
The service owns its startup configuration. daemon start --detectors <DIR>
selects its detector corpus, --cache-dir <DIR> selects its compiled Hyperscan
cache, and --backend auto|gpu|simd|cpu selects persisted autoroute or an
explicit diagnostic backend for requests the service can accept. Client scan
flags never rewrite those daemon-owned choices; the handshake rejects a corpus
or build identity mismatch instead of silently mixing them.
An autorouted daemon initializes scanner regex state and runs a bounded real GPU warmup before announcing readiness when a physical GPU is eligible. If that GPU cannot complete the probe, startup fails loudly instead of substituting CPU/SIMD. Autoroute therefore treats a ready daemon as a persistent warm runtime: GPU decisions use calibrated warm trials. An in-process scan uses the same calibration record but includes the measured first-dispatch GPU cost. These are separate derived decisions, not one generic “GPU time.”
An explicit daemon backend is validated before the readiness line. For example,
daemon start --backend gpu is rejected when this build/host has no eligible
physical GPU, and --backend simd is rejected without a live Hyperscan
prefilter; neither request is silently relabeled. Pre-readiness argument,
configuration, or capability rejection exits 2. Operator-correctable socket
path failures also exit 2, including a missing socket, permission denial,
invalid path/data, connection refusal, or an already-bound socket. Other
low-level operating-system I/O failures exit 3, and a selected GPU dispatch
that fails during the real warmup exits 12. An explicit CPU or SIMD daemon
does not warm or require the GPU; the GPU warmup is mandatory only for daemon
autoroute or an explicit GPU daemon.
What --daemon means
keyhog scan has one tri-state daemon policy. On Unix, omitting the flag is the
same as --daemon=auto:
| Policy | Compatible daemon active | Daemon inactive, stale, or request incompatible |
|---|---|---|
--daemon=auto (default on Unix) | Send an eligible request to the daemon. If IPC or daemon execution fails, report it and retry the same eligible workflow in process. | Stay or retry in process after reporting a connection/identity failure; an incompatible request is kept in process without sending it. |
--daemon=on or bare --daemon | Require the daemon response. | Fail with the specific availability, identity, or eligibility error. No in-process substitution occurs. |
--daemon=off | Do not connect; run in process. | Run in process. |
An automatic in-process retry is still an automatic scan: it uses the one-shot
autoroute decision for its real workload. If that decision is missing or stale,
the retry fails closed with autoroute calibration required; it does not pin a
CPU backend to make the retry succeed.
Small requests the daemon can serve
The fast path accepts exactly one input: --stdin or one regular file. The
client still applies the shared finding finalization needed by eligible scans,
including inline suppression, allowlist/rule suppression, match resolution, and
deduplication.
The in-process orchestrator is required for directories, multiple inputs, Git modes, remote/cloud/container/binary sources, baselines, Merkle skip state, live verification, explicit backend/GPU controls, calibration mode, and policy that the daemon cannot enforce exactly. Examples of incompatible policy include lockdown requirements, secret display, explicit confidence or severity floors, and custom detector/AWS-canary configuration.
In auto mode an incompatible request simply stays in process. In on mode it
fails with the specific unsupported requirement. This is intentional: daemon
availability must never change findings or weaken policy silently.
Repository and mass scans
Directory trees, multiple inputs, Git history, remote sources, and other high-volume workflows use the in-process orchestrator whether or not the daemon is active. That path can overlap source reads with fused scanner batches and can represent the complete source, verification, baseline, and reporting policy.
Autoroute decisions are looked up for the concrete batches produced by that
workflow. A calibration entry covers one exact workload key, whose numeric
dimensions are logarithmic ranges measured using a representative input; it is
not proof for every byte length inside the range. If any required key is absent,
KeyHog reports the missing key and fails closed instead of borrowing a
neighbouring range or silently selecting CPU. Run keyhog calibrate-autoroute
for the core file/tree ladder or the installer calibration for source-specific
fixtures. See Autoroute calibration.
Every scan connection performs a versioned handshake that checks the daemon’s
wire version, package version, Git hash, and detector-rules digest against the
client. This rejects a daemon left alive across an upgrade and a same-version
daemon started with a different --detectors corpus. daemon status and daemon stop intentionally tolerate an identity mismatch so the
operator can inspect and terminate it; status prints the exact mismatch and
the strict scan route refuses it. In --daemon=auto that refusal is visible on
stderr before the identical request runs in process. In --daemon=on it is an
error. Scan-result frames require suppression telemetry, dogfood telemetry, and
source-coverage fields; missing fields are malformed protocol data, not
permission to synthesize zeroes that could hide incomplete scanning.
Autoroute semantics
The daemon does not inherit a client process’s backend override. It loads the persisted fastest-correct decision table for its compiled detector/config/host identity and resolves each real workload bucket itself. Missing, stale, or incomplete evidence is an error, just as it is for a one-shot automatic scan.
Calibration records warm CPU/Hyperscan trials and one real GPU first dispatch followed by warm GPU trials. An in-process lookup compares the CPU/Hyperscan medians with the conservative cold-aware GPU representative. A ready daemon compares against the warm GPU median because accelerator state was initialized before requests were accepted. Scalar CPU, Hyperscan/SIMD, and the acquired GPU runtime remain peer execution classes; daemon mode is not permission to prefer GPU. GPU driver implementations are not independent autoroute candidates: the scanner exposes the single GPU runtime it acquired as the GPU class.
keyhog backend --autoroute renders separate one-shot and daemon rows for
every calibrated workload. Its JSON form names the persisted cold-aware route
as backend and the warm route as daemon_backend, with separate confidence,
basis, and margin fields for each. Neither runtime silently executes a different
backend after selection: unavailable SIMD fails, and unavailable or failed GPU
execution fails with the GPU error status.
Timeouts and status
daemon start --request-timeout-secs <N> bounds the time a client may take to
finish a request frame (default 300). daemon status reports uptime, scans
served, active scans, detector count, and any build/corpus identity mismatch. A
stale socket is removed only after ownership and directory trust checks pass.
The status counter labeled scans served currently counts completed scan
attempts, including attempts that returned a daemon error. Use it as activity
telemetry, not as a success counter.
CLI reference
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 clean, 1 findings
present, 2 user error, 3 system error, 10 live credential, 11 scanner
panic, 12 selected or required GPU unavailable, 13 requested source failed
or coverage incomplete.
Input selection
| Flag | Effect |
|---|---|
<PATH>... | One or more positional roots. Each may be a file or directory; nested/duplicate roots are folded into their covering parent. --git-staged accepts one directory inside one worktree and discovers its repository root. |
--path <PATH> | Explicit single-root spelling. Prefer positional roots when scanning several paths. |
--binary | In builds with binary analysis, extract and scan strings from supported working-tree binary inputs in addition to ordinary text handling. Run it separately from --git-staged, whose commit-boundary input is exact index blobs. |
--stdin | Read from stdin instead. Default 10 MiB cap; tune with --limit-stdin-bytes. |
--exclude-paths <GLOB>... | Skip files matching glob. Space-separated list, repeatable. |
--no-default-excludes | Disable the shipped lock-file, minified-file, build-output, and similar default exclusions. Explicit exclusions still apply. |
--git-staged | Scan exact git index blobs only (pre-commit mode), even when the working-tree copy differs. Honors path exclusions and .keyhogignore; accepts the worktree root or any directory beneath it. |
--git-history <PATH> | Walk commits added-line patches (default: HEAD only). |
--git-blobs <PATH> | Scan reachable repository blobs, deduplicated by blob ID. |
--git-diff <BASE_REF> | Scan only added lines since BASE_REF. |
--git-diff-path <PATH> | Select the repository used by --git-diff instead of the current directory. |
--max-commits <N> | Bound the number of commits traversed by git-history scanning. |
--docker-image <IMAGE> | Scan a saved Docker image archive. |
--github-org <ORG> | Clone and scan every repository in a GitHub organization. Requires KEYHOG_GITHUB_TOKEN (recommended) or --github-token. |
--gitlab-group <GROUP> | Clone and scan every project in a GitLab group, including subgroups. Requires KEYHOG_GITLAB_TOKEN (recommended) or --gitlab-token; use --gitlab-endpoint for self-managed GitLab. |
--bitbucket-workspace <WORKSPACE> | Clone and scan every repository in a Bitbucket Cloud workspace. Requires KEYHOG_BITBUCKET_USERNAME plus KEYHOG_BITBUCKET_TOKEN (recommended), or the corresponding flags; --bitbucket-endpoint selects the API root. |
--s3-bucket <BUCKET> | Scan an S3 bucket. Use --s3-prefix to narrow and --s3-endpoint for an S3-compatible API. |
--gcs-bucket <BUCKET> | Scan a Google Cloud Storage bucket. Use --gcs-prefix to narrow and --gcs-endpoint for a compatible API. |
--azure-container-url <URL> | Scan an Azure Blob container URL. Include a SAS query string for private containers; use --azure-prefix to narrow. |
--url <URL>... | Fetch + scan one or more HTTPS URLs (JS/source-map/WASM/text). |
--source <NAME> | Enable a named pluggable custom input source. Repeat as supported by the loaded source registry. |
Output
| Flag | Effect |
|---|---|
--format <text|json|jsonl|sarif|csv|github-annotations|gitlab-sast|html|junit> | Output format. Default text. The machine formats (json/jsonl/sarif/csv/github-annotations/gitlab-sast/junit) are findings-only: the banner/summary go to stderr (or are omitted), so stdout stays a clean parseable artifact. |
--output <FILE> | Write the report to FILE instead of stdout. |
--stream | Stream a one-line redacted preview per finding to stderr as they’re found; the full formatted report still lands on stdout/--output after verification. |
--show-secrets | Show full credentials. Default redacts. |
--severity <LEVEL> | Minimum reported severity: info, client-safe, low, medium, high, or critical. |
--min-confidence <FLOAT> | Only emit findings >= confidence. 0.0..=1.0. |
--progress | Force the live progress display. Mutually exclusive with --quiet. |
--quiet | Suppress banner, live ticker, and completion summary; coverage warnings and fatal errors remain visible. |
--no-color | Disable report and summary ANSI styling regardless of terminal detection. |
--dogfood | Surface suppression telemetry in output. |
Verification and HTTP transport
| Flag | Effect |
|---|---|
--verify | Call each detector’s verify endpoint. |
--timeout <SECONDS> | Set the per-request HTTP verification timeout (default 5); requires --verify. This is not a scan deadline. |
--verify-concurrency <N> | Cap in-flight verification requests per service (default 5, minimum 1); requires --verify. This is concurrency, not requests per second. |
--proxy <URL> | Route every outbound KeyHog HTTP client (remote sources and verification) through an explicit proxy (http://burp:8080, socks5://...). off disables all proxying, including TOML configuration; ambient proxy variables are ignored. |
--insecure | Skip TLS certificate verification for every outbound KeyHog HTTP client, including remote sources and verification. Use only in a controlled interception lab. |
--verify-rate <RPS> | Cap steady-state verification calls per service (default 5); requires --verify. |
--verify-batch | Serialize verification per service; requires --verify. |
--allow-script-verify | Permit script: verification only for a detector corpus the operator trusts; activation is warned because verifier-supplied code executes locally. |
--verify-oob | Enable callback-based verification; requires --verify. |
--oob-server <HOST> | Select the Interactsh collector for OOB verification. |
--oob-timeout <SECS> | Bound the per-finding OOB callback wait. |
Performance
| Flag | Effect |
|---|---|
--fast | Disable entropy discovery, ML scoring, and decode recursion (max_decode_depth = 0). Named regex detectors remain loaded; the speedup and recall change are workload-dependent. |
--deep | Seed decode depth 10 with entropy and ML enabled; later compatible flags may tighten those defaults. |
--precision | Seed a high-precision policy: decode depth 1, entropy discovery and the relaxed keyword bridge off, ML scoring retained, and a minimum confidence floor of 0.85. Explicit floors may tighten but not lower it. |
--incremental | Skip files whose content hash matches the Merkle index, then update the index after a successful scan. |
--incremental-cache <PATH> | Override the Merkle index used by --incremental. |
--daemon | Force daemon route for eligible stdin/single-file scans. Unix only; fails if the request needs the in-process pipeline. |
--daemon=auto | On Unix, use a reachable compatible daemon when it can honor the exact request; with no socket, run in process, and report failures that occur after selecting the daemon before retrying in process. This is also the absent-flag policy, except that explicit auto is rejected on platforms with no daemon transport. |
--daemon=off | Force in-process scan even if daemon is up. |
--daemon-socket <PATH> | Connect to the same non-default socket supplied to daemon start --socket; rejected with --daemon=off. |
--benchmark | Run the built-in backend benchmark corpus and exit instead of scanning the requested source. |
--profile | Emit the scanner-owned hierarchical profile report to stderr at scan end. |
--perf-trace | Emit low-level scan/GPU phase timing traces to stderr. |
Source Limits
Every limit below also has a [limits] key in .keyhog.toml with the same name
minus the limit- prefix and with dashes changed to underscores.
| Flag | Effect |
|---|---|
--limit-stdin-bytes <SIZE> | Maximum bytes read from --stdin. |
--limit-web-response-bytes <SIZE> | Maximum bytes fetched for one --url response. |
--limit-s3-object-bytes <SIZE> / --limit-gcs-object-bytes <SIZE> / --limit-azure-blob-bytes <SIZE> | Maximum bytes downloaded for one cloud object/blob. |
--limit-cloud-max-objects <N> | Maximum objects listed from one S3, GCS, or Azure container before coverage is reported incomplete. |
--limit-docker-tar-entry-bytes <SIZE> / --limit-docker-image-config-bytes <SIZE> / --limit-docker-tar-total-bytes <SIZE> | Docker/OCI archive and manifest/config ceilings. |
--limit-git-line-bytes <SIZE> / --limit-git-total-bytes <SIZE> / --limit-git-blob-bytes <SIZE> / --limit-git-chunks <N> | Git stdout-line, aggregate, per-blob, and chunk-count ceilings. |
--limit-binary-read-bytes <SIZE> / --limit-binary-decompiled-bytes <SIZE> | Binary strings and Ghidra output ceilings. |
--limit-hosted-git-pages <N> | Maximum API pages listed from one hosted Git organization, group, or workspace. |
Detector tuning
| Flag | Effect |
|---|---|
--detectors <DIR> | Use the detector TOMLs in DIR instead of the embedded corpus. To run a curated subset, copy the detector TOMLs you want into a directory and point --detectors at it (there is no per-ID enable/disable flag). |
--no-decode / --decode-depth <N> / --decode-size-limit <SIZE> | Disable recursive decoding, set its maximum depth, or bound the input size admitted to decode-through scanning. |
--no-entropy | Disable generic entropy discovery. Named detector matching remains active. |
--entropy-source-files | Admit entropy discovery in source-code files as well as configuration/data files. |
--entropy-threshold <BITS> | Set the scan-wide Shannon bits-per-byte threshold where detector-owned policy does not provide the effective gate. |
--entropy-bpe-max-bytes-per-token <RATIO> | Set the scan-wide BPE word-likeness ceiling; lower values suppress more word-like entropy candidates. |
--min-secret-len <N> | Set the minimum length for entropy-discovery candidates; named detectors retain their shape-specific lengths. |
--no-entropy-ml-scoring | Use the bare entropy heuristic rather than MoE scoring for entropy-discovery candidates. No effect when entropy or ML is disabled. |
--no-keyword-low-entropy | Disable the lower-floor generic-keyword-secret bridge so anchored generic candidates must satisfy the stricter generic-secret policy. |
--ml-threshold <FLOAT> | Raise the resolved global confidence floor. A detector-specific min_confidence remains that detector’s effective floor. |
--ml-weight <FLOAT> | Set the ML contribution to confidence scoring (0.0..=1.0). |
--no-ml | Disable ML-based confidence scoring. |
--no-unicode-norm | Disable Unicode normalization; use only for parity diagnostics because it can reduce recall. |
--scan-comments | Treat credentials in source comments as first-class findings rather than applying the default comment-context confidence penalty. |
--no-suppress-test-fixtures | Show findings on bundled example credentials. |
--baseline <FILE> | Compare against a prior scan; show only new. |
--create-baseline <FILE> | Write a new baseline from the current findings and exit. |
--update-baseline <FILE> | Merge current findings into an existing baseline. |
--hide-client-safe | Drop every CLIENT-SAFE finding (Sentry DSN, Stripe pk_*, Mapbox pk., PostHog phc_, etc.) before reporting. Use this for bug-bounty / exfiltration-impact workflows where keys public by design are noise. |
Scan controls
| Control | Effect |
|---|---|
keyhog scan --backend auto|gpu|simd|cpu | Use persisted automatic routing (auto) or explicitly force one diagnostic backend (gpu, simd, or cpu). Profiles and routing evidence use the descriptive labels gpu-region-presence, simd-regex, and cpu-fallback; retired MegaScan and implementation-name aliases are rejected. A selected GPU route that becomes unusable exits 12 instead of substituting CPU/SIMD. |
keyhog scan --gpu-batch-input-limit 512MB | Override the VRAM-adaptive byte limit for one GPU region-presence batch (clamped to 128 MiB–1 GiB). |
keyhog scan --max-file-size <SIZE> | Bound one filesystem input (default 100 MiB); larger files are named in the coverage summary. |
keyhog scan --regex-dfa-limit <SIZE> | Bound each regex lazy-DFA cache (default 1 MiB); lowering the safety ceiling may force complex patterns onto the slower NFA path. |
keyhog scan --no-gpu | Short-circuit GPU init at hardware-probe time. The scanner runs as if no GPU adapter existed. |
keyhog scan --require-gpu | Fail closed with exit 12 when GPU is unavailable before scanning or a selected GPU dispatch fails at runtime. |
keyhog scan --autoroute-calibrate | Installer/maintenance mode: benchmark parity-checked autoroute candidates and persist fastest-correct decisions. Normal scans do not use this mode. |
keyhog scan --autoroute-gpu | Low-level direct-calibration diagnostic: include eligible GPU candidates. keyhog calibrate-autoroute always includes every eligible backend. |
keyhog scan --no-autoroute-gpu | Low-level direct-calibration diagnostic: exclude GPU despite TOML. This is not used by canonical calibration, and its incomplete evidence is isolated from normal auto-scan identity. |
keyhog scan --batch-pipeline / --no-batch-pipeline | Explicitly select or reject the coalesced batch pipeline for this diagnostic/configuration identity. |
keyhog scan --per-chunk-timeout-ms <MS> | Attach an Instant deadline to every chunk scan. Default unset = no operator deadline; [scan].per_chunk_timeout_ms provides the persistent default. |
keyhog scan --threads <N> | Pin the rayon worker count for this run. .keyhog.toml [scan].threads provides the persistent default. |
keyhog scan --calibration-cache <PATH> | Apply one explicit per-detector Bayesian confidence cache. Missing or invalid files fail closed. |
keyhog scan --reader-threads <N> | Pin dedicated filesystem reader threads. .keyhog.toml [scan].reader_threads provides the persistent default. |
keyhog scan --fused-batch <N> | Pin fused filesystem pipeline batch size. .keyhog.toml [scan].fused_batch provides the persistent default. |
keyhog scan --fused-depth <N> | Pin fused filesystem pipeline channel depth. .keyhog.toml [scan].fused_depth provides the persistent default. |
keyhog scan --dedup <credential|file|none> | Select report grouping scope. Default credential. |
keyhog scan --no-config | Run from compiled defaults only: skip walk-up .keyhog.toml discovery and reject an explicit --config. |
keyhog scan --lockdown | Fail closed unless all memory/core-dump/cache protections activate; also forces HTTPS-only verification and forbids disk cache writes. |
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 MoE readback timeout is explicit scanner tuning:
.keyhog.toml [tuning].gpu_moe_timeout_ms. GPU region-presence parity/debug
recall-floor runs use .keyhog.toml [tuning].gpu_recall_floor = true.
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.
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
keyhog detectors
Lists every detector in the embedded corpus.
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.
keyhog explain <DETECTOR_ID>
Explain the loaded detector. Includes keywords, patterns, companion rules, verification endpoint, and detector-owned entropy/BPE/length/suppression policy.
keyhog explain stripe-secret-key
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.
keyhog watch src/ # watch the source tree
keyhog watch src/ config/ # watch several roots in one process
keyhog watch # watch the current directory
keyhog hook <install|uninstall>
Manages the git pre-commit hook. See Pre-commit hook for usage.
keyhog daemon <start|stop|status> (Unix only)
The daemon holds a compiled scanner and initialized accelerator state for
eligible stdin and single-file scans. Directory, Git, remote, baseline,
verification, explicit backend/calibration, and incompatible policy requests
use the in-process pipeline in auto mode; --daemon=on fails if the exact
daemon route cannot be honored.
| Subcommand | Effect |
|---|---|
daemon start | Bind the Unix socket and accept connections. Startup options include --socket, --detectors, --cache-dir, --backend, and --request-timeout-secs. |
daemon stop | Tell the running daemon to shut down. |
daemon status | Print uptime, scans served, active scans, and detector count. |
daemon start --request-timeout-secs <N> sets how long one client connection
may sit without completing a request frame before the daemon closes it and
reclaims the connection slot. Default: 300.
Default socket path: $XDG_RUNTIME_DIR/keyhog.sock when that directory is set;
otherwise the OS user cache directory (~/.cache/keyhog/server.sock on Linux
or ~/Library/Caches/keyhog/server.sock on macOS), with the OS temporary
directory plus keyhog/server.sock as the last fallback. Every daemon command
and scan client resolves the same default.
On Windows: every daemon subcommand and explicit scan --daemon=auto|on
prints a Unix-only error and exits non-zero. No Windows daemon transport ships;
an absent daemon flag or explicit --daemon=off runs the in-process scanner.
See Daemon and warm scans for the complete auto /
on / off contract, request eligibility, warm autoroute behavior, and socket
security semantics.
keyhog diff <FILE_A> <FILE_B>
Compare two scan outputs (JSON or NDJSON). Useful for “did this PR introduce a new finding?” gating in CI.
keyhog scan . --format json > baseline.json
git checkout pr-branch
keyhog scan . --format json > 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.
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 stripe-secret-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.
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.
--autoroute-cache <PATH> selects the evidence file; off is rejected because
calibration must persist its result. --quiet suppresses per-probe progress but
still prints the final summary.
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.
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 three named probes: moe_kernel for GPU
confidence scoring, vyre_literal_set for VYRE’s direct match-triple
diagnostic, and gpu_region_presence for the production scan route. The last
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.
--json is available for self-test
and autoroute inspection output. A failed self-test emits the complete report
and exits 4; a normal scan whose selected GPU route fails exits 12.
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
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
Install maintenance
| Command | Effect |
|---|---|
keyhog doctor | Report host and PATH state, detector corpus health, and end-to-end scanner/GPU self-tests. |
keyhog update --check | Check the newest complete stable release for this host; exits 10 when one is available. |
keyhog update [--version <TAG>] | Atomically install the newest complete stable release or an exact published tag and roll back if verification fails. |
keyhog repair [--force] [--version <TAG>] | Reinstall from the newest complete stable release or an exact published tag; without --force, a healthy install is left intact. |
keyhog uninstall [--yes] | Show what would be removed; --yes performs the uninstall. |
Linux uses one GPU-capable artifact that probes CUDA and WGPU at runtime. These
commands therefore have no backend or artifact-variant selector.
Implicit resolution excludes drafts and prereleases and requires the binary,
checksum, signature, GPU-literal sidecar, sidecar checksum, and sidecar
signature. An explicit --version may select a published prerelease but never
a draft.
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.
| Flag | Effect |
|---|---|
-V, --version | Print version + build info, then exit. |
--full | With --version, include the hardware probe. |
-h, --help | Print root help. |
Display controls are command-specific: scan --no-color disables report and
summary ANSI output, while detectors --verbose prints full matching detector
specifications.
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
Settings resolve in this order, rightmost winning:
compiled defaults → .keyhog.toml → CLI flags
(ScanConfig::default) (walked up from (always win)
the scan path)
- Compiled defaults are typed at their owning boundary. Detection defaults
live in
ScanConfig::default(); source limits, verifier policy, runtime workers, and scanner tuning have their own typed defaults. The effective resolver folds those owners into oneResolvedScanConfig, and behavioral tests pin the operator-visible result. .keyhog.tomlis discovered by walking up from the scan path to the filesystem root (first one found wins). Copy.keyhog.toml.exampleto your repo root and delete what you don’t need. A malformed.keyhog.tomlfails closed with the path and TOML error before any scan output is written. Unknown tables and keys are parse failures, not ignored compatibility shims. Use--no-configwhen you intentionally want compiled defaults.- CLI flags always override the file. A flag left unset falls through to the file, then to the compiled default.
There is no separate system/user config tier today: the walk-up .keyhog.toml
is the only file layer.
Detector policy has a separate, explicit provenance. keyhog explain <detector-id> prints fields declared by the loaded detector TOML and labels
unset optional fields as unresolved there: detector-field defaults or scan
policy apply only at scan time. keyhog config --effective is the scan-level
view and reports whether the BPE ceiling came from an explicit scan-override
or the compiled scan-fallback. During scanning, an eligible detector’s
declared BPE ceiling wins over that fallback, while an explicit scan override
wins over every eligible detector ceiling.
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.
| Setting | Default | .keyhog.toml key | CLI flag | Effect |
|---|---|---|---|---|
| Min confidence | 0.40 | [scan].min_confidence | --min-confidence | Drop findings scoring below this (0.0-1.0). Bench-tuned for max F1. |
| Decode depth | 10 | [scan].decode_depth | --decode-depth | Max recursive decode passes, e.g. base64(hex(url(secret))) (1-10). |
| Decode size limit | 512KB | decode_size_limit | --decode-size-limit | Per-file ceiling for decode-through; larger files skip encoding detection. |
| Entropy enabled | on | no_entropy = true disables | --no-entropy | Shannon-entropy detection for novel high-entropy strings. |
| Entropy in source | off | entropy_source_files | --entropy-source-files | Run entropy inside .py/.js/.go/… (off by default to cut FPs). |
| Entropy threshold | 4.5 | [scan].entropy_threshold | --entropy-threshold | Scan-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 detection path composes it with the owning detector’s evidence band. The byte-entropy domain is [0.0, 8.0]; non-finite and out-of-range requests fail closed. |
| BPE word-like bound | 2.2 | [scan].entropy_bpe_max_bytes_per_token | --entropy-bpe-max-bytes-per-token | With 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 length | 16 | [scan].min_secret_len | --min-secret-len | Minimum credential length for entropy-discovery candidates. Named detectors keep their own shape-specific length gates. |
| Keyword low-entropy | on | generic_keyword_low_entropy | --no-keyword-low-entropy | Admit 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 authority | on | no_entropy_ml_scoring = true disables | --no-entropy-ml-scoring | Score entropy-discovered candidates through the MoE instead of the bare entropy heuristic. No effect when entropy or ML is disabled. |
| ML enabled | on | no_ml = true disables | --no-ml | Include 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 | 0.5 | ml_weight | --ml-weight | Blend weight of the ML score vs heuristics (0.0-1.0). |
| Additional scan confidence floor | unset | [scan].ml_threshold | --ml-threshold | Despite 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 norm | on | no_unicode_norm = true disables | --no-unicode-norm | Normalise homoglyphs before matching (anti-evasion). |
| Scan comments | off | - | --scan-comments | Treat secrets in code comments at full confidence (default downgrades them). |
| Threads | #cores | [scan].threads | --threads | Parallel scan workers. |
| Reader threads | scan-pool-derived | [scan].reader_threads | --reader-threads | Dedicated filesystem read workers. |
| Fused batch | 32 | [scan].fused_batch | --fused-batch | Chunk batch size for the fused filesystem pipeline. |
| Fused depth | worker-count-derived | [scan].fused_depth | --fused-depth | Bounded channel depth for fused filesystem batches. |
| Per-chunk timeout | off | [scan].per_chunk_timeout_ms | --per-chunk-timeout-ms | Optional hard deadline per chunk scan in milliseconds. |
| Dedup scope | credential | [scan].dedup | --dedup | credential / file / none. |
| HTTP verification timeout | 5 seconds | timeout | --timeout | Per-request verifier deadline; it does not bound scanning. Use per_chunk_timeout_ms for the optional scanner chunk deadline. |
| Verification concurrency | 5 per service | verify_concurrency | --verify-concurrency | Maximum in-flight verification requests per service; zero is rejected. Distinct from the requests/second limiter. |
| Verification request rate | 5.0 RPS per service | - | --verify-rate | Steady-state request-rate ceiling. --verify-batch additionally forces concurrency to one. |
| Max file size | 100 MiB | max_file_size | --max-file-size | Walker skips files larger than this. |
| GPU batch input limit | VRAM-adaptive (128 MiB–1 GiB) | [scan].gpu_batch_input_limit | --gpu-batch-input-limit | Caps bytes admitted to one GPU region-presence batch. Retired MegaScan spellings are rejected; this one name owns CLI, config, effective-config output, and the Rust API. |
| Severity floor | (all) | [scan].severity | --severity | Minimum severity to report: info/client-safe/low/medium/high/critical. |
| Output format | text | [scan].format | --format | text/json/jsonl/sarif/csv/github-annotations/gitlab-sast/html/junit. |
| Show secrets | off | show_secrets | --show-secrets | Print plaintext credentials. Never enable in CI/logs. |
| Incremental cache | off | [scan].incremental / [scan].incremental_cache | --incremental / --incremental-cache | BLAKE3 Merkle skip-cache; 10-100× on CI re-runs. |
| Hyperscan cache dir | platform cache dir | [system].cache_dir | --cache-dir | Compiled-database cache directory. Must be an absolute user-owned path under the home directory or per-user keyhog temp cache root. |
| Autoroute cache file | platform cache file | [system].autoroute_cache | --autoroute-cache | Persisted fastest-correct backend decisions. Use an absolute file path or off to disable persistence and force auto-route cache misses to fail loudly. |
| Bayesian calibration cache | off | [system].calibration_cache | --calibration-cache | Explicit per-detector confidence calibration file written by keyhog calibrate. Missing or damaged explicit files fail closed before scanning. |
| GPU runtime policy | auto | [system].gpu | --no-gpu / --require-gpu | auto 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 control | off | [system].autoroute_gpu | --autoroute-gpu / --no-autoroute-gpu | Applies 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 pipeline | off | [system].batch_pipeline | --batch-pipeline / --no-batch-pipeline | Diagnostic/calibration route that bypasses the fused filesystem pipeline. Printed by keyhog config --effective and included in autoroute scan identity. |
| AWS canary issuer extensions | embedded 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 tuning | compiled scanner defaults | [tuning] | - | Detection/recall route gates that affect engine work selection. These are explicit config so autoroute calibration identity includes them; ambient KEYHOG_* tuning env vars are ignored. |
| Confidence prefixes | embedded scanner set | known_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 keywords | embedded scanner set | secret_keywords | - | Replace the scan-wide positive context words used by generic confidence scoring. Empty entries fail closed. |
| Test-context keywords | embedded scanner set | test_keywords | - | Replace the scan-wide test/mock context words used by confidence policy. Empty entries fail closed. |
| Placeholder keywords | embedded scanner set | placeholder_keywords | - | Replace the scan-wide placeholder markers used by confidence policy. Empty entries fail closed. |
| Backend | auto | - | --backend | auto/gpu/simd/cpu. Profiles and persisted evidence use the descriptive engine labels gpu-region-presence/simd-regex/cpu-fallback; retired MegaScan and implementation-name aliases are rejected. Auto uses a persisted installer-calibrated fastest-correct decision for the exact workload bucket; missing/stale/incomplete calibration is an error, not permission to substitute another backend. |
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.
| Limit | Default | .keyhog.toml key | CLI flag |
|---|---|---|---|
| Stdin bytes | 10 MiB | [limits].stdin_bytes | --limit-stdin-bytes |
| Web response bytes | 10 MiB | [limits].web_response_bytes | --limit-web-response-bytes |
| S3 object bytes | 10 MiB | [limits].s3_object_bytes | --limit-s3-object-bytes |
| GCS object bytes | 10 MiB | [limits].gcs_object_bytes | --limit-gcs-object-bytes |
| Azure blob bytes | 10 MiB | [limits].azure_blob_bytes | --limit-azure-blob-bytes |
| Cloud listed objects | 100000 | [limits].cloud_max_objects | --limit-cloud-max-objects |
| Docker tar entry bytes | 128 MiB | [limits].docker_tar_entry_bytes | --limit-docker-tar-entry-bytes |
| Docker config/manifest bytes | 16 MiB | [limits].docker_image_config_bytes | --limit-docker-image-config-bytes |
| Docker tar total bytes | 8 GiB | [limits].docker_tar_total_bytes | --limit-docker-tar-total-bytes |
| Git stdout line bytes | 10 MiB | [limits].git_line_bytes | --limit-git-line-bytes |
| Git aggregate bytes | 256 MiB | [limits].git_total_bytes | --limit-git-total-bytes |
| Git blob bytes | 10 MiB | [limits].git_blob_bytes | --limit-git-blob-bytes |
| Git emitted chunks | 500000 | [limits].git_chunks | --limit-git-chunks |
| Hosted-git listing pages | 1000 | [limits].hosted_git_pages | --limit-hosted-git-pages |
| Binary strings bytes | 64 MiB | [limits].binary_read_bytes | --limit-binary-read-bytes |
| Ghidra output bytes | 50 MiB | [limits].binary_decompiled_bytes | --limit-binary-decompiled-bytes |
Library note:
ScanConfig::max_file_sizeandScanConfig::dedupare scan pipeline settings, not regex-engine settings. The CLI applies them through the filesystem source and final deduplication stage;FilesystemSource::newuses the sameDEFAULT_MAX_FILE_SIZE_BYTESasScanConfig::default()so the shipped default cannot drift.
Presets
| Preset | TOML | CLI | What it does |
|---|---|---|---|
| Fast | fast = true | --fast | Disables decode recursion (max_decode_depth = 0), entropy discovery, and ML scoring. Named regex and multiline detection remain active. Refused under --lockdown. |
| Deep | deep = true | --deep | Enables entropy and ML and restores decode depth 10. It does not lower the canonical 0.40 confidence floor. |
| Precision | precision = true | --precision | Disables 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 mutually exclusive and conflict with
--no-decode/--no-entropy.
A preset is a BASE, not a terminal state. It seeds the decode/entropy/ML
defaults, then any explicit knob you pass on the same command line overrides
that base; --deep --decode-depth 3 runs the deep base at decode-depth 3, and
--deep --min-confidence 0.9 raises the floor on the deep base. Two overrides
are one-directional and cannot weaken a precision bar: under --precision,
--min-confidence may raise the 0.85 floor but never lower it, and
--no-keyword-low-entropy can only disable the relaxed keyword floor, never
re-enable it under a preset that turned it off. Everything else takes effect as
written.
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.
[scan]
The canonical owner for scan execution and reporting policy. This includes
severity, 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"
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 = 32
fused_depth = 4
per_chunk_timeout_ms = 30000
[detector.<id>]: per-detector overrides
Keyed by detector id (keyhog detectors lists them; keyhog explain <id>
shows one):
[detector.generic-api-key]
enabled = false # drop this detector from the corpus entirely
[detector.twilio-api-key]
min_confidence = 0.6 # per-detector floor, OVERRIDES the global one
There are two per-detector floor sources, in increasing precedence:
- Tier-B:
min_confidenceinside the detector’s own TOML underdetectors/<id>.toml([detector] min_confidence). The detector’s shipped baseline. .keyhog.toml[detector.<id>] min_confidence: validated operator intent; overrides the detector floor for that id and is compiled into the active scan policy before any candidate can be discarded.
enabled = false removes a detector from the active corpus. Shipped detector
availability and shipped confidence policy have no hidden Rust override lists;
the individual detector TOML is their single source.
[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"
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.
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 explicitly excludes GPU is stored under a diagnostic-only config identity,
so its incomplete candidate set cannot replace normal all-candidate evidence.
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
no_candidate_gate = true
fallback_localizer = false
gpu_recall_floor = false
gpu_moe_timeout_ms = 30000
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.
gpu_recall_floor forces the GPU region-presence path to compute the full CPU
trigger net during parity/debug scans and report any GPU under-fire it recovers.
gpu_moe_timeout_ms bounds one GPU MoE confidence readback; on timeout KeyHog
surfaces the GPU fault and scores the same candidates on CPU MoE.
[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.
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_config→ResolvedScanConfig). The scanner, router, reporter, and verifier consume that resolved policy rather than independently re-reading raw arguments.
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.
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 a backend for a calibrated workload key: Hyperscan/SIMD, scalar CPU, or the GPU runtime acquired by the scanner. 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 reference, and records the fastest survivor for the measured representative. CUDA and WGPU are not independently calibrated candidates; they are implementations behind the single acquired GPU class. The parity identity covers chunk membership; detector id/name/service/severity; hashes of the actual credential, stored hash, and companion names/values; full source/history location; entropy; confidence; and finding multiplicity. Plain credentials and companions never enter calibration logs. Normal scans then do a direct table lookup; they never benchmark mid-scan.
Performance selection uses the median of the recorded trials, not the single
fastest sample. If one route’s 95% Student-t confidence interval is entirely
below every competitor, it is the separated winner. If intervals overlap,
KeyHog reports that evidence as inconclusive and chooses the lowest measured
median among the statistically non-dominated routes; it does not pretend that
overlap proves equivalence or apply a CPU/GPU preference hierarchy. Engagement
overhead breaks only an exact median tie. keyhog backend --autoroute exposes
the representative times and a selection_basis for every decision, so this
distinction is visible in both text and JSON inspection output.
Because the decision is measured, it must be recorded before --backend auto
(the default) can run. A fresh install has no recorded decisions yet, so until
you calibrate, an auto scan fails closed with
exit code 2, autoroute calibration required, rather
than silently substituting a slower or unverified backend.
Operator workflow
- Install normally or run calibration for the workload families you use.
- Inspect the cache with
keyhog backend --autoroute. - Run with the default
--backend auto; normal scans never benchmark. - If a real scan names an uncovered key, calibrate that workload family or use an explicit backend only as a deliberate diagnostic override.
Calibrate core and source-specific workloads
A normal install calibrates automatically. Plain ./install.sh /
./install.ps1 runs the visible calibration phase after the binary is verified,
and the install fails rather than leave you with an uncalibrated default auto
route. You do not need to pass any flag to get calibrated.
To re-run calibration later without reinstalling (after hardware changes, or to cover source routes that were unavailable at install time):
# Unix: full sweep, including the git / docker / web source probes
./install.sh --calibrate
# Windows
./install.ps1 -Calibrate
The binary can also recalibrate its own core workloads in place:
keyhog calibrate-autoroute
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. File-tree probes cover every chunk-count band through the
default 32-chunk fused batch, plus decode-heavy and many-file shapes. It does not
cover the git / docker / web source probes; those need environment orchestration
(a repo, a running daemon, a served URL) that only the installer’s
--calibrate mode performs. The installers construct those fixtures and invoke
the low-level scan --autoroute-calibrate probe mode; that scan flag records one
caller-supplied workload but does not build or sweep the external fixtures. If
you scan those sources and hit
autoroute calibration required, re-run install.sh --calibrate /
install.ps1 -Calibrate rather than the subcommand. 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 deliberately writes
under a noncanonical config identity; its CPU-only evidence cannot overwrite a
normal all-candidate decision.
Calibration saves take an exclusive sibling-file lock across the complete read/merge/atomic-write cycle. Separate calibration processes therefore accumulate compatible config and workload decisions without a last-writer-wins loss; the operating system releases the lock if a writer exits or crashes. Only identity-compatible, structurally valid rows are preserved. If an existing cache is unreadable, incompatible, or invalid, calibration emits an unconditional stderr warning with the cache path and replacement reason, then starts a fresh cache; unrelated preset rows in that old file are not merged.
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 and, when the scanner can use a physical GPU, the GPU device, runtime backend, and driver/runtime identity. A missing or changed required field invalidates the evidence and requires recalibration.
- Each scan preset (default,
--fast,--deep,--precision) is calibrated separately. - Flags hashed into the scan config (for example
--threadsor--min-confidence) fork the decision;keyhog calibrate-autoroutesweeps 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. - Source policy (
--limit-*,--max-file-size,--no-default-excludes) and detector floors fork the decision for realstdin/directory buckets that feed different cache/chunk geometry. - Workload shape matters: a single file, a directory, and a piped
stdinstream are distinct buckets, andstdinis content-sensitive.
The host profile is deliberately checked, but it is not a complete performance- environment fingerprint: for example, cache age, CPU governor, system load, and every accelerator limit are not all identity fields. Decisions do not expire by age. 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.
Every lookup is exact at the complete workload-key level. Size, chunk-count, and maximum-file dimensions use one-power-of-two logarithmic ranges; decode density uses paired logarithmic ranges to resist content-sample jitter. The decision proves correctness and timing for the representative that was measured under that key. It does not prove that the same backend is fastest for every individual byte length inside the numeric range. A neighbouring range is not evidence for this one. Uncalibrated keys fail closed; KeyHog never interpolates or clamps them to a CPU/GPU substitute.
Large directory and multi-source scans run in process and produce multiple real batches. Each batch needs an exact key in the cache; one calibrated single-file key does not authorize every later tree shape. 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 GPU cost, so it is part of routing semantics. Calibration records warm CPU/Hyperscan medians and, for GPU, the real first dispatch followed by warm trials:
- An in-process one-shot scan includes cold GPU cost when choosing a backend.
- A ready daemon initializes accelerator state before accepting requests and chooses from the warm GPU trials.
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.
When an auto scan reports calibration required
The error names the missing workload bucket. Resolve it by either:
- Re-running calibration (
keyhog calibrate-autoroute, orinstall.sh --calibrate/install.ps1 -Calibrate) so the bucket gets a measured decision, or - Passing an explicit backend for a one-off diagnostic scan:
keyhog scan --backend simd(orgpu,cpu, …). An explicit--backendbypasses autoroute entirely; it is a diagnostic/benchmark override and does not prove autoroute correctness.
A STALE status means the cache was written for a different build; auto scans
reject it, so recalibrate after upgrading KeyHog.
Inspect what is calibrated
keyhog backend --autoroute # human-readable cache contents
keyhog backend --autoroute --json # machine-readable
keyhog doctor # reports calibrated / not calibrated / STALE
These show every persisted config, its workload buckets, representative median
route times, whether confidence was separated, the selection basis, and the
resolved one-shot and daemon backends. When a scan hits exit 2, you can
therefore see 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 per-decision JSON fields have these exact meanings:
| Field | Meaning |
|---|---|
backend | Cold-aware backend for an in-process one-shot scan. |
simd_ms, cpu_ms | Median trial time for that CPU route; cpu_ms is null when scalar CPU was not measured separately. |
gpu_ms | One-shot GPU representative: the greater of the real first dispatch and the warm-trial median. |
gpu_warm_ms | Warm GPU median used by a ready daemon; null when GPU was not eligible. |
confidence_separated | Whether the one-shot winner’s 95% interval is entirely below every competitor. |
selection_basis | separated-95pct-confidence, or lowest-measured-median-among-overlapping-confidence. |
selected_margin_ns | One-shot representative-time margin to the next candidate; null when there is no competitor. |
daemon_backend | Backend derived for a ready persistent daemon from warm GPU evidence. |
daemon_confidence_separated, daemon_selection_basis, daemon_selected_margin_ns | Daemon-route counterparts of the one-shot confidence, basis, and margin fields. |
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).
Exit codes
KeyHog uses exit codes to signal scan and maintenance outcomes. The numeric contract is stable across versions; consumers (CI gates, pre-commit hooks, IDE plugins, and health checks) can rely on it.
| Exit | Meaning |
|---|---|
0 | Command succeeded; for a scan, zero reported findings and no clean-blocking coverage/cache failure. |
1 | Findings present, none confirmed live (unverified, skipped, or verified-inactive: dead/revoked). |
2 | User/operator error: bad input/config, missing or invalid scan state, an unavailable required daemon, or missing/stale/incomplete autoroute evidence. Also operator-correctable I/O such as not-found, permission-denied, connection-refused, invalid-input, or invalid-data. |
3 | System error: a lower-level operating-system I/O failure, incremental-cache failure, or explicitly selected non-GPU backend that cannot execute. |
4 | Health/self-test failure: keyhog doctor unhealthy, keyhog repair could not restore a working binary, keyhog backend self-test failed. |
10 | LIVE credentials confirmed (a --verify scan where the vendor API accepted a found secret) - the highest-severity gate. Also returned by keyhog update --check when a newer release exists. |
11 | Scanner thread panicked. The finding count is NOT trustworthy - investigate, don’t ship. Distinct from 2/3 so CI can tell a code bug from a config error. |
12 | Selected/required GPU unavailable: --require-gpu, an explicit GPU backend, or persisted autoroute selected GPU but the stack or dispatch could not honor it. CPU/SIMD is not substituted. |
13 | A requested source failed before producing scan data, or a zero-finding scan had incomplete input coverage, so KeyHog refuses to report clean. |
130 | Interrupted (SIGINT / Ctrl-C). |
0 (clean)
Use case: a CI step like keyhog scan . exits 0 when the working tree
is clean. The job stays green.
With --verify, the exit code escalates when a credential is confirmed
live: a found secret the vendor API accepts exits 10, while a finding
that is unverified, skipped, or verified inactive (dead or revoked)
exits 1. So gating ONLY on live credentials needs no JSON parsing -
branch on the exit code:
keyhog scan . --verify
case $? in
0) echo "clean" ;;
10) echo "LIVE credentials present - block + page" ; exit 1 ;;
1) echo "findings, none confirmed live" ;;
esac
1 (findings present)
The most common non-zero. CI fails, pre-commit hook blocks the commit,
PR check turns red. Findings get printed to stdout in whatever format
--format selected.
Exit 1 means findings exist but none were confirmed live. That covers
findings that were not verified, findings whose verification was skipped,
and findings verified inactive (dead or revoked). A scan that confirms
a live credential exits 10 instead (see below), so “findings, none live”
vs “some live” is just 1 vs 10, no JSON parsing required.
2 (user error)
Things that exit 2:
- Unknown CLI flag.
- On Windows, direct
keyhog uninstall --yes: the running.execannot delete itself, so the command prints the path to remove after it exits. The PowerShell installer handles this outer-process cleanup normally. .keyhog.tomlparse error.- Detector load failure for a specific TOML (with a stderr warning; the rest of the scan continues but exits 2 at the end).
--baseline <FILE>where FILE doesn’t exist or isn’t valid JSON.- Missing, stale, invalid, or incomplete autoroute calibration for an
automatic backend decision. Inspect it with
keyhog backend --autoroute, then rerunkeyhog calibrate-autoroute,install.sh --calibrate, orinstall.ps1 -Calibrate. An explicit--backendbypasses the table for that diagnostic scan; it does not make the autoroute state valid. --daemon=on(or bare--daemon) when no compatible daemon can honor the request,daemon status/stopwhen no daemon is running, or a stale daemon rejected by the required route.--daemon=autoinstead reports a reachable daemon failure and runs the eligible request in process; with no socket it goes straight in process.- On Windows, explicit
--daemon=auto|onand daemon subcommands, because the Unix-domain-socket transport is unavailable. An omitted flag or--daemon=offis the supported in-process path. - Network error during
--verifyis NOT a2; it’s averification-errormarker per finding and the scan exits1if any unverified-live findings exist.
Stderr carries the error message. Stdout may have partial output depending on where the error happened.
3 (system error)
A failure below the operator-input boundary: a low-level I/O error that is not
one of the operator-correctable kinds mapped to 2, an incremental-cache
failure, or an explicitly selected SIMD/Hyperscan path that becomes
unavailable. A selected or required GPU failure is 12, not 3. A
missing/garbage --baseline is 2; a requested source that produced no scan
data (for example --git-history on a non-repo) is 13; and a detector TOML
load failure is 2. Distinct codes let automation choose whether to correct
configuration, repair a runner, or rescan uncovered input. Stderr carries the
cause.
4 (health / self-test failure)
Returned by the maintenance subcommands, not by scan: keyhog doctor
when the install fails its end-to-end self-test, keyhog repair when it
could not restore a working binary, and keyhog backend when its
self-test fails. A health monitor can treat 4 as “binary present but
not trustworthy.” Use keyhog backend --self-test --json on self-hosted
GPU runners when CI needs stable fields instead of stderr scraping.
10 (live credentials, or update available)
The highest-severity scan outcome: a --verify scan where the vendor
API accepted a found secret - it is real and exfil-capable right now.
Gate hard on this:
keyhog scan . --verify || rc=$?
[ "${rc:-0}" = "10" ] && { echo "::error::live credential confirmed"; exit 1; }
keyhog update --check reuses 10 to mean “a newer release exists”
(exit 0 = already current), so a self-update cron can branch on it.
11 (scanner panic)
A panic inside a scanner thread (regex compile bug, OOM in a windowed chunk, etc.). The scan was incomplete; the count of findings emitted is NOT trustworthy. CI should treat this as “investigate” rather than “ship anyway because exit 11 != 1”.
The reason this is 11 rather than 2:
- A panic is a code bug worth surfacing distinctly.
- Some CIs (older Jenkins, certain shell wrappers) collapse
2with “command not found” or other ambient errors.11is unambiguous. - Additional scan failure categories can be added without renumbering existing codes.
12 (selected or required GPU unavailable)
Returned when the operator explicitly required GPU execution (--require-gpu
or [system].gpu = "required"), explicitly selected the GPU backend, or a
persisted autoroute decision selected GPU, but the host cannot provide or keep
a usable GPU dispatch path. This can happen before scanning or during a runtime
dispatch. CPU/SIMD is not substituted. The distinct code lets CI identify a GPU
runner/driver regression without scraping stderr.
13 (requested source failed or coverage incomplete)
Returned when a source the operator explicitly requested produced no scan data,
or when the scan completed with zero findings but input coverage was incomplete:
for example --git-history on a non-git directory, a bad git ref, a remote
source that could not be read, an unreadable file, an oversized file skipped by
--max-file-size, a truncated archive, or a decode/source expansion cap. This
is distinct from clean 0 and generic user-error 2; the scan did not prove
the target clean because requested bytes were not scanned. If findings were
reported from the covered portion, the findings outcome (1, or 10 when a
credential was confirmed live) takes precedence while the coverage warning
still remains visible on stderr.
Composing in shell
set -e
keyhog scan . # exit 1 stops the shell here
Or to handle the non-zero explicitly:
keyhog scan . --verify || rc=$?
case "$rc" in
0|"") echo "clean" ;;
1) echo "findings (none live) -> opening PR comment" ;;
10) echo "LIVE credentials -> block + page on-call" ;;
2) echo "user error (bad flag/config) -> failing build" ;;
3) echo "system error -> retry / investigate" ;;
4) echo "health/self-test failure -> repair installation" ;;
11) echo "scanner panic -> paging on-call" ;;
12) echo "selected/required GPU unavailable -> repair runner or recalibrate" ;;
13) echo "source failed or coverage incomplete -> fix source/ref/token or rescan uncovered input" ;;
130) echo "interrupted" ;;
*) echo "unknown exit $rc" ;;
esac
What you can’t do
- No
--exit-zeroflag. KeyHog deliberately does not provide a way to lie to CI about findings. If you need to override (e.g. “this finding is accepted, ship anyway”), suppress it by hash in.keyhogignoreinstead. The exit code then reflects truth: there are no UN-suppressed findings, so it’s0.
Environment variables
KeyHog deliberately reads almost no environment variables, and none that change how a scan behaves. Detection, routing, suppression, limits, output, and every other knob come from exactly two places:
- compiled defaults, overridden by
- a single
.keyhog.toml(see Configuration),
with CLI flags as the per-invocation override on top. There is no
KEYHOG_* behavior knob and no environment override tier, so the same repo
scans identically on every machine, regardless of shell profile or CI
environment. A CI gate (production_env_reads_stay_on_the_allowlist) fails the
build if shipped code reads any environment variable outside the small allowlist
documented below.
The only environment variables KeyHog reads fall into three groups: the install scripts, OS/terminal standards, and cloud-provider credentials used purely for authentication.
Install scripts (install.sh / install.ps1)
These are read by the installer, not by the scanner.
| Variable | Default | Effect |
|---|---|---|
KEYHOG_VERSION | (latest release) | Pin the install to a specific release tag instead of latest. |
GITHUB_TOKEN | (unset) | Optional token for the fallback GitHub releases API lookup only; the default latest-asset redirect path does not use it. |
OS / terminal standards
| Variable | Default | Effect |
|---|---|---|
NO_COLOR | (unset) | Honored per no-color.org: if set, all ANSI styling is disabled. |
TERM, COLORTERM | (set by terminal) | Read only to detect terminal color capability for the human reporter. |
PATH | (OS) | Used when locating trusted system binaries (KeyHog never trusts a bare PATH lookup for credential-handling tools; see the safe-binary resolver). |
XDG_RUNTIME_DIR | (login session) | Preferred Unix daemon socket location: $XDG_RUNTIME_DIR/keyhog.sock. Without it, KeyHog uses the OS user cache directory (~/.cache/keyhog/server.sock on Linux or ~/Library/Caches/keyhog/server.sock on macOS), then the OS temporary directory plus keyhog/server.sock when no cache directory is available. The exact path is overridable per process with daemon start/stop/status --socket and scan --daemon-socket; there is no KEYHOG_* socket environment variable. |
RUST_LOG | keyhog=warn | Tracing filter. keyhog=debug for verbose detector/suppression telemetry; keyhog::routing=trace for per-chunk backend selection. |
RUST_BACKTRACE | (unset) | Standard Rust backtrace control on panic (1 short, full full). |
Cloud-provider credentials (authentication only)
Read only to authenticate to the matching cloud API for a remote-source scan. They never alter detection, and they are never forwarded to a non-matching custom endpoint without an explicit opt-in flag.
| Variable | Effect |
|---|---|
KEYHOG_GITHUB_TOKEN | GitHub PAT read only when --github-org explicitly selects an organization scan. Preferred over putting --github-token in the process arguments. |
KEYHOG_GITLAB_TOKEN | GitLab PAT read only when --gitlab-group explicitly selects a group scan. |
KEYHOG_BITBUCKET_USERNAME, KEYHOG_BITBUCKET_TOKEN | Bitbucket Cloud identity read only when --bitbucket-workspace explicitly selects a workspace scan. |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_REGION, AWS_DEFAULT_REGION | SigV4 signing for S3 ListObjectsV2 / object GET against AWS-owned endpoints. |
GOOGLE_OAUTH_ACCESS_TOKEN, GCS_BEARER_TOKEN | Bearer token for --gcs-bucket JSON-API listing/downloads (the Google token wins when both are set). |
Repository-collection tokens (GitHub / GitLab / Bitbucket) and the scan target
itself are CLI-only: --github-token, --gitlab-token, --bitbucket-token,
--source, --s3-bucket, --gcs-bucket, etc. KeyHog does not register sources
or read forge tokens from ambient environment.
Removed behavior environment controls
Every behavior/config KeyHog-owned environment variable was removed. Its setting
now lives in .keyhog.toml or a CLI flag, and retired variable names are
intentionally absent from this reference so they cannot be mistaken for live
controls. The common replacements are:
| Need | Now set via |
|---|---|
| Backend override | --backend <auto|gpu|simd|cpu> |
| GPU routing requirement or disablement | --require-gpu, --no-gpu, or [system].gpu = "required" / "off" |
| Direct diagnostic calibration GPU control | --autoroute-gpu, --no-autoroute-gpu, or [system].autoroute_gpu; canonical keyhog calibrate-autoroute measures all eligible peers |
| Scanner concurrency and per-chunk limits | --threads plus [scan].threads, reader_threads, per_chunk_timeout_ms, fused_batch, and fused_depth |
| Detector directory | --detectors or top-level detectors |
| 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 / knockoff_accounts |
| Verifier/source proxy and lab TLS override | --proxy <URL>, [http] proxy, --insecure, or [http] insecure_tls |
| Dogfood capture | --dogfood |
Autoroute calibration is explicit and persistent. The installer runs a visible
calibration phase, and keyhog scan --autoroute-calibrate is the scan-owned
calibration entry point for writing parity-checked fastest-correct decisions.
Normal scans never benchmark on cache miss; they require a valid persisted
decision or an explicit diagnostic --backend override.
See Configuration for the full .keyhog.toml schema.
Precedence
For any setting, the highest source present wins:
- CLI flag (e.g.
--proxy http://a) .keyhog.toml(discovered at the scan root, or--config <path>)- compiled default
Environment variables are not in this list for behavior, by design.
What KeyHog deliberately does NOT read
- Any
KEYHOG_*knob that changes detection, routing, suppression, output, or configuration. Tuning is.keyhog.toml-only so a scan reproduces across machines without environment contamination. - No proxy or TLS environment variable participates in verification or HTTP
source routing: ambient
HTTPS_PROXY/HTTP_PROXY/ALL_PROXYare neutralized, and removed KeyHog-owned TLS/proxy controls such asKEYHOG_INSECURE_TLSare ignored. Use--proxy,[http] proxy,--insecure, or[http] insecure_tlsexplicitly. - Ambient forge tokens or source-selecting variables (
SLACK_TOKEN,S3_BUCKET, …): sources and their credentials are explicit CLI flags. - Anything named
KEYHOG_API_KEY/KEYHOG_TOKEN/KEYHOG_TELEMETRY_*. There is no telemetry and no service to authenticate to; findings stay local.
.keyhogignore.toml - declarative finding suppression
A .keyhogignore.toml file in your scan root expresses suppression
rules in TOML, evaluated per-finding via VYRE’s CPU rule engine
(vyre_libs::rule). Sits alongside the legacy line-based
.keyhogignore - both are loaded; either alone suppresses a finding.
Schema
Every rule is a [[suppress]] table. Within one table, named
predicates combine with AND. Across multiple tables they combine
with OR.
| Field | Type | Predicate |
|---|---|---|
literal_true | boolean | explicit unconditional match; true suppresses every finding |
detector | string | detector_id exact match |
service | string | service exact match |
severity | string | severity exact match (info, client-safe, low, medium, high, or critical) |
severity_lte | string | severity ≤ threshold (curated rank set) |
path_eq | string | file path exact match |
path_contains | string | file path contains substring |
path_starts_with | string | file path starts with prefix |
path_ends_with | string | file path ends with suffix |
path_regex | string | file path matches regex |
credential_hash | string | credential SHA-256 exact match |
A [[suppress]] table with no predicates is rejected at parse time
(prevents accidentally suppressing every finding). If an unconditional rule is
intentional, write literal_true = true; the explicit name makes a
match-everything policy reviewable. literal_true = false does not count as a
predicate and is rejected when it is the only field. Because predicates in one
table use AND semantics, combining literal_true = true with another field is
equivalent to using that other field alone; use it alone to suppress everything.
Examples
# Drop every aws-access-key finding inside test directories.
[[suppress]]
detector = "aws-access-key"
path_contains = "/tests/"
# Drop every low, client-safe, or info Stripe finding regardless of where it lives.
[[suppress]]
service = "stripe"
severity_lte = "low"
# Drop a single credential by hash, anywhere it appears.
[[suppress]]
credential_hash = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
# Drop everything in vendored/minified files.
[[suppress]]
path_starts_with = "vendor/"
[[suppress]]
path_ends_with = ".min.js"
[[suppress]]
path_regex = "^docs/[a-z]+\\.md$"
# Deliberately suppress every finding. Prefer a scoped predicate whenever possible.
[[suppress]]
literal_true = true
Why TOML and why a rule engine
The legacy .keyhogignore is one allowlist entry per line:
hash:<sha>, detector:<id>, path:<glob>. That covers the simple
cases but can’t express “drop aws-access-key findings ONLY in
/tests/” - the conditions need to combine.
Each TOML table compiles into a VYRE RuleFormula that ANDs typed conditions
such as FieldInSet and SubstringMatch; KeyHog applies OR semantics across
tables by accepting the first formula that evaluates true. VYRE’s CPU evaluator
(vyre_libs::rule::evaluate_formula) walks those formulas once per finding.
VYRE also exposes GPU lowering through
vyre_libs::rule::build_rule_program, but KeyHog does not use that route for
suppression decisions; the active contract is the deterministic CPU evaluator
after finding extraction.
Errors
Missing .keyhogignore.toml means no declarative rules. A present file that
cannot be read or parsed is fatal: KeyHog prints the file and corrective action,
refuses to scan with silently ignored suppression policy, and exits 2. The
legacy .keyhogignore is loaded independently, but it does not make a malformed
declarative policy safe to ignore.
Out-of-Band (OOB) Verification
Live key ≠ exploitable key.
Standard verification asks “does the API return 200?” - a per-service heuristic that confirms a credential parses. For webhook-, mailer-, and callback-shaped credentials that’s necessary but not sufficient: a 200 OK can mean “your key parses but has no useful scope,” “the webhook URL is dead but silent,” or “your credential reached our staging system and got swallowed.” OOB verification proves the credential is exfil-capable: we mint a unique per-finding subdomain on an interactsh collector, embed it in the verification probe, and confirm the service actually called back. A callback means the credential really moved attacker-controlled traffic.
Quick start
# Default: HTTP-only verification (what you've always had)
keyhog scan ./repo --verify
# Opt in to OOB verification using the public oast.fun collector
keyhog scan ./repo --verify --verify-oob
# Self-host an interactsh-server and point keyhog at it
keyhog scan ./repo --verify --verify-oob --oob-server interactsh.mycorp.internal
# Tune how long we wait for callbacks per finding (default 30s, max 120s)
keyhog scan ./repo --verify --verify-oob --oob-timeout 60
OOB is off by default. A normal --verify run never speaks to a
collector. This is intentional: OOB ships traffic to a third-party server
(or a self-hosted one) and you should opt in deliberately.
Threat model
What the collector sees:
- 20-character correlation IDs - random, per-scan.
- 33-character per-finding subdomains.
- Source IP, timestamp, and protocol (DNS / HTTP / SMTP) of services calling back.
- Whatever the calling service includes in its outbound request - typically a User-Agent, no body, no credentials.
What the collector never sees:
- The leaked credential. The credential is sent to the legitimate service (Slack, Mailgun, etc.), not to the collector.
- The repository, file path, or commit being scanned.
- Any other finding metadata.
For high-sensitivity scans (regulated environments, customer code, audits),
self-host projectdiscovery/interactsh-server and pass
--oob-server <your-host>. The protocol is wire-compatible.
Detector schema
Detectors opt into OOB verification by adding [detector.verify.oob] to
their TOML. The verifier substitutes a per-finding callback URL into any
{{interactsh}} / {{interactsh.host}} / {{interactsh.url}} /
{{interactsh.id}} token inside the verify spec.
[detector]
id = "example-webhook"
service = "example"
# ... patterns, keywords ...
[detector.verify]
method = "POST"
url = "{{match}}"
# Embed the OOB host in the request body. The service will (we expect)
# fetch the URL we control, and the callback proves exfil-capability.
body = '{"text":"https://{{interactsh}}/probe"}'
[detector.verify.success]
# Probe-level HTTP success - the webhook has to accept the payload.
status = 200
[detector.verify.oob]
# Wait for an outbound HTTP request. `dns` / `smtp` / `any` also valid.
protocol = "http"
# Per-finding wait timeout in seconds. Optional; defaults to --oob-timeout.
timeout_secs = 30
# Verification policy:
# "oob_and_http" - both must hold (default; strict)
# "oob_only" - ignore HTTP, trust the callback
# "oob_optional" - HTTP success suffices; OOB enriches metadata only
policy = "oob_and_http"
Token expansion
| Token | Value | Example |
|---|---|---|
{{interactsh}} | bare host | abc...xyz.oast.fun |
{{interactsh.host}} | bare host (alias) | abc...xyz.oast.fun |
{{interactsh.url}} | full HTTPS URL | https://abc...xyz.oast.fun |
{{interactsh.id}} | 33-char unique ID without server suffix | abc...xyz |
These tokens are NOT URL-encoded - the host is already URL-safe and we expect templates to embed it verbatim into JSON bodies, headers, and URL paths.
Verification policy
policy = "oob_and_http" (default) is the strict mode for webhook-style
detectors. A finding is Live only when both the HTTP probe succeeds AND
the OOB callback arrives within timeout_secs. If HTTP says alive but no
callback comes, the verdict is Dead - the credential parses but isn’t
exfil-capable, which for a webhook is the security-relevant question.
policy = "oob_only" skips HTTP success entirely. Use for credentials
where the API has no useful HTTP response shape (one-way push tokens, fire-
and-forget event triggers) but provably triggers an outbound request.
policy = "oob_optional" is HTTP-only verification with OOB observation
enriching the metadata. Use to roll out OOB to a detector for visibility
before flipping to strict mode.
Metadata
Verified findings produced under OOB verification carry:
oob_unique_id- the 33-char correlation ID minted for this finding.oob_observed-"true"or"false".oob_protocol-"Dns","Http","Smtp","Other"(when observed).oob_remote_address- IP that called back (when observed).oob_timestamp- collector timestamp (when observed).oob_disabled- reason string when an OOB-required verifier cannot use an active session.
These propagate to every output format (JSON, JSONL, SARIF, plain-text).
Failure modes
OOB infrastructure failures are non-fatal for detectors that do not require
OOB. Detectors with [detector.verify.oob] fail closed when the required OOB
session is unavailable.
- Collector unreachable at startup: the engine logs a warning and continues without an OOB session. HTTP-only detectors still run normally; OOB-required detectors fail closed before sending any HTTP probe.
- Collector goes silent mid-scan: the poller backs off (1s → 32s),
in-flight waits time out as
NotObserved, and policy evaluation continues from that observation. If the session is disabled during callback wait, the finding fails closed withVerificationResult::Errorandoob_disabled. - OOB session not enabled but detector requests it: verification fails
closed before sending any HTTP probe. The finding metadata carries
oob_disabled = "no active OOB session".
Performance
The interactsh handshake costs ~150ms at engine boot (RSA-2048 keygen + register POST). That cost is paid once per scan, not per finding.
The polling loop adds one HTTPS request every poll_interval (default 2s)
and decrypts batched interactions in-process. AES-256-CFB decryption is a
few hundred bytes per callback - negligible relative to scan cost.
The dominant added latency is the per-finding wait. For a webhook-shaped
detector you’re paying oob_timeout worst-case per finding that doesn’t
call back. Tune --oob-timeout to your service profile: aggressive
real-time webhooks can use --oob-timeout 5; queued mail systems need
30+.
Self-hosting interactsh
# On a server with public DNS pointed at it (NS records for $YOUR_DOMAIN
# delegated to this host):
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 run keyhog with --oob-server $YOUR_DOMAIN. The wire protocol is
identical to the public oast.fun deployment - keyhog’s client does not
care which it talks to.
VYRE integration
KeyHog pins the five VYRE runtime crates to exact crates.io version =0.6.4
(vyre v0.6.4). The pin is shared by every workspace crate and recorded in
Cargo.lock; KeyHog does not carry a vendored VYRE tree or resolve 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 capability | KeyHog owner | Production use |
|---|---|---|
| GPU literal-set region presence | keyhog-scanner::engine::gpu_region_dispatch | Produces one candidate-detector bitmap per input region. WGPU and optional CUDA implementations share this boundary. |
| GPU literal artifacts and cache | keyhog-scanner::engine::{gpu_artifacts,gpu_cache} | Compiles and caches detector-derived literal programs under detector, binary, backend, and runtime identity. |
| GPU regex-DFA admission | keyhog-scanner::engine::phase2_gpu_dfa | Narrows eligible prefixless phase-two work; host extraction remains authoritative. |
| Metadata interning | keyhog-scanner::static_intern | Freezes detector metadata for allocation-light scan state. |
| Declarative rule evaluation | keyhog-core::rule_filter | Evaluates .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 performs trigger production only. Every candidate then passes through the same KeyHog phase-two extraction, decode, suppression, confidence, deduplication, and reporting code used by CPU routes. Release parity compares detector id, credential, file, line, byte offset, confidence, and ordering; 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 --profile
--backend gpu 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.
A selected GPU route that fails dispatch exits 12; KeyHog does not silently
substitute a CPU/SIMD backend.
Feature boundaries
| Build feature | VYRE surface |
|---|---|
portable | CPU-side VYRE support primitives only; no VYRE scan backend, WGPU, or CUDA driver |
gpu | Runtime-probed WGPU and CUDA 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.
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
| What | How |
|---|---|
| Report a bug | Open an issue with a minimal reproducer. |
| Report a security issue | Use GitHub private vulnerability reporting first. If unavailable, email security@santh.dev; PGP is not required. |
| Add a detector | Add one detector TOML with its inline truth pair, then add its adversarial contract. |
| Fix an FP | Find the regex / shape gate that’s firing. Tighten it. Add a negative test that would catch the regression. |
| Document something undocumented | Edit the canonical page under docs/src/; the site rebuilds from that mdBook source. |
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
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.
-
Write the detector TOML at
detectors/<service>-<thing>.toml. Use an existing detector as a template; the schema is documented in Detectors. -
Add the inline truth pair in that same TOML:
[[detector.tests]] test_positive = "SERVICE_API_KEY=<valid-shaped-test-value>" test_negative = "SERVICE_API_KEY=YOUR_API_KEY_HERE"Use synthetic or vendor-published test material, never a live credential. The positive must emit this detector’s exact ID; the negative must remain silent through the production scan path.
-
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
perfblock withfixture_bytes+max_microseconds - A
scaleblock withfixture_bytes+min_findings+max_seconds
-
Run the detector truth gates locally:
cargo test -p keyhog-scanner --test detector_inline_test_truth cargo test -p keyhog-scanner --test contracts_runnerMust pass before you push. CI re-runs it with strict env vars set, which exercise more aggressive adversarial corpus.
-
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:
-
Reproduce. Get the FPs into a
.envseal-sealed corpus or a public sanitized fixture you can commit. -
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. -
Wire it through the shared policy boundary.
suppression/api.rsexposes the composed shape decisions andadjudicate/owns the final suppression verdict. Do not add an emission-path-onlylooks_like_*check: CPU, SIMD, GPU, generic, entropy, and fast-prefix paths must reach the same decision. -
Add a unit test. Inputs that should trip the filter (5+ variants), inputs that should not (3+ legitimate credentials).
-
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 -- --checkand 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.
License
MIT. By contributing, you agree that your contributions are licensed under the MIT license too.
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.
[Unreleased]
Changed
--git-stagednow 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/keyhogowner instead of relying on the former-owner redirect. Security reports use GitHub Private Vulnerability Reporting first, withsecurity@santh.devas 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
.keyhogignoreexclusions 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.
- 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 explainretains 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.
- 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-safeand[scan].severity = "client-safe"now select the real tier betweeninfoandlow; CLI help, config validation, and the reference all expose the same six accepted levels.config --effectivenow 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
3for unavailable selected SIMD and12for unavailable or failed selected GPU execution, rather than returning findings from an unselected engine. - The documented
.keyhogignore.tomlliteral_true = trueescape hatch now works and is behavior-tested, while empty tables andliteral_true = falsealone remain rejected as accidental match-everything policy. backend --self-test --require-gpunow fails with exit4and a visiblegpu_adapterfailure when no eligible physical GPU exists; ordinary no-GPU self-tests retain their explicit skip report and exit0.- 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
12instead 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_presenceinstead of the retiredvyre_ac_kernellabel. The scanner library self-test isgpu_region_presence_self_test, andbackend --self-test --jsonuses the same name for its production-path probe. Dispatch failures remain structured so the health command emits its complete report and exit4; normal selected-GPU scans exit12. - The VYRE direct match-triple self-test is now explicitly diagnostic. Its
classified limitation reports
knownand other failures reportwarning; production GPU eligibility is owned by thegpu_region_presenceprobe, 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
--yesnow 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 --helpflag 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.tomlnow has one canonical owner: the[scan]table. Retired flat spellings such asformat,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]; renameexclude_pathsto[scan].exclude. - Multi-root positional parsing now uses one visible variadic
PATHvector; generated help reports[PATH]...and the hiddenEXTRA_PATHcompatibility carrier is gone. Mixing stdin shorthand-with filesystem roots now fails explicitly instead of producing a split-source request. Library consumers that readScanArgs::inputdirectly should useScanArgs::scan_roots();inputnow stores the complete ordered positional vector. - CLI help and reference documentation now identify
--timeoutas 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
--proxyand--insecureapply 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/ratespellings 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-rateremains the requests/second control. config --effectivenow reports the resolved verifier timeout, concurrency, request rate, TLS, OOB, and proxy policy. Proxy URLs are reduced tounset/off/configuredso 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
--verifyso 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
watchprocess from the independently started Unix-socket daemon and describe--backend autoas persisted routing rather than a forced backend. The scan reference now states the explicit-versus-absent--daemon=autoplatform 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|oninstead of silently replacing the requested daemon-capable policy with in-process execution; an absent flag and portable--daemon=offcontinue 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 autodecision, and aligned the CLI reference with actual root options, detector maintenance, Elvish completion, fast-mode behavior, and finalized report semantics. - Added detector-owned
max_lenforphase2-genericTOMLs, 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.updateandrepairnow 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
latestand floating major tags. Release-tag Action inputs now normalize one optionalvprefix. - 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_64artifact. 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-coreandkeyhog-sourcesroot 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.tomlinto 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
/tmpfiles. Maintained generation remains undertools/; release and organization entrypoints remain underscripts/. Absence gates now reject reintroduction of publicBACKLOG.mdorplanning/vyre-accelerationstate. - Removed the duplicate
ScanBackend::MegaScanidentity and its deprecatedmegascan_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-daemoncompatibility flag.--daemon=auto|on|offis the single daemon policy across CLI help, release scripts, diagnostics, tests, and documentation.--daemon=offcombined with--daemon-socketnow fails visibly instead of ignoring the socket. - Removed the duplicate
keyhog-linux-x86_64-cudarelease job,cudaCargo feature alias, installer--variant=cpu|cudasurface, 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.
[0.5.41] - 2026-07-11
Added
keyhog scan --quietand--no-color.--quietsuppresses the banner, progress, and summary vanity while keeping findings and errors (the flag CI logs want without--format json);--no-colordisables ANSI styling even on a TTY and is honored by every output path (progress, findings, reports), equivalent to settingNO_COLOR. Both are first-class documented flags onscan;--quietconflicts with--progress.keyhog calibratevalidates 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-keypreviously seeded a counter no detector would ever read, silently.- Confidence-calibration reference page.
docs/src/reference/confidence-calibration.mddocuments 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-xmlmoves to 0.41.0,crossbeam-epochto 0.9.20, andanyhowto 1.0.103. The remaining accepted advisories are pinned, usage-audited, and documented inSECURITY.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, andgpu_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::scanandscan_coalescedAPIs now use the portable CPU reference instead of a host-size heuristic. Accelerated library execution is explicit, while CLIautoremains 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,
--streampreviews, and watch-mode events all render severity through the one canonicalSeverity::as_str()(uppercased at the display edge), fixing the--streamdrift whereClientSafeprinted viaDebugcasing. The Bayesian posterior-mean/observation math is likewise now a single publicBetaCountersAPI inkeyhog-coreinstead of three private copies. -
keyhog backendlabels its routing matrix as heuristic. The decision-matrix table now states in the output itself that it is a fixed hardware-heuristic reference; a realscan --backend autoroutes 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
NEEDLESandVENDORED_JS_PREFIXESincrates/scanner/src/suppression/path_filter.rsare moved to a Tier-B data filerules/path-filter-lists.tomlusingLazyLockloading. -
Moved ML feature markers to TOML. Inline marker lists
COMMENT_PREFIXES,BINARY_MARKERS,CI_MARKERS,INFRA_MARKERS,SOURCE_MARKERS,SOURCE_EXTENSIONS, andCONFIG_MARKERSincrates/scanner/src/ml_scorer/ml_features.rsare moved to a Tier-B data filerules/ml-feature-markers.tomlusingLazyLockloading.
Removed
- Duplicate backend aliases and the retired MegaScan CLI route.
--backendnow presents four choices:auto,gpu,simd, andcpu. 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 publicScanBackend::MegaScanvariant remains as a source-compatible library migration boundary and still executes the GPU region-presence route when supplied programmatically. - The no-op
kubernetes-secretdetector shim. KubernetesSecret.datavalues continue through the structured decoder and are attributed to the detector that recognizes the decoded credential. The retired detector only matched an internalNEVER__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 tuilive-scan dashboard. The interactive TUI subcommand (thetuimodule,Tui/TuiArgs, thetuiCargo feature, and theratatui/crosstermdependencies) is removed in full. It was an interactive frontend over the in-process scanner that duplicatedkeyhog 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 syntheticdemo/tree anddemo.taperecording now drivekeyhog 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_keyenvironment 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-corenow 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
megascanbackend 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-passwordnow 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_speclane 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-confidencearguments in concise round-trippable float form and remove obsolete direct-Commandimports 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
gpufeature 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
Resultsfooter already pluralized; the stderr summary did not). Singular/plural nouns now come from one sharedsecret_noun/finding_nounowner, 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 NaNconfidence(reachable through the publicVerifiedFindingfield) 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’sfinalize_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 inPATHwith 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_COLORnow follows the no-color.org contract exactly: an emptyNO_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 usereqwest::blocking, whose internal runtime panics if dropped inside an async context. Each source now runs its (already eager) collection on a scopedstd::threadwith 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-orgwith an invalid token now exits 2 cleanly. -
A requested scan source that fails entirely (produces zero chunks and errors, e.g.
--git-history/--git-diffon a non-repository or bad ref,--github-orgwith 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 --stdinnow lossy-decodes its input (matching the filesystem source) instead of rejecting non-UTF-8 bytes.cat binaryfile | keyhog scan --stdinpreviously errored (and, under the new fail-closed, exited 2) whilekeyhog scan binaryfilehappily 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 mislabelledsq0atp/sq0csp(Square, not Squarespace) in its keyword list. Surfaced by a differential against the mirror corpus; theEAAA…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/fullprofiles; 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 theregexcrate’s sharedPool<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. AddedChunkMetadata::base_line, populated per-window by the filesystem source and per-hunk by the git diff/history sources (now-U0,base_line = new_start - 1via sharedgit::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-pipelineor[system].batch_pipeline = trueremains available for A/B verification against the coalesced batch path; CredData SIMD--daemon=offkeeps 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 megascanis 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
/jsonlayer, 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/privatesubstrings. 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 megascanstill 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=falsewhile 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 ingeneric-high-entropy-stringcorpus paths without opening new broadening routes. - Fix telemetry dogfood assertions and related redaction tests to match canonical
keyhog_core::redactoutput shape (prefix...suffix) rather than legacy fixed-prefix assumptions. - Route the
generic-secretandentropy-api-keyfallback 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 atmin_confidence=0.0while the bench’s 0.40 floor suppresses the blobs. - Widen
drata-api-tokento 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-keybarepk_/sk_patterns with a leading\bword boundary so they no longer fire on apk_/sk_substring sitting inside a longer identifier (e.g. thesk_inside Pinecone’spcsk_keys). Klaviyo recall for real boundary-delimited keys is unchanged; the spurious cross-detector match that shadowedpinecone-api-keyis 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_claimstill pinned the stale"889 service-specific detectors"string to the current899, so thecontracts_runner::every_contract_readme_claim_presentgate (which requires each claim to appear verbatim in the README) is green again. The generator already pins899; these were un-regenerated stragglers. - Update the Docker integration
detectors-countscenario (tests/docker/scenarios.sh) from the staleLoaded 894 detectorstoLoaded 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 gpubuild on Apple Silicon hit a fatal wgpu abort because the Metal backend advertisesPIPELINE_CACHEyet rejects pipeline-cache creation. The vendored vyre wgpu driver now only requestsPIPELINE_CACHEon 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_probeGPU-routing unit tests host-independent. Six assertions droveselect_backend()with syntheticHardwareCaps { gpu_available: true, .. }and expectedScanBackend::Gpu, butselect_backendfirst short-circuits through the runtimegpu::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-freegpu_could_engage()crossover predicate (newly re-exported fromhw_probe), which depends only on the passed caps.KEYHOG_NO_GPU=1reproduces 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.tomlwith thefindings_registry_integritygate. Fourteen findings pointed theirtestpath into the gitignoredcoordination/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) intocrates/scanner/tests/gap/and repoint them; de-scope the eleven open or design-conflictingci-operabilityfindings 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(andKEYHOG_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 siblingPATH.sha256if present;install.ps1 -FromFileis 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.shcurl now passes--retry 5 --retry-delay 2 --retry-connrefused;install.ps1’sInvoke-WebRequestretries up to 5 times with linear backoff. - Normalise a bare-semver
--version/-Versionto the v-prefixed release tag. keyhog tags are allvX.Y.Z, so--version=0.5.37built a download URL against a non-existent0.5.37tag 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 prependvto a digit-leading version and leave an explicitv…, branch, or sha untouched. Covered byedge_cases.sh2.9/2.10 and the corrected 14.2 (bare2.0.0→ tagv2.0.0). - Add
tests/install/install_from_local_build.shand wire it into the macOS Build and Build Release CI jobs: prove current-source → install (via--from-file) → working binary on every push:keyhog doctorself-test, seeded scan (exit 1 + findings), SARIF, the local-checksum gate (good vs tampered), and the premium interactive wizard (driven through a PTY whenexpectis 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-suppressbenchmarks/baselines/andbenchmarks/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 existingCHANGELOG.md/ analysis-doc suppressions. - Smoke harness:
keyhog backend | head -30SIGPIPE’d keyhog (exit 141 under the runner’sbash -o pipefail) when the routing matrix printed more than 30 lines, spuriously failing theintegration-smokeBackend-probe step on Ubuntu. The step now runskeyhog backendto 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/benchis now the single source of accuracy truth: the retiredtools/secretbench/scoring/scorer and the retiredtools/diff_benchdifferential runner are both replaced bybench’s canonical scorer + scanner adapters, and the mirror corpus generator plus the competitor home-turf harvesters move underbenchmarks/generators/. Committed scoreboard anchors move tobenchmarks/baselines/. Thebench-nightly(renamed fromsecretbench-nightly) anddifferential-benchworkflows now drivepython -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-recallfloors and/or a committed--baseline(within--epsilon); exit 2 if keyhog produced no usable result. It replaces the per-fixturediff_benchF1 gate and is the forcing function for the continuous-improvement loop. - Add the production continuous-improvement loop:
make -C benchmarks loopruns 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 thedifferential-benchworkflow fail red on an F1 regression below the anchor, not only on a competitor overtaking keyhog.loopnever--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 intoresults-cross-device/<device>/(kept out of the README-feedingresults/). Fixes a Python-3.9 portability bug the macOS run surfaced (bench/runner.pyuseddatetime.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-fastCI 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-msin 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 exit1. - Move the composite Action scan/count/summary path into a tested local script, validate
format/severity/verifybefore scanner invocation, expose the rawexit-codeoutput, sanitize job-summary cells, and count text reports by the stableSecret:field instead of a non-portable box-drawing grep. - Validate
fail-on-findingsandupload-sarifin 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
.sha256files 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=1pass 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
--verifyfrom 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.exeasset on Windows bash runners and preserve the.exeinstall name after checksum verification. - Teach the composite Action to select
keyhog-linux-x86_64-cudaon CUDA-ready Linux runners and preserve--features cudawhen 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_teststarget 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 (
keyhogexit 10 underverify: "true") fail the composite Action after report/SARIF upload even when ordinary findings are configured as advisory withfail-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-codeoutput fails closed without workflow-command reflection. - Dogfood the composite Action’s real-binary text-report path, proving actual KeyHog
format: textoutput is counted through the wrapper’s stableSecret: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-reportartifact name. - Keep
--lockdownfail-closed on non-empty KeyHog cache directories while allowing an empty$XDG_CACHE_HOME/keyhogdirectory 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/keyhogbefore PATH, while still preferringtarget/release/keyhogwhen 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 runto execute one measured scanner/corpus row, emitRunResultJSON, score labeled corpora, compute throughput, and preserve scanner exit code and timeout state in artifacts. - Add
python -m bench leaderboard/make leaderboardto run the default scanner matrix, including Nosey Parker, and write oneRunResultJSON 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
keyhogbinary in benchmark runs, with explicitKEYHOG_BINand constructor overrides still taking precedence, so leaderboard runs score the current source instead of a stale PATH install. - Add
python -m bench analyze/make analyzeto 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
RunResultrows 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/resultsartifacts, including updated per-scanner runtime/RSS values and the current private-key category gap. - Score KeyHog
additional_locationsin 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.mdinstead of leaving the report-check gate stale. - Make
python -m bench report --checkread-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 measuredmin_confidencefloor reports and TOML overlays for lossless false-positive cuts on labeled corpora. - Keep the KeyHog benchmark
autobackend row on the same deterministic fused filesystem route as production default scans, while forcedgpu/megascanrows 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
timesupport before wrapping benchmark subprocesses, so BSD/macOS/usr/bin/timefalls back toresource.getrusageinstead of breaking scanner runs. - Add a tested benchmark contract package with shared
RunResultschema, 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-mlruns. - Wire the full CLI contract-test module set into
all_tests, fix the newly enforced public contracts fordiffmissing-baseline exit codes, explicit piped--progress, optionalwatch [PATH]help, and top-level exit-code docs. - In non-progress mode, keep
--max-file-sizeskip-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-fixturesalso disable test/example path confidence penalties and hard suppression, so real secrets undertests/fixturescan be surfaced for recall audits. - Document the canonical
.keyhog.tomlprecedence, 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-pathsapply 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_keyfixtures 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_TOKENfixtures 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 Linuxdrivers/netsubset 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=1scan timing for coalesced phase splits and orchestrator scan/receive wait time, keeping perf diagnosis operator-visible without changing default output. - Wire
--no-decodetomax_decode_depth = 0in the engine config and keep--fastcoherent 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 reportsvyre_ac_kernel=passand recommends GPU instead of degrading on degenerate triples. - Let
KEYHOG_REQUIRE_GPU=1proceed 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-setfailures name the failed branch, shard, and cap/output condition. - Add
keyhog backend --self-test --json, preserving exit4for 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=1andbackend --self-testname 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
vyre0.6.1 release, add a doc/pin coherence gate, and fix stale scannerRawMatchtest 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
.envsynthetic 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-providedKEYHOG_NO_GPU=0so 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
CsrU32storage 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 <= startmatch 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 reviewed0.20confidence floors, PostgreSQL recognizespg-url/PG_URLcontext and seeds bothpostgresql://andpostgres://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=1output, 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/jsonviews 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 ofsrc, restoringkeyhog-core --test all_tests. - Tighten the Azure Container Registry username pattern so
ACR_USER 0x00000000C register constants do not report as credentials. - Remove the dead fragment-cache
shard_indexwrapper 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.comshapes 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_confidencefloors 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-keydo 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-fixturesis active, so the opt-out preserves the full heuristic confidence for real findings under fixture paths.
Sources
- Fix default
--git-diff HEADto compare the base commit against uncommitted worktree changes rather than resolving both sides toHEAD. - 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-sourcesdefault test compilation by marking the S3 ambient credential forwarding integration test as requiring thes3feature. - 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.rsis back under the 500-line modularity target. - Split WebSource SSRF, URL redaction, redirect validation, and DNS pinning helpers into
web/ssrf.rs, bringingweb.rsunder the 500-line modularity target. - Split filesystem extraction and walker/filter policy into
filesystem/extract.rsandfilesystem/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 skippinghttp_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_iterwaits 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 soapi.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_floorboost 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 ciproduces 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
baselineinput 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"andcloudsmith.api.key=valuefailed 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_runnergreen) 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 nameGOOGLE_APPLICATION_CREDENTIALSinstead 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 RAM | Shards / batch | 1-GiB-scan sequential batches |
|---|---|---|
| < 16 GiB | 64 | >= 8 |
| 16-32 GiB | 128 | 4 |
| >= 32 GiB | 256 | 2 |
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 detected | Input length | Adapter examples |
|---|---|---|
| >= 24 GiB | 1 GiB | RTX 4090 / 5090, A100 / H100 |
| 12 - 23 GiB | 512 MiB | RTX 3090, RTX 4080, M-Max |
| 8 - 11 GiB | 256 MiB | RTX 3080, RTX 4070, M-Pro |
| < 8 GiB / Unknown | 128 MiB | iGPU, 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
warnwith the valid-up-to byte offset; previouslyreturn Ok(None)silently dropped the chunk. - Git history walk: tree-entry, blob-header, blob-read failures
log at
debuginstead of silentlycontinue;. UTF-8 decode failures on git blobs stay silent (legitimate binary blob). - GPU MoE confidence: staging-buffer
recvandmap_asyncerrors nowwarnbefore 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.rsSIMD entry points now carry proper# Safetydocs (caller-must-have-AVX2 / SSE2 / NEON), satisfying-D clippy::missing_safety_docafter they were promoted topubfor 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_backendMegaScan path: now checks rule_pipeline readiness (was only checkinggpu_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 togit/history).subcommands/tui::worker: file-read failure (wasunwrap_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_postprocesswithfold_overlapping_same_pid_inplaceattribute_matches_to_chunks(5 unit tests). Replaces two byte-identical phase-1 tails ingpu_ac_phase1+gpu_literal_phase1.
- New
cli::formatwithformat_bytes(4 unit tests). Replaces two near-identical copies inscan_system+tui::renderthat had drifted (one capped at GiB, the other handled TiB). - Engine
scan.rssplit intoscan/extract/processmodules (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_overridecollapsed into a thin re-export ofscanner::hw_probe::forced_backend_from_envso 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_chunksre-exported as apubAPI so the scanner property-test fixtures build.- Stale unused-imports cleanup in
scan.rsafter 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_literal_phase1.rspreviously routed all WGPU hosts through theliteral_setGpuLiteralSet 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 byappend_match_subgroupBEFORE driver-specific emission, so WGPU hosts hit the same_vyre_match_leader is referenced before bindingrejection and silently dropped to CPU. The kernel select is now AC-by-default for every GPU backend;KEYHOG_GPU_KERNEL=literal-setis the diagnostic opt-in for bisection / vyre IR work.keyhog backend --self-testgained a newvyre_ac_kernelstep that compiles a one-detector scanner, runs a scan throughscan_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_setself-test no longer reports redFAILwhen it hits the documented lowering gap; it surfaces yellowKNOWNwith 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.rsgainedvyre_ac_kernel_self_test()VyreAcKernelSelfTestso 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=Gpustartup banner used to collapse the CUDA path and the WGPU fallback under the sameGpulabel, 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(orgpu=wgpu,gpu=none), pulling the liveVyreBackend::id()of the acquired backend. NewCompiledScanner::gpu_backend_label()exposes the same info to any downstream consumer (daemon health endpoint,keyhog backenddiagnostics, 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 noweprintln!a loud warning instead oftracing::debug!-ing into the void. The user paid for the GPU; they need to know we couldn’t use it.KEYHOG_NO_GPU=1silences the warning (operator opted in to CPU).KEYHOG_REQUIRE_GPU=1exits with code 2 instead of falling back.scanner/src/engine/backend.rs(scan dispatch path): whenscan_chunks_with_backend_internalis called withScanBackend::GpuorScanBackend::MegaScanbut the compiled scanner has no GPU literals or no GPU backend, the same loud one-shot warning fires viawarn_on_gpu_degradationand 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
OnceLockguard 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_degradepreviously only panicked whenKEYHOG_BACKENDforced 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 runningkeyhog backend --self-teston a real CUDA host, which exposed a vyre IR lowering issue that rejects the GpuLiteralSet program (“variable_vyre_match_leaderis 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.ymlemits a.sha256file alongside each binary asset using portablesha256sum/shasumacross the three runner OSes.install.shandinstall.ps1download the.sha256alongside 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=nameoutput, 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.ps1rewritten. The Linux / macOS installer now detects host state (OS, arch, NVIDIA GPU, loadablelibcuda.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:
--diagnoseprints a full host + binary status report and changes nothing.--repairre-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).--uninstallremoves 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/=cpuforce a variant.--yes/-yaccepts every default for non-interactive runs.--no-colordisables ANSI output for log capture.KEYHOG_VERSIONandKEYHOG_INSTALLenv-vars work as before.
New: CUDA-on-Linux release variant
keyhog-linux-x86_64-cudaships as a 5th release asset. Built with--features cudaafter provisioning CUDA 12.6 toolkit on the GH ubuntu runner viaJimver/cuda-toolkit@v0.2.19. The installer prefers this asset on Linux hosts wherenvidia-smireports a GPU ANDlibcuda.sois loadable (via ldconfig or the four common path probes). On the same host with no CUDA, the installer keeps picking the existing defaultkeyhog-linux-x86_64build (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
-cudaasset 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.shis a 12-scenario harness that mocksuname/nvidia-smi/ldconfig/curlper scenario via a sandbox dir prepended to PATH. Covers: CUDA host, macOS arm64, macOS x86_64,KEYHOG_VARIANT=cuda/=cpuoverrides, unsupported platform,--help/--uninstallmode 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.
--repairand--diagnosewere exercised on the upgraded macbook to confirm post-install behavior.
Metrics / repo hygiene
- Daily star tracker.
metrics/stars.jsonrecords{date, count}snapshots;.github/workflows/record-stars.ymlruns 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 (seesanthsecurity/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.harnow parses the HAR 1.2 JSON and expands it into one chunk per request and one chunk per response. Each chunk’ssource_typeiswire:har:requestorwire:har:response, so a bug-bounty hunter can filter findings to outbound credentials only:
Thekeyhog scan capture.har --format json | \ jq '.[] | select(.location.source == "wire:har:request")'file_pathfor each finding is<har-path>#<request-url>. Newcrates/sources/src/har.rsmodule; 4 unit tests covering positive expansion, non-HAR JSON, non-JSON binary, and malformed-JSON fallthrough. 4xmax_sizebudget on cumulative request+response body bytes guards against decompressed-gigabyte DoS.serde+serde_jsonpromoted from optional (per-feature) to unconditional deps inkeyhog-sourcesbecause the always-on filesystem path now depends on them. Removed redundantdep:serde/dep:serde_jsonfromweb/github/slack/s3feature lists.
Docs
- New chapter: HTTP and wire scanning. Documents the existing
--urlflag (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.mitmsupport, header/body provenance, live proxy mode, and WebSocket frame scanning as the next wire-scanning items. docs/src/detectors.mddocuments theclient-safeseverity tier +client_safe = trueper-pattern flag.docs/src/reference/cli.mddocuments--hide-client-safe+ theKEYHOG_NO_GPU/KEYHOG_PER_CHUNK_TIMEOUT_MS/KEYHOG_BACKEND/KEYHOG_THREADS/KEYHOG_DETECTORS/KEYHOG_CACHE_DIRenv vars in one place.
Repo hygiene
- Bazel scaffolding untracked. The 8 in-tree Bazel files (
.bazelrc,.bazelversion, root + 5 per-crateBUILD.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 andMODULE.bazelwas pinned to keyhog0.5.7while 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;.gitignorecatches future Bazel scratch.
Detector tagging (client-safe)
clerk-api-key: publishablepk_live_*/pk_test_*- same shape asclerk-frontend-api-keyfrom 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=1now 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 FIRSTbackend::get_gpu()call, and the hardware probe path (hw_probe.rs:82 -> gpu_probe -> backend::get_gpu) reaches it beforecompile()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 callingget_gpu(); on set, returns(false, None, None)sohw_probereportsgpu_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 witherror: path '-' does not exist.keyhog scan - --stdin <<<...andkeyhog scan - <<<...both work now;--stdinis no longer required when the path is-.
Detector tagging (client-safe)
segment-write-key: write-only keys shipped in everyanalytics.js/ Analytics SDK init. Server-side admin issegment-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::ClientSafeis a new tier belowLow. Detectors with a per-patternclient_safe = trueflag 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), Mapboxpk.eyJ(sk.eyJ stays critical), PostHogphc_(phx_ stays high), Mixpanel project token, Algolia search-only key (admin key is a separate detector and stays critical).--hide-client-safeCLI 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=1env-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_MSenv-var attaches anInstantdeadline to the publicscan/scan_with_backendentry points. Any future pathological pattern that escapes the per-patternMAX_INNER_LOOP_ITERScap times out at the per-chunk boundary instead of hanging the whole scan. Default unset preserves prior behavior.
Schema
[[detector.patterns]]blocks accept a newclient_safe: boolfield (defaultfalse). 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-SAFE11-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→ SARIFnotelevel (same asInfo/Low). - Rule-filter /
.keyhogignoreseverity-name:client-safe(kebab-case, matches the new serderename_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 scanhang 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 inmultiline::config::has_var_ref_concat_line- the{1,8}-bounded alternation drivesregex1.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). AddsKEYHOG_PER_CHUNK_TIMEOUT_MSenv-var deadline as a belt-and-suspenders backstop on the publicscan/scan_with_backendentry points so any future pathological pattern caps out instead of hanging the whole scan. - Windows UNC verbatim-prefix strip. Every finding’s
location.file_pathrendered as\\?\C:\Users\...(Rust’sstd::fs::canonicalizealways 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:\\...". Addedpub(crate) display_path(&Path) -> Stringinkeyhog-sources::filesystemthat strips the\\?\prefix on Windows; the underlyingPathBufwe use for I/O keeps the UNC form so >260-char paths still resolve. Wired through eight chunk-emit sites (filesystem.rswindowed mmap + buffered fallback + plain file + archive entries text/binary;binary/mod.rsghidra decompiled + strings + section/strings). - Cross-platform detector-dir discovery.
auto_discover_detectorshardcoded/usr/share/keyhog/detectorsand/usr/local/share/keyhog/detectorswhich silently no-op on Windows. Wrapped the Unix paths incfg!(unix)and addeddirs::data_dir()/dirs::data_local_dir()lookups so Windows users get%APPDATA%\keyhog\detectors/%LOCALAPPDATA%\keyhog\detectorsdiscovery. 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 bleedrule, standalone repos likesanthsecurity/keyhogtrack 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 backupsanthsecurity/Santhmonorepo - they just stop polluting the prod repo a crates.io / GitHub-Pages reader sees. Extended.gitignorewithWAVE*.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 undercrates/cli/tests/unit/orchestrator/build_scanner_config_*.rsimport the function and need it externally visible. Was a pre-existing breakage incargo test --workspace --no-runthat CI didn’t catch because the failing tests aren’t in the per-crate--libsubset CI runs.exclude_paths_parses_from_cliRust-1.83 fix. Old assertionSome(&["a.txt"[..]])produced&[str; 1]which Rust 1.83+ rejects as an unsized array element. Rebuilt as aVec<&str>collected from theVec<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) -
daemonmodule gated#[cfg(unix)]. It hard-importedtokio::net::UnixStreamandstd::os::unix::net::UnixStream, neither of which exist on Windows.keyhog daemonand--daemonnow 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 orcontains("/dir/")for vendored-tree detection. Windows checkouts (C:\src\app\node_modules\…) silently skipped every gate. Switched torsplit(['/', '\\'])+ newcontains_path_segmenthelper that tests both/seg/and\seg\. Behaviour on POSIX paths unchanged. - UTF-16 BOM file decode -
decode_text_fileunconditionally rejected every file starting with the literal UTF-16 BOM (\xff\xfe/\xfe\xff) as binary, beforedecode_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_utf16handles 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 inshould_suppress_named_detector_findingas 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. Newis_generic_or_entropy_detectorhelper gates the five filters as Tier-B (generic-* / entropy-* only).looks_like_punctuation_decorated_identifierstays 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 withoutcase_insensitive(true), but Hyperscan compiles every patternCASELESS. Detectors with mixed-case alternations ((?:FRAMER|framer)[_=:\s"']+(?:api[_-]?)?(?:key|token)) bake uppercase only in the leading anchor, leavingapi/keylowercase.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 wastransifex\.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 trippinglooks_like_pure_identifierafter v0.5.21’s filter widened to catch CamelCase / single-underscore identifiers in the 8..=40 alpha range. Test fixture now usesSTRADDLE_A1CDEFGH2JKLMNOPQ8ST(digits sprinkled in), matching the AWS-access-key shape the test was designed to mirror. - README banner pattern count -
README_PATTERN_COUNT = 1646→1647(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 insuppression.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-85ebd1e2c23din 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:89USER=…captured because of nearbyPASSWORD=line). Email addresses are public identifiers, never credentials. Tightened local + domain alphabet checks keep realuser:passwordDSN 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_keyin 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.txtis a 600 KiB pure-base64 PNG flag file. Random byte sequences in the base64 stream coincidentally match the AWS Session TokenASIA[A-Z0-9]{16}literal-prefix hot pattern. The base64 decoder still produces its ownfilesystem/base64chunk; only raw text-mode hits on these files are suppressed. Wired in BOTHshould_suppress_named_detector_findingand 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_segmentno longer gate on detector_id. Service detectors likecryptocompare-api-keywere firing onSetMultipartFormDataGo method names because their regex usedAuthorization[=:\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_identifierfixed for PEM blocks. Theb'-'leading-sigil reject was too eager ------BEGIN ... PRIVATE KEY-----starts with 5 dashes and was being suppressed alongside--api-secretCLI flags. Tightened tobytes.starts_with(b"--") && bytes[2] != b'-'so PEM markers (3+ dashes) survive but--CLI flags still reject. -
.b64/.base64raw-file skip. Files explicitly marked as base64-encoded blobs (metasploitable3/resources/flags/jack_of_diamonds.b64is a base64-encoded PNG) hold alphabet-coincidence matches inside the base64 stream (AIza…,sk-…,ASIA…). The base64 decoder pass still produces a separatefilesystem/base64chunk 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:Secretdocumentation examples (llama-cpp arg.cpp:2468--override-kv tokenizer.ggml.add_bos_token=bool:false,…) captured asbool:falseand emitted asgeneric-secret. Real credentials never have this<3-15 alpha>:<≤10 alpha>shape. -
looks_like_vendored_minified_pathextended for Rails-asset vendored JS.app/assets/javascripts/<name>.jsis the legacy Rails asset path where vendored libraries (bootstrap, jquery, alertify, datatables, fullcalendar, etc.) live. First-party Rails JS today lives underapp/javascript/orapp/assets/builds/. Match by basename prefix against a known-vendor list. Catches the railsgoatbootstrap-image-gallery-main.jshoneybadger-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 thatlooks_like_regex_literal_tailleft after unicode-escape / caesar decoders mangle trailing sigils.looks_like_regex_literal_tailpromoted + 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-stringsandfilesystem/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 optionalbinaryfeature (Ghidra extraction with context). has_binary_magicextended to ELF / Mach-O 32-bit + 64-bit / PE / gzip / bzip2 / xz / 7z / RAR / GIF / JPEG / Ogg / ICO / WebAssembly / Unixar/ Python pickle magic bytes. Previously only PDF / ZIP / PNG / OLE - a 2.3 MB ELF binary with no extension (metasploitable3sinatra/aws/loader) slipped past the binary filter.- Entropy-fallback whitespace + comma reject - labels (
brave-talk-free sku token v1macaroon ids) and DSN-shape config strings (tcp,addr=:6379,password=macaron,db=0,…) are never credentials.
Detector tightening
z85-encoded-secret: dropped genericencodedkeyword anchor. Go/JS/Python ubiquitously name their base64/hex output variableencoded; the detector was firing on everyencoded := …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_UTF8in 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)/gor})\bin 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_identifiernow wired into fallback_generic. Previously the named-detector path applied this filter (suppressinggetParameter/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’sutils/powershell/parser.tsline 1343 (pwd: 'Get-Location'). -
bandwidth-api-key dropped its bare
ClientID/ClientSecretpattern. Those tokens are generic OAuth2 terminology, not Bandwidth-specific. alist’s drivers/pikpak/util.go, drivers/thunder/driver.go, drivers/pcloud/util.go all haveClientSecret = "..."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 itsclient[_-]?secretkeyword 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 onMSG.lengthJavaScript 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 exceptAKIA/ASIA.PASTE_HERE_MSG.lengthcontains the substringSG.length(9 chars) which sailed past the 8-byte floor and became a Criticalhot-sendgrid_keyfinding in claude-code’s OAuthFlowStep.tsx. Same class affectedghp_(8-byteghp_xxxxpasses),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_identifierwidened. The single-underscore / kebab-case shape escaped the prior>= 2 underscoresor0 separatorsbranches. Added<= 1 separator (_ or -) + pure ASCII letters + no digit + 8..=40 charsarm. Coverscurlx_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. Wastoken[=:\s\"']+([a-f0-9]{24,32})- fired on everyAuthorization: token <hex>line in any REST-API test fixture (41 Shopify API test SHAs in v0.5.19 dogfood). Replaced with host-scoped pattern requiringapi.blockcypher.comin the URL. 41 → 0 FPs. -
oxylabs-credentials: dropped the global
user-X:Xpattern. Matched every CSSuser-select:none,user-modify:read-write,user-drag:autodeclaration 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 recognizepr.oxylabs.io/dc.oxylabs.iohostnames). 20+ CSS FPs killed.
Dogfood scope
49-target sweep with all v0.5.20 fixes:
| metric | v0.5.19 | v0.5.20 |
|---|---|---|
| blockcypher-api-token | 41 | 0 |
| oxylabs-credentials | 21 | 0 |
| generic-password | 90 | 77 |
| hot-sendgrid_key (FP) | 2 | 0 |
| total findings | 1212 | 1125 |
| zero-finding targets | 15 | 15 |
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 inscripts/*.shorMakefileoutside.github/. -
i18n / translation files: entropy-* now skipped in
/locale/,/locales/,/i18n/,/l10n/,/translations/,/lang/,/langs/directories,.po/.potfiles (gettext), and filename conventions likelocale_<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_identifierfrom 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:
| detector | v0.5.18 | v0.5.19 | Δ |
|---|---|---|---|
| entropy-password | 107 | 11 | -90% |
| entropy-token | 26 | 13 | -50% |
| entropy-api-key | 21 | 8 | -62% |
| entropy total | 154 | 32 | -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 everyorg_sqlite_jni_capi_CApi_*macro injavah-generated C headers (41 FPs in sqlite alone, applies to every Java-bindings library shipping JNI). Tightened toorg_[a-zA-Z0-9]{30,}- real Deel org tokens are opaque base62 with no underscores or hyphens. Same fix for theorganization_variant. - generic-secret captured C++ / Rust scope resolution. The bridge
regex consumed one
:; the second stayed in-value because:is in the alphabet to keepnginx@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,ShopifyTokenslipped 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) capturedgetParameter(12-char pure CamelCase, no digit). Extendedlooks_like_pure_identifierto 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.jksnext toKEYSTORE_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 theSourcetrait. Both fixed:bconcat!(...)→concat!(...).as_bytes(),use keyhog_core::Source;added to the S3 gate. - Exit code consolidation.
main.rswas redefiningEXIT_SCANNER_PANIC = 11locally; now importskeyhog::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:
| target | v0.5.17 | v0.5.18 | Δ |
|---|---|---|---|
| sqlite (deel JNI) | 41 | 6 | -85% |
| llama-cpp (jinja) | 41 | 7 | -83% |
| webgoat (Java) | 5 | 3 | -40% |
| malachite (Rust) | 10 | 8 | -20% |
| shopify-api-ruby | 10 | 8 | -20% |
| shopify-cli | 5 | 4 | -20% |
| bat-go (filenames) | 29 | 28 | -3% |
| orb-firmware | 13 | 13 | 0 |
| brave-talk | 5 | 5 | 0 |
| nginx | 1 | 1 | 0 |
| nitriding-daemon | 0 | 0 | ✓ |
| _self (keyhog repo) | 0 | 0 | ✓ |
| total | 160 | 83 | -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_urlwasClient::builder().timeout().resolve_to_addrs().build()- silently inheriting reqwest’s defaultPolicy::limited(10)instead of the engine’sPolicy::none(). An attacker-controlled verification target could return302 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 setsredirect(Policy::none()). Adversarial testpinned_client_does_not_follow_redirect_to_private_targetproves it. - SSRF bypass via hex / octal-encoded IPv4 hosts closed.
verifier::ssrf::is_private_urlblocked 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. Seecrates/verifier/src/ssrf.rs.
Fixed
--insecureflag now honored on the DNS-pinned path. Same root cause as the redirect bypass above: the per-request client rebuild droppeddanger_accept_invalid_certs(insecure_tls)baked into the engine’s base client, so--insecure(andKEYHOG_INSECURE_TLS) silently did nothing for direct (non-proxy) verifications. Threadedinsecure_tlsthroughVerifyTaskShared→verify_with_retry→resolved_client_for_urland 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 --audituses 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’sEXIT_SCANNER_PANICand documented inkeyhog --help. - scan-system exit code.
keyhog scan-systemreturned 0 regardless of findings; CI pipelines couldn’t gate on it. Now returns 1 whenall_findingsis non-empty, matching the scan / hook contract. - find_companion off-by-one.
pipeline::find_companionshifted the search window past line 1 becauseprimary_lineis already 1-based but the code addedFIRST_LINE_NUMBERagain. Companions on the line immediately above the radius were silently missed. - UTF-8 in JSON value extraction.
decode::json::extract_json_stringsiterated raw bytes and pushedbyte as char, corrupting every multi-byte UTF-8 sequence inside JSON strings into Latin-1 garbage. Switched tochar_indices(). - Zero-width regex hits in
extract_plain_matches. Sibling functionextract_grouped_matchesalready 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 onAhoCorasick::new/toml::from_strpoisonedLazyLockand killed worker threads on any platform-specific compile failure. Converted to soft fallback (Option/empty list) withtracing::warn!. Worker threads now survive a corrupted-binary / build regression.
Changed
InteractshClient::for_testreturnsResultinstead of panicking. The helper formerly carriedRsaPrivateKey::new(...).expect("test RSA key generates")- a panic-in-production path the no-unwrap gate caught. ReturnsResult<Self, InteractshError>now (mapped toKeyGen); test callers wrap with.unwrap()at the test boundary. Source: gateoob_client_no_unwrap_expect.oob::clientsplit:decrypt_entrymoved tooob::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: gateoob_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-secretdetector 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 + decodesdata: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-keydetector matches the 40-char body in SCREAMING_SNAKE, camelCase, INI / properties, and kebab-case contexts. Newazure-storage-account-keydetector matches the 88-char body afterAccountKey=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 withpython3 site/build.py; deploy to GitHub Pages. - Per-detector self-validation test
(
tests/all_detectors_self_validate.rs). Walks every TOML indetectors/, asserts each loads, compiles into the scanner regex backend, declares ≥1 keyword ≥3 chars, has service + patterns metadata, and contributes to thetests/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 genericSECRET_KEY=…. Matches real-repo distribution.fn_analyze.pycompanion tofp_analyze.pyfor 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 viaprintfinto a temp file. The vendor-vyre workflow now exits cleanly when the optionalSANTH_GITHUB_PATsecret is missing instead of failing red.
Performance
-
SecretBench-medium scoreboard (15k fixtures, seed 0):
run F1 precision recall TP FP FN v17 0.7710 0.8449 0.7089 10634 1952 4366 v18 0.7120 0.7078 0.7162 10743 4436 4257 v19 0.7815 0.9018 0.6895 10342 1126 4658 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):
| state | variants firing |
|---|---|
| v0.5.15 | 5719 / 5792 (73 JSON-wrapper misses) |
| v0.5.16 | 5792 / 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):
| encoding | before | after | delta |
|---|---|---|---|
| base64-std | 30.5% | 93.1% | +62.6pp |
| base64-url | 30.5% | 92.8% | +62.3pp |
| hex | 30.5% | 92.8% | +62.3pp |
| url-percent | 15.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 toKEYHOG_PROXY/HTTPS_PROXY/HTTP_PROXY/ALL_PROXYenv.--proxy offdisables 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::httppolicy module. Single source of truth for proxy + TLS + UA so an operator settingKEYHOG_PROXYaffects 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 emitsdifferential_results.jsonwith per-scanner precision / recall / F1 / timing..github/workflows/differential-bench.ymlruns 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,portablefeatures)keyhog-macos-x86_64(Intel mac,portablefeatures) - newkeyhog-windows-x86_64.exe(MSVC,portablefeatures) - 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
dapitoken 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_claimsgate. - 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_lookalikefixture 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 populatedcompanion.usernamefrom inside the primary’s own assignment line and verification proceeded despitemust_not_verify. Re-anchored tobitbucket_username=shape. - ringcentral companion now anchored to client_secret= shape
so id-only text no longer populates
client_pairand triggers VERIFY-RISK. - Three twilio companion fixtures used
xxx/fakeplaceholders 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.rsre-joined now-short calls after thematching→scanmodule migration.
Changed
.gitattributesnow coverscontracts/companion/*.tomlin 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: santhsecurity/keyhog/.github/actions/keyhog@v0.5.10now 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 rancargo buildwithout setup, so every downstream Ubuntu run failed withcargo: command not foundor a hyperscan-sys linker error. SARIF output auto-uploads to code-scanning whenformat: sarif. README example was also pointing at a nonexistentkeyhog/keyhog-action@v1repo - fixed to the bundled action path. .github/workflows/release.yml- tag-driven binary build- upload. Pushing a
v*tag now compileskeyhogforkeyhog-linux-x86_64(default features incl. Hyperscan via apt) andkeyhog-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.
- upload. Pushing a
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--dogfoodoutput. 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 | statusruns a long- lived scanner over a Unix socket (default$XDG_RUNTIME_DIR/keyhog.sock, falls back to~/.cache/keyhog/server.sock; socket ischmod 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 sCompiledScanner::compilecold 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=offforces 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). .keyhogignoregitignore-style shorthand. Bare path globs (*.log,node_modules/,vendor/**/*.json) and bare 64-char hex hashes are now accepted alongside the explicitpath:/hash:/detector:prefixes. Lets users drop a copied.gitignorein place and have it work.--max-file-sizeskip 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 sline on stderr every 250 ms; suppressed under--streamor 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 withmust_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 atcompanion_contracts_runner.rsenforces all three shapes per contract.
Fixed
contracts_runnerwas flaky across CI vs local. The 341-fixture loop reused a singleCompiledScannerand never calledclear_fragment_cache()between scans, so the cross-file reassembly cache accumulated. CI’s filesystem-iteration order put braintree’ssandbox_…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 incontracts_runner.rs(5 sites) andcompanion_contracts_runner.rs(3 sites) per the documented test-isolation API inengine/mod.rs:747-760.blur-api-keyregex required uppercaseKEYwhile the contract evasion uses lowercasekey. 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
--dogfoodwas inert. Engine-side telemetry (record_example_suppressioncalls frompipeline.rs::should_suppress_known_example_credential_*) fired inside the daemon process - the client never saw any of it, sokeyhog scan --dogfood demo-secret.envagainst a daemon silently dropped every suppression event and the reporter counter stayed at 0. Wire protocol bumped 1 → 2:Response::ScanResultsnow carriesengine_example_suppressions: u64anddogfood_events: Vec<DogfoodEvent>(both#[serde(default)], so a v2 client tolerates a v1 daemon). Daemon drains its per-scan telemetry after eachscanner.scan(...)and resets; client merges the values into its ownOnceLock<Telemetry>via two new public helpers (add_example_suppressions(n),append_events(iter)). Verified locally:--daemon=offAND a fresh daemon both emit “No real secrets - but 6 example/test keys suppressed. Pass –dogfood to see them.” demo-secret.envsummary regressed to the clean-repo message. The v0.5.7 fix wiredTextReporterto read the suppression count, but the orchestrator’stest_fixture_suppressions.suppresses()branch ran before any telemetry write -AKIAIOSFODNN7EXAMPLEmatched the bundled substring suppression list and returnedfalsewithout incrementing the counter, so the reporter still saw 0 and printed “Your code is clean.” Now bumpsrecord_example_suppression(..., "test_fixture_suppression")before returning. Same patch in the daemon-sidefinalize_for_reportfilter. Locked bye2e_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-scanon 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 ofscan_coalesced_megascannow 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::scanwas passedmax_matches_per_dispatch=1_000_000which trips vyre’s hard-codedmax_hits=10_000static buffer declaration. Capping the dispatch atNFA_HITS_PER_DISPATCH=10_000keeps 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 inmain.rstoargs_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 invalidsilently 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..keyhogignoredetector:entries were dead. The parser populatedignored_detectorsbut the orchestrator’s per-finding filter never read it. Now applied alongsideis_path_ignored/is_raw_hash_ignored.- RefCell double-borrow panic in
fallback.rs. Per-pool thread-local borrows nowtry_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 placeholderghp_xxxxxxxx…flagged at 0.80; xoxb tokens with ascending-digit runs flagged. Tightened lastpass-dev-creds to requirelastpasscontext within 40 chars; extendedlooks_like_prefixed_masked_sequenceto suppress x/X-dominance, all-same-char, and ascending-digit-run ≥ 13.
Improved
- CUDA driver is opt-in. The
cudafeature was on by default, which madecargo buildfail on any host withoutlibcuda.so/libnvrtc.so/libcudart.so- including macOS, most CI runners, and any Linux box without an NVIDIA driver stack. The default scanner build now useswgpu(Vulkan on Linux, Metal on macOS) for GPU dispatch. CUDA users opt in with--features cudawhen they want the CUDA backend specifically. Drops the link-time CUDA requirement from every default build. scripts/publish.shreads the version fromCargo.toml. Renamed frompublish-0.5.6.sh(which would silently emit “All v0.5.6 crates published” even when publishing v0.5.7). The new scriptawks[workspace.package].versionand uses that everywhere - no per-release rename or message edit.- LayeredPipelineCache short-circuits compile on warm hits. The
prior
rule_pipeline_cachedalways calledbuild_rule_pipelineupfront to keep typed-error semantics for vyre’s infallible-closurecached_load_or_compile, which made the on-disk cache pointless. Now uses vyre’sengine_cache_path+ manual load/save so a warm hit returns the deserialisedRulePipelinewithout paying the compile. PreparedChunk::line_offsets()memoised viaOnceLock.compute_line_offsetsused 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-scaninvocation 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()inkeyhog-core, not the orchestrator - so the misleading banner still printed.TextReporternow takes the suppression count viaset_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 againstdemo-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 ---dogfoodis a decision tracer, not a credential exfil channel.- Honest scan summary when only example keys were found. Previously,
scanning
demo-secret.env(which holdsAKIAIOSFODNN7EXAMPLE) 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::telemetrymodule: per-scan atomic counters + optional event log. Engines callrecord_example_suppression(...)from the existingshould_suppress_known_example_credential_*paths; the orchestrator drains events at the end ofrun(). Zero new state threaded through engine boundaries - singleOnceLockprocess-local container with areset()for tests. - Two regression tests pinning the demo-secret.env case + the dogfood
redaction contract. Telemetry-touching tests serialise behind a
module-local
Mutexsocargo 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:
| Tier | Adapter examples | min_bytes | solo cap |
|---|---|---|---|
| High | RTX 40/50, A100/H100, M-Max/Ultra, RX 7900 | 2 MiB | 16 MiB |
| Mid | RTX 20/30, GTX 16, Arc, M-Pro/base, RX 6/7 | 16 MiB | 64 MiB |
| Low | iGPU, older discretes, unknown | 64 MiB | 256 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 forRuleCondition/RuleFormulatrees. 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 fromSetMembership(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
smallvecworkspace 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 putSmallVec<[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.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_1mswas unconditionally failing undercargo teston debug builds (2.5 ms vs the 1 ms threshold). Marked#[ignore]matching the two sibling perf-threshold tests; rerun locally withcargo test -- --ignoredagainst a release build.crates/cli/src/scan_runtime.rswas a 0-byte dead module with no references anywhere in the workspace. Deleted.- Workspace
licensefield downgraded fromMIT OR Apache-2.0toMIT- the only license file shipped in the repo is the MIT one. Honesty over ecosystem convention. cargo clippy --workspace --all-targetsnow clean (was 4 warnings: unused-mut indedup.rs, items-after-test-module inorchestrator_config.rs, an unnecessaryas_ref()in the new streaming preview, and an explicit-counter loop inextract_plain_matchesthat’s intentional for deadline-cadence gating and now carries an explanatory#[allow]).detectors/.keyhog-cache.json(runtime parse cache) is now gitignored ANDkeyhog-core/Cargo.tomlcarries an explicitexcludeso a stale cache file can’t sneak into the published tarball.scripts/audit.shwrapscargo auditwith the four accept-with-rationale--ignoreflags so local audits exit clean the way CI does (cargo-audit 0.22 doesn’t auto-loadaudit.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:
SensitiveStringmigration,MADV_DONTDUMPzero-leak buffers, proximity-aware multiline reassembly, hardened ratelimiter, AC prefilter forhas_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 syntheticXX_FAKE_*shape in v0.6.1. crates/sources/src/slack.rs:54data: 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 (theextract_printable_stringswrapper claimedVec<String>while the underlying call returnedVec<SensitiveString>). Any build with--features binarypreviously failed to compile.aws-access-key.toml: droppedrequired = truefrom thesecret_keycompanion. 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: theno_detector_uses_singular_companion_tabletest now mirrorscrates/core/build.rs’s symlink fallback so it works on Windows checkouts wherecrates/core/detectorslands as a literal file containing the link target.crates/scanner/tests/performance_regression.rs: replaced the CRC32-invalidghp_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-featuresand asserted behavior that requires those features.
Hygiene
cargo clippy --workspace --no-default-features --all-targetsclean (zero warnings) under both--no-default-featuresand the default-minus-simd matrix.cargo fmt --checkclean.- 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.funby default;--oob-server HOSTto self-host). Detector TOML gains an[detector.verify.oob]block withprotocol={dns,http,smtp, any},policy={oob_and_http,oob_only,oob_optional}, andaccept={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 indocs/OOB.md. keyhog_core::spec::validatenow 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 singleCaptureLocationsand aligns to UTF-8 boundaries; out-of-range detector index now fails closed instead of panicking.- Required companions (
required = true) actually short-circuit: priorunwrap_or_default()swallowed the “missing required companion” signal and shipped the finding anyway. OobSession::wait_forrace: registers theNotifiedwaiter viaNotified::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 inSlackResponse<T>would have failed any build that exercised the slack feature.
Performance
- Aho-Corasick prefilter for
has_secret_keyword_fastandhas_generic_assignment_keyword(single-pass). extract_inner_literalsAST walker promotes inner literals into the prefilter alphabet (corpus coverage test pins ≥3 patterns promoted).find_companionsplits into a capture-group-free fast path (find_iter) and a grouped path that reusesCaptureLocations.- Active-fallback bitmap precomputed at scanner construction; per-chunk
thread-local
ACTIVE_PATTERNS_POOLavoids reallocation. - Filesystem reader: two-sided
looks_binaryearly exit, streaming UTF-16 decode, valid-UTF-8 fast path. - Slack source fetches per-channel history concurrently (rayon, 8 threads).
Hardening
looks_binaryshort-circuit verified against full-scan baseline across page-boundary cases.open_file_saferejects 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=Nenv var threads the rayon pool size; with--threadstaking 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::thresholdsmodule 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
Mutexguard since they mutate process env (ba0e3fc,3c4924c). keyhog backendsubcommand: dumps detected hardware, the active backend, the env override (if set), and a routing decision matrix at every documented threshold;--probe-bytesand--patternsfor 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 == Cpuadapters 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-chunktracing::trace!onkeyhog::routingfor full audit trails (3c4924c,ba0e3fc). - Verifier gained
danger_allow_httpopt-in flag to support HTTP test mocks while keeping production HTTPS-only (0da1f94).
Performance - CPU saturation
scan_chunks_with_backend_internalnow usesrayon::par_iteron the non-GPU paths - was serial, pinned to a single core even on 32-core boxes (a693ba2).scan_coalescedparallelizes 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_homoglyphsandprepare_chunk- ASCII fast path no longer clones (7e7cd55). post_process_matchesdedup keys areArc<str>, notString(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). BTreeMapdedup →indexmap::IndexMapfor 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
DashMapfor verifierVerificationCache,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 viashared_regex()- same regex across detectors compiles once (a38e79c). - Pre-built
index_setonce onBaseline::loadviaOnceLock(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/headvsgit/history, and the latter’s findings drop one severity tier (410dc0e). - JWT structural validation - header.payload decode with
alg/typ/expinspection andalg=noneanomaly 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 useA1_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_hostpost-resolve check; HTTPS-only no-localhost-exception (7feb723). - AWS SigV4 dates derived from
SystemTime::nowvia Howard-Hinnant civil arithmetic (no chrono runtime cost) (7feb723). fragment_cachemodule relocated undermultiline/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 hardenedis_hash_allowedAPI (no plaintext fallback) (b2b405d). - Wrapped
?-using doctests in explicitfn main() -> Resultso 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, andcargo clippy --workspace -- -D warnings.