262 lines
10 KiB
Python
262 lines
10 KiB
Python
"""WP-100 broker security and bounded-output tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from services.docker_read_broker import (
|
|
BrokerApp,
|
|
FakeDockerBackend,
|
|
RegistrationStore,
|
|
ServiceTokenVerifier,
|
|
issue_service_token,
|
|
)
|
|
from services.docker_read_broker.broker import (
|
|
BrokerError,
|
|
Registration,
|
|
_decode_docker_log_stream,
|
|
_decode_http_response,
|
|
_json_query,
|
|
_storage_rows,
|
|
)
|
|
from services.docker_read_broker.server import _load_registrations
|
|
|
|
|
|
class SocketUnavailableBackend(FakeDockerBackend):
|
|
def host(self):
|
|
raise BrokerError("BROKER_SOCKET_UNAVAILABLE", "Docker socket is unavailable", 503)
|
|
|
|
|
|
class SpoofedLabelBackend(FakeDockerBackend):
|
|
def inspect(self, container_id: str):
|
|
value = dict(super().inspect(container_id))
|
|
config = dict(value["Config"])
|
|
config["Labels"] = dict(config["Labels"], **{"com.docker.compose.service": "spoofed"})
|
|
value["Config"] = config
|
|
return value
|
|
|
|
|
|
class AmbiguousLabelBackend(FakeDockerBackend):
|
|
def containers(self, state=None):
|
|
first = dict(super().containers(state)[0])
|
|
second = dict(first, Id="container-web-002")
|
|
return [first, second]
|
|
|
|
|
|
def make_app(backend=None) -> tuple[BrokerApp, str]:
|
|
secret = "test-broker-secret" # noqa: S105
|
|
registration = Registration(
|
|
project="ludarium",
|
|
environment="prod",
|
|
registered_id="ludarium-web",
|
|
container_id="container-web-001",
|
|
compose_project="ludarium",
|
|
service="web",
|
|
allowed_reads=frozenset({"inspect", "health", "logs"}),
|
|
)
|
|
app = BrokerApp(
|
|
backend or FakeDockerBackend(),
|
|
RegistrationStore((registration,)),
|
|
ServiceTokenVerifier(secret),
|
|
)
|
|
token = issue_service_token(secret, "ludarium", "prod")
|
|
return app, token
|
|
|
|
|
|
class DockerReadBrokerTests(unittest.TestCase):
|
|
def test_broker_runtime_import_does_not_require_the_mcp_sdk(self) -> None:
|
|
script = """
|
|
import builtins
|
|
|
|
real_import = builtins.__import__
|
|
def without_mcp(name, *args, **kwargs):
|
|
if name == "mcp" or name.startswith("mcp."):
|
|
raise ModuleNotFoundError("mcp is intentionally unavailable")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
builtins.__import__ = without_mcp
|
|
from services.docker_read_broker import issue_service_token
|
|
assert callable(issue_service_token)
|
|
"""
|
|
result = subprocess.run( # noqa: S603 - fixed interpreter and static script
|
|
[sys.executable, "-c", script],
|
|
cwd=Path(__file__).resolve().parents[1],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
def test_docker_chunked_http_response_is_decoded(self) -> None:
|
|
body = b'[{"Id":"container-web-001"}]'
|
|
response = (
|
|
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
|
|
+ f"{len(body):X}\r\n".encode()
|
|
+ body
|
|
+ b"\r\n0\r\n\r\n"
|
|
)
|
|
self.assertEqual(_decode_http_response(response), body)
|
|
|
|
def test_docker_http_parser_fails_closed_on_invalid_chunk(self) -> None:
|
|
response = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nZZ\r\n{}\r\n0\r\n\r\n"
|
|
with self.assertRaisesRegex(BrokerError, "invalid chunks"):
|
|
_decode_http_response(response)
|
|
|
|
def test_docker_http_parser_hides_engine_error_body(self) -> None:
|
|
response = b"HTTP/1.1 500 Error\r\nContent-Length: 20\r\n\r\nsensitive engine msg"
|
|
with self.assertRaisesRegex(BrokerError, "Docker read request failed") as raised:
|
|
_decode_http_response(response)
|
|
self.assertNotIn("sensitive", str(raised.exception))
|
|
|
|
def test_docker_multiplexed_logs_are_decoded(self) -> None:
|
|
stdout = b"first line\n"
|
|
stderr = b"second line\n"
|
|
payload = (
|
|
b"\x01\x00\x00\x00"
|
|
+ len(stdout).to_bytes(4, "big")
|
|
+ stdout
|
|
+ b"\x02\x00\x00\x00"
|
|
+ len(stderr).to_bytes(4, "big")
|
|
+ stderr
|
|
)
|
|
self.assertEqual(_decode_docker_log_stream(payload), ["first line", "second line"])
|
|
|
|
def test_docker_log_parser_rejects_invalid_frame_header(self) -> None:
|
|
payload = b"\x01\x01\x00\x00\x00\x00\x00\x01x"
|
|
with self.assertRaisesRegex(BrokerError, "invalid log frames"):
|
|
_decode_docker_log_stream(payload)
|
|
|
|
def test_docker_filters_are_url_encoded_json(self) -> None:
|
|
self.assertEqual(
|
|
_json_query({"status": ["running"]}),
|
|
"%7B%22status%22%3A%5B%22running%22%5D%7D",
|
|
)
|
|
|
|
def test_docker_storage_projection_is_bounded_and_structured(self) -> None:
|
|
rows = _storage_rows({"Driver": "btrfs", "DockerRootDir": "/secret/host/path"})
|
|
self.assertEqual(
|
|
rows, [{"name": "docker-root", "driver": "btrfs", "usageBytes": 0, "capacityBytes": 0}]
|
|
)
|
|
self.assertNotIn("DockerRootDir", repr(rows))
|
|
|
|
def test_runtime_registration_resolves_stable_compose_labels(self) -> None:
|
|
document = {
|
|
"schemaVersion": "1.0",
|
|
"defaultDecision": "deny",
|
|
"containers": [
|
|
{
|
|
"registeredId": "ludarium-web",
|
|
"project": "ludarium",
|
|
"environment": "prod",
|
|
"match": {"composeProject": "ludarium", "service": "web"},
|
|
"allowedReads": ["inspect", "health", "logs"],
|
|
}
|
|
],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "registrations.json"
|
|
path.write_text(json.dumps(document), encoding="utf-8")
|
|
with patch.dict(os.environ, {"BROKER_REGISTRATIONS_FILE": str(path)}):
|
|
store = _load_registrations(FakeDockerBackend())
|
|
registration = store.resolve("ludarium", "prod", "ludarium-web", "inspect")
|
|
self.assertEqual(registration.container_id, "container-web-001")
|
|
|
|
def test_runtime_registration_fails_closed_on_ambiguous_labels(self) -> None:
|
|
document = {
|
|
"schemaVersion": "1.0",
|
|
"defaultDecision": "deny",
|
|
"containers": [
|
|
{
|
|
"registeredId": "ludarium-web",
|
|
"project": "ludarium",
|
|
"environment": "prod",
|
|
"match": {"composeProject": "ludarium", "service": "web"},
|
|
"allowedReads": ["inspect"],
|
|
}
|
|
],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "registrations.json"
|
|
path.write_text(json.dumps(document), encoding="utf-8")
|
|
with patch.dict(os.environ, {"BROKER_REGISTRATIONS_FILE": str(path)}):
|
|
with self.assertRaisesRegex(SystemExit, "registration validation failed"):
|
|
_load_registrations(AmbiguousLabelBackend())
|
|
|
|
def test_only_registered_safe_inventory_is_returned(self) -> None:
|
|
app, token = make_app()
|
|
status, body = app.handle("GET", "/v1/containers", {"Authorization": f"Bearer {token}"})
|
|
self.assertEqual(status, 200)
|
|
item = body["items"][0]
|
|
self.assertEqual(item["containerRef"], "ludarium-web")
|
|
self.assertNotIn("Env", item)
|
|
self.assertNotIn("Mounts", item)
|
|
self.assertNotIn("TOKEN", repr(body))
|
|
|
|
def test_inspect_health_and_logs_are_registration_bound(self) -> None:
|
|
app, token = make_app()
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
for route in (
|
|
"/v1/containers/ludarium-web",
|
|
"/v1/containers/ludarium-web/health",
|
|
"/v1/containers/ludarium-web/logs?tail=10",
|
|
):
|
|
status, body = app.handle("GET", route, headers)
|
|
self.assertEqual(status, 200, route)
|
|
if route.endswith("logs?tail=10"):
|
|
self.assertIn("<redacted>", repr(body))
|
|
self.assertNotIn("super-secret-token", repr(body))
|
|
|
|
status, _ = app.handle("GET", "/v1/containers/unknown/inspect", headers)
|
|
self.assertEqual(status, 404)
|
|
|
|
def test_mutation_exec_and_raw_routes_are_denied(self) -> None:
|
|
app, token = make_app()
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
for method, path in (
|
|
("POST", "/v1/containers/ludarium-web/start"),
|
|
("POST", "/v1/containers/ludarium-web/exec"),
|
|
("GET", "/v1/containers/json"),
|
|
("GET", "/v1/images/json"),
|
|
):
|
|
status, body = app.handle(method, path, headers)
|
|
self.assertIn(status, {403, 404, 405})
|
|
self.assertTrue(body["code"].startswith("BROKER_"))
|
|
|
|
def test_auth_scope_and_token_expiry_are_enforced(self) -> None:
|
|
app, _ = make_app()
|
|
status, _ = app.handle("GET", "/v1/host")
|
|
self.assertEqual(status, 401)
|
|
foreign = issue_service_token("test-broker-secret", "other-project", "prod")
|
|
status, _ = app.handle(
|
|
"GET", "/v1/containers/ludarium-web", {"Authorization": f"Bearer {foreign}"}
|
|
)
|
|
self.assertEqual(status, 403)
|
|
expired = issue_service_token("test-broker-secret", "ludarium", "prod", ttl_seconds=-1)
|
|
status, _ = app.handle("GET", "/v1/host", {"Authorization": f"Bearer {expired}"})
|
|
self.assertEqual(status, 401)
|
|
|
|
def test_label_spoof_is_denied(self) -> None:
|
|
app, token = make_app(SpoofedLabelBackend())
|
|
status, body = app.handle(
|
|
"GET", "/v1/containers/ludarium-web", {"Authorization": f"Bearer {token}"}
|
|
)
|
|
self.assertEqual(status, 403)
|
|
self.assertEqual(body["code"], "BROKER_REGISTRATION_MISMATCH")
|
|
|
|
def test_socket_unavailable_degrades_broker_capability(self) -> None:
|
|
app, token = make_app(SocketUnavailableBackend())
|
|
status, body = app.handle("GET", "/v1/host", {"Authorization": f"Bearer {token}"})
|
|
self.assertEqual(status, 503)
|
|
self.assertEqual(body["code"], "BROKER_SOCKET_UNAVAILABLE")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|