fix(redact-prepush): scan the pushed range, not the whole repo - #2398
fix(redact-prepush): scan the pushed range, not the whole repo#2398stormeoio wants to merge 2 commits into
Conversation
An `engine.*` finding means the scan never ran. The hook nevertheless reported "credential(s) in the pushed diff" and instructed the operator to rotate a secret — on a diff it had never read. Blocking stays correct (fail closed); only the stated reason changes. Engine findings now get their own message: no credential was found because none was looked for, plus the likely cause (an unresolvable base branch makes the hook fall back to EMPTY_TREE..local, i.e. the whole repo as "added lines") and the commands to diagnose it. A guardrail that cries wolf is one that gets bypassed by reflex, which is worse than no guardrail at all.
When the remote tip is unknown (new branch, or a sha absent locally) and merge-base against the guessed default branch fails, the hook fell back to EMPTY_TREE..local — the entire repository as "added lines". That fallback is documented as "scans more, never less". In practice it inverts. On any real repository the input overshoots the engine's byte cap, so engine.input_too_large blocks the push having scanned NOTHING. And when it does fit, it re-reports secrets already on the remote as if this push introduced them, telling the operator to rotate a key over an old commit that was never part of the push. merge-base fails on ordinary setups: a default branch named trunk or develop, or an unset origin/HEAD, makes defaultRemoteBranch() guess origin/main, which does not resolve. Now: commits reachable from local but from no remote-tracking branch are what the push actually adds; the parent of the oldest is the base. EMPTY_TREE stays for a genuinely fresh repo with no remote refs, where the whole history IS new content. Two tests added: a trunk-default repo pushing a clean branch no longer blocks on a secret already on the remote, and a fresh repo with no remotes still blocks. Verified separately that a secret in the pushed commit, and in an earlier unpushed commit of the branch, both still block.
|
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 |
|
Applied this on top of v1.61.0.0 and ran it against a repo where the hook has been firing. It fixes the unknown-tip path as described, but there's a third route to The known-tip path is untouched. The new range = `${remoteSha}..${localSha}`;That range spans any merge on the branch, so Repro, with this PR applied. Feature branch off a slightly older main, one commit, then 4.2 MB scanned, of which the genuinely new content was 1.3 KB. Three pushes in one session were blocked this way, each cleared by hand-scanning and then Also worth flagging: the new message misdiagnoses this case. What I ended up with, if useful — it replaces the range diff rather than the base derivation: const args = ["log", "--format=", "--unified=0", "--no-color", "--cc",
localSha, "--not", "--remotes"];
if (!ZERO.test(remoteSha) && objectExists(remoteSha)) args.push(remoteSha);
Because it walks commits rather than computing a base, it also covers what One caveat I hit that isn't obvious: combined diffs prefix each line with one column per parent, so a two-parent merge emits Happy to open this as a PR on top of yours, or leave it here if you'd rather fold it in — your call, it's your patch that's furthest along. |
The problem
When the remote tip is unknown — a new branch, or a sha absent locally — the hook derives
its base with
merge-baseagainstdefaultRemoteBranch(). If that fails it falls back toEMPTY_TREE..local: the entire repository treated as added lines.merge-basefails on ordinary setups. A default branch namedtrunkordevelop, or anunset
origin/HEAD, makesdefaultRemoteBranch()guessorigin/main, which does notresolve.
The fallback is commented "scans more, never less". In practice it inverts, two ways:
byte cap, so
engine.input_too_largeblocks the push having scanned zero bytes.reported as though this push introduced them.
Reproduced on a
trunk-default repo, pushing a branch whose only new file is clean:That credential was several commits old and already on the remote — the push did not
contain it. A key rotation was demanded for nothing, and a guardrail that cries wolf is one
that gets bypassed by reflex.
The fix
unknownRemoteTipBase(), ordered most precise to most conservative:merge-basewith the remote default branch — unchanged, still the common case.(
git rev-list <local> --not --remotes) are what the push actually adds; the parent ofthe oldest one is the base.
EMPTY_TREEonly for a genuinely fresh repo with no remote refs — there the wholehistory is new content, so scanning all of it is the correct answer.
The two duplicated arms (
ZERO/!objectExists) are merged, as they now share oneresolution path.
--remotesspans every remote rather than just the push target: content already publishedanywhere has already left the machine. Git passes the remote name to pre-push in argv,
which this hook does not read; narrowing to it would only matter for a repo that pushes
secrets to one remote but not another.
Also here:
engine.*findings no longer read as found credentialsThe first commit is the same honesty problem one layer up. An
engine.*finding means thescan never ran, yet it was rendered through the credential path — "credential(s) in the
pushed diff", followed by "Rotate the credential (a pushed secret is compromised)" — on
a diff that had never been read. That is how the bug above was first noticed.
Blocking stays correct and fail-closed; only the stated reason changes. Engine findings now
get their own message (no credential was found because none was looked for), with the likely
cause and the commands to diagnose it. The credential wording is reserved for actual
credential findings.
Tests
Two added to
test/redact-prepush-hook.test.ts:trunk-default repo pushing a clean branch no longer blocks on a secret already on theremote;
17/17 on the hook file, 96/96 across the other four redact files.
Because a narrowed base is exactly where a hole could open, the blocking cases were also
verified by driving the hook through its stdin protocol directly — all still exit 1:
The last is the subtle one:
--not --remotesincludes it, so it stays covered.🤖 Generated with Claude Code