README
¶
confessecrets
Every config file has something to confess.
A secret scanner for structured configuration files that gets your configs to spill their guts. It walks a file or directory, hears out every confession, and reports likely secrets as newline-delimited JSON (NDJSON).
It coaxes out secrets two complementary ways:
- Name-driven — a key whose name practically advertises guilt (e.g.
password,api_key,client_secret) paired with a populated, secret-looking value. - Value-driven — a value whose shape gives it away (gitleaks-style patterns
such as
AKIA…,ghp_…,sk_live_…), no matter how innocent its key name claims to be.
Supported formats: JSON / JSONC, YAML, XML (including .NET
App.config / web.config and other .config files), dotenv
(.env, .env.*, *.env), Java properties (.properties), and
INI (.ini).
It also scans source code — Python, JavaScript, TypeScript, Go, Java, C#,
Ruby, PHP, Kotlin, and Rust — with tree-sitter, so it can tell a hardcoded secret
from a runtime lookup.
password = "hunter2" confesses; password = os.environ.get("SECRET") does not,
because the value is a call, not a string literal. See
Scanning source code.
Requirements
- Go 1.26+ (see
go.mod)
Build
# Build a binary into ./confessecrets
go build -o confessecrets ./cmd/confessecrets
# Or install it onto your PATH (into $GOBIN / $GOPATH/bin)
go install github.com/rezen/confessecrets/cmd/confessecrets@latest
Releasing
Stamp the commit and build date into the binary via -ldflags so they show up
in version.String() (the Number const is bumped in the source — see
pkg/version/version.go):
go build -ldflags "\
-X github.com/rezen/confessecrets/pkg/version.Commit=$(git rev-parse --short HEAD) \
-X github.com/rezen/confessecrets/pkg/version.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o confessecrets ./cmd/confessecrets
Run
The scanner is the cmd/confessecrets package — run the package, not a single
file:
# From source
go run ./cmd/confessecrets -config config.yaml -path ./path/to/scan
# Or with the built binary
./confessecrets -config config.yaml -path ./path/to/scan
Note:
go run main.gowill fail — the program is split across several files in the package. Usego run ./cmd/confessecrets(orgo run .from inside that directory).
Findings are written as NDJSON to stdout by default. Redirect or tee to save:
go run ./cmd/confessecrets -path ~/repos | tee found.txt
Flags
| Flag | Default | Description |
|---|---|---|
-config |
config.yaml |
Path to the scanner config |
-path |
. |
File or directory to scan |
-output |
- |
Output file, or - for stdout |
-repo-config |
true |
Respect repo-local config at repo roots (=false off) |
-scan |
all |
What to scan: all, source (only source code), or config (only structured config, omit source code) |
-show-filtered |
false |
Keep findings excluded by a filter rule, marked filtered: true with a filtered_reason, instead of dropping them |
Exit codes
| Code | Meaning |
|---|---|
0 |
Scan completed, no findings |
1 |
Scan completed, findings written |
2 |
A fatal error occurred |
This makes it CI-friendly: a non-zero exit fails the job the moment something confesses.
Output
One JSON object per confession, per line. Values are redacted — what's said in the confessional stays in the confessional — but the SHA-256 of the raw value is included so you can correlate without storing the secret itself.
{
"file": "app/config.py",
"line": 12,
"lang": "python",
"level": "high",
"name_path": "value_pattern",
"value_path": "value_pattern",
"name": "ci_token",
"value": "ghp_********wxyz",
"raw_value": "ghp_0123456789abcdefghijklmnopqrstuvwxyz",
"value_sha256": "6675cd0c…",
"entropy": 4.71,
"name_value_distance": 38,
"reason": "gitleaks:github-pat",
"tags": ["value-pattern"]
}
Every finding carries a lang field naming the source language or config format
it came from (e.g. python, json, dotenv), and a level: high for a
detected secret (the default) or info for an informational match such as a
recognized service URL (info:azure-app-service, info:aws-lambda-url). For the
line-oriented formats and source code the 1-based line is set and the redundant
path is omitted; structured documents (cleanly parsed JSON/YAML/INI) instead
carry a path locator like $.db.password and omit line. A value-shape match
in a named assignment keeps that name (e.g. ci_token above), not an empty one.
The tags array holds the id of any tag-action filter the
finding matched and any correlation tag. It is omitted when a finding has no tags.
Every finding carries the entropy field: the Shannon entropy (bits/symbol) of
the raw value, rounded to two decimals — handy for triage and tuning the
min_entropy / high_entropy_threshold thresholds.
name_value_similarity scores how closely the value resembles its key name, in
[0,1] where 1 is identical. It is the max of normalized Levenshtein and
Jaro-Winkler similarity (both case-insensitive) — Jaro-Winkler rewards the shared
prefixes typical of placeholder mutations (secret/secrets,
passwd/passw0rd). A high score flags a value echoing its key; a low one
points to a genuine opaque secret. Set the rule's max_name_value_similarity to
drop name-driven findings at or above a chosen similarity (0 disables it).
The reason field explains why it was flagged — e.g. jwt_indicator,
url_credentials, private_key_indicator, name_indicates_secret for a
name-driven finding, or gitleaks:<rule-id> for a value-pattern match. Findings can also carry an
optional meta object with value-derived context:
jwt— for JSON Web Token values: the decodedheader, the parsed claims (issuer,iat,expiration,is_expired), and any remaining claims underextra.username,host,url— derived from URL credentials (user:pass@host) and connection strings.client_id,client_key— derived from connection strings and from correlated partner findings (e.g. theclient_idpaired with aclient_secret).
All meta fields are optional and omitted when absent.
A finding may also carry a correlated array holding partner findings folded into
it by a correlation rule (e.g. the client_id paired with this client_secret).
Correlated partners are always in the same file as their primary, so each embedded
entry omits the redundant file field and keeps only its in-file location (path).
Scanning source code
Beyond structured config, confessecrets scans source files in Python, JavaScript/TypeScript, Go, Java, C#, Ruby, PHP, Kotlin, and Rust. It parses each file with tree-sitter and inspects the syntax of each assignment, which lets it avoid the classic false positive that trips up regex-only scanners:
password = "hunter2supersecret" # flagged — value is a string literal
api_token = os.environ.get("API_TOKEN") # not flagged — value is a runtime lookup
secret = f"sk_{var}" # not flagged — interpolated, not a literal
Detection covers several shapes:
- name-driven — a secret-looking variable assigned a string literal
(
password = "…"). - value-driven — any string literal whose shape matches a gitleaks pattern,
regardless of the surrounding name (
"AKIA…","ghp_…"). - env fallback — a hardcoded default behind an environment/config lookup,
whether passed as an argument (
os.getenv("DB_PASSWORD", "hunter2")) or via a logical default (process.env.API_KEY || "fallback",GetEnvironmentVariable(…) ?? "fallback"). - call argument — a secret passed into any call when the assignment target
signals a secret (
password = vault.fetch("key", "s3cr3t!")).
Because it reads the syntax tree, it skips what regex scanners get wrong: runtime
lookups (the value is a call), interpolated/dynamic strings (f-strings, template
literals), and comparisons (process.env.MODE == "prod" is not a default). Call
arguments and defaults are gated more strictly to avoid flagging prompts and
labels (e.g. getpass("Enter password: ")).
No setup required
Parsing uses a pure-Go tree-sitter runtime
(gotreesitter) with the grammars
embedded in the binary. There is nothing to install, download, or compile —
no libtree-sitter, no per-language grammar libraries, no C toolchain. The build
stays CGO-free, so confessecrets cross-compiles to any GOOS/GOARCH from a
single machine (CGO_ENABLED=0).
To actually scan source files, add their globs to the config allow list (e.g.
"**/*.py", "**/*.go"); the default config.yaml already includes them.
Note:
.tsxis parsed with the TypeScript grammar, which does not understand JSX; embedded-JSX files may parse partially.
Configuration
The config (default config.yaml) decides whose confessions you hear — which
files are scanned and what actually counts as a secret worth flagging.
files:
allow: # glob patterns to scan (doublestar syntax)
- "**/*.json"
- "**/*.yaml"
- "**/*.yml"
- "**/.env"
- "**/.env.*"
- "**/*.env"
- "**/*.xml"
- "**/*.properties"
- "**/*.ini"
deny: # glob patterns to skip (checked before allow)
- "**/test/**"
- "**/.git/**"
- "**/node_modules/**"
rules:
- name_paths: [name, key, field] # keys that may name a secret (structured)
value_paths: [value, val, secret] # sibling keys holding the value
# A name matching ANY entry signals a secret. Each entry is either a bare
# regex string or a {name, regex} mapping (the name is a label for the rule).
name_regexes:
- '(?i)(secret|token|api[_-]?key|password|credential|auth)'
- name: camelcase-key
regex: '(?-i:[a-z0-9]Key([A-Z0-9_]|$))'
min_value_len: 8 # default 8 when omitted
min_entropy: 2.0 # gate: drop low-variety placeholders (0 = off)
high_entropy_threshold: 0 # flag any opaque value this random (0 = off)
max_name_value_similarity: 0.85 # drop values this similar to the name (0 = off)
# Names matching any of these are never treated as secrets, even if they
# match a name pattern (e.g. "label"/"labelKey").
ignore_name_patterns:
- '(?i)(label|text|title|description)'
# Values starting with these prefixes are ignored (vault refs, placeholders…).
ignore_value_prefixes:
- vault://
- ${
# Values matching these regexes are ignored.
ignore_value_patterns:
- '^ENC\[.*\]$'
- '^arn:aws:secretsmanager:'
# Extra stopwords applied across all rules, on top of the built-in set (see below).
stopwords:
- redacted
- internalfixture
Value-pattern (gitleaks) scanning is built in and always on; it honors
ignore_value_prefixes / ignore_value_patterns so you can suppress
false positives.
Two optional entropy controls measure the Shannon entropy (bits/symbol) of a value to catch what the name and shape rules miss:
min_entropyis a gate: a value flagged only because its key name looks secret-y must clear this threshold, so repetitive placeholders (aaaaaaaa) are dropped. Values with a definite secret reason (JWT, private key, URL credentials) bypass the gate.high_entropy_thresholdis a detector: any opaque token-like value (whitespace-free, ≤200 chars) whose entropy meets it is flagged regardless of key name, reported ashigh_entropy:<measured>. Built-in gitleaks and custom detectors take precedence, and ruleignore_*suppressions still apply. It is noisy when scanning source code (many string literals sit near the secret range), so it ships disabled; enable it (e.g.4.5) for config-only scans (-scan config). Keep it above ~4.0, where hex digests and UUIDs sit.
high_entropy_threshold defaults to 0 (disabled); min_entropy is a pure
filter and is safe to leave on.
A value that merely echoes its key name — password="password",
api_key="your-api-key", token="TOKEN", secret="<my-secret>" — is treated
as a placeholder and dropped automatically. The comparison ignores case,
separators, camelCase, and common filler words ("your", "my", "example", …), so
these obvious fakes never count as findings.
A value that is, or embeds, a variable/template placeholder is likewise
dropped — the real secret is substituted in later, so the literal text is not a
credential. The brace/paren forms ${DB_HOST}, $(secret), and {{ db_host }}
are recognized anywhere in the value (e.g. password=${PW} in a connection
string); the single-character @DB_HOST@ and %DB_HOST% forms are recognized
only as a whole value, since @ and % also occur inside genuine secrets.
A name-driven candidate is also dropped when its value contains a stopword —
a common word or placeholder fragment that marks it as a non-secret. The built-in
set is gitleaks'
DefaultStopWords,
matched the same way gitleaks does: case-insensitively, by substring (a value
containing changeme, example, test, … anywhere is skipped). The built-in
set is always on; add project-specific entries with the top-level stopwords
list, which applies across all rules (also matched case-insensitively by
substring). Values carrying a definite secret
reason (JWT, private key, URL credentials) and built-in gitleaks value patterns
are matched before this check, so a real token is never lost to a stopword.
Custom value-pattern detectors
Beyond the built-in gitleaks patterns, you can define your own value-shape
detectors using trufflehog's custom-detector schema.
Each detector flags a value by its shape alone — regardless of the surrounding
key name — and matches are tagged custom:<name> in the reason field.
detectors:
- name: acme-api-key
keywords: # at least one must appear in the value or its key name
- acme # (case-insensitive); omit for an always-on detector
regex: # every named regex must match the value
key: 'AKME-[0-9a-f]{32}'
primary_regex_name: key # which regex supplies the reported value
exclude_regexes_match: # drop matches whose value matches any of these
- '^AKME-0+$'
exclude_words: # drop a candidate when any of these is present
- example
entropy: 3.0 # require this minimum Shannon entropy (bits/symbol)
A detector fires when at least one keyword is present (in the value or its key
name) and every named regex matches the value. When a regex defines a capture
group, its first group is the reported secret; otherwise the whole match is.
primary_regex_name (optional) selects which regex's match is reported and
entropy/exclude-checked, defaulting to the alphabetically first. The
exclude_* and entropy fields are optional false-positive filters. Custom
detectors honor the same ignore_value_prefixes / ignore_value_patterns
suppressions as the built-in patterns, and a built-in gitleaks match takes
precedence over a custom one for the same value.
Notes:
- Detection is per value: a multi-regex detector requires every regex to match the same scalar value (most custom detectors use a single regex).
- Live HTTP
verifyendpoints from trufflehog's schema are not supported — confessecrets is an offline scanner that redacts values rather than sending them anywhere — so that field is ignored if present.
Custom filters
The top-level filter is a list of filter rules, each an
expr-lang expression evaluated against every finding.
When a rule's expression is true for a finding, its action decides what
happens:
filter(the default) — drop the finding. This is the suppression behavior: silence whole classes of false positives by their computed properties.tag— keep the finding and add the rule'sidto itstags. A flexible way to label findings (e.g. for downstream triage) without removing them.
A rule is written either as a bare string (an expression with the default
filter action) or as an {id, action, filter} mapping:
filter:
# Bare string → filter action: drop low-entropy values whose name echoes the value.
- 'entropy <= 4 && name_value_similarity > 0.65'
# Mapping → tag action: keep value-pattern hits, tagged "value-pattern".
- id: value-pattern
action: tag
filter: 'reason startsWith "gitleaks:"'
Every rule runs against every finding, so one finding can be tagged by several
rules; the first filter-action rule to match drops it. A tag rule must carry
an id (the tag it applies). Leave the list empty to disable filtering.
The variables available to an expression are:
| Variable | Type | Meaning |
|---|---|---|
entropy |
number | Shannon entropy of the value (bits/symbol) |
name_value_similarity |
number | name/value similarity, 0..1 |
value_length |
number | length of the raw value in bytes |
name |
string | the key name |
value |
string | the raw (unredacted) value |
reason |
string | why the value was flagged |
file, path, name_path, value_path |
string | location fields |
expr-lang operators and built-ins work too, so richer rules like
value matches "(?i)example$", name contains "test", or
reason startsWith "gitleaks:" are valid. Each expression is type-checked at load
time, so a bad filter fails fast.
To see what the filter-action rules are removing, run with -show-filtered:
excluded findings are kept in the output with "filtered": true and a
"filtered_reason" holding the matched expression, rather than being dropped.
Filtered findings are informational and do not affect the exit code, so a scan
whose only findings are filtered still exits 0.
Repo-local config
When the scan descends into a repository root — a directory containing a
.git entry (a normal clone, or a .git file for worktrees/submodules) — the
scanner looks for a repo-local config there and uses it for every file in that
repository. The file names checked, in order, are:
.confessecrets.yaml
.confessecrets.yml
This lets each repository carry its own allow/deny globs and rules (e.g. an
internal repo that wants stricter rules, or one that needs extra
ignore_value_* entries). A repo-local config has the same shape as the main
config and fully replaces the base config for that repository's files.
Semantics:
- A repository boundary is respected: a repo without its own config uses
the base
-config, even if a parent repository defines one. Nested repos use the config of their nearest enclosing repository. - A repo-local config that fails to load or compile is reported to stderr and skipped — the scan continues with the base config for that repo.
- Pass
-repo-config=falseto ignore repo-local configs entirely and apply the base-configeverywhere.
Test
go test ./...
go vet ./...
Sample golden-file tests
TestSamples (in pkg/scanner/samples_test.go) is a golden-file suite driven by
the samples/ directory. Every sample (e.g. samples/function_url.py) has a
sibling .verify file (samples/function_url.verify) holding the exact NDJSON
the scanner should emit for it — one JSON finding per line. The test scans each
sample with the repo's config.yaml and asserts the output matches its .verify
byte for byte.
# Run just the sample suite (one subtest per sample)
go test ./pkg/scanner/ -run TestSamples -v
To add a case, drop in a sample file plus its .verify; the test picks it up
automatically. To seed a new sample's .verify — or refresh expectations after
an intended behavior change — regenerate the golden files and review the diff:
go test ./pkg/scanner/ -run TestSamples -update
Project layout
cmd/confessecrets/ CLI entry point (flag parsing, output)
pkg/scanner/ library: config, file walking, detection
models.go types (Config, Rule, RuleSet, Finding, Meta, Detector…)
files.go config loading/compiling, file walking/filtering, format dispatch
detect.go per-format detectors + classification helpers
patterns.go gitleaks-style value patterns
detectors.go custom (trufflehog-style) value-pattern detectors
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
confessecrets
command
|
|
|
generate/patterns
command
Command patterns derives value-shape secret patterns from a gitleaks config (gitleaks.toml) and emits a generated Go source file for package scanner.
|
Command patterns derives value-shape secret patterns from a gitleaks config (gitleaks.toml) and emits a generated Go source file for package scanner. |
|
pkg
|
|
|
version
Package version is the single source of truth for the confessecrets release version.
|
Package version is the single source of truth for the confessecrets release version. |