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.