Skip to content

fix(redact-prepush): scan the pushed range, not the whole repo - #2398

Open
stormeoio wants to merge 2 commits into
garrytan:mainfrom
stormeoio:fix/redact-prepush-honest-base
Open

fix(redact-prepush): scan the pushed range, not the whole repo#2398
stormeoio wants to merge 2 commits into
garrytan:mainfrom
stormeoio:fix/redact-prepush-honest-base

Conversation

@stormeoio

Copy link
Copy Markdown

The problem

When the remote tip is unknown — a new branch, or a sha absent locally — the hook derives
its base with merge-base against defaultRemoteBranch(). If that fails it falls back to
EMPTY_TREE..local: the entire repository treated as added lines.

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.

The fallback is commented "scans more, never less". In practice it inverts, two ways:

  1. It scans nothing. On any real repository the full history overshoots the engine's
    byte cap, so engine.input_too_large blocks the push having scanned zero bytes.
  2. It blames the wrong push. When it does fit, secrets already on the remote are
    reported as though this push introduced them.

Reproduced on a trunk-default repo, pushing a branch whose only new file is clean:

⛔ gstack-redact-prepush BLOCKED the push — credential(s) in the pushed diff:

  HIGH  aws.access_key  AKIA********…

Rotate the credential (a pushed secret is compromised) and remove it from the diff.

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:

  1. merge-base with the remote default branch — unchanged, still the common case.
  2. New: commits reachable from local but from no remote-tracking branch
    (git rev-list <local> --not --remotes) are what the push actually adds; the parent of
    the oldest one is the base.
  3. EMPTY_TREE only for a genuinely fresh repo with no remote refs — there the whole
    history is new content, so scanning all of it is the correct answer.

The two duplicated arms (ZERO / !objectExists) are merged, as they now share one
resolution path.

--remotes spans every remote rather than just the push target: content already published
anywhere 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 credentials

The first commit is the same honesty problem one layer up. An engine.* finding means the
scan 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:

  • a trunk-default repo pushing a clean branch no longer blocks on a secret already on the
    remote;
  • a fresh repo with no remote refs still blocks.

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:

Case Result
Fresh repo, no remote, secret in history blocks
Secret in the pushed commit blocks
Secret in an earlier unpushed commit of the branch blocks

The last is the subtle one: --not --remotes includes it, so it stays covered.


🤖 Generated with Claude Code

Stormeo.io added 2 commits July 30, 2026 16:15
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.
@trunk-io

trunk-io Bot commented Jul 30, 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

@adlai88

adlai88 commented Aug 11, 2026

Copy link
Copy Markdown

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 engine.input_too_large that it doesn't reach, and I think it's the common one on an active repo.

The known-tip path is untouched. The new unknownRemoteTipBase() only runs under ZERO.test(remoteSha) || !objectExists(remoteSha). When the remote tip is known and present locally, the else arm still does:

range = `${remoteSha}..${localSha}`;

That range spans any merge on the branch, so git merge origin/main puts all of main's content inside it — content already on the remote, already scanned when it was pushed there.

Repro, with this PR applied. Feature branch off a slightly older main, one commit, then git merge origin/main. Remote tip known and present, so it takes the else arm:

⛔ gstack-redact-prepush BLOCKED the push — the diff could NOT be scanned.
   No credential was found; none was looked for. Blocking fail-closed.

  engine.input_too_large: Input too large to scan safely (4232829 > 1048576 bytes)

Likely cause: the base branch could not be resolved, so the whole repo was
treated as added lines. Check `git rev-parse --abbrev-ref origin/HEAD` and

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 GSTACK_REDACT_PREPUSH=skip — the reflex bypass your PR description is about.

Also worth flagging: the new message misdiagnoses this case. origin/HEAD resolves fine here; the base was never in question. Someone following that hint finds nothing wrong and reaches for the valve.

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);

--cc renders a merge as a combined diff, so a merge contributes only its conflict resolutions; content arriving unchanged from a parent belongs to that parent's own commit, which is scanned in its own right whenever it is itself new to the remote. Nothing new to the remote goes unscanned. On the repro above that's 4.2 MB → 1.3 KB.

Because it walks commits rather than computing a base, it also covers what unknownRemoteTipBase() covers: with a zero or absent remote tip you just omit the exclusion, --remotes still bounds it, and a fresh repo with no remote refs walks every commit — which is the empty-tree answer, arrived at without the special case. The ZERO / !objectExists arms collapse the same way yours does.

One caveat I hit that isn't obvious: combined diffs prefix each line with one column per parent, so a two-parent merge emits ++, + and +. The existing startsWith("+") && !startsWith("+++") filter drops + outright and leaves a stray column on the rest, so this change wants hunk-state parsing to go with it. That same filter independently drops a real added line whose content starts with ++ — it renders as +++… and reads as a file header — which is #2498's first defect, reachable today without any of this.

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.

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.

2 participants