diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index e3cad28..5b010f9 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -280,7 +280,14 @@ while IFS=' ' read -r old new ref; do [ -z "$ref" ] && continue [ "$new" = "$zero" ] && continue if [ "$old" = "$zero" ]; then - log_opts="$new" + # 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 @@ -300,7 +307,7 @@ if [ ! -f "$hostsfile" ]; then echo "git-gate: add KnownHostKey to the bottle.git entry and restart the bottle" >&2 exit 1 fi -ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes" +ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10" while IFS=' ' read -r old new ref; do [ -z "$ref" ] && continue @@ -355,7 +362,7 @@ if [ -z "$keyfile" ] || [ ! -f "$hostsfile" ]; then echo "git-gate: missing credentials for $repo_dir; refusing fetch" >&2 exit 1 fi -ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes" +ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10" echo "git-gate: refreshing $repo_dir from upstream" >&2 if ! GIT_SSH_COMMAND="$ssh_cmd" git -C "$repo_dir" fetch origin --prune >&2; then diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index d83bec8..7e7884e 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -197,6 +197,24 @@ 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). + hook = git_gate_render_hook() + self.assertIn('log_opts="$new --not --all"', hook) + # The old over-broad full-ancestry range must be gone. + self.assertNotIn('log_opts="$new"', hook) + # Existing-branch delta scan is unchanged. + self.assertIn('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 + # upstream fails fast instead of hanging the receive-pack. + hook = git_gate_render_hook() + self.assertIn("BatchMode=yes", hook) + self.assertIn("ConnectTimeout=", hook) + class TestAccessHookRender(unittest.TestCase): def test_access_hook_refreshes_origin_on_upload_pack(self): @@ -216,6 +234,13 @@ class TestAccessHookRender(unittest.TestCase): self.assertIn("refusing to serve stale data", hook) self.assertIn("exit 1", hook) + def test_access_hook_ssh_is_non_interactive_and_bounded(self): + # Same hardening as the forward path: the fetch ssh must not + # prompt and must time out rather than hang upload-pack. + hook = git_gate_render_access_hook() + self.assertIn("BatchMode=yes", hook) + self.assertIn("ConnectTimeout=", hook) + class TestPrepare(unittest.TestCase): def setUp(self):