envdrift guard¶
Scan repositories for unencrypted .env files and exposed secrets.
Synopsis¶
Description¶
envdrift guard is a defense-in-depth scanner designed to catch secrets that
slip past other guardrails (hooks, CI, reviews). It detects:
- Unencrypted env files missing dotenvx or SOPS markers
- Common secret patterns (tokens, API keys, credentials)
- Password hashes (bcrypt, sha512crypt), with the native scanner
- High-entropy strings (native scanner; env files by default, all files with
--entropy, disable with--no-entropy) - Secrets in git history (optional)
The unencrypted-file policy covers every env-file naming shape: .env,
.env.<environment> (including .env.local and .env.test), the trailing
<name>.env convention (production.env), and custom vault.sync mapped files.
Only the templates .env.example, .env.sample, and .env.template are skipped.
Files are decoded through UTF-8/UTF-16/UTF-32 BOMs (and BOM-less UTF-16), so a
UTF-16 env file written by Windows tools is scanned like its UTF-8 equivalent.
The native scanner always runs. By default, gitleaks runs too. You can enable
trufflehog, detect-secrets, or kingfisher with flags or envdrift.toml. If no
paths are provided, the current directory is scanned.
Arguments¶
| Argument | Description | Default |
|---|---|---|
[paths] |
Files or directories to scan | . |
How paths are shown¶
Every finding names its file relative to the directory guard runs in when its path, as given, is under
that directory, and by its absolute path otherwise. Symlinks in an absolute path are not resolved for
this, so an absolute path through a symlinked working directory (such as macOS /tmp) is shown
absolute. On Windows a file on another drive is always shown absolute. Every scanner uses this one
form, in the findings table, in --json, and in the finding messages, and so do --staged,
--pr-base and --history. A file outside the working directory, such as one changed by a pull
request when guard runs in a subdirectory, is shown absolute, not as ../file. The findings table
shortens a long path at its start, so the file name and line stay visible. SARIF artifact URIs stay
relative to the repository root (see --sarif below).
A path you pass through a symlink is shown under the name you gave: envdrift guard linkproj reports
linkproj/sub/app.env from every scanner, including those that resolve symlinks. A symlinked
directory is scanned in its absolute form too (envdrift guard "$PWD/linkproj"), and a symlink to a
file is scanned through to its target and shown as the link. A .. after a symlink names the link
target's parent: envdrift guard linkproj/.. scans that directory and shows its files where they are.
A file that two of the paths you pass reach by two names is one finding for each scanner.
envdrift guard . linkproj, where linkproj is a symlink to proj, scans proj twice, and the secret in
proj/sub/app.env is reported once by each scanner, under the name the file really has (proj/sub/app.env),
whichever path comes first. When only links to the directory are scanned (envdrift guard link2 link1), the
first one you pass names it (link2/sub/app.env), for every scanner. A symlink to a file that is scanned too is
that file, not a second finding, and the file is shown by its own name, not the link's, unless the finding
reported under the link has a higher severity, or is verified when the severities tie. Two secrets on one
line, or the same secret on two lines, stay separate findings, and so do the reports of two scanners, which
name the rule differently.
The path shown is not always the path in a gitleaks or Infisical fingerprint. envdrift guard with
no path and absolute paths are fingerprinted by absolute path; see
gitleaks and Infisical ignore files.
envdrift 11.1.1 and earlier reported a file twice when two of the paths you passed reached it by
different names (guard . linkproj), because duplicates were found by the name shown.
envdrift 11.0.8 and earlier could show one file in two forms in one report. The native scanner showed
every file of a directory it scanned by its absolute path, gitleaks and Infisical showed it in the
form of the path you passed, and gitleaks showed history findings by their absolute path. gitleaks and
Infisical also reported nothing, and no error, for the absolute path of a symlinked directory (or of
one followed by ..) and for a symlink to a file.
Older envdrift releases ran trufflehog on each path as you passed it. It joined a relative path onto
itself (proj/proj/sub/app.env), and reported nothing, and no error, for a symlinked directory or file
or a .. after a symlink. With --history it failed on a subdirectory of a repository, on every path on
Windows and on a repository whose path holds # or %, and scanned a path outside any repository as
plain files instead of failing.
Options¶
--native-only¶
Use only the native scanner and skip external tools.
--gitleaks / --no-gitleaks¶
Enable or disable gitleaks. Enabled by default unless --native-only is set.
--trufflehog / --no-trufflehog¶
Enable or disable trufflehog. Disabled by default.
envdrift runs trufflehog with --no-update. Without that flag trufflehog asks oss.trufflehog.org for a
newer release before every scan and writes it over its own binary, replacing the version envdrift pins
and verifies. Older envdrift releases did not pass it.
--detect-secrets / --no-detect-secrets¶
Enable or disable detect-secrets. Disabled by default.
envdrift runs the detect-secrets package installed next to it (python -m detect_secrets) with every
plugin enabled, and installs the pinned version when the package is missing and auto-install is on.
Which files it scans. Inside a git repository detect-secrets scans the files git tracks, so a new
file is scanned once you git add it, and a file git ignores is not. That holds for the top level of
the repository and for any subdirectory of it: envdrift guard sub scans the tracked files below
sub, and so does envdrift guard . run inside it. Outside a git repository it scans every file in
the directory. Either way it skips everything below a directory named node_modules, .venv, venv,
.tox, .nox, site-packages, __pycache__, .mypy_cache, .pytest_cache, .ruff_cache, .git,
dist, build, vendor or coverage. Only directories count: a script called build is scanned, and
so is the directory you point it at, whatever it is called. A file you name is always scanned. A
directory outside git can hold more such trees than that list names; [guard] ignore_paths drops their
findings (see Configuration), though not the time spent scanning them.
A tracked file with a non-ASCII name (café.env) is scanned too. envdrift runs detect-secrets with git's
core.quotepath off (git 2.31 or later), because with it on git ls-files writes that name as
"caf\303\251.env", which names no file, and detect-secrets dropped the file without a word. Git quotes a
name with a double quote, a backslash or a control character whatever the setting, and detect-secrets splits
the list at U+0085, U+2028 and U+2029 as it does at a newline, so a name with one of those is lost too.
detect-secrets also fails on a name that is not valid UTF-8 anywhere in the repository when the setting is
off, even in a directory or file it would not scan, so envdrift turns the setting on (over your own git
configuration) in a repository that tracks one. A git older than 2.31 ignores the setting altogether, and so
does a core.quotepath in GIT_CONFIG_PARAMETERS, which git -c core.quotepath=... exports, in your
environment. envdrift does not guess which names that affects: it runs the command detect-secrets runs, under
the environment detect-secrets gets, and compares. For every file of the scan whose name is not in the list,
the Scanner Warnings say "Not scanned in ..." and list the first three, instead of letting the file pass for
clean. Rename the file or scan it with another scanner.
When git tracks nothing that detect-secrets would scan in a directory (a repository whose files are not
added yet, or a directory that is all ignored), detect-secrets prints an empty report, as for a
directory with no secret. guard says so instead of letting that pass for a clean run: the
Scanner Warnings panel, scanner_results[].warnings and the --sarif notifications carry "Scanned
nothing in ..." and the fix, git add the files. It is a warning, not an error, and does not
change the exit code. If git cannot list the files of the repository (a damaged index), the warning says
"Could not list the files git tracks in ...", because detect-secrets lists them with the same command and
may have scanned none. If detect-secrets cannot list the files of a directory at all, or prints a report
envdrift cannot read, the scan is an error (exit 5), never a clean run.
Older envdrift releases passed detect-secrets no flag for a directory outside a git repository: it listed
no files, printed a notice on stderr and exited 0, and envdrift reported a clean run, exit 0, for a
directory nothing had looked at (#851). They also
reported a clean run for a subdirectory of a repository (envdrift guard sub, or envdrift guard .
run inside it), because detect-secrets compares the paths git lists, relative to the top level of the
repository, with the paths it finds relative to the directory it runs in, and in a subdirectory not one
of them agrees. A file called build, dist, vendor or coverage was skipped as well, as if it were
one of those directories.
Which version. envdrift grades detect-secrets findings by the detector names of the version it
pins in constants.json. Another version can rename or add detectors, and a finding from a detector
envdrift does not know is graded HIGH, so severities may be inaccurate. When the installed version is
not the pinned one, or cannot be read, guard warns, naming the installed and the pinned version, and
gives the fix: pip install detect-secrets==<pinned version>. The warning is a Scanner Warnings panel in
the human output, scanner_results[].warnings in --json, a warning entry of
toolExecutionNotifications in --sarif, and a line on stderr under --json and --sarif. It does not
change the exit code, and envdrift never installs or changes the package for it.
--kingfisher / --no-kingfisher¶
Enable or disable Kingfisher scanner. Disabled by default.
Kingfisher provides:
- Betterleaks' detection rules (Infisical's rule set) and Veles' rules
- Active secret validation (checks if secrets are still valid)
- Archive extraction and binary file scanning
Kingfisher 2.x, unlike 1.x, has no rule for password hashes: the native scanner finds bcrypt and sha512crypt hashes.
--git-secrets / --no-git-secrets¶
Enable or disable git-secrets scanner. Disabled by default.
git-secrets provides:
- AWS credential detection (access keys, secret keys)
- Pre-commit hook integration
- Custom pattern support
- Allowed patterns for false positive management
git-secrets publishes no release binaries. On Linux, auto-install clones its repository and builds the
commit envdrift pins in constants.json, and fails when that commit cannot be checked out. On macOS it
installs the Homebrew formula. envdrift runs the git-secrets it installed, in the active venv or
~/.local/bin, even when that directory is not on your PATH. Older releases did not find it there,
so the scan failed with "git-secrets reported a match ... that envdrift cannot parse" and every run
installed it again. On Windows, git-secrets is a Bash script that only Git's shell runs: envdrift runs
it as git secrets, unless your PATH holds a git-secrets program or batch file (.exe, .com,
.bat or .cmd).
--talisman / --no-talisman¶
Enable or disable Talisman scanner. Disabled by default.
Talisman (from ThoughtWorks) provides:
- Entropy-based secret detection
- File content pattern analysis
- Encoded content detection (base64, hex)
- Credit card number detection
- Suspicious file name detection (.pem, .key)
--trivy / --no-trivy¶
Enable or disable Trivy scanner. Disabled by default.
Trivy (from Aqua Security) provides:
- Comprehensive multi-target security scanning
- Built-in rules for AWS, GCP, GitHub, GitLab, Slack, etc.
- Custom regex pattern support
- Severity-based filtering
--infisical / --no-infisical¶
Enable or disable Infisical scanner. Disabled by default.
Infisical provides:
- 140+ secret type detection
- Git history scanning
- Staged changes scanning
- Custom regex patterns and entropy detection
Infisical reports no severity of its own, so envdrift grades each finding by its rule ID, with the
same table as gitleaks and the other scanners: see Severity. A custom Infisical rule
config that emits the IDs aws-access-key-id, google-api-key, slack-token or stripe-api-key is
graded CRITICAL too.
On 0.43.135, the version envdrift auto-installs, Infisical has no rule for AWS secret access keys or
Azure storage account keys and reports both as generic-api-key, which is HIGH. Every version does
the same for an AWS secret access key with no key ID next to it. So --fail-on critical does not
block these keys on Infisical's findings alone. In the working tree the native scanner still grades
them CRITICAL (its azure-storage-key rule, and its aws-secret-access-key rule for a value assigned
to a SECRET_ACCESS_KEY name), but it does not scan git history.
Note: Infisical CLI 0.43.136 and later (the betterleaks engine) report less than earlier releases (#834). envdrift makes up for most of it:
- When
infisical scan --helplists--confidence, envdrift scans with--confidence low. The default,medium, hides lone AWS secret access keys, generic API keys and passwords, which only the engine's low-confidence generic rules report. Releases up to 0.43.135 reject the flag, so they never get it.- Betterleaks nests the secret access key of a paired AWS credential inside the key ID's finding. envdrift reports it as its own finding, and does the same for the few other nested values that are secrets (the Exoscale and Polymarket API secrets, the Polymarket passphrase, the OVH consumer key and the Alibaba STS security token). The other values betterleaks nests, such as Azure tenant and client IDs, usernames, account names and hosts, are not secrets, so envdrift does not report them.
Betterleaks still never reports an AWS access key ID that has no secret access key within a few lines of it: none of its built-in rules does, at any
--confidencelevel. So the auto-installed binary stays pinned to 0.43.135, the last gitleaks-engine release. Aninfisicalalready on yourPATHis used as-is, though: checkinfisical --versionif you installed it yourself.
--history, -H¶
Include git history in the scan. Requires a git repository and at least one active history-capable
scanner (gitleaks, trufflehog, kingfisher, git-secrets, talisman, or infisical). If none of the
active scanners can scan git history (for example --history --native-only), guard exits with an
error instead of silently skipping the history.
gitleaks, infisical and trufflehog scan the history of the whole repository that contains each
scanned path, a file or a subdirectory alike, once per repository, so --pr-base --history with many
changed files runs one history scan. A path outside any git repository, or in a repository with no
commits, is a scanner error for them, never a clean history: with no blocking finding, guard exits 5
(scan incomplete).
--entropy / --no-entropy, -e¶
Control entropy-based detection in the native scanner. By default (no flag,
no config) entropy detection runs on env files only. --entropy extends
it to every scanned file; --no-entropy disables it entirely, env files
included. The flags override check_entropy in envdrift.toml.
# Scan every file for high-entropy strings
envdrift guard --entropy
# Disable entropy detection completely (overrides check_entropy = true)
envdrift guard --no-entropy
--skip-clear / --no-skip-clear¶
Control whether .clear files are scanned. By default, .clear files ARE scanned.
Use --skip-clear to exclude them entirely.
# Skip .clear files from scanning
envdrift guard --skip-clear
# Explicitly scan .clear files (default behavior)
envdrift guard --no-skip-clear
--skip-duplicate / --no-skip-duplicate¶
Show only unique secrets by value, ignoring which scanner found them or where they appear. Useful when multiple scanners detect the same secret across multiple files.
# Show each unique secret only once
envdrift guard --skip-duplicate
# Show all findings including duplicates (default behavior)
envdrift guard --no-skip-duplicate
By default (--no-skip-duplicate), findings are deduplicated by file, line, rule, and
secret value: the same secret matched by the same rule at one location collapses to a
single finding, but two distinct secrets matching the same rule on the same line
(for example two AWS keys on one .env line) are both reported. Because the rule is
part of the key and each scanner namespaces its rule IDs, the same secret flagged by
two different scanners stays as two findings on this default path. --skip-duplicate
instead keys solely on the secret value, so each unique secret appears once regardless
of where or by which scanner it was found — that is the mode that collapses the same
secret across scanners.
Trivy reports matches with the secret already redacted to asterisks, so envdrift
recovers the raw value from the scanned file before hashing — two distinct secrets
that share a variable name and length stay distinct under --skip-duplicate, and a
secret found by both trivy and another scanner still collapses to one finding. When
the raw value cannot be recovered (for example the file changed mid-scan, the finding
spans multiple lines, or several secrets share one line so the mask boundary is
ambiguous), each finding instead keeps a synthetic hash qualified by its file, line
span, rule and a per-location occurrence index. Distinct findings therefore never
share a fallback hash — even the byte-identical findings trivy emits for two distinct
same-rule secrets on one line — but an unrecovered finding also cannot collapse with
the same secret reported by another scanner.
--skip-encrypted / --no-skip-encrypted¶
Skip findings from files that contain dotenvx or SOPS encryption markers. Enabled by default. Encrypted files contain ciphertext that can trigger false positives from scanners detecting high-entropy strings.
# Skip findings from encrypted files (default behavior)
envdrift guard --skip-encrypted
# Scan encrypted files too (may produce false positives)
envdrift guard --no-skip-encrypted
--skip-gitignored / --no-skip-gitignored¶
Skip findings from files that are in .gitignore. This uses git check-ignore for
reliable detection of ignored files. Useful for filtering out findings from build
artifacts, dependencies, or other generated files.
# Skip findings from gitignored files
envdrift guard --skip-gitignored
# Scan all files including gitignored ones (default behavior)
envdrift guard --no-skip-gitignored
Note: This feature uses git check-ignore when git is available and the scan is
run inside a git repository. If git is not installed or the repository check fails,
the tool will log a warning and continue by returning the original findings (no
git-based filtering will be applied). Paths exchanged with git are encoded and
decoded as UTF-8 on every platform, so non-ASCII filenames are matched correctly
regardless of the system locale (e.g. cp1252 on Windows).
--auto-install / --no-auto-install¶
Control auto-installation of external scanners.
With --no-auto-install, a missing scanner binary is handled by how the
scanner was selected. A scanner you selected explicitly — its CLI flag
(e.g. --gitleaks) or a [guard] scanners list written in your config —
fails closed: the failure is recorded and the run exits 5 (scan
incomplete). A scanner active only via the built-in default set is
skipped instead, with a visible warning (Scanners Skipped panel, stderr
warning) and a truthful skipped/skip_reason record in --json; the
exit code is unaffected, so guard --no-auto-install stays green on a
machine that simply doesn't have the optional binary.
Auto-installed scanner binaries are verified against the upstream-published
SHA256 checksums before they are installed or executed; the install fails
closed when the checksum is missing, unreachable, or mismatched. Set
ENVDRIFT_INSECURE_SKIP_CHECKSUM=1 to skip verification (unsafe).
git-secrets, which publishes no binaries, is built on Linux from the commit
pinned in constants.json instead (see --git-secrets).
--json, -j¶
Output results as JSON.
Each entry of scanner_results has name, files_scanned, duration_ms, error, skipped,
skip_reason and warnings. warnings lists the non-fatal notices of a scanner that ran, such as an
unexpected detect-secrets version; it is empty when there are none, and a warning never changes
exit_code. findings[].severity is critical, high, medium, low or info; how a finding is
graded is described under Severity.
--sarif¶
Output results as SARIF for code scanning tools.
Artifact URIs are emitted relative to the enclosing git repository root
(declared as the SRCROOT base via originalUriBaseIds), percent-encoded
per RFC 3986, no matter which directory guard runs from, so GitHub/GitLab
Code Scanning maps every alert to a repo file; a finding outside the
repository falls back to an absolute file:// URI. When the findings span
several repositories, the root is the repository guard runs in if it holds a
finding, and otherwise the first repository, by path, that does; the order
of the findings does not change it. Each result carries a
stable fingerprint built from the rule id, the location, and a truncated
hash of the secret value — never the matched text — so two different secrets
on the same line stay separate alerts. The redacted preview is attached as
the result's properties.secretPreview.
A failed scanner is an error entry of the invocation's toolExecutionNotifications, and the
non-fatal warnings of a scanner that ran (see --detect-secrets) are warning entries. A warning does
not change executionSuccessful or exitCode. A result's level is error for CRITICAL and HIGH
findings, warning for MEDIUM and note for LOW and INFO, and the security-severity of its rule is
9.0, 7.0, 5.0, 3.0 and 1.0 respectively, which is what GitHub ranks alerts by. A rule has one
security-severity, that of the most severe finding reported under it, so a Stripe test-mode key
(HIGH) is ranked with the 9.0 of the rule's live keys when both are found; its level stays per
result.
Upgrading from older releases
The fingerprint format changed in this release: existing GitHub Code Scanning alerts created from older envdrift SARIF uploads are re-keyed once (closed and re-opened as new alerts) on the first upload from this version.
--ci¶
CI mode: no colors, strict exit codes, and --fail-on threshold applied.
--fail-on¶
Minimum severity to return a non-zero exit code in CI mode. Accepts
critical, high, medium, or low. Any finding at or above the chosen
threshold fails CI with that severity's exit code (see Exit Codes)
— including --fail-on low on a LOW-only result, which exits with code 4,
distinct from HIGH's code 2. An unencrypted environment file is HIGH and exits
with code 2 when it blocks the run.
INFO findings are informational only and never fail CI. How a finding is graded critical, high
or medium is described under Severity.
--verbose, -v¶
Show scanner info and extra details.
--config, -c¶
Specify a config file path. If omitted, envdrift searches for envdrift.toml or
pyproject.toml.
--staged, -s¶
Scan only git staged files. Useful for pre-commit hooks.
The scan reads the staged index content (git show :<path>), not the working-tree copies, so a
secret that is staged but already edited away (or deleted) in the working tree is still caught
before the commit ships it. Findings are reported against the real repository paths. Running
--staged outside a git repository is an operational error (exit 6).
--pr-base¶
Scan only files changed since the specified base branch. Useful for CI/CD PR checks.
If the base ref cannot be resolved (typo, or a shallow CI clone that never fetched it), guard exits
with an error instead of treating the failed diff as "no changed files" — a misconfigured base must
never pass the secret gate green. A failed git fetch of the base is reported as a warning on
stderr.
Examples¶
Basic scan¶
Scan specific directories¶
Native-only scan (no external tools)¶
CI scan with SARIF output¶
Pre-commit hook (staged files only)¶
git exports GIT_DIR, GIT_INDEX_FILE and the like to its hooks: on every
commit in a linked worktree, and for git commit -a or git commit <path>,
where GIT_INDEX_FILE is the index git is about to commit. guard runs every
scanner without GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE and
GIT_COMMON_DIR, and so does the git it runs to find a scanned path's
repository. A scan from a hook reads the repository each scanned path is in, and
leaves the index git is committing alone. --staged still reads the staged
files from the index the hook was given, the one being committed.
CI/CD PR scanning¶
# In GitHub Actions, scan only files changed in the PR
envdrift guard --pr-base origin/main --ci --fail-on high
Scan git history for leaked secrets¶
Maximum detection with Kingfisher¶
# Kingfisher adds the Betterleaks and Veles rules and validates the secrets its rules can check
envdrift guard --kingfisher --gitleaks
Find password hashes in database dumps¶
# The native scanner finds bcrypt hashes, and sha512crypt hashes with no rounds= field and a
# salt of 8 to 16 characters (#934)
envdrift guard ./db/users.sql --native-only
Name each dump file. In a git checkout a directory scan skips the untracked files git ignores,
apart from env files (.env, .env.* and *.env, but not .env.keys), and dumps often are such
files. The native scanner also skips any file over 10 MiB without a warning
(#933).
Severity¶
Every finding has a severity. It decides the exit code (see Exit Codes) and whether
--fail-on blocks the run. The native, gitleaks, Infisical, detect-secrets, Kingfisher and
git-secrets scanners give a credential class the same severity. None of the last five reports a
severity of its own, so envdrift grades each of their findings by the class of credential it matched,
and the native scanner's grade for a class is the reference. trufflehog, trivy and talisman are the
exception, described below the table.
| Credential class | Severity |
|---|---|
| AWS access key IDs, secret access keys, session tokens and Bedrock API keys | CRITICAL |
| Azure storage account keys, Azure AD client secrets and Azure connection strings | CRITICAL |
| GCP service-account keys, application default credentials, OAuth client secrets and Google API keys | CRITICAL |
| IBM Cloud, IBM COS, Cloudant and SoftLayer credentials | CRITICAL |
| GitHub tokens: personal access, OAuth, app, refresh and fine-grained | CRITICAL |
| GitLab tokens: personal access, pipeline trigger, runner, deploy, session cookie and others | CRITICAL |
| npm and PyPI tokens, HashiCorp Vault tokens | CRITICAL |
| Private keys: PEM, OpenSSH, PGP, PuTTY, age and wallet keys | CRITICAL |
Password hashes (bcrypt, sha512crypt), .netrc credentials |
CRITICAL |
| Stripe live secret and restricted keys, Square and Braintree tokens | CRITICAL |
| Slack tokens and webhook URLs, Discord bot tokens and webhook URLs, Telegram bot tokens | CRITICAL |
| OpenAI, Anthropic, SendGrid, Mailchimp, Mailgun, Heroku, Shopify, DigitalOcean, Doppler, Postman, Linear, Supabase and Facebook keys and tokens | CRITICAL |
| Generic secrets: an API key, secret, token or password assigned to a value | HIGH |
| JSON Web Tokens | HIGH |
| Credentials in a URL: database, Redis and basic-auth URLs | HIGH |
Stripe test-mode keys (sk_test_, rk_test_), Twilio account and API key SIDs |
HIGH |
| Artifactory, Docker registry, Datadog, Atlassian, Algolia and Cloudflare credentials | HIGH |
| High-entropy strings | MEDIUM |
The table lists classes, not rule IDs. A class covers every rule, detector or pattern that finds it, in
every scanner that has one, and envdrift guard --json shows the rule ID of each finding. A test checks
the rule IDs of gitleaks, Infisical and Kingfisher against the table, so no credential rule of a provider
in a CRITICAL class is left out by mistake; the rules for an identifier or a public value (an Azure
tenant ID, a Discord client ID, a Mailgun public key) are the only ones of such a provider left at HIGH.
A rule that no class lists, such as one a newer tool adds, is HIGH, in every scanner but git-secrets
(below). A scanner without a rule for a class can still find its secrets, but only under a generic rule,
which is HIGH: gitleaks reports an Azure storage account key as generic-api-key, for example, where the
native scanner's own rule grades it CRITICAL. The native scanner also grades its policy findings itself:
an unencrypted environment file is HIGH, and a plaintext partial-encryption .secret file or a tracked
dotenvx .env.keys is CRITICAL.
Some grades are judgment calls or known gaps:
- A JSON Web Token is HIGH, never lower. Some are public by design (an OIDC ID token, a Supabase anon
key), but a bearer or session token and a Supabase
service_rolekey have the sameeyJ...eyJ...shape, and no rule can tell them apart, so a JWT that leaked is treated as one that matters. gitleaks, Infisical and detect-secrets have always graded it HIGH; the native scanner called it MEDIUM and now agrees with them. A JWT that is public on purpose can be kept out of the report with# envdrift:ignoreon its line or by path (see Handling False Positives). - A Twilio account SID or API key SID is HIGH. It identifies an account or a key; the secret is another
value. detect-secrets reports any
ACorSKfollowed by 32 lower-case letters or digits as a Twilio API key,CACHE_KEY=AC...included, which is why CRITICAL would raise false alarms at the top severity. - A credential in a URL is HIGH, except that the native scanner grades one in a GitHub, GitLab or Bitbucket URL CRITICAL. detect-secrets' basic-auth detector cannot tell the host, so it is HIGH for every URL.
- git-secrets reports a matching line, not a rule, so envdrift names the credential from the line. A
line with an AWS access key ID is CRITICAL, as is a line with a secret access key (the 40-character
value assigned to the AWS secret field itself, with
=,:or=>) or a private key (a-----BEGIN ... PRIVATE KEYheader, or a long base64 value assigned toprivate_keyitself). A line that only names a key it does not hold (private_key_path = ...) is HIGH at most, and a line that names no credential at all is MEDIUM: this is the one place where an unclassified match is not HIGH.
A grade is looked up by the scanner's own name for the credential: the native scanner's pattern ID, the
rule ID of gitleaks and Infisical (one shared table, because they share a rule set), the detector name of
detect-secrets, and the kind of line for git-secrets. Kingfisher's main catalog is betterleaks', the rule
set of Infisical 0.43.136 and later, and its rules are named betterleaks.<id>, so it looks those up in
the same table as gitleaks and Infisical. Its other rules (the veles. ones, and rules from a custom
rules file) are CRITICAL when a keyword of a CRITICAL class appears in the rule's ID or name, and HIGH
otherwise. A test holds each of those tables to the table above, so they cannot drift apart again.
trufflehog, trivy and talisman do not use the table. trufflehog grades a finding CRITICAL when it verified the secret with the provider and HIGH when it did not, and trivy and talisman pass the severity their own tool reports through, so they can grade a class differently from the six scanners above.
Stricter severities and exit codes
Older envdrift releases graded every gitleaks finding HIGH, and graded the same credential
differently in each scanner: the native scanner called a Heroku, Slack, OpenAI, SendGrid or Google
API key CRITICAL while Infisical called it HIGH, and detect-secrets called a private key HIGH where
the native scanner and Infisical called it CRITICAL. envdrift 11.0.7 and earlier also graded most of
Infisical's CRITICAL rules HIGH, AWS key IDs and Stripe live keys included
(#844). Every scanner now follows the table above
(#850), which changes what blocks a run. Most
changes make the gate stricter. Two lower a grade, a git-secrets line that only mentions asia and a
detect-secrets Twilio SID, and each gives the reason the old grade was wrong:
--fail-on criticalnow blocks a gitleaks, Infisical, detect-secrets, Kingfisher or git-secrets finding in a CRITICAL class. It used to pass an AWS key ID that only gitleaks found, a private key that only detect-secrets found, or a Slack token that only Infisical found.- A run whose worst finding is one of those now exits
1, not2. A pipeline that branches on the exit code2for such a credential must test for1; one that fails on any non-zero code is unaffected. - In
--json,findings[].severity,summary.by_severityandexit_codechange with the grades. In--sarif, thesecurity-severityof those alerts is9.0, not7.0. - The native scanner now grades Discord and Telegram bot tokens, Mailchimp API keys and bcrypt and
sha512crypt hashes CRITICAL, where it graded them HIGH, so that it agrees with detect-secrets and
Kingfisher. It grades a JSON Web Token HIGH, where it graded it MEDIUM, to agree with gitleaks,
Infisical and detect-secrets, which always did. A run without gitleaks (
--native-only,--no-gitleaks) therefore exits2on a JWT, where it used to exit3, or0with--ciat the default--fail-on high. - detect-secrets' keyword and Artifactory detectors are HIGH, not MEDIUM, and its private key detector is CRITICAL, not HIGH. git-secrets grades an AWS key CRITICAL, not HIGH, a private key CRITICAL, not MEDIUM, and a password, token or API key HIGH, not MEDIUM.
- Kingfisher grades its betterleaks rules like gitleaks and Infisical do, and its other rules HIGH unless a CRITICAL keyword names them, where it graded a rule that matched no keyword MEDIUM. A rule that names GitLab, OpenAI, Heroku, Discord or another provider of a CRITICAL class is CRITICAL.
- The shared gitleaks and Infisical table grades the credential rules it used to leave to the HIGH
fallback CRITICAL, for example the Amazon Bedrock API keys, Azure connection strings, GitLab session
cookies and age keys, so
--fail-on criticalblocks them too. - git-secrets tells an AWS access key by the key ID itself (
AKIA,ASIA,ABIA,ACCAorA3Tplus one more character and 16 more, 20 in all), no longer by the lettersakiaorasiaanywhere in the line.password=asia-northeast1is a password (HIGH) and not an AWS key, and a line such asregion=asia-south1that git-secrets flagged for another reason is MEDIUM, not HIGH. Likewise a line that only names a secret access key or a private key, without holding one, is HIGH at most. - One grade went down: a detect-secrets Twilio API key is HIGH, not CRITICAL, to match the native
scanner and every other scanner. The detector matches
ACandSKfollowed by 32 letters or digits, which are account and key SIDs, identifiers whose secret is another value (it reportsCACHE_KEY=AC...too), and it does not match a Twilio auth token at all. With--fail-on critical, a Twilio SID that only detect-secrets found no longer blocks the run.
Exit Codes¶
envdrift guard uses severity-based exit codes, plus dedicated codes for an
incomplete scan and for operational errors:
| Code | Meaning |
|---|---|
| 0 | No blocking findings |
| 1 | Critical findings |
| 2 | High findings (including unencrypted environment files) |
| 3 | Medium findings |
| 4 | Low findings |
| 5 | Scan incomplete: a selected scanner ran but failed |
| 6 | Operational error (bad config, invalid path or flags) |
Each severity has its own code so a pipeline branching on a specific exit code
(if [ $? -eq 2 ]) can tell them apart — a LOW-only result never collides with
HIGH's code 2, and a missing config file (6) never looks like a critical
secret (1).
Collection errors are operational errors and exit 6, reported as an error message instead
of findings: an unresolvable --pr-base ref, --staged outside a git repository, or
--history with no active history-capable scanner. These misconfigurations previously passed
with exit 0; a broken secret gate now fails loudly instead of green, and exit 6 (never 1)
keeps them distinct from a real critical finding for an exit-code-only pipeline.
With --ci, the --fail-on threshold controls what counts as blocking. A
finding at or above the threshold fails CI with the severity-derived code above;
anything below the threshold exits 0.
A run in which a selected scanner errored never reports the all-clear 0: if no
finding blocks the run (none found, or all below the --fail-on threshold) but
a scanner failed, guard exits 5, because the requested scan did not complete.
Blocking findings take precedence — a critical finding plus a scanner error
still exits 1. The scanner errors are listed in the human output (Scanner
Errors panel), in --json under scanner_results[].error, and in --sarif
as invocation toolExecutionNotifications.
A scanner fails when its binary reports an error, runs out of time, or writes a report envdrift cannot parse. A report cut short, a message where the report should be, or a finding whose fields envdrift cannot read leaves the findings unknown, so the run is not clean; the error names the scanner and the path it scanned, never the report, which may hold the secrets it found. A report that holds no findings is a clean scan: that is what the binaries write when they find nothing. So is no report at all after a successful run, except from detect-secrets, which always prints one.
The native scanner fails too when git cannot list some of the files in a
directory it scans: the untracked files, or the ignored .env files it always
checks. It still scans the files git did list, including the ones a listing
printed before it failed or timed out, except those its usual ignore rules skip.
Its error names the directory and why the listing failed: git's message, or that
it timed out.
A scanner still running 10 minutes after it started has failed: guard stops
waiting for it and records Scanner timed out after 600s as its error, the
other scanners' results stand, and with no blocking finding guard exits 5. At
most four scanners run at once, and each one's 10 minutes count from when it
starts. A scanner that timed out no longer counts toward the four. The tool it
was running (gitleaks, trufflehog, ...) is killed when its time is up, and guard
waits up to 5 seconds for that before it goes on, once for all the scanners that
time out together, as the scanners that start together do. The scanner starts no tool and
no download after that, so none of its tools keeps running after guard exits.
A scanner that finishes only after its time is up has timed out too, since a tool
it ran may have been cut short. A tool is killed together with the processes it
started, such as the git grep that git-secrets runs. Earlier releases killed
only the tool, and those processes ran on after guard had exited, until they next
wrote their output or their work was done
(#911). On Linux, so is a
process that a tool left running in the background when it exited, if it still
holds the tool's output open, as guard waits for that output
(#922). When guard cannot read
/proc on Linux, for example because it has run out of file descriptors, it still
kills the tool and the processes it got to before then, and logs a warning that it
could not kill every process the tool started.
Exit 5 applies to scanners you selected — a CLI flag or a [guard]
scanners list written in your config. A scanner active only via the built-in
default set whose binary is missing under --no-auto-install never ran and
never failed: it is skipped with a visible warning and a
scanner_results[].skipped/skip_reason record, and does not change the
exit code (see --auto-install above).
A scanner warning is neither a failure nor a skip. The scanner ran and completed, so its findings
stand and the exit code is unchanged, but they may be less reliable than they look (for example, the
installed detect-secrets is not the pinned version, see --detect-secrets). Warnings are listed in the
human output (Scanner Warnings panel), in --json under scanner_results[].warnings, and in --sarif
as warning entries of toolExecutionNotifications.
The machine-readable verdict fields always match the process exit code: the
--json document's exit_code/has_blocking_findings and the --sarif
invocation's exitCode/executionSuccessful are computed from the same
threshold-adjusted result the process returns.
Operational-error paths keep machine output parseable too — including a bad
or wrong-typed [guard] config value and git failures under
--staged/--pr-base. With --json, stdout is a {"error": "..."}
document; with --sarif, a schema-valid run with
executionSuccessful: false and the error as a tool notification.
Configuration¶
Guard settings live under [guard] in envdrift.toml or
[tool.envdrift.guard] in pyproject.toml.
[guard]
scanners = ["native", "gitleaks", "trufflehog", "detect-secrets", "kingfisher", "git-secrets", "talisman", "trivy", "infisical"]
auto_install = true
include_history = false
check_entropy = false # true = entropy scan on all files, false = off everywhere,
# unset = env files only (default)
entropy_threshold = 4.5
fail_on_severity = "high"
skip_clear_files = false # Set to true to skip .clear files entirely
skip_duplicate = false # Set to true to show only unique secrets by value
skip_encrypted_files = true # Set to false to scan encrypted files (default: skip)
skip_gitignored = false # Set to true to skip findings from gitignored files
ignore_paths = ["tests/**", "*.test.py"]
# Rule-specific path ignores (see Handling False Positives below)
[guard.ignore_rules]
"ftp-password" = ["**/locales/**", "**/*.json"]
"connection-string-password" = ["**/helm/**"]
Notes:
scannerscontrols which external scanners are enabled by default.skip_clear_filesskips.clearfiles entirely (disabled by default - they ARE scanned).skip_duplicateshows only unique secrets by value, ignoring scanner source and location.skip_encrypted_filesskips findings from encrypted files with dotenvx/SOPS markers (enabled by default).skip_gitignoredskips findings from gitignored files usinggit check-ignore.ignore_pathsapplies globally to all scanners.ignore_rulesallows ignoring specific rules in specific path patterns.- CLI flags override config values.
git-secretsis ideal for AWS-heavy environments.talismanexcels at entropy and encoded content detection.trivyprovides comprehensive multi-target scanning.infisicalsupports 140+ secret types with git history scanning.
Handling False Positives¶
Envdrift provides a centralized ignore system that works across ALL scanners (native, gitleaks, trufflehog, detect-secrets, kingfisher, git-secrets).
Built-in Default Ignores¶
Config and lock files (pyproject.toml, envdrift.toml, mkdocs.yml, *.lock,
package-lock.json, *-lock.json, *.sum, ...) only have noisy findings
suppressed by default: rules whose ids contain generic, entropy, or keyword,
which routinely false-positive on "secret"-keyword config keys and integrity hashes.
Distinctive-prefix detections (github-pat, aws-access-key-id, pypi-token, ...)
are never suppressed by the defaults — a real token committed in pyproject.toml or
a lock file is still reported. User-configured ignore_paths suppress everything in
the matching paths.
Inline Ignore Comments¶
Add comments directly in your source files:
# Ignore all rules on this line
password = ref(false) # envdrift:ignore
# Ignore a specific rule only
SECRET_KEY = "test-key" # envdrift:ignore:django-secret-key
# Ignore with a reason (recommended for maintainability)
API_KEY = "xxx" # envdrift:ignore reason="test fixture"
Supported comment formats:
# envdrift:ignore- Python, Shell, YAML// envdrift:ignore- JavaScript, Go, C, TypeScript/* envdrift:ignore */- CSS, C-style block comments
TOML Configuration¶
For bulk ignores across many files:
[guard]
# Skip entire directories
ignore_paths = [
"**/tests/**",
"**/fixtures/**",
"**/locales/**",
]
# Ignore specific rules in specific paths
[guard.ignore_rules]
"ftp-password" = ["**/*.json"] # Matches translation "Mot de passe"
"django-secret-key" = ["**/test_settings.py"]
A pattern in ignore_paths or ignore_rules that does not start with /, // or a Windows drive (C:/) is relative.
For a file inside the directory guard runs in, it is matched against the file's paths relative to that directory: the
path guard shows when that is inside the directory, and the path of the file's real location, with symlinks resolved,
when that is inside it. Each is also matched as if it started with /, so **/tests/** matches tests/test_app.py,
and by any trailing part (config/settings.txt matches deploy/config/settings.txt). A file is inside when one of those
paths is. A path that runs through another spelling of the directory is inside it too, though guard shows it absolute
(an absolute path through macOS /tmp, see How paths are shown). The directories above the
directory guard runs in never take part for such a file: **/build/** does not hide a checkout that lives under a
build directory, and ../repo/** run from repo matches nothing inside it. So a relative pattern gives one verdict for
a file inside that directory, whichever scanner reports it and however you spell the scan path: envdrift guard,
envdrift guard ., envdrift guard proj and envdrift guard "$PWD/proj".
A relative pattern without a path separator (node_modules, proj, *.env, .env*) names a directory or a file
wherever it is, like a bare name in .gitignore. It is matched against each part of the path on its own, so
node_modules hides everything below a node_modules directory at any depth, for every scanner. It is not matched
against the whole path, where * crosses /: [!.]*.test does not hide apps/web/.env.test because of the first
letter of apps. Add a / to match a path instead (proj/**, **/node_modules/**). Earlier releases matched such a
pattern against the whole path or a trailing part only, in the filter that runs on the findings of every scanner, while
the built-in scanner also skipped a file when a part of its path matched. So proj hid proj/sub/app.env from the
built-in scanner and not from gitleaks or Infisical (#865); it now
hides it from all of them, and [!.]*.test no longer hides a nested dotfile such as apps/web/.env.test from any.
A file outside the directory guard runs in is shown absolute, and a relative pattern is matched against that absolute
path, the path the scanner reported, and the absolute path of its real location, each with any trailing part, so the
directories above the checkout do take part. ../config/* matches the ../config/settings.txt every scanner reports
under --pr-base run from a subdirectory, and **/build/** hides that file there when the checkout lives under a build
directory. A pattern without a separator is the exception: it is matched against the parts of the file's path below
a scan path that holds it (a file you name yourself has only its own name), never against the directories above the
scan path, so build does not hide envdrift guard ../build/repo run from a sibling directory. With several scan
paths the file is hidden only if your patterns hide it below each scan path that holds it, so adding a scan path
never hides what another reports. What a scanner reports for such a file depends on how the scan path is spelled, so
a path pattern can give different verdicts for it: write patterns for the directory guard runs in, and run guard from
there.
An absolute pattern, such as /home/me/repo/tests/**, C:/repo/tests/** or //server/share/repo/**, is matched against
a file's whole absolute path: the directory guard runs in joined with the path guard shows, the path the scanner reported
when that is absolute, and its real location with symlinks resolved. Write it with symlinks resolved, since the real
location is always among those paths and a path through a symlink only when you spell the scan path that way: on macOS a
checkout under /tmp is under /private/tmp. Write patterns with / on every platform: Windows paths are compared with
their \ turned into /, though a pattern that starts with \ or C:\ is read as absolute too.
A file scanned through a symlink matches a relative pattern for the link name when the link is inside the directory guard
runs in, and one for its real location when that is inside it; scanned through its real location it matches only the
latter, so name the real location when you scan both ways. Under --staged a pattern matches the staged file, not the
temporary copy guard scans: a staged file outside the directory guard runs in (--staged run from a subdirectory) is
matched by its path in the repository, such as config/app.env, with any trailing part and as if it started with /,
and by its absolute path. Inline comments are read from the file itself.
envdrift 11.1.0 and earlier also matched a relative pattern against the directories above the directory guard runs in.
They used the absolute paths that the built-in scanner reports, as does every scanner given no path or an absolute one,
and under --staged the path of the temporary copy. So **/build/** hid every finding of a checkout under a build
directory, and tmp/** every staged finding where the temporary directory is /tmp. Those findings are reported now.
Under --staged run from a subdirectory, a staged file inside it is now matched by its path below that directory, like
any other file there, and no longer by its path in the repository (sub/app.env for app.env run from sub).
11.1.0 also matched a pattern that starts with / against the path guard shows taken as if it started with /, so
/proj/** matched proj/ in the directory guard runs in; such a pattern is now an absolute path, so write proj/**,
which also matches a proj directory deeper down. An absolute pattern used to match a file inside the directory guard
runs in only when a scanner reported the file by its absolute path. It now matches the file's absolute path whatever a
scanner reported, so it also hides findings that 11.1.0 reported: those of gitleaks and Infisical under
envdrift guard . or envdrift guard proj, and staged files under --staged. An ignore_rules pattern with a
character class, such as [ab].env, now matches a trailing part of a path (x/a.env), as an ignore_paths pattern
always did. If you relied on a directory above the one guard runs in, name it in an absolute pattern, or run guard from
the directory your relative patterns are written for: guard also reads an envdrift.toml in a parent directory, but
matches its relative patterns below the directory it runs in, where a pattern that starts with **/ matches at any
depth.
gitleaks and Infisical ignore files¶
gitleaks and Infisical also read their own ignore files, .gitleaksignore and .infisicalignore.
Each line is a fingerprint, <file>:<rule-id>:<line>, as the binaries print it in their own reports.
gitleaks also accepts <commit>:<file>:<rule-id>:<line> for a history finding. envdrift runs both
binaries in the directory guard runs in and passes each path as you gave it, so <file> names the
file the way gitleaks dir <path> run by hand from that directory does:
| How guard is run | <file> in the fingerprint |
|---|---|
envdrift guard ., envdrift guard <dir>, envdrift guard <file> |
Relative to the directory guard runs in, e.g. config/settings.txt |
envdrift guard --pr-base <ref> |
Relative to the directory guard runs in: repo-relative from the repository root, ../config/settings.txt from a subdirectory |
envdrift guard with no path |
Absolute: guard passes the absolute path of the directory it runs in |
envdrift guard <absolute path> |
Absolute |
--history |
Relative to the repository root |
Where each binary looks for its ignore file:
| Scanned path | .gitleaksignore (gitleaks) |
.infisicalignore (Infisical) |
|---|---|---|
| A directory | The directory guard runs in, and the scanned directory | The scanned directory only |
A file, including every --pr-base file |
The directory guard runs in | None |
--history |
The repository root | The repository root |
So under --pr-base run from the repository root, a repo-root .gitleaksignore with repo-relative
fingerprints covers a changed file at any depth; run from a subdirectory, gitleaks reads that
directory's .gitleaksignore instead. Infisical reads no ignore file when it scans a single file, so
.infisicalignore never applies under --pr-base. Under --staged both binaries scan temporary
copies of the staged files: their fingerprints name those copies, so no ignore-file entry matches; use
inline comments or the [guard] ignores instead. A symlink to a file is scanned through to its
target, and its fingerprint names the target. A directory path in which .. follows a symlink is
passed in its physical form, which its fingerprints name.
The same paths are what the scanner configuration in a scanned directory sees: both binaries read
.gitleaks.toml or .infisical-scan.toml from the directory they scan and match its allowlist paths
against <file> as above. For envdrift guard proj that is proj/sub/app.env, so ^proj/sub/app\.env$
matches it and ^sub/app\.env$ does not. An unanchored (^|/)sub/app\.env$ matches either way.
envdrift guard, envdrift guard . and --history read the same ignore files, with the same
fingerprints, as in earlier releases. envdrift 11.0.8 ran both binaries inside the scanned directory,
or inside a scanned file's directory. gitleaks read only the .gitleaksignore there. Infisical read
the .infisicalignore of a scanned directory and, as it still does, none for a scanned file. The
fingerprints of a relative directory or file path, including every --pr-base file, were relative to
that directory (settings.txt:github-pat:1 for config/settings.txt), and so were the paths a
scanned directory's allowlist matched. Rewrite such entries relative to the directory guard runs in,
and put gitleaks entries for a scanned file in the .gitleaksignore there, the only one gitleaks
reads for a file. Fingerprints of absolute paths have not changed.
Common Rule IDs¶
| Rule ID | What It Detects |
|---|---|
aws-access-key-id |
AWS access key (AKIA...) |
aws-secret-access-key |
AWS secret key |
github-pat |
GitHub PAT (ghp_) |
github-oauth |
GitHub OAuth token (gho_) |
github-app-token |
GitHub App token (ghu_/ghs_) |
django-secret-key |
Django SECRET_KEY |
laravel-app-key |
Laravel APP_KEY |
connection-string-password |
DB connection string password |
ftp-password |
Password in JSON config |
high-entropy-string |
High entropy value |
unencrypted-env-file |
.env without encryption |
unencrypted-secret-file |
partial-encryption .secret file left plaintext |
committed-private-key |
dotenvx .env.keys tracked/staged in git |
Use --verbose or --json to see rule IDs for your findings.
See the Guard Scanning Guide for more details and examples.