"""Synthetic security test suite for agent-broker. Everything here runs OFFLINE against fixture credentials: example-user / example-password-DO-NOT-USE / example-token-DO-NOT-USE No real vault data, no network to vault.paginica.com. The daemon under test uses the real BitwardenCliBackend driving a fake `bw` binary (see tests/fakebw.py) that serves fixture items -- the same code path the real daemon runs, end to end. """ from __future__ import annotations import base64 import json import os import subprocess import sys import tempfile import threading import unittest from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parent)) from fakebw import FAKE_BW from agent_credential_broker import policy from agent_credential_broker.adapters import common as ac from agent_credential_broker.adapters.cli_profile import _validate_args from agent_credential_broker.config import Config from agent_credential_broker.core import BrokerCore from agent_credential_broker.errors import BrokerError from agent_credential_broker.sanitize import sanitize_text, sanitize_headers from agent_credential_broker.server import _Server, call # ---------------------------------------------------------------- fixtures MASTER_PW = "fixture-master-password-DO-NOT-USE" PW = "example-password-DO-NOT-USE" TOKEN = "example-token-DO-NOT-USE" USER = "example-user" def fixture_items(): """bw-JSON shaped items served by the fake `bw` (list/get item).""" def item(i, name, username, password, uris, fields): return {"id": f"fixture-item-{i}", "type": 1, "name": name, "login": {"username": username, "password": password, "totp": None, "uris": [{"uri": u} for u in uris]}, "fields": [{"name": k, "value": v, "type": 0} for k, v in fields.items()], "notes": None} return [ item(1, "gitea admin", "gitea-admin", PW, ["https://git2.paginica.com"], {"agent.ref": "gitea.admin", "agent.adapters": "browser,http,git", "agent.allowed_urls": "https://git2.paginica.com", "agent.auth_type": "bearer", "agent.confirmation": "AUTO"}), item(2, "vps root", "root", PW, [], {"agent.ref": "vps.root", "agent.adapters": "ssh", "agent.allowed_hosts": "vps.paginica.com,192.0.2.1", "agent.confirmation": "EVERY_USE"}), item(3, "api token", "", TOKEN, ["https://service.example"], {"agent.ref": "test.service", "agent.adapters": "http,cli-profile", "agent.allowed_urls": "https://service.example", "agent.auth_type": "bearer", "agent.confirmation": "AUTO", "agent.cli_profile": "example-echo"}), item(4, "dup a", "a", PW, [], {"agent.ref": "dup.ref", "agent.adapters": "http", "agent.allowed_urls": "https://service.example", "agent.confirmation": "AUTO"}), item(5, "dup b", "b", PW, [], {"agent.ref": "dup.ref", "agent.adapters": "http", "agent.allowed_urls": "https://service.example", "agent.confirmation": "AUTO"}), ] def _write(path: Path, text: str, mode=0o600): path.write_text(text) os.chmod(path, mode) class BrokerHarness(unittest.TestCase): """Spins a real daemon (unix socket in a temp runtime dir) backed by the real BitwardenCliBackend driving a fake `bw` binary with fixture client credentials and items.""" @classmethod def setUpClass(cls): cls.tmp = tempfile.TemporaryDirectory() base = Path(cls.tmp.name) os.environ["XDG_RUNTIME_DIR"] = str(base / "rt") os.environ["XDG_STATE_HOME"] = str(base / "state") (base / "rt").mkdir() cred = base / "cred" cred.mkdir() _write(cred / "id.txt", "user.fixture-id") _write(cred / "secret.token", "fixture-client-secret") bin_dir = base / "bin" bin_dir.mkdir() script = bin_dir / "echo-fixture" script.write_text( "#!/bin/sh\n" 'echo "argv: $*"\n' 'echo "envlen: ${#EXAMPLE_TOKEN}"\n' ) os.chmod(script, 0o755) cls.bw = bin_dir / "bw" cls.bw.write_text(FAKE_BW) os.chmod(cls.bw, 0o755) os.environ["PATH"] = f"{bin_dir}:{os.environ.get('PATH', '')}" cls.appdata = base / "bw-appdata" cls.appdata.mkdir(mode=0o700) (cls.appdata / "items.json").write_text(json.dumps(fixture_items())) cls.cfg_path = base / "broker-config.json" cls.cfg_path.write_text(json.dumps({ "vault_url": "https://vault.paginica.com", "client_id_file": str(cred / "id.txt"), "client_secret_file": str(cred / "secret.token"), "vault_backend": "bitwarden-cli", "bw_path": str(cls.bw), "bw_appdata_dir": str(cls.appdata), "unlock_ttl_seconds": 3600, "git_workdir_roots": [str(Path.home())], })) os.environ["AGENT_BROKER_CONFIG"] = str(cls.cfg_path) os.environ["AGENT_BROKER_SOCKET"] = str(base / "rt" / "agent-broker" / "broker.sock") os.environ["AGENT_BROKER_PROFILES_DIR"] = str(Path(__file__).resolve().parents[1] / "profiles") cls.socket_path = Path(os.environ["AGENT_BROKER_SOCKET"]) cls.socket_path.parent.mkdir(parents=True, exist_ok=True) config = Config.load() cls.server = _Server(str(cls.socket_path), BrokerCore(config)) os.chmod(cls.socket_path, 0o600) threading.Thread(target=cls.server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True).start() @classmethod def tearDownClass(cls): cls.server.shutdown() cls.tmp.cleanup() def call(self, method, params=None): return call(method, params or {}) class TestLifecycle(BrokerHarness): def test_1_starts_locked(self): st = self.call("status") self.assertTrue(st["locked"]) self.assertTrue(st["configured"]) def test_2_locked_denies_ops(self): with self.assertRaises(BrokerError) as e: self.call("list_refs") self.assertEqual(e.exception.code, "BROKER_LOCKED") with self.assertRaises(BrokerError) as e: self.call("resolve", {"ref": "gitea.admin"}) self.assertEqual(e.exception.code, "BROKER_LOCKED") def test_3_unlock_and_lock(self): res = self.call("unlock", {"password": MASTER_PW}) self.assertTrue(res["unlocked"]) self.assertEqual(res["items"], len(fixture_items())) st = self.call("status") self.assertFalse(st["locked"]) self.call("lock") self.assertTrue(self.call("status")["locked"]) class TestUnlocked(BrokerHarness): @classmethod def setUpClass(cls): super().setUpClass() call("unlock", {"password": MASTER_PW}) @classmethod def tearDownClass(cls): try: call("lock") except Exception: pass super().tearDownClass() def test_refs_listed_without_values(self): refs = {r["ref"]: r for r in self.call("list_refs")} self.assertIn("gitea.admin", refs) self.assertIn("vps.root", refs) self.assertIn("test.service", refs) blob = json.dumps(refs) self.assertNotIn(PW, blob) self.assertNotIn(TOKEN, blob) def test_exact_matching(self): with self.assertRaises(BrokerError) as e: self.call("resolve", {"ref": "gitea.adm"}) self.assertEqual(e.exception.code, "NOT_FOUND") with self.assertRaises(BrokerError) as e: self.call("resolve", {"ref": "gitea"}) self.assertEqual(e.exception.code, "NOT_FOUND") def test_unknown_ref(self): with self.assertRaises(BrokerError) as e: self.call("http_request", {"credential_ref": "nope.ref", "method": "GET", "url": "https://service.example/"}) self.assertEqual(e.exception.code, "NOT_FOUND") def test_duplicate_ref(self): with self.assertRaises(BrokerError) as e: self.call("resolve", {"ref": "dup.ref"}) self.assertEqual(e.exception.code, "AMBIGUOUS_REF") meta = [r for r in self.call("list_refs") if r["ref"] == "dup.ref"] self.assertTrue(meta[0]["duplicate"]) def test_find_for_url(self): self.assertEqual(self.call("find_for_url", {"url": "https://git2.paginica.com/api/v1/user"}), ["gitea.admin"]) self.assertEqual(self.call("find_for_url", {"url": "https://evil.example"}), []) # -- adapter policy ------------------------------------------------- def test_wrong_adapter(self): with self.assertRaises(BrokerError) as e: self.call("browser_login", {"credential_ref": "vps.root", "url": "https://git2.paginica.com"}) self.assertEqual(e.exception.code, "ADAPTER_DENIED") def test_adapter_not_allowed_cli(self): with self.assertRaises(BrokerError) as e: self.call("cli_run", {"profile": "example-echo", "credential_ref": "gitea.admin", "args": ["hello"]}) self.assertEqual(e.exception.code, "ADAPTER_DENIED") # -- confirmation ---------------------------------------------------- def test_every_use_needs_ticket(self): with self.assertRaises(BrokerError) as e: self.call("ssh_exec", {"credential_ref": "vps.root", "host": "vps.paginica.com", "command": "id"}) self.assertEqual(e.exception.code, "CONFIRMATION_REQUIRED") ticket = e.exception.ticket self.assertTrue(ticket) info = self.call("confirm", {"ticket": ticket}) self.assertTrue(info["approved"]) try: # 192.0.2.1 (TEST-NET-1) is in allowed_hosts and unreachable, # so ssh exits fast without touching real infrastructure. res = self.call("ssh_exec", {"credential_ref": "vps.root", "host": "192.0.2.1", "command": "id", "ticket": ticket}) # executed: sanitized result, no secret material self.assertIn("exit_code", res) self.assertNotIn(PW, json.dumps(res)) except BrokerError as e2: # execution may fail on the network, but never on policy self.assertNotIn(e2.exception.code, ("CONFIRMATION_REQUIRED", "DENIED", "ADAPTER_DENIED", "ORIGIN_DENIED")) def test_origin_denied_ssh(self): with self.assertRaises(BrokerError) as e: self.call("ssh_exec", {"credential_ref": "vps.root", "host": "evil.example", "command": "id"}) self.assertEqual(e.exception.code, "ORIGIN_DENIED") # -- HTTP adapter --------------------------------------------------- def test_http_bearer_and_sanitized(self): captured = {} class H(BaseHTTPRequestHandler): def log_message(self, *a): pass def do_GET(self): captured["auth"] = self.headers.get("Authorization") body = json.dumps({"ok": True, "echo": TOKEN}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Set-Cookie", "session=abc123") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) srv = ThreadingHTTPServer(("127.0.0.1", 0), H) threading.Thread(target=srv.serve_forever, daemon=True).start() url = f"http://127.0.0.1:{srv.server_address[1]}/api" # service.example is allowed but the fixture has insecure_http off, # so add a temporary item bound to the test host via a second item: # instead we validate the auth header construction directly. item = {"username": USER, "password": TOKEN, "fields": { "agent.ref": "x", "agent.adapters": "http", "agent.allowed_urls": url, "agent.auth_type": "bearer", "agent.confirmation": "AUTO", "agent.insecure_http": "true"}} from agent_credential_broker.adapters import http as hmod p = policy.policy_for(item) res = hmod.run(item, p, {"url": url, "method": "GET"}, None) srv.shutdown() self.assertEqual(captured["auth"], f"Bearer {TOKEN}") self.assertEqual(res["status"], 200) self.assertNotIn(TOKEN, res["body"]) self.assertIn("[REDACTED]", res["body"]) self.assertEqual(res["headers"]["Set-Cookie"], "[REDACTED]") def test_http_basic_auth_header(self): captured = {} class H(BaseHTTPRequestHandler): def log_message(self, *a): pass def do_GET(self): captured["auth"] = self.headers.get("Authorization") self.send_response(204) self.send_header("Content-Length", "0") self.end_headers() srv = ThreadingHTTPServer(("127.0.0.1", 0), H) threading.Thread(target=srv.serve_forever, daemon=True).start() url = f"http://127.0.0.1:{srv.server_address[1]}/" item = {"username": USER, "password": PW, "fields": { "agent.ref": "x", "agent.adapters": "http", "agent.allowed_urls": url, "agent.auth_type": "basic", "agent.confirmation": "AUTO", "agent.insecure_http": "true"}} from agent_credential_broker.adapters import http as hmod res = hmod.run(item, policy.policy_for(item), {"url": url, "method": "GET"}, None) srv.shutdown() want = "Basic " + base64.b64encode(f"{USER}:{PW}".encode()).decode() self.assertEqual(captured["auth"], want) self.assertEqual(res["status"], 204) def test_http_wrong_origin(self): item = {"username": USER, "password": TOKEN, "fields": { "agent.ref": "x", "agent.adapters": "http", "agent.allowed_urls": "https://service.example", "agent.auth_type": "bearer", "agent.confirmation": "AUTO"}} from agent_credential_broker.adapters import http as hmod with self.assertRaises(BrokerError) as e: hmod.run(item, policy.policy_for(item), {"url": "https://evil.example/", "method": "GET"}, None) self.assertEqual(e.exception.code, "ORIGIN_DENIED") def test_http_no_embedded_creds_via_adapter(self): with self.assertRaises(BrokerError) as e: self.call("git_op", {"credential_ref": "gitea.admin", "op": "clone", "repository": "https://user:pw@git2.paginica.com/x/y.git", "dest": "/tmp/nowhere"}) self.assertEqual(e.exception.code, "DENIED_INVALID_ARGUMENT") class TestCliProfile(BrokerHarness): @classmethod def setUpClass(cls): super().setUpClass() call("unlock", {"password": MASTER_PW}) @classmethod def tearDownClass(cls): try: call("lock") except Exception: pass super().tearDownClass() def test_cli_profile_executes(self): res = self.call("cli_run", {"profile": "example-echo", "credential_ref": "test.service", "args": ["hello"]}) self.assertEqual(res["exit_code"], 0) self.assertIn("argv: hello", res["stdout"]) self.assertNotIn(TOKEN, res["stdout"]) def test_unknown_profile(self): with self.assertRaises(BrokerError) as e: self.call("cli_run", {"profile": "unknown", "credential_ref": "test.service", "args": ["env"]}) self.assertEqual(e.exception.code, "DENIED") def test_malicious_args(self): for bad in ("; env", "$(printenv)", "../../bin/sh", "a`id`b", "x|cat /etc/passwd"): with self.assertRaises(BrokerError) as e: self.call("cli_run", {"profile": "example-echo", "credential_ref": "test.service", "args": [bad]}) self.assertIn(e.exception.code, ("DENIED_INVALID_ARGUMENT",)) def test_subcommand_allowlist(self): with self.assertRaises(BrokerError): self.call("cli_run", {"profile": "example-echo", "credential_ref": "test.service", "args": ["pwned"]}) class TestSanitizer(unittest.TestCase): def test_exact_secret(self): self.assertEqual(sanitize_text(f"data {PW} tail", [PW]), "data [REDACTED] tail") def test_child_returns_secret(self): res = ac.run_subprocess(["/bin/sh", "-c", f"echo {PW} >&2; echo {PW}"], secrets=[PW]) self.assertNotIn(PW, res["stdout"]) self.assertNotIn(PW, res["stderr"]) self.assertIn("[REDACTED]", res["stdout"]) self.assertIn("[REDACTED]", res["stderr"]) def test_kv_patterns(self): out = sanitize_text("password=abc123 token: xyz token=AAAA1111", []) self.assertNotIn("abc123", out) self.assertNotIn("AAAA1111", out) def test_headers(self): h = sanitize_headers({"Authorization": "Bearer x", "X-Ok": "1", "Set-Cookie": "a=b"}) self.assertEqual(h["Authorization"], "[REDACTED]") self.assertEqual(h["Set-Cookie"], "[REDACTED]") self.assertEqual(h["X-Ok"], "1") class TestArgvIsolation(BrokerHarness): """Spawn broker-side children and confirm secrets are absent from /proc//cmdline.""" @classmethod def setUpClass(cls): super().setUpClass() call("unlock", {"password": MASTER_PW}) @classmethod def tearDownClass(cls): try: call("lock") except Exception: pass super().tearDownClass() def test_askpass_child_cmdline_clean(self): """Simulate what the ssh/git adapters do: an askpass child whose argv contains only script + file paths -- never the secret.""" with ac.secret_dir() as d: pwfile = d / "pw" ac.write_secret_file(pwfile, PW) askpass = ac.write_askpass(d, None, pwfile) proc = subprocess.Popen([str(askpass), "Password:"], stdout=subprocess.PIPE) cmdline = Path(f"/proc/{proc.pid}/cmdline").read_bytes() out, _ = proc.communicate() self.assertNotIn(PW.encode(), cmdline) self.assertEqual(out.strip(), PW.encode()) # secret went to the tool only def test_env_not_inherited(self): """clean_env must not leak ambient credential env vars.""" os.environ["DO_NOT_LEAK_ME"] = "leaky-value" env = ac.clean_env({"INJECTED": "ok"}) self.assertNotIn("DO_NOT_LEAK_ME", env) self.assertEqual(env["INJECTED"], "ok") del os.environ["DO_NOT_LEAK_ME"] def test_cli_env_isolation(self): """The injected env var exists ONLY in the child's environment -- the broker process env must not contain the secret afterwards.""" self.assertNotIn("EXAMPLE_TOKEN", os.environ) res = self.call("cli_run", {"profile": "example-echo", "credential_ref": "test.service", "args": ["hello"]}) self.assertIn("envlen: 24", res["stdout"]) # len(example-token-DO-NOT-USE) self.assertNotIn(TOKEN, json.dumps(res)) self.assertNotIn("EXAMPLE_TOKEN", os.environ) class TestNativePlugin(BrokerHarness): """The agent-browser credential.read plugin path: plugin stdin gets a credential.resolve envelope, stdout returns the credential -- the ONLY channel where secret material legitimately leaves the broker, and it goes to agent-browser, not to the agent.""" @classmethod def setUpClass(cls): super().setUpClass() call("unlock", {"password": MASTER_PW}) @classmethod def tearDownClass(cls): try: call("lock") except Exception: pass super().tearDownClass() def _run_plugin(self, envelope): import io from agent_credential_broker import plugin old_in, old_out = sys.stdin, sys.stdout sys.stdin = io.StringIO(json.dumps(envelope)) sys.stdout = io.StringIO() try: rc = plugin.main() return rc, json.loads(sys.stdout.getvalue()) finally: sys.stdin, sys.stdout = old_in, old_out def test_native_provider_resolve(self): rc, resp = self._run_plugin({ "protocol": "agent-browser.plugin.v1", "type": "credential.resolve", "capability": "credential.read", "request": {"itemRef": "gitea.admin", "url": "https://git2.paginica.com/user/login"}, }) self.assertEqual(resp["protocol"], "agent-browser.plugin.v1") self.assertTrue(resp["success"]) # the plugin is authorized to hand the secret to agent-browser self.assertEqual(resp["credential"]["password"], PW) self.assertEqual(resp["credential"]["username"], "gitea-admin") def test_native_provider_wrong_origin(self): rc, resp = self._run_plugin({ "protocol": "agent-browser.plugin.v1", "type": "credential.resolve", "capability": "credential.read", "request": {"itemRef": "gitea.admin", "url": "https://evil.example/login"}, }) self.assertFalse(resp["success"]) self.assertIn("ORIGIN_DENIED", resp["error"]) def test_native_provider_adapter_denied(self): rc, resp = self._run_plugin({ "protocol": "agent-browser.plugin.v1", "type": "credential.resolve", "capability": "credential.read", "request": {"itemRef": "vps.root", "url": "https://vps.paginica.com/login"}, }) self.assertFalse(resp["success"]) self.assertIn("ADAPTER_DENIED", resp["error"]) class TestMcpInterface(unittest.TestCase): def test_no_retrieval_tools(self): from agent_credential_broker import mcp_server resp = mcp_server._handle({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) names = [t["name"] for t in resp["result"]["tools"]] forbidden = ("get_password", "get_secret", "get_token", "get_private_key", "reveal", "dump", "resolve", "read_credential") for n in names: for f in forbidden: self.assertNotIn(f, n.lower(), f"forbidden tool name {n}") for must in ("vault_status", "vault_list_refs", "credential_http_request", "ssh_exec", "git_operation", "cli_run", "browser_login", "browser_fill_credential", "vault_find_for_url"): self.assertIn(must, names) def test_initialize(self): from agent_credential_broker import mcp_server resp = mcp_server._handle({"jsonrpc": "2.0", "id": 1, "method": "initialize"}) self.assertEqual(resp["result"]["serverInfo"]["name"], "agent-broker") class TestPolicyUnit(unittest.TestCase): def _p(self, **fields): f = {"agent.ref": "x", "agent.adapters": "http", "agent.confirmation": "AUTO"} f.update(fields) item = {"id": "i", "name": "n", "fields": f, "uris": []} return policy.policy_for(item), item def test_origin_path_prefix(self): p, _ = self._p(**{"agent.allowed_urls": "https://h.example/api"}) policy.require_url_allowed(p, "https://h.example/api/v1/users") with self.assertRaises(BrokerError): policy.require_url_allowed(p, "https://h.example/other") def test_https_required(self): p, _ = self._p(**{"agent.allowed_urls": "http://h.example"}) with self.assertRaises(BrokerError): policy.require_url_allowed(p, "http://h.example") p2, _ = self._p(**{"agent.allowed_urls": "http://h.example", "agent.insecure_http": "true"}) policy.require_url_allowed(p2, "http://h.example") def test_no_fields_no_policy(self): self.assertIsNone(policy.policy_for({"fields": {}, "uris": []})) if __name__ == "__main__": unittest.main(verbosity=2)