fix(git-gate): scan $new --not --all for every push, not $old..$new
test / unit (push) Successful in 32s
test / integration (push) Successful in 31s
lint / lint (push) Successful in 43s
Update Quality Badges / update-badges (push) Successful in 38s
test / coverage (push) Successful in 40s

The pre-receive hook scanned existing-branch updates with the delta range
$old..$new. On a rebase / non-fast-forward force-push onto an advanced main,
$old is no longer an ancestor of $new, so $old..$new expands to all of main's
new history — including the deliberate sandbox-escape gitleaks fixtures — and
the push is rejected on commits that belong to main, not the branch.

Unify the range on `$new --not --all` for every non-delete push (this is the
deferred open question from PRD 0028, which already applied it to new refs
for #106). It scans only the commits the push introduces and is
security-equivalent: the bare repo's refs come only from trusted upstream
mirror-fetch and gitleaks-gated pushes, so an excluded commit is
already-upstream or already-scanned. It is also more correct for
non-fast-forward pushes, where $old..$new can skip commits off the direct path.

Fixes #421

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1qRZTJC6qgBsUSjNrBdkX
This commit was merged in pull request #422.
This commit is contained in:
2026-07-18 16:45:15 -04:00
parent 015ff52eda
commit 5b359fe8d2
2 changed files with 27 additions and 19 deletions
+18 -12
View File
@@ -419,18 +419,24 @@ PY
while IFS=' ' read -r old new ref; do while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue [ -z "$ref" ] && continue
[ "$new" = "$zero" ] && continue [ "$new" = "$zero" ] && continue
if [ "$old" = "$zero" ]; then # Scan only the commits this push introduces — those reachable from
# New ref: scan only the commits this push introduces — those # $new but not from any ref the gate already has. Everything already
# reachable from $new but not from any ref the gate already has. # on the gate arrived via upstream mirror-fetch or a previously
# Everything already on the gate arrived via upstream mirror-fetch # gitleaks-scanned push, so it's already-upstream or already-scanned;
# or a previously gitleaks-scanned push, so it's already-upstream # re-scanning it only resurfaces historical fixture findings.
# or already-scanned; re-scanning it (the old `$new` full-ancestry #
# range) only resurfaces historical findings and blocks every new # Applies to both new refs and updates. The old existing-branch range
# branch. See PRD 0028 / issue #106. # `$old..$new` walks commits reachable from the new tip but not the
log_opts="$new --not --all" # *old branch tip*: on a rebase/force-push onto a freshly-advanced
else # main that pulls in all of main's new history (incl. the deliberate
log_opts="$old..$new" # sandbox-escape gitleaks fixtures), blocking the push. `--not --all`
fi # excludes anything already on the gate regardless of ancestry, so it
# is also correct for non-fast-forward pushes (a rebase can skip
# commits off the direct path). Security-equivalent per PRD 0028's
# analysis: the bare repo's refs come only from trusted upstream
# mirror-fetch or gitleaks-gated pushes.
# See PRD 0028 (open question) / issues #106, #346.
log_opts="$new --not --all"
echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2 echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2
if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then
echo "git-gate: gitleaks rejected push to $ref" >&2 echo "git-gate: gitleaks rejected push to $ref" >&2
+9 -7
View File
@@ -168,16 +168,18 @@ class TestHookRender(unittest.TestCase):
# Stdin is buffered to a tempfile so both phases can re-read. # Stdin is buffered to a tempfile so both phases can re-read.
self.assertIn("refs_file=$(mktemp)", hook) self.assertIn("refs_file=$(mktemp)", hook)
def test_new_ref_scan_scoped_to_incoming_commits(self): def test_scan_scoped_to_incoming_commits(self):
# A new branch (old=all-zeros) must scan only commits new to the # Every non-delete push scans only commits new to the gate, not
# gate, not the full ancestry — otherwise historical findings # the full ancestry and not the `$old..$new` delta — otherwise
# block every new-branch push (PRD 0028 / issue #106). # historical fixtures block new-branch pushes (PRD 0028 / #106)
# and a rebase/force-push onto an advanced main drags in main's
# history incl. the sandbox-escape fixtures (#346).
hook = git_gate_render_hook() hook = git_gate_render_hook()
self.assertIn('log_opts="$new --not --all"', hook) self.assertIn('log_opts="$new --not --all"', hook)
# The old over-broad full-ancestry range must be gone. # Neither the full-ancestry range nor the ancestry-blind delta
# range may survive.
self.assertNotIn('log_opts="$new"', hook) self.assertNotIn('log_opts="$new"', hook)
# Existing-branch delta scan is unchanged. self.assertNotIn('log_opts="$old..$new"', hook)
self.assertIn('log_opts="$old..$new"', hook)
def test_forward_ssh_is_non_interactive_and_bounded(self): def test_forward_ssh_is_non_interactive_and_bounded(self):
# No prompt (BatchMode) and a connect timeout, so an unreachable # No prompt (BatchMode) and a connect timeout, so an unreachable