"""Docker inventory providers with an explicit security boundary. The legacy Unix-socket provider is retained for compatibility. The broker provider is opt-in and deliberately never falls back to the socket: a failed broker configuration must be visible to the operator rather than silently re-expanding ServiceMap's Docker privilege boundary. """ from __future__ import annotations import json import os import re from dataclasses import dataclass from pathlib import Path from typing import Any from urllib.parse import urlsplit, urlunsplit import docker import httpx class DockerProviderError(ValueError): pass _BROKER_CONNECT_TIMEOUT_SECONDS = 2.0 _BROKER_OPERATION_TIMEOUT_SECONDS = 5.0 _BROKER_MAX_RESPONSE_BYTES = 8 * 1024 * 1024 _BROKER_MAX_CONTAINER_ITEMS = 10_000 _BROKER_PATH = "/v1/containers" _BROKER_BEARER_TOKEN = re.compile(r"[^\x21-\x7e]") def _invalid_inventory() -> DockerProviderError: return DockerProviderError("Docker broker returned an invalid inventory response") def _contains_ascii_control(value: str) -> bool: return any(ord(char) < 32 or ord(char) == 127 for char in value) @dataclass class BrokerImage: tags: list[str] @dataclass class BrokerContainer: id: str name: str status: str attrs: dict[str, Any] image: BrokerImage class BrokerContainers: def __init__(self, provider: BrokerDockerClient) -> None: # type: ignore[name-defined] self.provider = provider def list(self, *, all: bool = True) -> list[BrokerContainer]: del all # Broker registration scope is deliberate; it returns all allowed targets. payload = self.provider._get(_BROKER_PATH) items = payload.get("items") if not isinstance(items, list) or len(items) > _BROKER_MAX_CONTAINER_ITEMS: raise _invalid_inventory() if any(not isinstance(item, dict) for item in items): raise _invalid_inventory() return [self.provider._container(item) for item in items] class BrokerDockerClient: """Minimal Docker SDK-shaped client over the registered read broker.""" def __init__(self, base_url: str, token: str) -> None: self.base_url = self._validated_base_url(base_url) if ( not isinstance(token, str) or not token or len(token) > 8192 or _BROKER_BEARER_TOKEN.search(token) is not None ): raise DockerProviderError("Docker broker token is invalid") # Keep the credential out of client-wide headers: health checks are # public and only the one fixed inventory request needs authorization. self._authorization = f"Bearer {token}" self.http = httpx.Client( timeout=httpx.Timeout( _BROKER_OPERATION_TIMEOUT_SECONDS, connect=_BROKER_CONNECT_TIMEOUT_SECONDS, pool=_BROKER_CONNECT_TIMEOUT_SECONDS, ), follow_redirects=False, trust_env=False, ) self.containers = BrokerContainers(self) @staticmethod def _validated_base_url(base_url: str) -> str: if ( not isinstance(base_url, str) or not base_url or any(ord(char) < 33 for char in base_url) ): raise DockerProviderError("Docker broker URL is invalid") try: parsed = urlsplit(base_url) # Accessing .port also rejects malformed or out-of-range ports. _ = parsed.port except ValueError: raise DockerProviderError("Docker broker URL is invalid") from None if ( parsed.scheme.casefold() not in {"http", "https"} or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or "\\" in parsed.path or any(part == ".." for part in parsed.path.split("/")) ): raise DockerProviderError("Docker broker URL is invalid") return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")) @classmethod def from_environment(cls) -> BrokerDockerClient: # Never accept a request-provided Docker host as a broker destination: # that could otherwise send the dedicated bearer token to an attacker- # controlled endpoint. Broker identity is deployment configuration only. url = (os.getenv("SERVICEMAP_DOCKER_BROKER_URL") or "").strip() token_file = (os.getenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE") or "").strip() if not url or not token_file: raise DockerProviderError( "Broker mode requires SERVICEMAP_DOCKER_BROKER_URL and SERVICEMAP_DOCKER_BROKER_TOKEN_FILE" ) path = Path(token_file) if not path.is_file(): raise DockerProviderError("Docker broker token file is unavailable") try: token = path.read_text(encoding="utf-8").strip() except OSError: raise DockerProviderError("Docker broker token file cannot be read") from None if not token or len(token) > 8192 or _BROKER_BEARER_TOKEN.search(token) is not None: raise DockerProviderError("Docker broker token file is invalid") return cls(url, token) def _get(self, path: str) -> dict[str, Any]: if path != _BROKER_PATH: raise DockerProviderError("Docker broker operation is unsupported") try: with self.http.stream( "GET", f"{self.base_url}{path}", headers={"Authorization": self._authorization}, ) as response: response.raise_for_status() if ( response.headers.get("content-type", "").split(";", 1)[0].strip().casefold() != "application/json" ): raise _invalid_inventory() length = response.headers.get("content-length") if length is not None: try: if int(length) < 0 or int(length) > _BROKER_MAX_RESPONSE_BYTES: raise _invalid_inventory() except ValueError: raise _invalid_inventory() from None raw = bytearray() for chunk in response.iter_bytes(): if len(raw) + len(chunk) > _BROKER_MAX_RESPONSE_BYTES: raise _invalid_inventory() raw.extend(chunk) except httpx.HTTPError: raise DockerProviderError("Docker broker request failed") from None try: payload = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): raise DockerProviderError("Docker broker returned invalid JSON") from None if not isinstance(payload, dict): raise _invalid_inventory() return payload def ping(self) -> bool: # /healthz is intentionally unprivileged; an authenticated inventory # request immediately follows and remains the actual authorization test. try: with self.http.stream("GET", f"{self.base_url}/healthz") as response: response.raise_for_status() except httpx.HTTPError: raise DockerProviderError("Docker broker health check failed") from None return True @staticmethod def _container(item: dict[str, Any]) -> BrokerContainer: def text_field(field: str, *, optional_default: str | None = None) -> str: if field not in item: if optional_default is not None: return optional_default raise _invalid_inventory() value = item[field] if optional_default is not None and value is None: return optional_default if ( not isinstance(value, str) or not value or len(value) > 4096 or _contains_ascii_control(value) ): raise _invalid_inventory() return value ref = text_field("containerRef") name = text_field("name").lstrip("/") if not name: raise _invalid_inventory() state = text_field("state") health = text_field("health", optional_default="none") image = text_field("image") labels_value = item.get("labels") if not isinstance(labels_value, dict) or any( not isinstance(key, str) or not isinstance(value, str) or not key or len(key) > 4096 or len(value) > 4096 or _contains_ascii_control(key + value) for key, value in labels_value.items() ): raise _invalid_inventory() networks_value = item.get("networks") if not isinstance(networks_value, list) or any( not isinstance(name, str) or not name or len(name) > 4096 or _contains_ascii_control(name) for name in networks_value ): raise _invalid_inventory() # The deployed broker reports Docker health but does not yet emit an # explicit healthcheckPresent flag. A concrete health status itself # proves a healthcheck exists; preserve that discovery signal. healthcheck_present = item.get("healthcheckPresent", health.casefold() not in {"none", "unknown"}) if not isinstance(healthcheck_present, bool): raise _invalid_inventory() ports: dict[str, list[dict[str, str]]] = {} raw_ports = item.get("ports") if not isinstance(raw_ports, dict): raise _invalid_inventory() for key, bindings in raw_ports.items(): if ( not isinstance(key, str) or not key or len(key) > 128 or _contains_ascii_control(key) ): raise _invalid_inventory() if bindings is None: ports[key] = [] continue if not isinstance(bindings, list): raise _invalid_inventory() ports[key] = [] for binding in bindings: if not isinstance(binding, dict): raise _invalid_inventory() host_ip = binding.get("hostIp", "") host_port = binding.get("hostPort", "") if host_ip is None: host_ip = "" if host_port is None: host_port = "" if ( not isinstance(host_ip, str) or not isinstance(host_port, (str, int)) or isinstance(host_port, bool) ): raise _invalid_inventory() host_port = str(host_port) if ( len(host_ip) > 128 or _contains_ascii_control(host_ip + host_port) or ( host_port and ( len(host_port) > 5 or not all("0" <= char <= "9" for char in host_port) or not 1 <= int(host_port) <= 65535 ) ) ): raise _invalid_inventory() ports[key].append({"HostIp": host_ip, "HostPort": host_port}) return BrokerContainer( id=ref, name=name, status=state, attrs={ "State": { "Status": state, "Health": {"Status": health} if health != "none" else {}, }, "Config": { "Image": image, "Labels": labels_value, "Env": [], **({"Healthcheck": {"BrokerReported": True}} if healthcheck_present else {}), }, "NetworkSettings": { "Networks": {name: {} for name in networks_value}, "Ports": ports, }, }, image=BrokerImage(tags=[image]), ) def close(self) -> None: self.http.close() def docker_client_for_inventory(docker_host: str): provider = os.getenv("SERVICEMAP_DOCKER_PROVIDER", "socket").strip().casefold() if provider == "socket": return docker.DockerClient(base_url=docker_host, timeout=5) if provider == "broker": return BrokerDockerClient.from_environment() raise DockerProviderError("SERVICEMAP_DOCKER_PROVIDER must be 'socket' or 'broker'") def docker_provider_mode() -> str: return os.getenv("SERVICEMAP_DOCKER_PROVIDER", "socket").strip().casefold() or "socket"