335 lines
13 KiB
Python
335 lines
13 KiB
Python
"""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"
|