Skip to content

sec(gitleaks): a low-entropy defaulted credential passes both required secret gates (BACKLOG #1091) - #545

Closed
wshallwshall wants to merge 5 commits into
mainfrom
claude/builder-1-1091-credential-rule
Closed

sec(gitleaks): a low-entropy defaulted credential passes both required secret gates (BACKLOG #1091)#545
wshallwshall wants to merge 5 commits into
mainfrom
claude/builder-1-1091-credential-rule

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Two files, 185 insertions: .gitleaks.toml and a new tests/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:

  • bandit does not parse .yaml or .ps1 at all.
  • gitleaks' generic rule is entropy-gated, so a low-entropy default like ${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.yml carries nine id-token: write lines. 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 Secret and matches allowlist regexes against it, so the rule reported Secret: "password" — the key name. Useless in a report and it silently broke the allowlist. Fixed with a non-capturing group plus secretGroup; 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 paths entry scoped to this rule and this file, so the file stays covered by every other rule. Verified narrow — the rule still fires on compose.yaml:158.

Verification

  • 7 passed in tests/test_gitleaks_credential_rule.py; ruff format and check clean; all pre-commit hooks passed, including gitleaks itself.
  • Measured both directions on gitleaks 8.30.1: fires on docker/compose.yaml:158 and on a fresh ${VAR:-changeme} in .yaml and .ps1; silent on ${VAR} with no fallback and on .github/workflows/.
  • Differential against the real tree: adds exactly one finding.
  • Independently screened here over all 185 added lines with positive controls firing first: zero real credential shapes.

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:553 under generic-api-key. Whether that is live in the CI gate depends on how CI invokes gitleaks — git mode versus --no-git changes what is scanned. Out of scope here and unchecked.

Cut clean from origin/main at 5eb261de, 1 ahead, 0 behind. Touches no docs/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

wshallwshall and others added 5 commits August 23, 2026 08:27
…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>
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Closing. Superseded by a placement fix, not abandoned — and the red gate was correct throughout.

The decision, by the rule's author

A leaked secret is compromised the moment it is pushed — history is correct for it.
A defaulted credential is a property of the shipped artifact — fix the tip and it is gone.

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 mefor-defaulted-credential from .gitleaks.toml and re-implement it tip-scoped, the way this repo already does its other lints. Every default gitleaks rule is untouched and still runs at fetch-depth: 0, which is right for them. Coverage moves surface, it does not move away.

What was measured on the way here

  • The gate runs gitleaks detect with fetch-depth: 0 and no --no-git — full history, and the workflow comment says so.
  • The tip is genuinely clean: 99650504 is an ancestor of the head and compose.yaml:156 reads POSTGRES_USER. The fix was never lost — I nearly reported that it was, and checked.
  • The failing run was on the current head, not a stale job. I checked that too, having first read an older job's log.

That compose fix stands and was always the real defect. It carries into the rebuild.

One thing worth recording about how this was decided

The author's earlier candidate needed a gitleaks 8.30.1 feature test that was denied, and they refused to recommend a remedy on an unrun test. The placement fix removes the need for that answer entirely:

I am not routing around the denial; I stopped needing the answer.

My own --log-opts suggestion was withdrawn for the same reason one level up: it bounded the whole scan to narrow one rule, so its cost fell on the rules that were working.

Closing rather than parking, because a permanently-red PR reads as in-progress when it is superseded. #1091 remains open and the work continues on a rebuilt branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant