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.
72 lines
2.3 KiB
Python
72 lines
2.3 KiB
Python
"""Output sanitizer -- defence in depth.
|
|
|
|
The *primary* guarantee is architectural: secret values never enter
|
|
argv/stdout/stderr of adapter calls. This layer additionally scans any
|
|
text crossing the broker boundary (child output, HTTP bodies/headers,
|
|
exception text) and replaces occurrences of known secret material with
|
|
[REDACTED]. If a secret appears in output, that is already an adapter
|
|
bug -- the sanitizer makes sure it can never reach the agent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
REDACTED = "[REDACTED]"
|
|
|
|
# Header names that must never be echoed back to the agent.
|
|
SENSITIVE_HEADERS = {
|
|
"authorization",
|
|
"proxy-authorization",
|
|
"set-cookie",
|
|
"cookie",
|
|
"x-api-key",
|
|
"x-auth-token",
|
|
"x-csrf-token",
|
|
"x-session-token",
|
|
"www-authenticate",
|
|
}
|
|
|
|
# name=value / name: value patterns whose values are suppressed.
|
|
_SECRET_KV = re.compile(
|
|
r"(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?token|"
|
|
r"refresh[_-]?token|session[_-]?(id|token|key)|client[_-]?secret|"
|
|
r"private[_-]?key|authorization)\b(\s*[:=]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;&}]{4,})"
|
|
)
|
|
|
|
_BEARER = re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]{8,}")
|
|
|
|
_CRED_IN_URL = re.compile(r"(https?|ssh|git)://[^/@\s]+(:[^/@\s]*)?@")
|
|
|
|
|
|
def sanitize_text(text: str, secrets: list[str] | tuple[str, ...] = ()) -> str:
|
|
if not text:
|
|
return text
|
|
out = text
|
|
for secret in secrets:
|
|
if secret and len(secret) >= 4:
|
|
out = out.replace(secret, REDACTED)
|
|
out = _BEARER.sub("Bearer " + REDACTED, out)
|
|
out = _CRED_IN_URL.sub(r"\1://" + REDACTED + "@", out)
|
|
out = _SECRET_KV.sub(lambda m: m.group(1) + m.group(3) + REDACTED, out)
|
|
return out
|
|
|
|
|
|
def sanitize_headers(headers: dict, secrets=()) -> dict:
|
|
"""Return a copy of headers with sensitive entries dropped and
|
|
remaining values scrubbed for accidental secret content."""
|
|
out = {}
|
|
for k, v in headers.items():
|
|
if k.lower() in SENSITIVE_HEADERS:
|
|
out[k] = REDACTED
|
|
else:
|
|
out[k] = sanitize_text(str(v), secrets)
|
|
return out
|
|
|
|
|
|
def sanitize_bytes(data: bytes, secrets: list[bytes] | tuple[bytes, ...] = ()) -> bytes:
|
|
for secret in secrets:
|
|
if secret:
|
|
data = data.replace(secret, REDACTED.encode())
|
|
return data
|