fix(git-gate): scan $new --not --all for every push, not $old..$new
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user