Tell a real clean from a skipped input

August 5, 2026 ยท View on GitHub

An empty findings list has two very different causes. KeyHog read your input and found nothing. Or KeyHog never read your input. This page shows you how to tell those apart on any workload.

The reason this needs a page at all is that a clean result and a total failure look the same. "No secrets found" is what you hope for and also what you get when nothing was scanned, so the answer you want cannot distinguish itself from the failure. A slow scan announces itself; a scan that skipped your input does not. That is why the check below is a byte count rather than a look at the findings list.

Read one number first:

keyhog scan . --format json-envelope -o keyhog.json
jq '.metadata.source_bytes_scanned' keyhog.json

source_bytes_scanned is the count of source bytes that actually entered the detector pipeline. 0 means nothing was scanned. A read-nothing scan now also exits 13, which makes this check louder rather than redundant: a scan that read SOME bytes but not the ones you cared about still exits 0, and this field is what catches that.

The coverage check

Run this after any scan whose result you intend to act on:

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

Treat the result as a real clean only when bytes is greater than zero, and greater than you expect for an empty input. A repository whose scan reports a few dozen bytes did not scan the repository.

Read the structured fields, not the warnings. It is tempting to build a CI check by searching stderr for the word WARN, but this page is about detecting an ABSENCE, and a text search that silently matches nothing returns exactly the answer you were hoping for. KeyHog also has more than one wording for most failure classes, so a search for one of them passes over the others. The exit code and coverage_gap_summary are complete and stable; the prose is neither. Use the warnings to find out WHICH path or object failed, after a structured field has already told you that something did.

What each field means

source_bytes_scanned counts bytes handed to detectors. Decoding can raise it above the on-disk size, because a decoded layer is scanned in addition to the raw bytes it came from. A 32 MiB minified bundle scanned as bundle.js reports about 38 MiB for that reason.

source_chunks_scanned counts the units of work. One small file is one chunk. Files above the 1 MiB window size become several chunks. Empty stdin reports one chunk and zero bytes, so chunk count alone does not prove coverage.

scan_status is one of five values.

ValueMeaning
successNo coverage gap was recorded and no backend recovery happened.
complete_after_recoveryCoverage is complete. A backend fault or missing autoroute calibration was recovered through scalar correctness recovery.
partialAt least one coverage gap was recorded.
cancelledThe run was interrupted.
failedThe run failed before it could report.

coverage_gap_summary is an array of {reason, count} rows. Each row names one class of input that KeyHog did not fully scan.

partial is not a reject rule

An ordinary working-tree scan of a Git repository reports partial:

keyhog scan . --format json-envelope -o keyhog.json
jq '{status: .scan_status, gaps: .coverage_gap_summary}' keyhog.json
{
  "status": "partial",
  "gaps": [
    {
      "reason": "binary (extension or content sniff)",
      "count": 1
    },
    {
      "reason": "default exclusion policy (lock files, minified/bundled assets, vendored and build-output trees). User `.keyhogignore` / --exclude-paths removals are not counted here",
      "count": 6690
    }
  ]
}

The default skips for .git/, lockfiles, vendored trees, and minified bundles are counted as coverage gaps, and on this repository that is 6,690 of them. Almost every real repository therefore reports partial. A CI rule that fails on partial fails on every scan and gets switched off within a week.

Gate on the gap reasons you care about instead:

jq -e '[.coverage_gap_summary[]
        | select(.reason | test("exclusion policy") | not)]
       | length == 0' keyhog.json

That expression exits non-zero when any gap other than the exclusion-policy gap is present. On the sample above it exits 1, because of the binary row, which is the behavior you want: the exclusion gap is expected and the binary gap is worth a look. Add .metadata.source_bytes_scanned > 0 to the same expression when the input is one you can size in advance.

Gap reasons

Every reason string below comes from the scanner. The wording in the report is longer; the fragment shown here is enough to match on.

Reason fragmentWhat was not scannedWhat to do
exceeded --max-file-sizeA file larger than the cap, 100 MiB by default.Raise --max-file-size with a unit, or scan the file on its own.
binary (extension or content sniff)A file KeyHog classified as binary. A directory walk does not scan compiled executables: they produce this gap and zero bytes, so an all-binary directory reports a clean scan it never performed.Expected for images. Never read it as coverage of an executable. --no-default-excludes does not change it. Use keyhog scan --binary <file> on a build with the binary feature.
unreadable (permission denied or I/O error)A file KeyHog could not open.Fix permissions or rerun with the right identity.
default exclusion policy (lock files, minified/...)A path removed by the default skips. Your own .keyhogignore and --exclude-paths removals are NOT counted here.Expected on repositories. See the next section for the limits.
source emitted error rowsA source returned an error for part of its input.Read the stderr warnings, which name the exact path or object.
source scan truncated by aggregate source capInput past a total-bytes ceiling.Raise the source cap or partition the input.
archive or container extraction truncated by an unpack budgetArchive or image members past the expansion budget.Read the stderr warnings, which name the exact cap. Unpack with a trusted tool and scan the result.
Git-LFS pointerThe blob behind an LFS pointer file.Run git lfs pull, then rescan.
git object unreadableA Git object that could not be read.Repair the repository, then rescan.
scanner decode-through truncated by budget/capDeeper encoded layers inside a chunk.Raise --decode-depth.
scanner decode-through declined by --decode-size-limitEverything encoded inside an oversize chunk.Raise --decode-size-limit.
scanner structured decode-through skipped by size capEncoded values inside a large structured file, such as a Kubernetes data block.Split the file. The structured parse cap has no CLI flag.
scanner chunk abandoned at its per-chunk deadlineThe remaining bytes of one chunk.Raise --per-chunk-timeout-ms.
binary deep analysis degraded to strings-onlyStructured analysis of a native binary.Expected without a working deep-analysis backend. Strings were still scanned.
binary unreadableA native binary that could not be opened.Fix permissions, then rescan.

Exit codes and coverage

Exit 13 means a requested source failed or coverage was incomplete. It is the loud failure. Two examples, both real:

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

Exit code 13.

keyhog scan --s3-bucket no-such-bucket
WARN source: failed to read source: S3 source listing failed: bucket request
returned 404 Not Found; objects were not scanned.

Exit code 13.

A total source failure still writes a report

Both examples above exit 13 and write a report. So does an oversize file scanned on its own, and so does a directory whose only file is unreadable. --format json-envelope -o keyhog.json always produces a parseable envelope, carrying whatever findings there were and the gaps naming what was not covered:

{
  "scan_status": "partial",
  "coverage_gap_summary": [
    {"reason": "scan covered nothing (zero source bytes read; ...)", "count": 1},
    {"reason": "source emitted error rows (requested input was not fully scanned)", "count": 1},
    {"reason": "exceeded a configured size cap (--max-file-size or the matching --limit-*-bytes)", "count": 1}
  ]
}

That gives non-zero exit and no report file a single meaning. A missing report is an output-path failure, which exits 3 and prints a stderr line naming the path, not 13. Exit 13 means KeyHog could not cover your input and always comes with a report saying so.

Findings are never discarded because part of the input failed. A directory holding one readable file with a credential and one unreadable file reports the finding, both gap rows, and exits 1.

A CI job can therefore parse the report unconditionally:

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

Keep the rm -f. Its original reason is gone, but a report left by an earlier run is still indistinguishable from a fresh one if anything ever stops this scan writing. Give every scan its own output path, or delete the path before you write to it.

Exit 13 does not track coverage gaps. It tracks source error rows. That distinction decides whether your CI job notices.

A gap that is a skip leaves the exit code alone. The scan reports partial and exits 0:

GapExit
binary (extension or content sniff)0
exclusion policy (...)0
scanner decode-through declined by --decode-size-limit0

That last row is the one to take seriously. It means a credential inside an encoded payload was never decoded, on the default preset, over an ordinary file, and your build did not fail. In any file over 1 MiB only the tail is decode-reachable, so the same payload is found at the end of the file and missed in the middle of it. See Encoded payloads: position decides.

A gap that is an error also emits a source emitted error rows row, and that is what produces exit 13:

GapAlso emitsExit
exceeded --max-file-sizesource emitted error rows13
unreadable (permission denied or I/O error)source emitted error rows13
scan covered nothingzero source bytes read, whatever the cause13

So a directory whose content is partly skipped exits 0 with scan_status partial, a directory with one unreadable file exits 13, and a scan that read nothing at all exits 13. All are partial. Only the first is quiet.

When the covered part of the input does have findings, the findings exit code wins: 1 for findings, or 10 for a confirmed live credential under --verify. The coverage gap stays in the report either way. Do not read exit 1 as complete coverage, and do not read exit 0 as coverage at all. Read coverage_gap_summary and source_bytes_scanned.

Exit codes lists every code KeyHog returns.

Shipped cases where a clean scan is wrong

Each case below returns an empty findings list over input that contains a live-shaped credential. Two of them are now fixed and are kept because the shape still catches people out; the surviving silent one is the Git-history archive. Check for each on the workloads where it applies.

Minified and vendored paths report nothing (fixed)

A directory containing only app.min.js reports no findings, and its source_bytes_scanned count is zero. The file is skipped by the default exclusion policy. That is now loud: the scan exits 13 with two gap rows, a scan covered nothing row and a default exclusion policy row naming which policy did it.

--no-default-excludes now makes the walker read the file AND turns off the post-match drop, so the credential is reported. Without that flag, every finding whose path ends in .min.js, .bundle.js, or .min.css, or sits under node_modules/, bower_components/, jspm_packages/, site-packages/, wp-includes/, wp-content/plugins/, wp-content/themes/, public/plugins/, public/static/, public/vendor/, static/vendor/, dist/vendor/, dist/assets/, or vendor/assets/ is still dropped after matching. Each drop is counted and reported as its own coverage-gap row naming how many findings were dropped, so the suppression is visible rather than silent.

Measured: a directory holding only app.min.js with a planted credential reports zero findings by default and exit 13, and with --no-default-excludes reports the credential and exits 1.

--dogfood shows the individual suppressed matches and their reason, which is more detail than the gap-row count:

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

The stderr trace names the reason vendored_minified_path.

To scan a bundle you own, copy it to a path outside those names and scan the copy:

cp dist/app.min.js /tmp/audit/app.js
keyhog scan /tmp/audit

The same bytes report the finding at that path.

An archive reached through Git history is not opened

Container handling applies to files on disk. It does not apply to Git objects or to cloud object bodies. The same archive gives different coverage depending on how you reach it:

keyhog scan repo/ --no-default-excludes --format json-envelope
keyhog scan --git-history repo --format json-envelope

The working-tree scan descends into the archive and reports the credential inside it. The Git-history scan reports a binary (extension or content sniff) gap for that blob, scan_status partial, and exit 0.

The gap row is there, so this is not fully silent. It is still easy to misread: a binary gap normally means an image or a compiled object you did not want scanned, and here it means an archive nobody opened. --git-blobs reports the same gap and exits 13, so the two Git commands disagree on loudness over the same bytes.

Unpack committed archives and scan the result as a separate job when history is in scope. See Container images and OCI layers.

A total ignore rule scans nothing (fixed)

A .keyhogignore containing path:** matches every path, so the scan reads zero source bytes. That now exits 13 and carries a scan covered nothing gap row, and the text report says the scan covered nothing instead of reporting no secrets. Path rules in .keyhogignore and --exclude-paths still record no gap of their own, so a too-broad rule that leaves SOME bytes readable stays invisible in the report. Only the read-nothing case is loud.

Measured: --exclude-paths '**' and a .keyhogignore containing path:** each exit 13 with scan_status partial, zero source_bytes_scanned, and a scan covered nothing gap row.

Find out whether an allowlist file is in effect before you trust a clean scan on a repository whose ignore file you did not write:

keyhog config --effective

The allowlist_file line names the .keyhogignore that will be loaded. Read that file, then confirm source_bytes_scanned is a plausible size for the tree.

Prove your check works

Plant a credential-shaped value, scan, and confirm your check reports it. A canary is a fake credential you place on purpose so you can prove the scan reached it.

Generate the value instead of copying one. KeyHog suppresses the well-known documentation samples on purpose, so sk_live_4eC39HqLyjWDarjtT1zdp7dc and AKIAIOSFODNN7EXAMPLE both report zero findings and teach you nothing:

mkdir -p /tmp/keyhog-canary
printf 'STRIPE_SECRET_KEY=sk_live_%s\n' \
  "$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)" \
  > /tmp/keyhog-canary/canary.env
keyhog scan /tmp/keyhog-canary --format json-envelope | jq '.findings | length'

Expect 1. If your pipeline reports 0 for that input, the pipeline is broken, not the repository.

Put the canary inside the shape you actually scan. A canary in a plain file proves nothing about a scan whose real input is a container layer, an archive member, or a Git commit that no longer exists in the working tree.