From 5b359fe8d233cf3cd8d76ba3fbb8005d5e15a202 Mon Sep 17 00:00:00 2001 From: didericis Date: Sat, 18 Jul 2026 16:45:15 -0400 Subject: [PATCH] fix(git-gate): scan $new --not --all for every push, not $old..$new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01S1qRZTJC6qgBsUSjNrBdkX --- bot_bottle/git_gate_render.py | 30 ++++++++++++++++++------------ tests/unit/test_git_gate.py | 16 +++++++++------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/bot_bottle/git_gate_render.py b/bot_bottle/git_gate_render.py index a7a93e8..2a2378c 100644 --- a/bot_bottle/git_gate_render.py +++ b/bot_bottle/git_gate_render.py @@ -419,18 +419,24 @@ PY while IFS=' ' read -r old new ref; do [ -z "$ref" ] && continue [ "$new" = "$zero" ] && continue - if [ "$old" = "$zero" ]; then - # New ref: scan only the commits this push introduces — those - # reachable from $new but not from any ref the gate already has. - # Everything already on the gate arrived via upstream mirror-fetch - # or a previously gitleaks-scanned push, so it's already-upstream - # or already-scanned; re-scanning it (the old `$new` full-ancestry - # range) only resurfaces historical findings and blocks every new - # branch. See PRD 0028 / issue #106. - log_opts="$new --not --all" - else - log_opts="$old..$new" - fi + # Scan only the commits this push introduces — those reachable from + # $new but not from any ref the gate already has. Everything already + # on the gate arrived via upstream mirror-fetch or a previously + # gitleaks-scanned push, so it's already-upstream or already-scanned; + # re-scanning it only resurfaces historical fixture findings. + # + # Applies to both new refs and updates. The old existing-branch range + # `$old..$new` walks commits reachable from the new tip but not the + # *old branch tip*: on a rebase/force-push onto a freshly-advanced + # main that pulls in all of main's new history (incl. the deliberate + # sandbox-escape gitleaks fixtures), blocking the push. `--not --all` + # 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 if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then echo "git-gate: gitleaks rejected push to $ref" >&2 diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index 2f0acf4..cca39c5 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -168,16 +168,18 @@ class TestHookRender(unittest.TestCase): # Stdin is buffered to a tempfile so both phases can re-read. self.assertIn("refs_file=$(mktemp)", hook) - def test_new_ref_scan_scoped_to_incoming_commits(self): - # A new branch (old=all-zeros) must scan only commits new to the - # gate, not the full ancestry — otherwise historical findings - # block every new-branch push (PRD 0028 / issue #106). + def test_scan_scoped_to_incoming_commits(self): + # Every non-delete push scans only commits new to the gate, not + # the full ancestry and not the `$old..$new` delta — otherwise + # 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() 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) - # Existing-branch delta scan is unchanged. - self.assertIn('log_opts="$old..$new"', hook) + self.assertNotIn('log_opts="$old..$new"', hook) def test_forward_ssh_is_non_interactive_and_bounded(self): # No prompt (BatchMode) and a connect timeout, so an unreachable