git-gate rejects all new-branch pushes: pre-receive scans full history and trips on test-fixture secrets #106

Closed
opened 2026-05-29 01:42:12 -04:00 by didericis · 2 comments
Owner

Summary

Pushing a new branch through git-gate never reaches the upstream. The pre-receive hook scans the entire ancestry of a new ref, and the repo history contains 11 deliberately secret-shaped strings (test fixtures + demo files), so gitleaks rejects every new-branch push. To the agent it presents as a silent hang, not a rejection.

Repro

From inside a bottle whose bottle.git points at this repo:

git checkout -b test/push-check && git commit --allow-empty -m 'test' && git push origin test/push-check

The push hangs and the branch never appears on Gitea. The ref is never written to git-gate's bare repo either.

Root cause

In git_gate_render_hook() (bot_bottle/git_gate.py), for a new ref (old = 0000…):

if [ "$old" = "$zero" ]; then
  log_opts="$new"          # `git log <newsha>` = the ENTIRE history, not just incoming commits
else
  log_opts="$old..$new"
fi
gitleaks git --log-opts="$log_opts" ...   # exit 1 if ANY finding anywhere in history

A new branch therefore re-scans all ~500 commits. gitleaks finds 11 pre-existing hits and the hook exit 1s, rejecting the push. Confirmed against the live bare repo: 438 commits scanned … leaks found: 11.

The 11 are all fixtures/demos, never real secrets:

  • 5x generic-api-key, 4x github-pat, 2x aws-access-token
  • in bot-bottle.demo.json / claude-bottle.demo.json, docs/demo.tape, and the tests/integration/test_pipelock_blocks_secret_*, test_sandbox_escape, test_git_gate_sidecar files — i.e. strings that exist to exercise the DLP.

Because they're in history, no new branch can ever be pushed through the gate.

Secondary problems surfaced

  1. The rejection is invisible to the client. The hook writes git-gate: gitleaks rejected push to … to stderr, but over the git:// + smolmachines port-forward path the agent saw a ~75s silent hang with no remote: lines. Blocks should be visible.
  2. Forward ssh lacks BatchMode=yes / ConnectTimeout (ssh -i $keyfile -o UserKnownHostsFile=… -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes). Any prompt or unreachable upstream hangs the hook indefinitely. (Not the cause here — the forward itself works, verified Everything up-to-date — but a latent hang risk.)

Proposed fix

  • Scope the new-ref scan to incoming commits. Use --log-opts="$new --not --all" (or exclude refs already on the gate/upstream) so a push is scanned for what it introduces, not the whole repo history. A pre-existing historical finding must not block unrelated new branches.
  • Surface the rejection to the client so a gitleaks/forward block shows as a remote: error instead of a silent hang (investigate sideband relay over git:// + the smolmachines forward).
  • Harden the forward ssh with BatchMode=yes and a ConnectTimeout so a bad upstream fails fast instead of hanging.
  • Decide policy for the existing fixture findings (they're intentional; scoping the scan to incoming commits resolves the practical problem without scrubbing history).

Notes

Diagnosed live: the rewrite/insteadOf, the deploy key (gitea-delos-2, authenticates as didericis with write), and the forward push all work — the only blocker is the full-history gitleaks scan on new refs.

## Summary Pushing a **new branch** through git-gate never reaches the upstream. The pre-receive hook scans the *entire ancestry* of a new ref, and the repo history contains 11 deliberately secret-shaped strings (test fixtures + demo files), so gitleaks rejects every new-branch push. To the agent it presents as a silent hang, not a rejection. ## Repro From inside a bottle whose `bottle.git` points at this repo: ``` git checkout -b test/push-check && git commit --allow-empty -m 'test' && git push origin test/push-check ``` The push hangs and the branch never appears on Gitea. The ref is never written to git-gate's bare repo either. ## Root cause In `git_gate_render_hook()` (`bot_bottle/git_gate.py`), for a new ref (`old = 0000…`): ```sh if [ "$old" = "$zero" ]; then log_opts="$new" # `git log <newsha>` = the ENTIRE history, not just incoming commits else log_opts="$old..$new" fi gitleaks git --log-opts="$log_opts" ... # exit 1 if ANY finding anywhere in history ``` A new branch therefore re-scans all ~500 commits. `gitleaks` finds **11** pre-existing hits and the hook `exit 1`s, rejecting the push. Confirmed against the live bare repo: `438 commits scanned … leaks found: 11`. The 11 are all fixtures/demos, never real secrets: - 5x `generic-api-key`, 4x `github-pat`, 2x `aws-access-token` - in `bot-bottle.demo.json` / `claude-bottle.demo.json`, `docs/demo.tape`, and the `tests/integration/test_pipelock_blocks_secret_*`, `test_sandbox_escape`, `test_git_gate_sidecar` files — i.e. strings that *exist to exercise the DLP*. Because they're in history, **no new branch can ever be pushed** through the gate. ## Secondary problems surfaced 2. **The rejection is invisible to the client.** The hook writes `git-gate: gitleaks rejected push to …` to stderr, but over the `git://` + smolmachines port-forward path the agent saw a ~75s silent hang with no `remote:` lines. Blocks should be visible. 3. **Forward `ssh` lacks `BatchMode=yes` / `ConnectTimeout`** (`ssh -i $keyfile -o UserKnownHostsFile=… -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes`). Any prompt or unreachable upstream hangs the hook indefinitely. (Not the cause here — the forward itself works, verified `Everything up-to-date` — but a latent hang risk.) ## Proposed fix - [x] **Scope the new-ref scan to incoming commits.** Use `--log-opts="$new --not --all"` (or exclude refs already on the gate/upstream) so a push is scanned for what *it* introduces, not the whole repo history. A pre-existing historical finding must not block unrelated new branches. - [x] **Surface the rejection to the client** so a gitleaks/forward block shows as a `remote:` error instead of a silent hang (investigate sideband relay over git:// + the smolmachines forward). - [x] **Harden the forward `ssh`** with `BatchMode=yes` and a `ConnectTimeout` so a bad upstream fails fast instead of hanging. - [x] Decide policy for the existing fixture findings (they're intentional; scoping the scan to incoming commits resolves the practical problem without scrubbing history). ## Notes Diagnosed live: the rewrite/`insteadOf`, the deploy key (`gitea-delos-2`, authenticates as `didericis` with write), and the forward push all work — the only blocker is the full-history gitleaks scan on new refs.
didericis added the Kind/Bug label 2026-05-29 01:42:39 -04:00
didericis-claude was assigned by didericis 2026-05-29 01:42:44 -04:00
Author
Owner

Why does gitleaks default to scanning all? Is there a potential for pushing to the upstream if we exclude refs already on the gate? I want to ensure change doesn't introduce a security issue.

Why does gitleaks default to scanning all? Is there a potential for pushing to the upstream if we exclude refs already on the gate? I want to ensure change doesn't introduce a security issue.
Collaborator

Good question — two parts: why it scans everything today, and whether scoping it is safe.

Why it scans all today

gitleaks isn't defaulting to anything — it scans whatever revision range the hook hands it. The hook picks the range:

if [ "$old" = "$zero" ]; then log_opts="$new"; else log_opts="$old..$new"; fi

For an existing branch there's an old tip to diff against ($old..$new = just the pushed delta). For a new branch old is all-zeros, so the hook falls back to $new — and git log <new> means "everything reachable from the new tip," i.e. the full ancestry. So it's the new-ref branch of the hook that's over-broad, not gitleaks.

Is --not --all safe? Yes — here's the argument.

git log $new --not --all = commits reachable from the pushed tip but not from any ref the gate already has. The worry is whether excluding "already-on-the-gate" commits lets a secret reach the upstream unscanned. It can't, because of how commits enter the gate's bare repo. There are only two paths:

  1. mirror-fetch from the upstream — the bare repo is remote add --mirror=fetch origin, and the access-hook fetches upstream before every upload-pack; and
  2. a push through the gate — which is gitleaks-scanned before it's forwarded.

So every commit reachable from a gate ref is either already on the upstream or was already scanned when pushed. A commit excluded by --not --all therefore can't be a new secret arriving upstream via this push:

  • if it's already upstream, re-scanning changes nothing (the secret, if any, is already there — blocking this branch wouldn't remove it); and
  • if it entered via an earlier push, it was already scanned.

The only commits that can carry a new secret upstream are the ones this push introduces — exactly the set $new --not --all scans. No gap. An agent can't pre-seed a secret commit as "already known" to dodge the scan: it can't write refs into the bare repo except by pushing (scanned), and the mirror refs come only from the trusted upstream.

Invariant the fix relies on: the bare repo's refs are populated only by upstream mirror-fetch and gitleaks-gated pushes. That holds in the current design (nothing writes refs out-of-band); if it ever changes, revisit.

Bonus: --not --all is also safer than today's $old..$new for force-pushes — on a non-fast-forward update $old..$new can skip commits off the direct path, whereas --not --all scans everything genuinely new regardless of ancestry shape. Worth using it for both branches of the hook.

Good question — two parts: why it scans everything today, and whether scoping it is safe. **Why it scans all today** gitleaks isn't defaulting to anything — it scans whatever revision range the hook hands it. The hook picks the range: ```sh if [ "$old" = "$zero" ]; then log_opts="$new"; else log_opts="$old..$new"; fi ``` For an existing branch there's an `old` tip to diff against (`$old..$new` = just the pushed delta). For a **new** branch `old` is all-zeros, so the hook falls back to `$new` — and `git log <new>` means "everything reachable from the new tip," i.e. the full ancestry. So it's the new-ref branch of the hook that's over-broad, not gitleaks. **Is `--not --all` safe? Yes — here's the argument.** `git log $new --not --all` = commits reachable from the pushed tip but **not** from any ref the gate already has. The worry is whether excluding "already-on-the-gate" commits lets a secret reach the upstream unscanned. It can't, because of how commits enter the gate's bare repo. There are only two paths: 1. **mirror-fetch from the upstream** — the bare repo is `remote add --mirror=fetch origin`, and the access-hook fetches upstream before every upload-pack; and 2. **a push through the gate** — which is gitleaks-scanned before it's forwarded. So every commit reachable from a gate ref is *either already on the upstream* or *was already scanned when pushed*. A commit excluded by `--not --all` therefore can't be a new secret arriving upstream via this push: - if it's already upstream, re-scanning changes nothing (the secret, if any, is already there — blocking this branch wouldn't remove it); and - if it entered via an earlier push, it was already scanned. The only commits that can carry a *new* secret upstream are the ones this push introduces — exactly the set `$new --not --all` scans. No gap. An agent can't pre-seed a secret commit as "already known" to dodge the scan: it can't write refs into the bare repo except by pushing (scanned), and the mirror refs come only from the trusted upstream. **Invariant the fix relies on:** the bare repo's refs are populated *only* by upstream mirror-fetch and gitleaks-gated pushes. That holds in the current design (nothing writes refs out-of-band); if it ever changes, revisit. **Bonus:** `--not --all` is also *safer* than today's `$old..$new` for force-pushes — on a non-fast-forward update `$old..$new` can skip commits off the direct path, whereas `--not --all` scans everything genuinely new regardless of ancestry shape. Worth using it for both branches of the hook.
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: didericis/bot-bottle#106