Repository navigation
Conversation
… real credential Four paths made the pre-push credential scanner exit 0 with the secret going out anyway, and two adjacent defects in the same functions had to land with them. Range resolution: defaultRemoteBranch() asked origin regardless of the push target, so pushing to a second remote while HEAD matched origin/main resolved HEAD..HEAD and scanned nothing; and a well-shaped but absent remote sha let a guessed base's empty diff read as "nothing to scan". The probe is now scoped to the push target and a guess that scanned nothing blocks with a fetch hint. Slicing: the no-overlap argument holds for a pattern's match but not for its proximity requirement, so a label at the end of one slice and its secret at the start of the next never fired; and budgeting in raw bytes let zero-width padding decide the seam using bytes the engine strips before matching. Slices now overlap by 16 KiB and are budgeted in zero-width-stripped bytes. Adjacent: the fallback range's hardcoded SHA-1 empty-tree id does not exist in a SHA-256 repository and hard-blocked every legitimate first push there, which the remote scoping makes reachable more often; and an over-budget single line was handed to the engine whole, blocking without the content ever being read. test/redact-prepush-fail-open.sh is the gate: 26 scenarios against real repositories with synthetic credentials, PASS here and FAIL on the four rows against the scanner this branch forks from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Merging to
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 |
…asses Three shapes still exited 0 with a credential in the push. A push to a URL borrowed origin's tip as its base and excluded every commit any remote had seen, so a credential already on origin shipped to a new, empty URL remote unscanned. A URL is described by no remote-tracking ref: the push target is now a three-way value (configured remote / URL / unknown), a URL target anchors on nothing and excludes nothing, and an unknown target keeps the historical origin-shaped behavior. When git named a remote tip the local clone lacks, only an EMPTY guessed range was blocked; a stale tracking ref holding the credential plus one harmless local commit produced a non-empty, credential-free guess that passed. An absent named tip now gets no local narrowing at all and the whole reachable history is scanned, which is affordable because every scan is sliced under the engine's cap. The inter-slice overlap carried whole lines only, so a line longer than the overlap contributed nothing and a label at its end never met the secret at the start of the next slice. The overlap now carries such a line's suffix. Gate: test/redact-prepush-fail-open.sh grows to 30 scenarios. E5 expected the URL-push leak to be ALLOWED and now expects the block, E5b pins that a clean URL push is still allowed, 19 and 20 pin the other two shapes, and E8b pins that a clean SHA-256 first push is allowed (E8 carried a credential, so it could not tell a working empty-tree fix from a broken one). The S1 URL-push case in redact-prepush-scan-range.test.ts keeps its no-error assertion and now also asserts the block, because its fixture's secret really is delivered to that URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hen empty The overlap carried a long line's suffix only when the tail was otherwise empty. One short line between the long label line and the secret landed in the tail first, the tail was no longer empty, and the label was dropped again, so aws.secret_key never fired. The overlap is now filled from the seam backwards to its exact budget, taking a partial line wherever a whole one no longer fits; a character is added only if it still fits, so the slice budget invariant keeps holding. Gate rows 21 (one short line) and 22 (several) pin it; both fail on the scanner this branch forks from. The absent-tip comment claimed the range scans everything reachable. It scans the net diff of emptyTree..localSha, i.e. the final tree, not each commit, and now says so, including that a credential added and later removed inside one push is not in any diff this hook computes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entry promised "close four bypass classes completely", "the whole reachable history is scanned" and "everything reachable is scanned". The hook scans the diff of the range it computes, which is the content of the pushed tip, not each commit along the way, so a credential added and later removed inside one push is not reported. Four specific paths are closed; the scan's shape is unchanged, and the entry now has a Known limitation section saying so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(settings): preserve symlinked settings targets Resolve the selected target for locking, mutation, backup, and rollback; refuse target changes and preserve private modes. Addresses #2830. * fix(redact): bind masking to original detected spans Inspired by #2929's anchored-span diagnosis; independently implemented using normalization offsets. Addresses #2930 and the relocation portion of #2912 without changing detection sensitivity. * fix(evals): exclude operator credentials from prefix admission Adapts the credential-suffix screen proposed in #2636, with real launched-child regression coverage and deliberate provider-auth exceptions. * fix(artifacts): retain custom allowlist rules on reinitialization Preserve the exact user-owned suffix and publish only a successfully assembled replacement. Independently implements the repair reported in #2907. * test(cso): verify exact masked reads and unmaskable payload refusal * fix(cso): preserve exact filesystem identities through lease recovery Preserve 64-bit device/inode identity and nanosecond race checks. Add native NTFS lifecycle coverage for #2927; retain ambiguous legacy-state refusal without claiming Windows PID-reuse recovery is resolved. * fix(redact): bind pre-push scans to destination and preserve seam context Uses #2935 (bd07318) as source evidence for push-target range and slice-overlap defects. Independently implemented; no cherry-pick or release metadata adoption. * test(ci): gate native agent ownership and settings links on macOS * fix(browse): bind agent lifetimes and cleanup to owned generations Uses #2931 by Chris Hutton / Claude Fable 5.1 as attributed design input; independently implemented without broad sweeps or copied code. Keep uncertain children and locks rather than deleting foreign state. * test(ci): include concurrent shutdown controls in the native macOS gate * v1.88.1.0 fix: harden credential boundaries and owned state * fix(redact): preserve target provenance and scan boundary semantics * test(artifacts): read managed rules from atomic allowlist assembly * fix: preserve native exit observations and fixture prerequisites * fix: preserve UTF-16 offsets through redaction normalization
|
Thank you @lubosxyz for the detailed destination/range and normalized-proximity reproductions, including the follow-up negative controls. Your PR is credited as diagnostic source in #2942, merged as The independently implemented repair binds scans to the actual destination name and effective URL, treats absent advertised objects conservatively, computes the correct empty tree for SHA-256 repositories, and preserves normalized proximity context across bounded scan windows. The implementation is in Closing this PR as superseded, not cherry-picked or merged. One deliberate difference: a line or required context over the existing 1 MiB cap is still refused, rather than claiming it was fully scanned. This closure also makes no new claim about the separately documented net-diff/transient-secret or stale-tracking-ref limitations. Thank you for identifying both the security failures and the boundaries of your proposed fix. |
Why (in your own words)
bin/gstack-redact-prepushis the last thing standing between a working tree and a pushedcredential. On current
mainthere are four ways to make it exit 0 with the secret going outanyway. None of them needs an attacker — three are ordinary git usage and the fourth is a
consequence of the scanner's own slicing.
The first two are range-resolution bugs.
remotesExclusion()already reasons about whichremote the push targets, but
defaultRemoteBranch()kept askingoriginregardless, and the twotogether choose the scan range. On a repo where
HEAD == origin/main,git push publish HEAD:mainto a second remote resolves a merge-base of
HEADagainstorigin/main, which isHEAD..HEAD—an empty diff. The whole tree ships to that remote with nothing scanned. Separately, the shape
check accepts any 40 plausible hex digits, so when git names a remote tip we do not have, the base
is guessed; when the guess yields an empty diff the hook concludes "nothing to scan" and allows,
while git is about to send whatever the real difference is. A guess that scanned nothing is not
evidence that there is nothing.
The other two come from
scanAddedLines(). Its comment argues that overlap between slices isunnecessary "because every pattern is single-line … a line boundary cannot bisect a detectable
secret". That is true of the match and false of the proximity requirement: five patterns in
lib/redact-patterns.tscarry anearRegexand fire only when their qualifying label sits withinnearWindowcharacters —aws.secret_key(HIGH) needsaws_secret_access_keywithin 100,gcp.service_account(HIGH) within 300. Put the label at the end of one slice and the secret at thestart of the next and the pattern never fires at all. The zero-width variant is the same hole
measured in the wrong unit: the engine strips zero-width characters before matching, so a budget
counted in raw bytes decides the seam using bytes the engine will never see, and invisible padding
can separate a label from its secret while their normalized distance stays a couple of characters.
Two adjacent defects in the same functions have to land with them. The fallback range used a
hardcoded SHA-1 empty-tree object id, which does not exist in a SHA-256 repository and hard-blocks
every legitimate first push of a new branch there — and the remote-scoping fix above makes that
fallback reachable more often, so it stops being cosmetic. And an over-budget single line (a
minified bundle, a one-line JSON blob) was handed to the engine whole, tripping
engine.input_too_large: blocked, which is safe, but blocked without the content ever beingread. That is the behavior that teaches an operator to reach for the skip switch, and the next
time the gate is right they skip anyway.
This is a follow-up to #2498, which you closed as absorbed by v1.67.0.0. Five of those six paths
are indeed closed on
maintoday —++-prefixed lines, unparseable stdin,diff.external, theshape check on both shas, and
localSha="0". My gate confirms all five still hold. These four arethe ones that survived, plus two the slicing introduced afterwards. The change is deliberately
targeted and leaves your architecture alone:
addedLinesFromNewCommits()(#2592/#2573),remotesExclusion()'s push-target scoping, and the 768 KiB slicing are all preserved.Live evidence
test/redact-prepush-fail-open.shis new in this PR: 32 scenarios, each driving the real scannerthrough real git repositories with synthetic credentials assembled at runtime from halves that
match nothing on their own. It defaults to the in-tree scanner and takes an optional path, so the
same gate runs against any build.
Against the scanner on
main(v1.87.5.0,bin/gstack-redact-prepushsha256f9c85d22cdd13803ebaaf8cfbe9aa2469524bc47b51367a6cdb41ae219e5f209), the rows that differ:Nine of those are fail-open —
mainexits 0 with the credential in the push. The last two are theSHA-256 pair:
mainhard-blocks a clean first push there on a diff error, which is unusable ratherthan safe. The other 21 rows pass identically on both builds, and that is the point of including
them: they are the no-detection-regression check.
Against this PR's head:
Every test that references the scanner, on this head:
Free suite. The full local
bun run testfails intest/pty-screen.test.ts,test/relink.test.tsand the Chromium and compiled-CSO lanes — they need artifacts CI builds first(
build:gates,build:cso,vendor:xterm, the browse node server) which I did not build here, andCONTRIBUTING notes dev boxes see flakes in that lane while the required CI free lane turns on its
retry pass. Following CLAUDE.md's blame protocol, I measured it rather than asserting it: the same
two files, same machine, runs serialized —
main(35dd014) 62 pass / 8 fail, this branch62 pass / 8 fail, and a
diffof the sorted failing-test lists is identical. This branchintroduces none of them.
What changed after review
A first version of this branch closed the four classes only partially, and your review process
should see that rather than discover it. An independent cross-family review found three shapes the
first version still let through with exit 0, and one gate row that could not have caught a broken
fix. All four are fixed in a separate commit so the delta is readable:
pushRemote()returns null for a URL, anddefaultRemoteBranch()then fell back toorigin;remotesExclusion()fell back to a bare--remotes. With the credential already onorigin/mainand a harmless local commit, a firstpush to a new empty URL remote anchored on origin, excluded everything origin had seen, scanned
the harmless commit only, and shipped the credential. My gate's E5 row asserted
ALLOWforexactly that shape — it encoded the hole as expected behaviour. The push target is now a
three-way value (configured remote / URL / unknown): a URL anchors on nothing and excludes
nothing; an unknown target (no argv, stdin or CLI) keeps the historical origin-shaped behaviour.
E5 now expects the block, and E5b pins that a clean URL push is still allowed.
EMPTY. A stale tracking ref holding the credential plus one harmless commit gives a non-empty,
credential-free guess, and it passed. An absent named tip now gets no local narrowing: the whole
reachable history is scanned. That is slower on that rare path and affordable only because every
scan is sliced under the engine's cap — which is exactly why the old code avoided it. A new branch
(zero remote sha) is deliberately not treated this way, since git named no tip there and the
tracking refs are not contradicted. Row 20.
16 KiB contributed nothing to the seam. A ~770 KB line ending in
aws_secret_access_key =followed by the key never fired. The overlap now carries such a line's suffix. Row 19.
SHA-256 fallback range still "blocked" — with a diff error instead of a finding. E8b is a clean
SHA-256 first push that must be allowed, and
mainfails it.A second review round found that my overlap fix was itself incomplete: it carried a long line's
suffix only when the tail was otherwise empty, so one short line between the long label line and
the secret landed in the tail first and the label was dropped again. The overlap is now filled from
the seam backwards to its exact budget, taking a partial line wherever a whole one no longer fits.
Rows 21 and 22 pin the one-short-line and several-short-lines forms;
mainfails both.One upstream test changes, and I want to be explicit about it because it reverses a choice you
made. In
test/redact-prepush-scan-range.test.ts, the S1 case "an unconfigured name (URL push)also falls back rather than erroring" asserted
exit 0. Its fixture merges a commit whose secretwas pushed only to a private
otherremote, then pushes to a URL whose claimed tip does notcontain it — so that push really delivers the secret to the URL. The S1 comment accepts this as
"falling back scans LESS than the narrowed form would, but never less than the hook historically
did". That trade is the same fail-open as E5. I kept the test's stated purpose — it still asserts
the hook does not error on an unconfigured name — and changed the outcome assertion to the block.
The updated test fails on
mainand passes here. If you would rather keep the historical leniencyfor URL pushes, this is the one assertion to revert, and E5 goes with it.
Why S1's URL case changes but its no-argv neighbour does not, since the two share a fixture and
now carry opposite verdicts. The URL case is a real push: git hands the hook a destination, and that
destination is described by no tracking ref, so nothing may be excluded. The no-argv case is the
documented contract for invoking the hook without arguments — stdin or a direct CLI call, where no
destination exists at all — and real pushes always carry the name. There is nothing to protect
there, so it keeps the historical behaviour, and
pushTarget()models it as its ownunknownstaterather than as a URL.
Known limitation, not addressed here
The hook scans the net diff of the range it computes, never each commit. A credential added in
one commit and removed in a later one within the same push is absent from that diff, so it is not
seen — yet git delivers both commits. I measured this on
mainand on this branch, across the threepush shapes that pick different ranges:
It is identical on both, including the ordinary existing-branch push, so this branch neither causes
nor widens it; it is a property of diff-based scanning. Closing it means scanning each commit's own
diff in the range, which changes the hook's cost profile on every push and deserves its own PR and
its own review rather than riding in on this one. The code comment on the absent-tip path now says
this plainly instead of claiming to scan "everything reachable".
Scope
bin/gstack-redact-prepush(pushRemote()added;defaultRemoteBranch()scoped tothe push target and nullable;
unknownRemoteTipBase()falls through on a null default;addedLinesFor()blocks when a guessed base scanned nothing;emptyTree()asks git for theobject id;
SCAN_OVERLAP_BYTES+LINE_BUDGET_BYTES+ zero-width stripping on ingest +sliceLongLine()inscanAddedLines(), with finding dedup onid+previewbecause overlapdeliberately re-scans the seam). New:
test/redact-prepush-fail-open.sh,test/redact-prepush-fail-open.test.ts; the S1 URL-push assertion intest/redact-prepush-scan-range.test.ts(see above).VERSION→ 1.87.7.0 and aCHANGELOG.mdentry.mainscanner and thishead;
bun run test;shellcheck --severity=error; and a spawn-based bun test so the gate ispart of the free suite rather than a script nobody runs.
git pushto a real second remote over the network (the gate drives thehook's stdin protocol against local repositories instead, which is what the hook actually sees);
Windows; a genuinely huge repository for wall-clock regression beyond the gate's fixtures.
spawnSync, and a wall-clock budget.Both would mean rewriting
addedLinesFor()/addedLinesFromNewCommits()and throwing away yourrange narrowing, and the remaining 64 MiB
maxBufferlimit is fail-closed (BLOCK(unscannable)),not a hole. One thing stays open and I am not claiming otherwise: a stale tracking ref for the
target remote. If local
<remote>/<branch>survives a reset of the remote and the local commitis its descendant, the range is computed against that stale tip and the real content is not
scanned. Fixing that honestly needs
git ls-remote— a network call inside a pre-push hook —which is your call, not something to append here. Scenario 16 does not cover it: 16 is about a tip
git named that we do not have, not a tip we have wrongly.
Liveness proof (required for external contributors)
I have not attached a
GSTACK PRscreenshot, and I would rather say why than quietly skip the box.This change was prepared with agent assistance on my own machine and under my own account, so an
image produced from that session would be me manufacturing the human-presence attestation the gate
exists to check. GitHub also has no API for attaching an image to a PR body — only drag-drop in the
web UI — so it is not something that session could have produced honestly anyway.
What I can give you is the provenance the template asks agents for:
If you want the screenshot before reviewing, say so and I will add it myself in the web UI.
Checklist
GSTACK PRtyped live into a real surface) or PR author is @garrytan (owner exemption) — not attached; see the section above,gh api user --jq .login→lubosxyz, happy to add on requesttest/redact-prepush-fail-open.sh, which fails onmainand passes here