PRD 0008: Git gate #11
+10
-5
@@ -10,12 +10,16 @@
|
|||||||
# default route, so the image is fully self-contained: no apk pulls at
|
# default route, so the image is fully self-contained: no apk pulls at
|
||||||
# boot, no remote registry lookups during the entrypoint.
|
# boot, no remote registry lookups during the entrypoint.
|
||||||
|
|
||||||
FROM alpine:3.20
|
# Base on the upstream gitleaks image (alpine + gitleaks v8.x);
|
||||||
|
# alpine doesn't package gitleaks so this avoids a separate
|
||||||
|
# install path. Pinned by digest for reproducibility.
|
||||||
|
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
|
||||||
|
|
||||||
# git for the daemon + push-to-upstream;
|
# openssh-client supplies the upstream SSH transport the pre-receive
|
||||||
# openssh-client for the upstream SSH transport;
|
# hook uses to forward accepted refs. git-daemon is the listener the
|
||||||
# gitleaks is the actual scanner the pre-receive hook calls.
|
# agent pushes to (alpine ships `git-daemon` as a sub-package, not
|
||||||
RUN apk add --no-cache git openssh-client gitleaks
|
# part of `git`). The `git` core binary is already in the base image.
|
||||||
|
RUN apk add --no-cache openssh-client git-daemon
|
||||||
|
|
||||||
# Layout the gate uses at runtime:
|
# Layout the gate uses at runtime:
|
||||||
# /git-gate-entrypoint.sh — docker-cp'd at start time
|
# /git-gate-entrypoint.sh — docker-cp'd at start time
|
||||||
@@ -29,4 +33,5 @@ RUN apk add --no-cache git openssh-client gitleaks
|
|||||||
# defensively.
|
# defensively.
|
||||||
RUN mkdir -p /etc/git-gate /git-gate/creds /git
|
RUN mkdir -p /etc/git-gate /git-gate/creds /git
|
||||||
|
|
||||||
|
# Base image's ENTRYPOINT is the gitleaks binary; override explicitly.
|
||||||
ENTRYPOINT ["/bin/sh", "/git-gate-entrypoint.sh"]
|
ENTRYPOINT ["/bin/sh", "/git-gate-entrypoint.sh"]
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Integration: per-agent git-gate sidecar (PRD 0008).
|
||||||
|
|
||||||
|
Two tests against a real Docker daemon:
|
||||||
|
|
||||||
|
1. A freshly-started gate answers ls-remote requests on its
|
||||||
|
internal-network address. Proves the daemon is up and the
|
||||||
|
bare repos rendered by the entrypoint are exported.
|
||||||
|
2. A push containing a gitleaks-detectable secret is rejected
|
||||||
|
by the pre-receive hook with a non-zero exit on the agent
|
||||||
|
side and a gitleaks-rejection line in the response. This is
|
||||||
|
the PRD's success criterion.
|
||||||
|
|
||||||
|
A successful clean-push roundtrip needs a real upstream SSH host;
|
||||||
|
deferred to a follow-up integration test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_bottle.backend.docker.git_gate import (
|
||||||
|
DockerGitGate,
|
||||||
|
build_git_gate_image,
|
||||||
|
)
|
||||||
|
from claude_bottle.backend.docker.network import (
|
||||||
|
network_create_egress,
|
||||||
|
network_create_internal,
|
||||||
|
network_remove,
|
||||||
|
)
|
||||||
|
from claude_bottle.manifest import Manifest
|
||||||
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
# The official gitleaks image already has git + alpine; reusing it
|
||||||
|
# for the client side too saves a separate image pull.
|
||||||
|
CLIENT_IMAGE = "zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f"
|
||||||
|
|
||||||
|
# Synthetic high-entropy AKIA-shaped string; gitleaks's aws-access-token
|
||||||
|
# rule fires on this with the default config. AWS's own example
|
||||||
|
# ("AKIAIOSFODNN7EXAMPLE") is NOT flagged by gitleaks v8.x — entropy
|
||||||
|
# filter rejects it — so we use a distinct random-looking value.
|
||||||
|
FAKE_AWS_KEY = "AKIAQRJHK7N5ZPM2VXTL"
|
||||||
|
|
||||||
|
|
||||||
|
@skip_unless_docker()
|
||||||
|
class TestGitGateSidecar(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
# Pre-pull the client/gitleaks base so per-test runs aren't
|
||||||
|
# racing the registry. Skip cleanly on pull failure (a real
|
||||||
|
# outage is out of scope here).
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "pull", CLIENT_IMAGE],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise unittest.SkipTest(f"could not pull {CLIENT_IMAGE}")
|
||||||
|
# Build the gate image once for the class. Layer cache makes
|
||||||
|
# repeated runs cheap.
|
||||||
|
build_git_gate_image()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# DNS hostnames on user-defined Docker networks max out at 63
|
||||||
|
# chars per label (RFC 1035). The full container name is
|
||||||
|
# `claude-bottle-git-gate-<slug>` = 23 + len(slug), so the slug
|
||||||
|
# has to stay under ~40 to be resolvable. Keep it short.
|
||||||
|
suffix = self.id().rsplit('.', 1)[-1].replace('_', '-')[-12:]
|
||||||
|
self.slug = f"t{os.getpid()}-{suffix}"
|
||||||
|
self.gate_name = ""
|
||||||
|
self.internal_net = ""
|
||||||
|
self.egress_net = ""
|
||||||
|
self.work_dir = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if self.gate_name:
|
||||||
|
DockerGitGate().stop(self.gate_name)
|
||||||
|
for n in (self.internal_net, self.egress_net):
|
||||||
|
if n:
|
||||||
|
network_remove(n)
|
||||||
|
shutil.rmtree(self.work_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _start_gate(self, name: str = "foo") -> str:
|
||||||
|
"""Build a one-upstream gate and bring it up. Returns the
|
||||||
|
container name (== git-gate hostname on the internal net)."""
|
||||||
|
# Contents of the fake key don't matter for these tests — the
|
||||||
|
# rejection-path hook never reaches phase 2 where it would be
|
||||||
|
# used, and ls-remote doesn't push.
|
||||||
|
fake_key = self.work_dir / "fake-key"
|
||||||
|
fake_key.write_text("not-a-real-key\n")
|
||||||
|
|
||||||
|
manifest = Manifest.from_json_obj({
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"git": [{
|
||||||
|
"Name": name,
|
||||||
|
"Upstream": "ssh://git@upstream.invalid/path.git",
|
||||||
|
"IdentityFile": str(fake_key),
|
||||||
|
"KnownHostKey": "ssh-ed25519 AAAAEXAMPLE",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
bottle = manifest.bottles["dev"]
|
||||||
|
|
||||||
|
gate = DockerGitGate()
|
||||||
|
prep = gate.prepare(bottle, self.slug, self.work_dir)
|
||||||
|
|
||||||
|
self.internal_net = network_create_internal(self.slug)
|
||||||
|
self.egress_net = network_create_egress(self.slug)
|
||||||
|
plan = dataclasses.replace(
|
||||||
|
prep,
|
||||||
|
internal_network=self.internal_net,
|
||||||
|
egress_network=self.egress_net,
|
||||||
|
)
|
||||||
|
self.gate_name = gate.start(plan)
|
||||||
|
return self.gate_name
|
||||||
|
|
||||||
|
@unittest.skipIf(
|
||||||
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
|
"skipped under act_runner: docker socket mount topology breaks "
|
||||||
|
"in-process visibility of networks created on the host daemon",
|
||||||
|
)
|
||||||
|
def test_ls_remote_succeeds_against_fresh_gate(self):
|
||||||
|
"""A freshly-started gate has an empty bare repo per upstream;
|
||||||
|
`git ls-remote` returns no refs and exits 0. Probes the gate
|
||||||
|
from a sibling container on the same internal network — same
|
||||||
|
access topology the agent uses in production."""
|
||||||
|
gate = self._start_gate("foo")
|
||||||
|
# git ls-remote retries weren't strictly needed in local runs,
|
||||||
|
# but the daemon takes a beat to bind after docker start.
|
||||||
|
probe = subprocess.run(
|
||||||
|
["docker", "run", "--rm",
|
||||||
|
"--network", self.internal_net,
|
||||||
|
"--entrypoint", "sh",
|
||||||
|
CLIENT_IMAGE,
|
||||||
|
"-c",
|
||||||
|
f"for i in $(seq 1 15); do "
|
||||||
|
f" git ls-remote git://{gate}/foo.git >/tmp/out 2>&1 && exit 0;"
|
||||||
|
f" sleep 1;"
|
||||||
|
f"done;"
|
||||||
|
f"cat /tmp/out; exit 1"],
|
||||||
|
capture_output=True, text=True, timeout=60, check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0, probe.returncode,
|
||||||
|
f"ls-remote failed: stdout={probe.stdout!r} stderr={probe.stderr!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@unittest.skipIf(
|
||||||
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
|
"skipped under act_runner: docker socket mount topology breaks "
|
||||||
|
"in-process visibility of networks created on the host daemon",
|
||||||
|
)
|
||||||
|
def test_push_with_secret_is_rejected(self):
|
||||||
|
"""The PRD 0008 success criterion: a push containing a
|
||||||
|
gitleaks-detectable secret is rejected; the hook's "gitleaks
|
||||||
|
rejected" line appears in the response, and git push exits
|
||||||
|
non-zero on the client side."""
|
||||||
|
gate = self._start_gate("foo")
|
||||||
|
push_script = (
|
||||||
|
"set -e\n"
|
||||||
|
"cd /tmp\n"
|
||||||
|
# Wait for git daemon to bind. ls-remote retries until
|
||||||
|
# connection works; we then assume the gate is ready.
|
||||||
|
f"for i in $(seq 1 15); do "
|
||||||
|
f" git ls-remote git://{gate}/foo.git >/dev/null 2>&1 && break;"
|
||||||
|
f" sleep 1;"
|
||||||
|
f"done\n"
|
||||||
|
"git init -q -b main repo\n"
|
||||||
|
"cd repo\n"
|
||||||
|
"git config user.email test@example.com\n"
|
||||||
|
"git config user.name test\n"
|
||||||
|
f"echo '{FAKE_AWS_KEY}' > leak.txt\n"
|
||||||
|
"git add leak.txt\n"
|
||||||
|
"git commit -q -m leak\n"
|
||||||
|
f"git push git://{gate}/foo.git main 2>&1\n"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "run", "--rm",
|
||||||
|
"--network", self.internal_net,
|
||||||
|
"--entrypoint", "sh",
|
||||||
|
CLIENT_IMAGE,
|
||||||
|
"-c", push_script],
|
||||||
|
capture_output=True, text=True, timeout=120, check=False,
|
||||||
|
)
|
||||||
|
combined = result.stdout + result.stderr
|
||||||
|
self.assertNotEqual(
|
||||||
|
0, result.returncode,
|
||||||
|
f"expected push to fail; output={combined!r}",
|
||||||
|
)
|
||||||
|
# Hook's stderr is delivered to the client via the `remote:`
|
||||||
|
# prefix during a git push. Either token is enough to prove
|
||||||
|
# the pre-receive hook ran and rejected the push.
|
||||||
|
self.assertTrue(
|
||||||
|
"gitleaks rejected" in combined or "leaks found" in combined,
|
||||||
|
f"expected a gitleaks rejection in the response; got: {combined!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user