Your first scan

August 5, 2026 · View on GitHub

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

On Linux or macOS:

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

On Windows PowerShell:

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

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

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

Now scan your repository:

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

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

Exit codeMeaning
0Scan completed with no reportable findings
1Findings are present, but none were confirmed live; skipped, unverified, dead, and revoked credentials use this verdict
2User error, such as a bad flag or config, a missing or unreadable path, a missing baseline, detector-load failure, or invalid autoroute calibration
3Local system failure, such as low-level I/O, a fatal daemon failure, or an unavailable selected SIMD/Hyperscan backend
10At least one credential was confirmed live under --verify
11A scanner thread panicked; partial output is not a trustworthy clean verdict
12A required or explicitly selected GPU was unavailable
13A requested source failed or input coverage was incomplete
130You interrupted the scan with Ctrl-C/SIGINT

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

What you get out of it

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

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

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

Each finding gives you the detector, the redacted credential, the location, the confidence when it was measured, and remediation guidance. Use --output to write the report to a file instead of stdout.

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

    K E Y H O G
    ───────────
    v0.5.68 · secret scanner · 925 detectors
    by santh

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

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

Default suppressions

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

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

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

JSON output

keyhog scan . --format json-envelope

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

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

{
  "schema_version": {"major": 1, "minor": 10},
  "scan_status": "success",
  "metadata": {
    "scan_id": "0123456789abcdef0123456789abcdef",
    "scan_status": "success",
    "keyhog_version": "0.5.68",
    "git_hash": "<build-commit>",
    "detector_digest": "925-<digest>",
    "config_digest": "<effective-config-digest>",
    "resolved_scan": {
      "schema_version": 1,
      "preset": "default",
      "effective": {"max_decode_depth": "10", "entropy_enabled": "true"},
      "overrides": []
    },
    "generated_at": "2026-07-14T00:00:01",
    "scan_started_at": "2026-07-14T00:00:00",
    "scan_finished_at": "2026-07-14T00:00:01",
    "duration_ms": 1000,
    "targets": ["."],
    "source_chunks_scanned": 1,
    "source_bytes_scanned": 128,
    "detector_count": 925
  },
  "coverage_gap_summary": [],
  "findings": [
    {
      "detector_id":        "stripe-secret-key",
      "detector_name":      "Stripe Secret Key",
      "service":            "stripe",
      "severity":           "critical",
      "credential_redacted": "sk_l...p7dc",
      "credential_hash":     "sha256-hex",
      "companions_redacted": {},
      "location": {
        "source":    "filesystem",
        "file_path": "src/config/staging.env",
        "line":      14,
        "offset":    12,
        "commit":    null,
        "author":    null,
        "date":      null
      },
      "verification": "skipped",
      "metadata": {},
      "additional_locations": [],
      "entropy": 4.5,
      "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 .findings into jq, into a SARIF converter for the GitHub Security tab, or into your own dedup / triage tooling.

Limiting scope

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

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

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

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

Going further

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

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

Then continue with:

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