Files
agent-credential-broker/tests/test_broker.py
T
me be87900794 agent-credential-broker: fork of vaultwarden-agent-broker + OpenBao backend
Mechanical rename (package agent_credential_broker; entry points
agent-broker{,-mcp,-plugin}; env AGENT_BROKER_*; paths agent-broker;
agent-browser credential provider 'broker'; MCP server name agent-broker).

New backend 'openbao': stdlib-only HTTP client for OpenBao/Vault KV v2
(approle login or token file/env; metadata-only ref index; lazy re-login
on 401/403; token renewal). bitwarden-cli backend kept as transition.
2026-10-14 13:27:33 -06:00

598 lines
24 KiB
Python

"""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:[email protected]/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/<pid>/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)