sec(gitleaks): a low-entropy defaulted credential passes both required secret gates (BACKLOG #1091) - #545
sec(gitleaks): a low-entropy defaulted credential passes both required secret gates (BACKLOG #1091)#545wshallwshall wants to merge 5 commits into
Conversation
…BACKLOG #1091)
The two required secret gates share a blind spot over most of the places a credential
would actually live. bandit is a Python AST scanner and does not parse .yaml or .ps1 at
all. gitleaks' operative generic rule is ENTROPY-GATED, so `changeme`, a dev default or a
short site code falls below the threshold and is never reported.
SEVERITY, STATED PRECISELY: this is NOT a live exposure. Nothing is deployed and the
tracked values are development defaults. The defect is that the CONTROL CANNOT SEE THE
CLASS, so the next credential -- added by someone trusting the gate -- lands the same way.
THE ENV INDIRECTION IS NOT THE DEFECT, THE FALLBACK IS. `${MEFOR_STORE_PASSWORD}` is
correct usage and must never match. `${MEFOR_STORE_PASSWORD:-mefor-dev-password}` ships
that literal the moment the variable is unset, which on a first deployment is exactly what
happens. The rule keys on that shape and is deliberately entropy-free.
MEASURED IN BOTH DIRECTIONS against gitleaks 8.30.1:
FIRES docker/compose.yaml:158, the tracked demonstration
FIRES a fresh ${REAL_SECRET:-changeme}, in .yaml and in .ps1
SILENT ${VAR} with no fallback -- correct usage
SILENT .github/workflows/ -- NINE `id-token: write` lines live there. Those are GitHub
PERMISSIONS, not credentials, and a naive `token\s*[:=]` rule reddens a REQUIRED
gate nine times on its first run, which is how a control gets disabled not fixed.
DIFFERENTIALLY VERIFIED: against the real tree this adds EXACTLY ONE finding. The repo
config alone already reports one pre-existing hit elsewhere that is not this rule's.
THE TESTS RUN THE SCANNER RATHER THAN READING THE CONFIG. A text assertion that a rule is
PRESENT cannot tell a working rule from a regex that matches nothing -- the same "control
that cannot fire" defect BACKLOG #1313 found in the sdist leak gate. Each test builds a
fixture tree and runs real gitleaks against the real config, and a config gitleaks REFUSES
to load fails the test loudly instead of reading as "no findings".
THE ALLOWLIST TEST CAUGHT A REAL DEFECT BEFORE IT SHIPPED. gitleaks reports the FIRST
CAPTURE GROUP as `Secret` and matches allowlist regexes against it. With the key name
captured, every finding reported `Secret: "password"` -- useless in a report, and it
silently broke the allowlist, since no placeholder regex can match the literal word
"password". The key-name group is now non-capturing and `secretGroup` names the default
value, so a finding reports `mefor-dev-password`.
AND THE RULE BLOCKED THE COMMIT THAT INTRODUCED IT. The pre-commit hook fired three times
on the test fixtures, which ARE the detected shape by design -- the same class as BACKLOG
#1086, where rule 3c refuses the commit documenting it. Resolved with the NARROWEST
exemption that works: a `paths` entry scoped to THIS rule and THIS file, so the file stays
covered by every other rule and a real high-entropy credential dropped into it is still
caught. Widening the pattern to miss the fixtures would have bought the same convenience by
making the rule worse. Verified narrow: the rule still fires on docker/compose.yaml:158.
Two traps measured on the way: `[[rules.allowlist]]` builds an ARRAY and gitleaks 8.30
refuses the whole config with "expected a map, got 'slice'" -- loudly, which is correct for
a scanner. And `--no-git --source <dir>` scans the DIRECTORY, not the tracked tree; a first
run attributed ten .venv third-party findings to this change until the baseline was run
separately.
…ret gates cannot see (BACKLOG #1091)
The rule added in this branch fires on docker/compose.yaml, which is the rule
working: `${MEFOR_STORE_PASSWORD:-mefor-dev-password}` ships its literal the
moment the variable is unset, and neither required gate can see it -- bandit
does not parse yaml, and gitleaks' generic rule is entropy-gated.
Fixed rather than allowlisted. Exempting the only instance the rule finds would
restore, for exactly that instance, the blindness the rule exists to remove --
and an allowlist entry written in the same commit as the rule is indistinguishable
later from a rule that never worked.
`:?` rather than a bare `${VAR}`: bare substitution passes an EMPTY password and
postgres fails with a confusing error, while `:?` refuses to start and names what
to supply.
The error message names the MECHANISM, because the message this replaces did not.
The line already on main read "override via the env_file / your secret store" for
a service that has no env_file -- and compose substitution is not fed by env_file
at all. env_file sets variables inside the container; `${VAR}` is resolved at parse
time from the shell environment or a .env beside compose.yaml. A reader who followed
the old comment would have done exactly what it said and seen no change.
Measured with gitleaks 8.30.1 against this branch's own config, both directions:
a `:-` fallback fixture still fires (the rule is intact), and compose.yaml no
longer appears. The one remaining finding is a pre-existing generic-api-key hit in
docs/testing/master-test-plan/09-engine-api.md, unrelated to this change. A local
--no-git run reports it and CI's run did not, which is evidence for a git-mode
difference and is NOT a measurement of one.
Not a live exposure: zero deployments, the value was a dev default, the service is
behind profiles: ["ha"], and the value is redacted in the scan output.
AUTHORSHIP: the change is builder-1's, applied by the lander at their request
because the worktree gate refused their branch switch and the branch was already
checked out elsewhere. The BACKLOG #1091 claim was RELEASED BY ITS HOLDER for this
commit and re-taken transiently by the lander to satisfy the claim gate, which
requires the committer to hold it. It was not judged stale and -Force was declined.
ADR 0165 establishes a paired commit authored by the Lander as a legitimate shape;
this is that shape pointed the other way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…fest (BACKLOG #1091)
`test_every_non_engine_test_is_classified` refuses a `tests/*.py` that imports no
engine module and appears in neither `tooling_manifest.txt` nor the explicit
stay-list. `tests/test_gitleaks_credential_rule.py` is exactly that shape: it runs
the real gitleaks binary against fixture trees and imports nothing from
`messagefoundry`, so the manifest is where it belongs.
Measured rather than assumed, both directions:
manifest entry present 0 (control: an existing entry returns 1)
engine imports in the file 0 (control: test_wiring.py returns 21)
and the gate itself as the positive control -- without this line
`test_every_non_engine_test_is_classified` fails with
`assert not ['test_gitleaks_credential_rule.py']`; with it, it passes.
It also satisfies the manifest header's own admission rule. The header says to
LEAVE A TEST OFF when membership is ambiguous, because a test that reads real
engine source must stay on the engine leg. This one reads no engine source and
spawns a real child process per case, which is the cost profile the tooling tier
exists to move off the engine legs.
PLACED IN SORTED POSITION, which corrects something I told the fleet earlier. I
had reported this list as "not sorted" after three insertion attempts landed in
three different places on a sibling branch. Measured: 107 entries with 5 out of
order, so it is sorted with local exceptions, and sorted insertion is the right
rule. This entry sits between `test_gate_rule_scan_agreement.py` and
`test_ide_licence_packaging.py`, leaving the out-of-order count unchanged at 5.
Fourth branch to hit this gate (542, 544, #1040, this one), four authors. The
registration is invisible to a content review, to ruff, to mypy, and to a targeted
local run of the suite you actually changed -- it surfaces only when someone runs
test_tooling_partition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Closing. Superseded by a placement fix, not abandoned — and the red gate was correct throughout. The decision, by the rule's author
So putting a tip-property lint inside a full-history secret scanner is not a tuning problem, it is a category error — and the permanently-red gate is that error reporting itself correctly. 12 of 14 historical revisions matching was never noise to be silenced; it was the scanner doing its job on a rule that does not belong to it. Remedy: drop What was measured on the way here
That compose fix stands and was always the real defect. It carries into the rebuild. One thing worth recording about how this was decidedThe author's earlier candidate needed a
My own Closing rather than parking, because a permanently-red PR reads as in-progress when it is superseded. |
Two files, 185 insertions:
.gitleaks.tomland a newtests/test_gitleaks_credential_rule.py. No engine source, no workflow files.The defect is in the control, not in the tree
This is not a live exposure. There are zero deployments and the tracked values are dev defaults. The defect is that the control cannot see the class — so the next credential, added by someone who trusts the gate, lands exactly the same way.
Two required gates both miss it, for different reasons:
.yamlor.ps1at all.${VAR:-changeme}scores below threshold and passes.The new rule keys on the shape of a defaulted secret rather than on its randomness.
Why the rule is narrow, and the negative that forced it
.github/workflows/release.ymlcarries nineid-token: writelines. Those are GitHub permissions, not credentials.A naive
token\s*[:=]rule reddens a required gate nine times on its first run — which is how a security control gets switched off rather than fixed. The narrowness is the point.Three things about the author's own work, two of which nearly shipped
1. The tests run the scanner rather than reading the config. A text assertion that a rule is present cannot distinguish a working rule from a regex matching nothing — the same defect #1313 found in the sdist leak gate. A config gitleaks refuses to load now fails loudly instead of reading as "no findings".
2. The allowlist test caught a real defect. gitleaks reports the first capture group as
Secretand matches allowlist regexes against it, so the rule reportedSecret: "password"— the key name. Useless in a report and it silently broke the allowlist. Fixed with a non-capturing group plussecretGroup; findings now carry the actual default value.3. The rule blocked the commit that introduced it — the fixtures are the detected shape, the same class as #1086. Resolved with the narrowest exemption that works: a
pathsentry scoped to this rule and this file, so the file stays covered by every other rule. Verified narrow — the rule still fires oncompose.yaml:158.Verification
tests/test_gitleaks_credential_rule.py;ruff formatandcheckclean; all pre-commit hooks passed, including gitleaks itself.docker/compose.yaml:158and on a fresh${VAR:-changeme}in.yamland.ps1; silent on${VAR}with no fallback and on.github/workflows/.Not run, and stated rather than discovered later: the full suite on this branch. CI is the first execution of it.
Not measured and not asserted: the repo config already reports one pre-existing hit at
docs/testing/master-test-plan/09-engine-api.md:553undergeneric-api-key. Whether that is live in the CI gate depends on how CI invokes gitleaks — git mode versus--no-gitchanges what is scanned. Out of scope here and unchecked.Cut clean from
origin/mainat5eb261de, 1 ahead, 0 behind. Touches nodocs/BACKLOG.md, so it does not collide with #539, #542 or the residuals branch.Co-Authored-By: Claude Opus 5 noreply@anthropic.com
🤖 Generated with Claude Code