f77023db1d
test / integration-docker (pull_request) Successful in 11s
test / unit (pull_request) Successful in 43s
lint / lint (push) Successful in 56s
test / integration-firecracker (pull_request) Successful in 3m19s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 7s
Separate the gateway (data plane) from the orchestrator (control plane) at the
module level. The gateway runtime files move out of the package root — and the
backend-neutral Gateway lifecycle ABC + GATEWAY_* constants move out of
orchestrator/ — into a new bot_bottle/gateway/ package:
gateway/__init__.py (was orchestrator/gateway.py: Gateway ABC + consts
+ rotate_gateway_ca)
gateway/gateway_init.py (the PID-1 daemon supervisor)
gateway/egress_addon.py, egress_addon_core.py, egress_dlp_config.py,
dlp_detectors.py (the egress mitmproxy daemon)
gateway/git_http_backend.py (the git-http daemon)
gateway/git_gate_render.py (the git-gate pre-receive rendering)
gateway/supervise_server.py (the supervise MCP daemon)
gateway/policy_resolver.py (the data-plane control-plane RPC client)
orchestrator/ now holds only control-plane files. The shared plan/types/auth
layer (egress.py=EgressPlan, git_gate.py=GitGatePlan, supervise.py,
supervise_types.py, control_auth.py) and the launch-time git-gate provisioning
helpers stay at root, so orchestrator/ and backend/ still own them.
Because these daemons are invoked as `python3 -m bot_bottle.<name>`, loaded flat
by mitmproxy, and referenced in Dockerfile.gateway, the move updates more than
Python imports: the `-m` invocations (firecracker/macOS infra scripts), the
Dockerfile.gateway addon shim + ENTRYPOINT, gateway_init's _DAEMONS module
paths, and the git-gate CGI heredocs all now point at bot_bottle.gateway.*.
No behavior change; full unit suite green (2251).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
272 lines
9.6 KiB
Python
272 lines
9.6 KiB
Python
"""Unit: LOG_FULL credential redaction in _log_request / _log_response (issue #257).
|
|
|
|
egress_addon.py is gateway-only code that depends on mitmproxy, which is
|
|
not installed on the host. This file pre-populates sys.modules with the
|
|
minimum mocks needed so EgressAddon can be imported and tested without the
|
|
real mitmproxy package."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import types
|
|
import unittest
|
|
from io import StringIO
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mitmproxy stub — must run before importing egress_addon
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _ensure_shims() -> None:
|
|
# Resolver-only egress: importing the module builds the `addons` singleton,
|
|
# which requires an orchestrator URL. These tests exercise the log helpers
|
|
# on a __new__-built addon, so the value is never dialed.
|
|
os.environ.setdefault("BOT_BOTTLE_ORCHESTRATOR_URL", "http://127.0.0.1:0")
|
|
if "mitmproxy" not in sys.modules:
|
|
_mm = types.ModuleType("mitmproxy")
|
|
_mh = types.ModuleType("mitmproxy.http")
|
|
setattr(_mm, "http", _mh)
|
|
sys.modules["mitmproxy"] = _mm
|
|
sys.modules["mitmproxy.http"] = _mh
|
|
|
|
|
|
_ensure_shims()
|
|
|
|
from bot_bottle.gateway.egress_addon import EgressAddon # noqa: E402 (import after shims)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _addon() -> EgressAddon:
|
|
"""A bare EgressAddon for exercising the log helpers directly. The redaction
|
|
log methods take their env explicitly, so no resolver/config wiring is
|
|
needed here."""
|
|
return EgressAddon.__new__(EgressAddon)
|
|
|
|
|
|
class _Headers:
|
|
def __init__(self, d: dict[str, str]) -> None:
|
|
self._d = d
|
|
|
|
def items(self) -> list[tuple[str, str]]:
|
|
return list(self._d.items())
|
|
|
|
|
|
class _Request:
|
|
def __init__(
|
|
self,
|
|
host: str = "api.example.com",
|
|
method: str = "POST",
|
|
path: str = "/v1/messages",
|
|
headers: dict[str, str] | None = None,
|
|
body: str = "",
|
|
) -> None:
|
|
self.pretty_host = host
|
|
self.method = method
|
|
self.path = path
|
|
self.headers = _Headers(headers or {})
|
|
self._body = body
|
|
|
|
def get_text(self, *, strict: bool = True) -> str:
|
|
return self._body
|
|
|
|
|
|
class _Response:
|
|
def __init__(
|
|
self,
|
|
status_code: int = 200,
|
|
headers: dict[str, str] | None = None,
|
|
body: str = "",
|
|
) -> None:
|
|
self.status_code = status_code
|
|
self.headers = _Headers(headers or {})
|
|
self._body = body
|
|
|
|
def get_text(self, *, strict: bool = True) -> str:
|
|
return self._body
|
|
|
|
|
|
class _Flow:
|
|
def __init__(
|
|
self,
|
|
request: _Request | None = None,
|
|
response: _Response | None = None,
|
|
) -> None:
|
|
self.request = request or _Request()
|
|
self.response = response or _Response()
|
|
|
|
|
|
def _log_request(addon: EgressAddon, flow: _Flow) -> dict[str, Any]:
|
|
buf = StringIO()
|
|
with patch("sys.stderr", buf):
|
|
addon._log_request(flow, os.environ) # type: ignore[arg-type]
|
|
return json.loads(buf.getvalue())
|
|
|
|
|
|
def _log_response(addon: EgressAddon, flow: _Flow) -> dict[str, Any]:
|
|
buf = StringIO()
|
|
with patch("sys.stderr", buf):
|
|
addon._log_response(flow, os.environ) # type: ignore[arg-type]
|
|
return json.loads(buf.getvalue())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _log_request — authorization header stripped
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLogRequestAuthorizationStripped(unittest.TestCase):
|
|
def test_lowercase_authorization_excluded(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(request=_Request(headers={"authorization": "Bearer sk-real-secret"}))
|
|
entry = _log_request(addon, flow)
|
|
self.assertNotIn("authorization", entry["headers"])
|
|
|
|
def test_titlecase_authorization_excluded(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(request=_Request(headers={"Authorization": "Bearer sk-real-secret"}))
|
|
entry = _log_request(addon, flow)
|
|
self.assertNotIn("Authorization", entry["headers"])
|
|
self.assertNotIn("authorization", entry["headers"])
|
|
|
|
def test_non_auth_headers_retained(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(request=_Request(headers={
|
|
"authorization": "Bearer sk-real-secret",
|
|
"content-type": "application/json",
|
|
}))
|
|
entry = _log_request(addon, flow)
|
|
self.assertIn("content-type", entry["headers"])
|
|
self.assertEqual("application/json", entry["headers"]["content-type"])
|
|
|
|
def test_no_authorization_header_logs_all_others(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(request=_Request(headers={"x-request-id": "abc"}))
|
|
entry = _log_request(addon, flow)
|
|
self.assertEqual({"x-request-id": "abc"}, entry["headers"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _log_request — body redaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_OPENAI_KEY = "sk-" + "A" * 48
|
|
|
|
|
|
class TestLogRequestBodyRedacted(unittest.TestCase):
|
|
def test_token_pattern_in_body_scrubbed(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(request=_Request(body=f"key={_OPENAI_KEY}"))
|
|
entry = _log_request(addon, flow)
|
|
self.assertNotIn(_OPENAI_KEY, entry["body"])
|
|
self.assertIn("********", entry["body"])
|
|
|
|
def test_provisioned_secret_in_body_scrubbed(self) -> None:
|
|
addon = _addon()
|
|
secret = "provisioned-egress-secret-xyz"
|
|
flow = _Flow(request=_Request(body=f"token={secret}"))
|
|
with patch.dict("os.environ", {"EGRESS_TOKEN_0": secret}):
|
|
entry = _log_request(addon, flow)
|
|
self.assertNotIn(secret, entry["body"])
|
|
self.assertIn("********", entry["body"])
|
|
|
|
def test_clean_body_preserved(self) -> None:
|
|
addon = _addon()
|
|
payload = '{"model": "claude-3", "max_tokens": 1024}'
|
|
flow = _Flow(request=_Request(body=payload))
|
|
entry = _log_request(addon, flow)
|
|
self.assertEqual(payload, entry["body"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _log_request — non-authorization header value redaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLogRequestHeaderValuesRedacted(unittest.TestCase):
|
|
def test_token_in_custom_header_scrubbed(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(request=_Request(headers={"x-api-key": _OPENAI_KEY}))
|
|
entry = _log_request(addon, flow)
|
|
self.assertNotIn(_OPENAI_KEY, entry["headers"].get("x-api-key", ""))
|
|
self.assertIn("********", entry["headers"].get("x-api-key", ""))
|
|
|
|
def test_clean_header_value_preserved(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(request=_Request(headers={"accept": "application/json"}))
|
|
entry = _log_request(addon, flow)
|
|
self.assertEqual("application/json", entry["headers"]["accept"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _log_response — body redaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLogResponseBodyRedacted(unittest.TestCase):
|
|
def test_token_pattern_in_response_body_scrubbed(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(
|
|
request=_Request(),
|
|
response=_Response(body=f'{{"key": "{_OPENAI_KEY}"}}'),
|
|
)
|
|
entry = _log_response(addon, flow)
|
|
self.assertNotIn(_OPENAI_KEY, entry["body"])
|
|
self.assertIn("********", entry["body"])
|
|
|
|
def test_provisioned_secret_in_response_body_scrubbed(self) -> None:
|
|
addon = _addon()
|
|
secret = "provisioned-egress-secret-xyz"
|
|
flow = _Flow(
|
|
request=_Request(),
|
|
response=_Response(body=f'{{"token": "{secret}"}}'),
|
|
)
|
|
with patch.dict("os.environ", {"EGRESS_TOKEN_0": secret}):
|
|
entry = _log_response(addon, flow)
|
|
self.assertNotIn(secret, entry["body"])
|
|
self.assertIn("********", entry["body"])
|
|
|
|
def test_clean_response_body_preserved(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(request=_Request(), response=_Response(body='{"result": "ok"}'))
|
|
entry = _log_response(addon, flow)
|
|
self.assertEqual('{"result": "ok"}', entry["body"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _log_response — response header value redaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLogResponseHeaderValuesRedacted(unittest.TestCase):
|
|
def test_token_in_response_header_scrubbed(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(
|
|
request=_Request(),
|
|
response=_Response(headers={"set-cookie": f"token={_OPENAI_KEY}"}),
|
|
)
|
|
entry = _log_response(addon, flow)
|
|
cookie_val = entry["headers"].get("set-cookie", "")
|
|
self.assertNotIn(_OPENAI_KEY, cookie_val)
|
|
self.assertIn("********", cookie_val)
|
|
|
|
def test_clean_response_header_preserved(self) -> None:
|
|
addon = _addon()
|
|
flow = _Flow(
|
|
request=_Request(),
|
|
response=_Response(headers={"content-type": "application/json"}),
|
|
)
|
|
entry = _log_response(addon, flow)
|
|
self.assertEqual("application/json", entry["headers"]["content-type"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|