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.
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.
What it detects
Section titled “What it detects”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.
Cloud and platform credentials
Section titled “Cloud and platform credentials”| 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 |
AI provider keys
Section titled “AI provider keys”| 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 |
Generic and structural
Section titled “Generic and structural”| 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 |
About the aws-secret-key detector
Section titled “About the aws-secret-key detector”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.
About the HTTP Authorization detectors
Section titled “About the HTTP Authorization detectors”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.
About entropy detection
Section titled “About entropy detection”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.
Output
Section titled “Output”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/quickstartPreviews 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. |
Verification
Section titled “Verification”| 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. |
Git history
Section titled “Git history”| 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.
Baseline
Section titled “Baseline”| 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.
Verifying findings are live (--verify)
Section titled “Verifying findings are live (--verify)”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.
ov scan --verifyBecause of that, --verify asks for consent before making any network call:
Warning: --verify will send each detected credential to its vendor's live APIto 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.
Background update check
Section titled “Background update check”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_CHECKis set to any non-empty valueCIis 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:
export OV_NO_UPDATE_CHECK=1Configuration file (.ovscan.yaml)
Section titled “Configuration file (.ovscan.yaml)”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
Section titled “Custom patterns”custom_patterns teaches the scanner your organization’s own credential formats using declarative fields — every field below is required:
version: 1custom_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 | infoA 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.
Severity overrides
Section titled “Severity overrides”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": verifiedOverrides interact with --fail-on (default medium): downgrading a noisy detector below your fail threshold keeps it visible in output without failing CI.
Other keys
Section titled “Other keys”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.
ov scan –fix
Section titled “ov scan –fix”--fix walks you through storing every detected secret in OpaqueVault and then tells you exactly how to remove them from git history.
ov scan . --fixYou 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:
- Scan runs as normal and shows all findings
- 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. - You enter your master password once — it’s reused for all secrets in the session
- Each secret is encrypted locally and stored in OpaqueVault
- After storing, exact
git filter-repocommands 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_IDValue 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-fixFalse positives
Section titled “False positives”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).