diff --git a/tests/integration/test_mitmproxy_allows_normal_https.py b/tests/integration/test_mitmproxy_allows_normal_https.py new file mode 100644 index 0000000..35fc631 --- /dev/null +++ b/tests/integration/test_mitmproxy_allows_normal_https.py @@ -0,0 +1,167 @@ +"""Integration: with mitmproxy in front of pipelock, a plain HTTPS +GET to an allowlisted host with no credential pattern still gets +through end-to-end. + +The complement to test_mitmproxy_blocks_secret_https_post — together +they isolate the addon's two paths (block vs. allow). This test +also functions as the end-to-end TLS-trust check: if the agent's +trust store didn't have mitmproxy's CA installed, the TLS handshake +between the agent and mitmproxy's bumped cert would fail and the +fetch would throw before we ever saw a response. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +import unittest +from pathlib import Path + +from claude_bottle.backend import BottleSpec, get_bottle_backend +from tests._docker import skip_unless_docker +from tests.fixtures import fixture_minimal + + +# raw.githubusercontent.com is in the baked-in DEFAULT_ALLOWLIST. +# Pick a file path that's stable enough across runs — `git`'s own +# README.md on the master branch is a long-lived artifact and one +# of github's most-trafficked raw files. +_TARGET_URL = "https://raw.githubusercontent.com/git/git/master/README.md" + +# stdlib http (for CONNECT) + tls (for the bumped tunnel); see the +# block test for the rationale on not pulling undici in as a dep. +# +# Output contract: +# - "status=" HTTP status from upstream (or addon, if +# blocked) +# - "bridge=" X-Pipelock-Bridge header; empty on allow +# - "len=" response body length, sanity-check it's a +# real response and not an empty proxy stub +# - "error=<...>" thrown error +_PROBE_JS = r""" +const http = require('http'); +const tls = require('tls'); + +const proxy = new URL(process.env.HTTPS_PROXY); + +const connectReq = http.request({ + host: proxy.hostname, + port: proxy.port, + method: 'CONNECT', + path: 'raw.githubusercontent.com:443', +}); +connectReq.setTimeout(10000, () => { + console.log('timeout=connect'); + connectReq.destroy(); +}); +connectReq.on('error', (e) => { + console.log('error=' + (e.code || '') + ' ' + e.message); +}); +connectReq.on('connect', (res, socket) => { + if (res.statusCode !== 200) { + console.log('status=' + res.statusCode); + console.log('bridge=' + (res.headers['x-pipelock-bridge'] || '')); + return; + } + const tlsSocket = tls.connect({ + socket: socket, + servername: 'raw.githubusercontent.com', + }); + tlsSocket.on('secureConnect', () => { + tlsSocket.write( + 'GET /git/git/master/README.md HTTP/1.1\r\n' + + 'Host: raw.githubusercontent.com\r\n' + + 'User-Agent: claude-bottle-mitm-test\r\n' + + 'Accept: */*\r\n' + + 'Connection: close\r\n' + + '\r\n' + ); + }); + let buf = Buffer.alloc(0); + tlsSocket.on('data', (c) => { buf = Buffer.concat([buf, c]); }); + tlsSocket.on('end', () => { + const text = buf.toString('utf8'); + const headersEnd = text.indexOf('\r\n\r\n'); + const head = headersEnd >= 0 ? text.slice(0, headersEnd) : text; + const body = headersEnd >= 0 ? text.slice(headersEnd + 4) : ''; + const lines = head.split('\r\n'); + const m = lines[0].match(/HTTP\/[\d.]+ (\d+)/); + let bridge = ''; + for (let i = 1; i < lines.length; i++) { + const ix = lines[i].indexOf(': '); + if (ix < 0) continue; + if (lines[i].slice(0, ix).toLowerCase() === 'x-pipelock-bridge') { + bridge = lines[i].slice(ix + 2); + } + } + console.log('status=' + (m ? m[1] : '?')); + console.log('bridge=' + bridge); + console.log('len=' + body.length); + }); + tlsSocket.on('error', (e) => { + console.log('tls_error=' + (e.code || '') + ' ' + e.message); + }); +}); +connectReq.end(); +""" + + +@skip_unless_docker() +class TestMitmproxyAllowsNormalHttps(unittest.TestCase): + @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_https_get_to_allowed_host_succeeds(self): + backend = get_bottle_backend() + stage_dir = Path(tempfile.mkdtemp(prefix="cb-test-stage.")) + try: + spec = BottleSpec( + manifest=fixture_minimal(), + agent_name="demo", + copy_cwd=False, + user_cwd=str(stage_dir), + forward_oauth_token=False, + ) + plan = backend.prepare(spec, stage_dir=stage_dir) + with backend.launch(plan) as bottle: + script = ( + "set -e\n" + "cat > /tmp/probe.js <<'PROBE_EOF'\n" + f"{_PROBE_JS}\n" + "PROBE_EOF\n" + "node /tmp/probe.js\n" + ) + result = bottle.exec(script) + finally: + shutil.rmtree(stage_dir, ignore_errors=True) + + self.assertEqual( + 0, result.returncode, + f"exec wrapper failed: stdout={result.stdout!r} stderr={result.stderr!r}", + ) + # The TLS-trust setup is implicit here — if it had failed, + # fetch would have thrown rather than returned a status. + self.assertIn( + "status=200", result.stdout, + f"expected 200 from raw.githubusercontent.com; got: {result.stdout!r}", + ) + # X-Pipelock-Bridge is set only on the addon's short-circuit + # paths (block / misconfigured / scanner-unreachable). An + # allow flow goes straight through mitmproxy to upstream and + # the header should be absent. + self.assertIn( + "bridge=\n", result.stdout, + f"X-Pipelock-Bridge unexpectedly present on the allow " + f"path: {result.stdout!r}", + ) + # Sanity: the README is many KB. An empty body would suggest + # the response was synthesized by something in the chain + # rather than fetched from github. + self.assertNotIn("len=0\n", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_mitmproxy_blocks_secret_https_post.py b/tests/integration/test_mitmproxy_blocks_secret_https_post.py new file mode 100644 index 0000000..c6393df --- /dev/null +++ b/tests/integration/test_mitmproxy_blocks_secret_https_post.py @@ -0,0 +1,172 @@ +"""Integration: with mitmproxy in front of pipelock, a credential +POST sent over HTTPS is now blocked by pipelock's body-scan layer. + +This is the HTTPS variant of test_pipelock_blocks_secret_post — the +two together prove the TLS-interception layer is doing the work the +PRD targets. The earlier plain-HTTP test only fired because the agent +was forced to bypass TLS; real Claude Code traffic to api.anthropic.com +goes over CONNECT-tunneled HTTPS and would have slipped past pipelock +prior to this PRD. + +End-to-end: drives `BottleBackend.prepare → launch` so the real +image build, network plumbing, pipelock sidecar, mitmproxy sidecar, +ephemeral CA generation, and trust-store install are all in the +loop. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +import unittest +from pathlib import Path + +from claude_bottle.backend import BottleSpec, get_bottle_backend +from claude_bottle.manifest import Manifest +from tests._docker import skip_unless_docker + + +# Synthetic value shaped like a GitHub Personal Access Token; not a +# real credential. Pipelock's default DLP rules pattern-match this +# format and mitmproxy's addon short-circuits with the 403 it +# receives back. +_FAKE_TOKEN = "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ" + + +# Build the request by hand using stdlib `http` (for CONNECT) and +# `tls` (for the bumped tunnel). Node 22's `fetch` doesn't expose +# proxy configuration without undici as an installable dep, and +# this project keeps the bottle image dep-light. NODE_EXTRA_CA_CERTS +# is wired by launch.py so the agent trusts mitmproxy's bumped cert. +# +# Output contract (parsed by the test): +# - "status=" HTTP status of the decrypted response +# - "bridge=" X-Pipelock-Bridge header from the addon's +# short-circuit, empty on the allow path +# - "error=<...>" thrown error +_PROBE_JS = r""" +const http = require('http'); +const tls = require('tls'); + +const proxy = new URL(process.env.HTTPS_PROXY); +const body = 'token=' + process.env.FAKE_TOKEN; + +const connectReq = http.request({ + host: proxy.hostname, + port: proxy.port, + method: 'CONNECT', + path: 'api.anthropic.com:443', +}); +connectReq.setTimeout(8000, () => { + console.log('timeout=connect'); + connectReq.destroy(); +}); +connectReq.on('error', (e) => { + console.log('error=' + (e.code || '') + ' ' + e.message); +}); +connectReq.on('connect', (res, socket) => { + if (res.statusCode !== 200) { + console.log('status=' + res.statusCode); + console.log('bridge=' + (res.headers['x-pipelock-bridge'] || '')); + return; + } + const tlsSocket = tls.connect({ + socket: socket, + servername: 'api.anthropic.com', + }); + tlsSocket.on('secureConnect', () => { + tlsSocket.write( + 'POST /dlp-probe HTTP/1.1\r\n' + + 'Host: api.anthropic.com\r\n' + + 'Content-Type: application/x-www-form-urlencoded\r\n' + + 'Content-Length: ' + Buffer.byteLength(body) + '\r\n' + + 'Connection: close\r\n' + + '\r\n' + body + ); + }); + let buf = ''; + tlsSocket.on('data', (c) => { buf += c.toString('utf8'); }); + tlsSocket.on('end', () => { + const lines = buf.split('\r\n'); + const m = lines[0].match(/HTTP\/[\d.]+ (\d+)/); + let bridge = ''; + for (let i = 1; i < lines.length; i++) { + if (lines[i] === '') break; + const ix = lines[i].indexOf(': '); + if (ix < 0) continue; + if (lines[i].slice(0, ix).toLowerCase() === 'x-pipelock-bridge') { + bridge = lines[i].slice(ix + 2); + } + } + console.log('status=' + (m ? m[1] : '?')); + console.log('bridge=' + bridge); + }); + tlsSocket.on('error', (e) => { + console.log('tls_error=' + (e.code || '') + ' ' + e.message); + }); +}); +connectReq.end(); +""" + + +@skip_unless_docker() +class TestMitmproxyBlocksSecretHttpsPost(unittest.TestCase): + @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_https_post_with_credential_body_is_blocked(self): + manifest = Manifest.from_json_obj({ + "bottles": { + "dev": {"env": {"FAKE_TOKEN": _FAKE_TOKEN}}, + }, + "agents": { + "demo": {"skills": [], "prompt": "", "bottle": "dev"}, + }, + }) + backend = get_bottle_backend() + stage_dir = Path(tempfile.mkdtemp(prefix="cb-test-stage.")) + try: + spec = BottleSpec( + manifest=manifest, + agent_name="demo", + copy_cwd=False, + user_cwd=str(stage_dir), + forward_oauth_token=False, + ) + plan = backend.prepare(spec, stage_dir=stage_dir) + with backend.launch(plan) as bottle: + script = ( + "set -e\n" + "cat > /tmp/probe.js <<'PROBE_EOF'\n" + f"{_PROBE_JS}\n" + "PROBE_EOF\n" + "node /tmp/probe.js\n" + ) + result = bottle.exec(script) + finally: + shutil.rmtree(stage_dir, ignore_errors=True) + + self.assertEqual( + 0, result.returncode, + f"exec wrapper failed: stdout={result.stdout!r} stderr={result.stderr!r}", + ) + # The addon short-circuits the flow with X-Pipelock-Bridge: block + # on a pipelock block — the cleanest signal that the chain + # mitmproxy(bump) -> addon(forward) -> pipelock(scan) -> block + # all happened, end to end. + self.assertIn( + "status=403", result.stdout, + f"expected 403 from pipelock block; got: {result.stdout!r}", + ) + self.assertIn( + "bridge=block", result.stdout, + f"X-Pipelock-Bridge header missing; the addon may not be " + f"in path: {result.stdout!r}", + ) + + +if __name__ == "__main__": + unittest.main()