fix(git-gate): scan $new --not --all for every push, not $old..$new #422

Merged
didericis merged 1 commits from fix-git-gate-force-push-scan-range into main 2026-07-18 16:51:34 -04:00
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