Skip to content

ov scan

ov scan finds secrets committed or staged in your codebase. No file contents, line context, file paths, or repository data ever leave your machine — not to OpaqueVault, not to anyone. Detection is entirely local, and there is no telemetry and no account required for a plain scan.

Three things do make network calls, and all three are worth knowing about up front:

What When What is sent
--verify Only with the explicit --verify flag, after a y/N consent prompt The credential value itself, as an auth header, to that credential’s own vendor (GitHub, Stripe, …). Never to OpaqueVault.
--fix Only with the explicit --fix flag The credential value you type at the prompt, encrypted client-side, stored via the OpaqueVault API. Requires login.
Update check Normal ov commands including scan, at most once per 24h, TTY only, disabled in CI; stage, version-bounds, and hook fast paths bypass it Nothing about your scan — a plain GET for the latest published version string.

The separate ov scan-diff-base CI helper may fetch from your repo’s own origin remote to resolve a merge base for diff-aware scanning — that’s standard git history retrieval, not ov scan itself, and it never transmits file contents or findings.


Terminal window
ov scan [path] [flags]

path defaults to the current directory if omitted.

Using an AI agent? Start with ov scan --format agent .. Its versioned report uses opaque file references and excludes source snippets and filenames. The companion ov scan --format review . shows locations in your own terminal under the same fixed policy. See the agent contract and guided ZK remediation workflow for scope, exit codes and limitations.


36 detectors ship built in. This is the complete list — the ID in the first column is the name that appears in scan output, in severity_overrides, and as the basis for --fix secret names.

Detector ID Severity What it matches
aws-access-key Critical AKIA + exactly 16 uppercase alphanumerics, word-bounded
aws-secret-key Critical A 40-char [A-Za-z0-9/+=] value preceded on the same line by AWS keyword context — see below
github-token Critical ghp_/ghs_/gho_/ghr_ + 36 or more alphanumerics, or github_pat_ + 59 or more
gitlab-token Critical glpat- + 20 or more chars
stripe-key Critical (sk_test_ → High) sk_live_, sk_test_, or rk_live_ + 24 or more alphanumerics
stripe-webhook-secret Critical whsec_ + 20 or more alphanumerics
slack-token Critical xoxb-/xoxp-/xoxa-/xoxr-/xapp- + 20 or more chars
notion-token Critical ntn_ + exactly 46, or secret_ + exactly 43 alphanumerics
linear-token Critical lin_api_ + exactly 40 hex chars
npm-token Critical npm_ + exactly 36 alphanumerics
discord-bot-token Critical 17–19 digit snowflake . 6 chars . 27 chars
vercel-token Critical vercel_ or vc_ + 24 or more alphanumerics
supabase-token Critical sbp_ + exactly 40 hex chars
opaquevault-key Critical ov_live_ or ov_test_ + 32 or more chars
sendgrid-key Critical SG. + exactly 22 chars + . + exactly 43 chars (base64url)
pypi-token Critical (test.pypi.org → High) pypi- + the fixed macaroon header for pypi.org or test.pypi.org + 50 or more chars
doppler-token Critical dp.ct./dp.pt./dp.st./dp.sa./dp.scim./dp.audit., an optional config segment, then 40–44 alphanumerics
azure-storage-key Critical An AccountKey field (any case; = or :, optional spaces or quotes) + an 88-char base64 account key ending == (a storage connection string). The Azurite emulator’s public development key is ignored
slack-webhook High hooks.slack.com/services/T…/B…/ + exactly 24 alphanumerics, or a workflows/ / triggers/ webhook URL; /, JSON-escaped \/ and %2F separators
twilio-api-key High SK + exactly 32 hex chars — an API key SID; the paired secret has no prefix, so the SID is the signal
shopify-token Critical shpat_/shpca_/shppa_/shpss_ + exactly 32 hex chars
datadog-api-key High Exactly 32 hex chars assigned to a dd/datadog + optional api + key name (DD_API_KEY=, "datadog_api_key":, …) — keyword-anchored: a 32-hex value without that name is not matched
Detector ID Severity What it matches
anthropic-key Critical sk-ant-api<digit>- + 20 or more chars
openai-key Critical sk-proj- + 20 or more chars, or legacy sk- + 48 or more alphanumerics
google-ai-key Critical AIza + exactly 35 chars
groq-key Critical gsk_ + exactly 52 alphanumerics
huggingface-token Critical hf_ + 34 or more alphanumerics
replicate-token Critical r8_ + exactly 40 alphanumerics
Detector ID Severity What it matches
private-key Critical A PEM header line -----BEGIN [TYPE ]PRIVATE KEY[ BLOCK]-----
jwt High Three dot-separated base64url segments starting eyJ, each 10+ chars
postgres-url High postgres:// or postgresql:// URL with a non-empty user:password@host segment
url-with-password Medium Any scheme://user:password@host URL with a 4+ char password, except postgres:// (covered above)
http-bearer Medium A 20+ char [A-Za-z0-9._~+/=-] token after Bearer in an Authorization header context — see below
http-basic Medium A base64 value after Basic in an Authorization header context that decodes to printable user:password with a non-placeholder password
high-entropy Low 20+ char runs passing an adaptive entropy threshold — see below
base64 inherits A 100+ char base64 blob that decodes to printable text; the decoded text is rescanned with the 35 other detectors

This detector does no entropy analysis and has no adjacency-to-an-AWS-key-ID logic. It is a same-line keyword match. All of the following must appear on one physical line, case-insensitively:

aws …≤20 chars… secret …≤20 chars… (key|access) …≤10 chars… (=|:) ["']? <40-char value>

The value is exactly 40 characters from [A-Za-z0-9/+=] — not “40 or more”. Two practical consequences: a bare 40-char string with no aws…secret…key preamble is not flagged by this detector (it may still trip high-entropy at Low), and a .env file with the name on one line and the value on the next will not match, because the pattern cannot span a newline.

http-bearer and http-basic fire only when the value follows an Authorization (or Proxy-Authorization) header name and a separator: an HTTP header or log line (Authorization: Bearer …), a curl -H/--header argument, a JSON/JS/Python/PHP header map ("Authorization": "Bearer …"), or a Go Header.Set("Authorization", "Bearer …") call. The words “Bearer” and “Basic” on their own never match, so prose such as “Basic authentication” does not fire.

A bearer value is skipped when it is shorter than 20 characters, low-entropy for its alphabet (digit-only and hex tokens are measured against their smaller alphabets), a digit-free identifier made of word-shaped segments (access_token_from_config, yourAccessTokenGoesHere), a published specification/reference example (for instance RFC 6749’s 2YotnFZFEjr1zCsicMWpAA), a whole-value sequential run (ABCDEFGHIJKLMNOPQRSTUVWXYZ, 12345678901234567890), a placeholder (YOUR_…, REDACTED, xxxx, a single repeated character), display-truncated (ends in ..), or the literal prefix of a template (${TOKEN}, $TOKEN, {{token}}, <token> and %s never match the token charset in the first place). A value that the jwt pattern or a vendor pattern (ghp_…, sk-ant-…, xoxb-…, …) matches end to end is left to that detector, so a bearer header is reported once, under the most specific name; a vendor-shaped fragment inside a longer value does not count, and JWT-like values jwt cannot see (short segments, JWE) are reported here. A dotless value starting eyJ that is at most 64 characters long, or that decodes to a JWT header (alg/typ), is a JWT cut off after its header segment, which carries no key material, and is not reported.

A Basic value (at most 4 KiB encoded) must decode as base64 to UTF-8 user:password with no control characters and a non-empty password, and that password passes through the same placeholder and low-entropy check as url-with-password — admin:admin and user:password are not reported, and neither are the RFC 7617 / MDN Aladdin:open sesame examples or a password that is one unbroken alphabet run of 12+ characters (abcdefghijklmnopqrstuvwxyz). Short or broken runs such as 12345678 or abcd1234 are real weak passwords and are reported.

Both are Medium, so in file-backed working-tree scans they are subject to the ±3-line context suppression described above. Where the scanned content has no file path — the MCP interceptor, --history blob scans, and --format agent/review scans — they are not: that content is often a single long line, and a word like example elsewhere on it must not hide a credential.

high-entropy applies a length-banded Shannon threshold (AdaptiveEntropyThreshold), not a flat 4.5:

Value length Threshold (bits/char)
20–31 4.5
32–47 4.6
48–63 4.7
64 and up 4.8

A value is considered when its length is 20 or more, and it is flagged only when entropy is strictly greater than the band threshold — exactly 4.5 does not flag. UUIDs and path-like strings are excluded. A value below the Shannon threshold can still be flagged by a per-character-class check, which is what catches restricted-alphabet hex keys.

The same detection engine powers the MCP context interceptor — patterns that catch committed secrets also guard the results OpaqueVault’s MCP tools return to Claude.


FOUND 3 leaked secret(s) in your repo.
scripts/deploy.sh:31 aws-access-key AKIA************MPLE
config/database.yml:14 postgres-url postgres://admin:p*****@prod...
.env.backup:3 stripe-key sk_live_********************
Store them safely:
ov secret set AWS_ACCESS_KEY
ov secret set POSTGRES_URL
ov secret set STRIPE_KEY
→ https://opaquevault.com/docs/getting-started/quickstart

Previews are redacted — the full value is never written to output or logs. It leaves your machine only on the two explicit opt-in paths described above (--verify and --fix).

The suggested secret names are derived mechanically from the detector ID: uppercase it and replace - with _. So postgres-url → POSTGRES_URL and stripe-key → STRIPE_KEY.


All 18 flags, with their defaults.

Flag Default Description
--fix false Interactively store found secrets in OpaqueVault and print git history cleanup instructions. Requires login.
--fail-on medium Lowest severity tier that causes exit 1: verified, critical, high, medium, low, info, none
--format auto Output format: human, json, sarif, github, agent, review (auto = github in GitHub Actions, json when stdout is piped, otherwise human). Agent/review use a separate fixed policy; see the agent contract.
--config .ovscan.yaml Path to the ov scan config file
--include-only-paths-from-stdin false Scan only the newline- or NUL-delimited paths read from stdin (diff-aware / pre-commit; auto-detects git diff -z). Paths resolve from the current directory, so run it from the repo root.
--staged false Scan staged Git blobs with --format agent or --format review, independent of working-tree edits. Optional directory selects the repository. Mutually exclusive with stdin paths; see staged scope.
Flag Default Description
--verify false Verify each detected credential against its vendor’s live API. Sends credentials to third parties — see below.
--yes, -y false Skip the verification consent prompt (for CI / non-interactive use; also OV_VERIFY_NO_PROMPT=1)
--verify-quiet false Accepted but not yet functional. Reserved for suppressing non-verified-live findings.
--verify-rotated false Accepted but not yet functional. Reserved for re-verifying baseline findings to surface rotation status.
Flag Default Description
--history false Also scan full git history (all branches, stashes, dangling blobs, git notes). Cannot be combined with --fix.
--since — Limit history scan to commits after this date (YYYY-MM-DD or RFC3339); requires --history
--max-blob-size 10485760 (10 MB) Per-blob size cap for the history scan, in bytes; requires --history
--time-budget 0 (unbounded) History-mode safety valve, e.g. 10m

History findings report the path and commit where the secret’s file content first entered history — even if the file was later renamed or deleted. “First” means the oldest committer date, which whoever writes the commits controls, so treat it as a locator rather than evidence of who introduced a secret. JSON carries them in path and commit_hash, and marks history rows with "provenance": "history"; SARIF and GitHub annotations use the path as the location and name the short commit in the message; human output shows HISTORICAL <commit> <date> <branches> <path>:<line>. Terminal control and bidirectional-text characters in paths and previews are escaped in human output and GitHub annotations, and SARIF locations are percent-encoded (including :). JSON and SARIF output write C1 and bidirectional control characters as \u escapes. In a shallow clone, a secret already present at the clone boundary is reported as introduced “at or before” the boundary commit: it has no commit_hash or first_commit, and JSON carries the boundary as shallow_boundary, even if the value was edited in later commits. A commit-message finding has a commit but no path and is located as git-history://<commit>/<detector>. A finding with no commit at all (a dangling or unreachable blob, a git note, or a repository too large for the history index) is located as git-history://unknown/<detector>, and stderr reports how many rows there were. If a commit’s tree would expand to more than a million paths (submodule entries count as paths too) (for example a “tree bomb” that references one directory millions of times), ov scan skips attribution for the run, says so on stderr, and still scans every blob. Attribution also gets at most a quarter of --time-budget (capped at three minutes), so it cannot starve the scan itself. In a partial (blobless) clone with git 2.44 or newer, ov scan --history never downloads missing objects from the remote. Objects that are not present locally cannot be scanned, so the history scan counts as incomplete: stderr says how many objects were missing and the run exits 2 (unless --fail-on none). In CI, check out full history without a blob filter — for example actions/checkout with fetch-depth: 0 and no filter: blob:none.

If the history scan does not finish — the time budget runs out, it is cancelled, git fails, objects are missing locally, or objects cannot be read (a corrupt object store) — ov scan prints one history scan incomplete line per cause on stderr, reports what it found, and exits with status 2 (unless --fail-on none), so a CI gate fails closed instead of passing on partial results.

History findings are grouped per distinct secret — the same detector matching the same value — so a key that lived in a file across fifty edits is one row, not fifty. Each history row carries occurrences (how many history matches were grouped into it — every matching line of every blob version or commit message counts, so a value repeated on many lines of one file counts each line), first_commit and last_commit (the oldest and newest commits that introduced it), in_head (whether a blob holding the secret is in HEAD’s committed tree — the working tree, .ovscanignore and the scan directory do not affect it), paths (up to 20 paths the secret lived at, the row’s own path first) and path_count with path_count_exact (path_count is exact for a secret held in one file version; across several, counting stops after 5,000 path steps per secret, and then path_count_exact is false). If --time-budget runs out while rows are being grouped, rows list only their own path, omit path_count, have path_count_exact: false, and stderr says so. branches on a history row is the union across all its occurrences. The row’s path and commit_hash point at the first introduction. JSON carries all of these fields; SARIF carries the summary in result properties and the other paths as relatedLocations; human output appends (N occurrences, first seen <commit> <path>; also at …). Grouping uses a keyed hash with a random key generated for each run and never written anywhere, so no value or fingerprint appears in the output, and rows come out in the same order on every run. A secret that is still in the working tree also appears as a working-tree finding (fix it there); its history row says it was committed, so rotate it even after fixing the file. A copy in HEAD that the history scan skipped or did not report — at an ignore_paths or lockfile-allowlisted path, over --max-blob-size, older than --since, or suppressed there by a detector’s context rules (which can lower or drop a low- or medium-confidence match) — does not set in_head. If HEAD’s tree is too large to list, stderr says so and in_head is false on every row. --fail-on still sees each row’s highest severity, and --baseline still applies only to working-tree findings.

Flag Default Description
--baseline — Baseline snapshot file; only flag findings not in it (defaults to .ovscan-baseline.json if that file exists)
--baseline-init false Write current findings to the baseline and exit 0
--baseline-refresh false Re-walk the working tree and add new findings to an existing baseline (preserves the HMAC key)
--include-verified false With --baseline-init in local mode: allow baselining of verified-live findings
--allow-ci-baseline false Required to run --baseline-init in CI; intentionally awkward to discourage CI-time use

Mutually exclusive combinations that hard-error: --include-only-paths-from-stdin with a positional path or with --history; --baseline-init with --baseline-refresh; --history with --fix. Using --since or --max-blob-size without --history warns on stderr but continues.


By default ov scan tells you a credential looks like a Stripe key. --verify tells you whether it still works — which is the difference between a stale test key and an active incident.

Terminal window
ov scan --verify

Because of that, --verify asks for consent before making any network call:

Warning: --verify will send each detected credential to its vendor's live API
to confirm it is active. These calls appear in vendor activity logs.
Continue? [y/N]

Only y proceeds. Anything else prints Verification skipped. and the scan continues without verification. In CI, pass --yes or set OV_VERIFY_NO_PROMPT=1 (any non-empty value suppresses the prompt).

13 of the 36 detectors are verifiable. Each uses a read-only “who am I” endpoint chosen to be as low-impact as possible — Stripe uses /v1/balance rather than a charges list, and the Google AI key goes in a header rather than a query string so it stays out of URL access logs.

Verifiable Endpoint
github-token api.github.com/user
gitlab-token gitlab.com/api/v4/user
stripe-key api.stripe.com/v1/balance
slack-token slack.com/api/auth.test
linear-token api.linear.app/graphql
npm-token registry.npmjs.org/-/whoami
vercel-token api.vercel.com/v2/user
anthropic-key api.anthropic.com/v1/models
openai-key api.openai.com/v1/models
google-ai-key generativelanguage.googleapis.com/v1/models
groq-key api.groq.com/openai/v1/models
huggingface-token huggingface.co/api/whoami
replicate-token api.replicate.com/v1/account

The remaining detectors have no verifier and always render as ? unverified: aws-access-key and aws-secret-key (SigV4 needs the paired secret), plus high-entropy, jwt, postgres-url, url-with-password, private-key, notion-token, discord-bot-token, supabase-token, opaquevault-key, stripe-webhook-secret, http-bearer, and http-basic.

Results map as: HTTP 2xx → ✓ ACTIVE; 401 → ✗ inactive; anything else, including 403, timeouts, 5xx, and rate-limit 429s → ? unverified. A plain 403 means the vendor recognised the credential but refused that particular request (for example, a Stripe restricted key without Balance read access), so it is never reported as inactive. Two vendors report failures differently: Slack’s auth.test returns HTTP 200 for every failure, so only the invalid_auth, account_inactive, token_revoked and token_expired error codes count as inactive; Vercel returns 403 for a bad token, so a Vercel 403 counts as inactive only when the response body marks the token as invalid. Verification runs at most 5 concurrent requests with a 5-second timeout each, and there is no retry or backoff.


Independently of scanning, every ov command — including ov scan — may fetch https://releases.opaquevault.com/latest.txt in the background to tell you a newer version exists. It sends nothing about your repository or your scan; it is a plain GET for a short version string, capped at 64 bytes with a 3-second timeout.

It is skipped entirely — no network call at all — when any of these hold:

  • OV_NO_UPDATE_CHECK is set to any non-empty value
  • CI is set to any non-empty value (so it never runs in a pipeline)
  • stderr is not a TTY (redirected or piped output)
  • the binary is a dev build
  • a check already succeeded within the last 24 hours

To disable it permanently:

Terminal window
export OV_NO_UPDATE_CHECK=1

ov scan reads an optional .ovscan.yaml from the scanned directory (override with --config). It works with no config at all — the file exists to tune the scanner per repository.

custom_patterns teaches the scanner your organization’s own credential formats using declarative fields — every field below is required:

version: 1
custom_patterns:
- id: acme-api-key # your name for the pattern; letters, digits, dashes
prefix: "acme_" # literal prefix the credential starts with
length: 40 # total token length
charset: alnum # alnum | hex | base64 | base64url
entropy_min: 4.0 # minimum Shannon entropy (bits/char) after the prefix
severity: high # verified | critical | high | medium | low | info

A regex: form is intentionally not supported. A regex supplied via a file that arrives in pull requests is a denial-of-service surface (catastrophic backtracking against every scanned blob), so the scanner rejects it rather than accepting it silently — the declarative fields cover prefix-style credentials without that risk. If a config contains regex:, ov scan refuses to run and says so.

Tune the severity of any detector — built-in detectors by their id, custom patterns with a custom: prefix:

severity_overrides:
google-api-key: medium
"custom:acme-api-key": verified

Overrides interact with --fail-on (default medium): downgrading a noisy detector below your fail threshold keeps it visible in output without failing CI.

ignore_paths (glob patterns to skip), ignore_blob_content (regexes that suppress matching lines — useful for fixture files), verify (liveness-check behavior in CI), and baseline (path + refresh policy for the accepted-findings baseline). All optional.


--fix walks you through storing every detected secret in OpaqueVault and then tells you exactly how to remove them from git history.

Terminal window
ov scan . --fix

You must be logged in (ov auth login) before running --fix. If you’re not, it will print a link to register and exit.

The flow:

  1. Scan runs as normal and shows all findings
  2. For each unique detector found — not each finding, so 40 leaked Stripe keys produce one prompt — you’re prompted for a name. It defaults to the detector ID uppercased with - replaced by _, e.g. stripe-key → STRIPE_KEY. Press enter to accept the default, type a custom name, or type - to skip.
  3. You enter your master password once — it’s reused for all secrets in the session
  4. Each secret is encrypted locally and stored in OpaqueVault
  5. After storing, exact git filter-repo commands are printed for each affected file, along with force-push instructions

Example session:

FOUND 2 leaked secret(s) in your repo.
.env.backup:3 stripe-key sk_live_********************
scripts/ci.sh:8 aws-access-key AKIA************MPLE
Found 2 secret type(s). Let's store them in OpaqueVault.
App: my-saas
Master password: ••••••••••••
[stripe-key] Secret name (default: STRIPE_KEY, enter to skip):
Value for "STRIPE_KEY": ••••••••••••••••••••••••••••
✓ "STRIPE_KEY" stored
[aws-access-key] Secret name (default: AWS_ACCESS_KEY, enter to skip): AWS_ACCESS_KEY_ID
Value for "AWS_ACCESS_KEY_ID": ••••••••••••••••••••
✓ "AWS_ACCESS_KEY_ID" stored
✓ 2 secret(s) stored in OpaqueVault.
Next: remove them from your git history.
If you don't have git-filter-repo installed:
pip install git-filter-repo
For each file containing leaked secrets:
git filter-repo --path .env.backup --invert-paths
git filter-repo --path scripts/ci.sh --invert-paths
Then force-push to update the remote:
git push --force-with-lease
Note: notify any collaborators to re-clone after a force-push.
→ https://opaquevault.com/docs/getting-started/scan-fix

High-entropy detection may occasionally flag random-looking strings that aren’t secrets (e.g., hashed IDs, UUIDs, base64-encoded config). These are skipped automatically when running --fix — just enter - when prompted for the name.


How it’s different from GitGuardian / truffleHog

Section titled “How it’s different from GitGuardian / truffleHog”

ov scan is not trying to replace dedicated secret scanning platforms. It’s a local, instant, zero-friction tool designed for one purpose: get developers to discover they have a problem, and immediately give them a path to fix it with OpaqueVault.

No SaaS account is required to scan, and no file contents, line context, file paths, or repository data are ever transmitted anywhere. The only values that can leave your machine are individual credential strings, and only on the two explicit opt-in paths described above: --verify (to the credential’s own vendor) and --fix (encrypted, to OpaqueVault).