27b2d78b11
Three coupled fixes that close a documented bypass of git-gate's gitleaks pre-receive hook: 1. cred-proxy refuses git smart-HTTP push at runtime. Any path ending in /git-receive-pack or /info/refs?service=git-receive-pack returns 403 with a pointer at the bottle.git SSH path. Fetch (upload-pack) is still allowed — the bypass we're closing is push, where gitleaks is the load-bearing scanner. Hard guarantee. 2. The provisioner suppresses the cred-proxy `~/.gitconfig` insteadOf rewrite for any host already declared in bottle.git. git-gate is the canonical git path there; we don't write a competing rule that would let `git clone https://<host>/...` succeed in ways that confuse on push. Defense in depth — (1) is the hard guarantee. 3. cred-proxy routes its outbound HTTPS through pipelock. The sidecar's environ now sets HTTPS_PROXY=<pipelock-url>, and the image's entrypoint runs `update-ca-certificates` over the per-bottle pipelock CA (docker cp'd into /usr/local/share/ca-certificates/pipelock.crt before start) so the proxy's HTTPS client trusts pipelock's bumped certs. Consequence: pipelock's allowlist + body scanner now sit in the cred-proxy egress path the same way they sit in front of direct agent traffic. The cred-proxy upstream hosts (api.github.com, github.com, gitea hosts, registry.npmjs.org) come OFF pipelock's passthrough_domains. Only api.anthropic.com remains on passthrough (LLM body content legitimately trips DLP). PRD 0010 updated to reflect all three. Tests adjusted: the "cred-proxy hosts go on passthrough" assertion in test_pipelock_allowlist flips to "they don't", a new TestIsGitPushRequest exercises the smart-HTTP refusal predicate, and the gitconfig renderer tests cover the per-host suppression matrix.
144 lines
5.3 KiB
Python
144 lines
5.3 KiB
Python
"""Unit: cred-proxy agent-side provisioner renderers (PRD 0010).
|
|
|
|
The docker cp / docker exec side effects are exercised by integration
|
|
tests; these unit tests cover the pure render functions."""
|
|
|
|
import unittest
|
|
|
|
from claude_bottle.backend.docker.provision.cred_proxy import (
|
|
render_cred_proxy_gitconfig,
|
|
render_npmrc,
|
|
render_tea_config,
|
|
)
|
|
from claude_bottle.cred_proxy import cred_proxy_upstreams_for_bottle
|
|
from claude_bottle.manifest import Manifest
|
|
|
|
|
|
def _bottle(tokens):
|
|
return Manifest.from_json_obj({
|
|
"bottles": {"dev": {"tokens": tokens}},
|
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
}).bottles["dev"]
|
|
|
|
|
|
def _upstreams(tokens):
|
|
return cred_proxy_upstreams_for_bottle(_bottle(tokens))
|
|
|
|
|
|
class TestRenderNpmrc(unittest.TestCase):
|
|
def test_empty_when_no_npm_route(self):
|
|
self.assertEqual("", render_npmrc(_upstreams([])))
|
|
self.assertEqual("", render_npmrc(_upstreams([
|
|
{"Kind": "anthropic", "TokenRef": "A"},
|
|
])))
|
|
|
|
def test_writes_registry_line(self):
|
|
out = render_npmrc(_upstreams([
|
|
{"Kind": "npm", "TokenRef": "NPM_TOKEN"},
|
|
]))
|
|
self.assertEqual("registry=http://cred-proxy:9099/npm/\n", out)
|
|
|
|
def test_omits_authtoken(self):
|
|
# The proxy injects Authorization at request time. The npmrc
|
|
# deliberately carries no _authToken — a stale token there
|
|
# would just get stripped, but it also creates the false
|
|
# impression that the agent holds a credential.
|
|
out = render_npmrc(_upstreams([
|
|
{"Kind": "npm", "TokenRef": "NPM_TOKEN"},
|
|
]))
|
|
self.assertNotIn("_authToken", out)
|
|
self.assertNotIn("NPM_TOKEN", out)
|
|
|
|
|
|
class TestRenderGitconfig(unittest.TestCase):
|
|
def test_empty_when_no_github_or_gitea(self):
|
|
self.assertEqual("", render_cred_proxy_gitconfig(_upstreams([
|
|
{"Kind": "anthropic", "TokenRef": "A"},
|
|
{"Kind": "npm", "TokenRef": "N"},
|
|
])))
|
|
|
|
def test_github_writes_https_insteadof(self):
|
|
out = render_cred_proxy_gitconfig(_upstreams([
|
|
{"Kind": "github", "TokenRef": "GITHUB_TOKEN"},
|
|
]))
|
|
self.assertIn('[url "http://cred-proxy:9099/gh-git/"]', out)
|
|
self.assertIn("insteadOf = https://github.com/", out)
|
|
|
|
def test_gitea_writes_per_host_insteadof(self):
|
|
out = render_cred_proxy_gitconfig(_upstreams([
|
|
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
|
|
"Url": "https://gitea.dideric.is"},
|
|
]))
|
|
self.assertIn('[url "http://cred-proxy:9099/gitea/gitea.dideric.is/"]', out)
|
|
self.assertIn("insteadOf = https://gitea.dideric.is/", out)
|
|
|
|
def test_two_giteas_yield_two_rules(self):
|
|
out = render_cred_proxy_gitconfig(_upstreams([
|
|
{"Kind": "gitea", "TokenRef": "G1",
|
|
"Url": "https://gitea.dideric.is"},
|
|
{"Kind": "gitea", "TokenRef": "G2",
|
|
"Url": "https://gitea.example.com"},
|
|
]))
|
|
self.assertEqual(2, out.count("insteadOf"))
|
|
self.assertIn("gitea.dideric.is/", out)
|
|
self.assertIn("gitea.example.com/", out)
|
|
|
|
def test_github_suppressed_when_git_gate_covers_host(self):
|
|
# When bottle.git brokers github.com over SSH, git-gate is the
|
|
# canonical git path. The cred-proxy https://github.com/
|
|
# rewrite would let the agent push over HTTPS — bypassing
|
|
# gitleaks. Suppress it.
|
|
out = render_cred_proxy_gitconfig(
|
|
_upstreams([{"Kind": "github", "TokenRef": "GH"}]),
|
|
{"github.com"},
|
|
)
|
|
self.assertEqual("", out)
|
|
|
|
def test_gitea_suppressed_when_git_gate_covers_host(self):
|
|
out = render_cred_proxy_gitconfig(
|
|
_upstreams([{"Kind": "gitea", "TokenRef": "T",
|
|
"Url": "https://gitea.dideric.is"}]),
|
|
{"gitea.dideric.is"},
|
|
)
|
|
self.assertEqual("", out)
|
|
|
|
def test_partial_suppression_keeps_other_giteas(self):
|
|
# Two gitea instances; git-gate brokers one. The other still
|
|
# gets the cred-proxy rewrite.
|
|
out = render_cred_proxy_gitconfig(
|
|
_upstreams([
|
|
{"Kind": "gitea", "TokenRef": "T1",
|
|
"Url": "https://gitea.dideric.is"},
|
|
{"Kind": "gitea", "TokenRef": "T2",
|
|
"Url": "https://gitea.example.com"},
|
|
]),
|
|
{"gitea.dideric.is"},
|
|
)
|
|
self.assertNotIn("gitea.dideric.is/", out)
|
|
self.assertIn("gitea.example.com/", out)
|
|
|
|
|
|
class TestRenderTeaConfig(unittest.TestCase):
|
|
def test_empty_when_no_gitea(self):
|
|
self.assertEqual("", render_tea_config(_upstreams([
|
|
{"Kind": "github", "TokenRef": "G"},
|
|
])))
|
|
|
|
def test_single_gitea_login_block(self):
|
|
out = render_tea_config(_upstreams([
|
|
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
|
|
"Url": "https://gitea.dideric.is"},
|
|
]))
|
|
self.assertIn("logins:", out)
|
|
self.assertIn("- name: gitea.dideric.is", out)
|
|
self.assertIn("url: http://cred-proxy:9099/gitea/gitea.dideric.is/", out)
|
|
# Placeholder token, not the host env var name (which is not a
|
|
# secret but also not useful) or the real value (which the
|
|
# provisioner does not have).
|
|
self.assertIn("token: cred-proxy-placeholder", out)
|
|
self.assertNotIn("GITEA_TOKEN", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|