Skip to content

fix(redact-prepush): six paths where the hook exits 0 on a real credential - #2498

Open
lubosxyz wants to merge 1 commit into
garrytan:mainfrom
lubosxyz:fix/redact-prepush-fail-open
Open

fix(redact-prepush): six paths where the hook exits 0 on a real credential#2498
lubosxyz wants to merge 1 commit into
garrytan:mainfrom
lubosxyz:fix/redact-prepush-fail-open

Conversation

@lubosxyz

@lubosxyz lubosxyz commented Aug 9, 2026

Copy link
Copy Markdown

The pre-push credential scanner can be made to allow a push that carries a live key. Six separate paths, same outcome: exit 0, nothing scanned, secret on the remote.

# trigger shipped fixed
1 credential on a line whose content starts with ++ ALLOW BLOCK
2 malformed stdin (unparseable ref update) ALLOW BLOCK(unscannable)
3 diff.external set ALLOW BLOCK
4 push to a remote other than origin ALLOW BLOCK
5 junk remote sha ALLOW BLOCK(unscannable)
6 localSha="0" read as a branch delete ALLOW BLOCK(unscannable)

#1 is the one I'd fix first even alone: added lines are filtered with !line.startsWith("+++"), which is meant to drop the +++ b/file header — but a genuine added line whose content begins with ++ renders as +++… too. A committed .patch/.diff carrying a key sails through unscanned.

#3 needs no attacker: diff.external is ordinary user config. With it set, git diff emits no + lines, the scanner reads an empty diff, and everything is allowed. Silently.

The false positive that teaches the bypass

Over the engine's 1 MiB cap the hook reports engine.input_too_large as HIGH and tells the user to "Rotate the credential (a pushed secret is compromised)" — for content it never scanned. On one machine that produced five entries in ~/.gstack/security/prepush-skip.jsonl, including "0 HIGH at 1.33 MB" and "synthetic credential-scrubbing fixture". None held a credential.

That matters beyond ergonomics: a gate that cries wolf trains its user to reach for GSTACK_REDACT_PREPUSH=skip, and the habit is then applied to the push where the gate was right. The fix scans in windows so large-but-clean input passes, and reserves blocking for content that genuinely could not be read.

Fail-closed is preserved

An unreadable range still blocks (#1946 stands). What changed is the message: instead of died of signal 9 with no explanation, it names the range, the reason, how many MB were read, and the concrete next step.

Verification

test/redact-prepush-fail-open-verify.sh — 15 scenarios, each run against both the shipped and the fixed scanner, asserting an expected verdict per row and exiting non-zero on mismatch. Rows 2, 4, 5, 6, 9, 14 must agree between the two columns: that is the no-detection-regression check, including credentials straddling the window cut.

GATE: PASS (15/15)

I verified this independently of the change's author, against bin/gstack-redact-prepush and lib/redact-engine.ts that are byte-identical (sha256 5c06f7de… / c1f5df32…) to current main at v1.61.0.0, so the result applies to this base.

Relationship to open PRs

Disjoint from #2398 (pushed range vs whole repo), #2328 (path-ignore) and #2358 (chained pre-push.local stdin newline) — I diffed all three and none touches these six paths. #2328 is motivated by the same false positive from the other direction; #2358 is a seventh fail-open path in the wrapper that this PR does not address.

Happy to rebase behind any of them if you'd rather land those first.

…ntial

The pre-push credential scanner can be made to allow a push carrying a live
key. Each hole is a separate path to the same outcome — `exit 0`, nothing
scanned, secret on the remote:

1. Added lines are filtered with `!startsWith("+++")`, which also drops a real
   added line whose *content* begins with `++`. A committed .patch or .diff
   carrying a key is never scanned.
2. A ref update line that failed to parse was `continue`d, so malformed stdin
   meant exit 0 having scanned nothing.
3. `diff.external` (an ordinary user config) makes `git diff` emit no `+`
   lines at all; the scanner reads an empty diff and allows anything.
4. Pushing to a remote other than `origin`: the range was resolved against
   origin regardless, so HEAD..HEAD came back empty.
5. A junk remote sha was treated as a resolvable base.
6. `localSha="0"` was read as a branch delete and skipped.

Also fixes the false positive that drove users to the bypass: over the engine's
1 MiB cap the hook told them to "rotate the credential (a pushed secret is
compromised)" for content it had never scanned. On this machine that produced
five entries in ~/.gstack/security/prepush-skip.jsonl, none of which contained
a credential — a gate that cries wolf trains its user to reach for the escape
valve, which then gets used on the push where the gate was right.

Fail-closed is preserved throughout: an unreadable range still blocks, and the
new "unscannable" message names the range, the reason, how much was read, and
the concrete next step instead of dying with `signal 9`.

test/redact-prepush-fail-open-verify.sh runs all 15 scenarios against both the
shipped and the fixed scanner and exits non-zero on any mismatch. Rows 2,4,5,6,
9,14 must agree between the two — that is the no-detection-regression check.
@trunk-io

trunk-io Bot commented Aug 9, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

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