Files
ITWorx_MCP_Hub-Public/tests/test_docker_read_broker.py
T

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()