389 lines
17 KiB
Python
389 lines
17 KiB
Python
"""Fail closed when an RC Compose plan can overlap a protected installation.
|
|
|
|
The public-candidate workflow proves a disposable API/Web/Database stack. P12 adds a
|
|
GPU-capable Node Agent and Runtime Worker, which makes an accidental shared volume, host
|
|
network, port, or resource name materially more dangerous. This helper validates the rendered
|
|
Compose JSON and the resources observed after startup. It deliberately accepts no implicit
|
|
production defaults: callers must provide the candidate root and every protected port/name they
|
|
want to keep outside the validation project namespace.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from collections.abc import Mapping, Sequence
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
PROJECT_PATTERN = re.compile(
|
|
r"modelforge-validation-[a-z0-9](?:[a-z0-9-]{0,39}[a-z0-9])?\Z"
|
|
)
|
|
VOLUME_NAME_PATTERN = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9_.-]*")
|
|
CONFIG_TARGET = "/app/config"
|
|
CONFIG_MANIFEST = "models/initial-candidates.yaml"
|
|
|
|
|
|
class IsolationError(ValueError):
|
|
"""A candidate can share state or reach outside its owned RC namespace."""
|
|
|
|
|
|
def _mapping(value: object, label: str) -> Mapping[str, Any]:
|
|
if not isinstance(value, Mapping):
|
|
raise IsolationError(f"{label} must be an object")
|
|
return value
|
|
|
|
|
|
def _sequence(value: object, label: str) -> Sequence[Any]:
|
|
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
|
|
raise IsolationError(f"{label} must be a list")
|
|
return value
|
|
|
|
|
|
def validate_project(project: str) -> str:
|
|
if PROJECT_PATTERN.fullmatch(project) is None:
|
|
raise IsolationError(
|
|
"validation project must use a bounded modelforge-validation-* namespace"
|
|
)
|
|
return project
|
|
|
|
|
|
def _is_relative_to(path: Path, root: Path) -> bool:
|
|
try:
|
|
path.relative_to(root)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _published_port(port: Mapping[str, Any], service_name: str, public_tls_port: int | None) -> int | None:
|
|
published = port.get("published")
|
|
if published in (None, ""):
|
|
return None
|
|
try:
|
|
result = int(str(published))
|
|
except ValueError as exc:
|
|
raise IsolationError(f"{service_name} has an invalid published port") from exc
|
|
if not 0 <= result <= 65535:
|
|
raise IsolationError(f"{service_name} has an invalid published port")
|
|
host_ip = str(port.get("host_ip") or "")
|
|
if host_ip not in {"127.0.0.1", "::1"} and not (
|
|
service_name == "tls"
|
|
and public_tls_port == result == 18444
|
|
and host_ip == "0.0.0.0"
|
|
and str(port.get("target")) == "8443"
|
|
and port.get("protocol", "tcp") == "tcp"
|
|
):
|
|
raise IsolationError(f"{service_name} publishes a non-loopback port")
|
|
return result
|
|
|
|
|
|
def _validate_service_mount(
|
|
mount: Mapping[str, Any], *, service_name: str, candidate_root: Path, declared_volumes: set[str]
|
|
) -> None:
|
|
mount_type = mount.get("type")
|
|
source = mount.get("source")
|
|
target = str(mount.get("target") or "")
|
|
if target == "/var/run/docker.sock":
|
|
raise IsolationError(f"{service_name} mounts the Docker socket")
|
|
if mount_type == "bind":
|
|
if not isinstance(source, str) or not source:
|
|
raise IsolationError(f"{service_name} has an invalid bind source")
|
|
resolved = Path(source)
|
|
if not resolved.is_absolute():
|
|
resolved = candidate_root / resolved
|
|
try:
|
|
resolved = resolved.resolve()
|
|
except (OSError, RuntimeError) as exc:
|
|
raise IsolationError(f"{service_name} has an invalid bind source") from exc
|
|
if not _is_relative_to(resolved, candidate_root):
|
|
raise IsolationError(f"{service_name} bind source escapes the candidate root")
|
|
try:
|
|
resolved = resolved.resolve(strict=True)
|
|
except FileNotFoundError as exc:
|
|
raise IsolationError(f"{service_name} has a missing bind source") from exc
|
|
except (OSError, RuntimeError) as exc:
|
|
raise IsolationError(f"{service_name} has an invalid bind source") from exc
|
|
if not _is_relative_to(resolved, candidate_root):
|
|
raise IsolationError(f"{service_name} bind source escapes the candidate root")
|
|
if mount.get("read_only") is not True:
|
|
raise IsolationError(f"{service_name} has a writable bind mount")
|
|
if target == CONFIG_TARGET:
|
|
manifest = resolved / CONFIG_MANIFEST
|
|
try:
|
|
resolved_manifest = manifest.resolve(strict=True)
|
|
except FileNotFoundError as exc:
|
|
raise IsolationError(
|
|
f"{service_name} {CONFIG_TARGET} bind is missing {CONFIG_MANIFEST} under its source"
|
|
) from exc
|
|
except (OSError, RuntimeError) as exc:
|
|
raise IsolationError(
|
|
f"{service_name} {CONFIG_TARGET} bind has an invalid {CONFIG_MANIFEST}"
|
|
) from exc
|
|
if not _is_relative_to(resolved_manifest, resolved) or not resolved_manifest.is_file():
|
|
raise IsolationError(
|
|
f"{service_name} {CONFIG_TARGET} bind is missing {CONFIG_MANIFEST} under its source"
|
|
)
|
|
return
|
|
if mount_type == "volume":
|
|
if not isinstance(source, str) or VOLUME_NAME_PATTERN.fullmatch(source) is None:
|
|
raise IsolationError(f"{service_name} has an invalid named volume")
|
|
if source not in declared_volumes:
|
|
raise IsolationError(f"{service_name} uses an undeclared volume {source}")
|
|
return
|
|
if mount_type in {"tmpfs", "npipe"}:
|
|
return
|
|
raise IsolationError(f"{service_name} has unsupported mount type {mount_type!r}")
|
|
|
|
|
|
def validate_compose(
|
|
document: Mapping[str, Any],
|
|
*,
|
|
project: str,
|
|
candidate_root: Path,
|
|
protected_ports: set[int],
|
|
protected_resource_names: set[str],
|
|
public_tls_port: int | None = None,
|
|
) -> dict[str, object]:
|
|
"""Validate a rendered Compose document before any RC container starts."""
|
|
|
|
validate_project(project)
|
|
candidate_root = candidate_root.resolve()
|
|
services = _mapping(document.get("services"), "services")
|
|
if not services:
|
|
raise IsolationError("Compose plan has no services")
|
|
top_volumes = _mapping(document.get("volumes", {}), "volumes")
|
|
top_networks = _mapping(document.get("networks", {}), "networks")
|
|
declared_volumes = set(top_volumes)
|
|
declared_networks = set(top_networks)
|
|
if not declared_volumes or not declared_networks:
|
|
raise IsolationError("validation plan requires its own declared volumes and network")
|
|
if public_tls_port is not None and public_tls_port != 18444:
|
|
raise IsolationError("the only admitted public TLS test port is 18444")
|
|
if public_tls_port is not None and "tls" not in services:
|
|
raise IsolationError("public TLS test port requires the isolated tls service")
|
|
|
|
for name, configuration in top_volumes.items():
|
|
if VOLUME_NAME_PATTERN.fullmatch(name) is None:
|
|
raise IsolationError(f"invalid declared volume {name!r}")
|
|
config = _mapping(configuration, f"volume {name}")
|
|
if config.get("external"):
|
|
raise IsolationError(f"volume {name} is external")
|
|
actual_name = config.get("name")
|
|
if actual_name is not None and actual_name != f"{project}_{name}":
|
|
raise IsolationError(f"volume {name} has a non-project name")
|
|
|
|
for name, configuration in top_networks.items():
|
|
if VOLUME_NAME_PATTERN.fullmatch(name) is None:
|
|
raise IsolationError(f"invalid declared network {name!r}")
|
|
config = _mapping(configuration, f"network {name}")
|
|
if config.get("external"):
|
|
raise IsolationError(f"network {name} is external")
|
|
actual_name = config.get("name")
|
|
if actual_name is not None and actual_name != f"{project}_{name}":
|
|
raise IsolationError(f"network {name} has a non-project name")
|
|
|
|
published_ports: set[int] = set()
|
|
for name, configuration in services.items():
|
|
service = _mapping(configuration, f"service {name}")
|
|
if service.get("container_name"):
|
|
raise IsolationError(f"{name} declares a global container name")
|
|
if service.get("network_mode") == "host":
|
|
raise IsolationError(f"{name} uses the host network")
|
|
configured_networks = service.get("networks", [])
|
|
if isinstance(configured_networks, Mapping):
|
|
service_networks: Sequence[Any] = list(configured_networks)
|
|
else:
|
|
service_networks = _sequence(configured_networks, f"{name}.networks")
|
|
network_names = []
|
|
for network in service_networks:
|
|
network_name = network if isinstance(network, str) else _mapping(network, f"{name}.network").get("target")
|
|
if network_name not in declared_networks:
|
|
raise IsolationError(f"{name} uses an undeclared network")
|
|
network_names.append(network_name)
|
|
if name == "tls" and public_tls_port is not None:
|
|
if set(network_names) != {"ingress"}:
|
|
raise IsolationError("tls service must join only the candidate ingress network")
|
|
if service.get("image") != "caddy:2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d":
|
|
raise IsolationError("tls service must use the pinned Caddy image")
|
|
if service.get("read_only") is not True or service.get("cap_drop") != ["ALL"]:
|
|
raise IsolationError("tls service must have read-only root and no capabilities")
|
|
if service.get("privileged") or service.get("cap_add") or service.get("build"):
|
|
raise IsolationError("tls service cannot be privileged or locally built")
|
|
if "no-new-privileges:true" not in _sequence(service.get("security_opt", []), "tls.security_opt"):
|
|
raise IsolationError("tls service must refuse new privileges")
|
|
mounts = _sequence(service.get("volumes", []), "tls.volumes")
|
|
targets = {str(_mapping(mount, "tls.volume").get("target")) for mount in mounts}
|
|
if targets != {"/etc/caddy/Caddyfile", "/run/tls/fullchain.pem", "/run/tls/privkey.pem"} or len(mounts) != 3:
|
|
raise IsolationError("tls service must mount only its config and certificate pair")
|
|
tls_ports = _sequence(service.get("ports", []), "tls.ports")
|
|
if len(tls_ports) != 1:
|
|
raise IsolationError("tls service must publish exactly one port")
|
|
tls_port = _mapping(tls_ports[0], "tls.port")
|
|
if str(tls_port.get("published")) != "18444" or tls_port.get("host_ip") != "0.0.0.0":
|
|
raise IsolationError("tls service must bind only public test port 18444")
|
|
for mount in _sequence(service.get("volumes", []), f"{name}.volumes"):
|
|
_validate_service_mount(
|
|
_mapping(mount, f"{name}.volume"),
|
|
service_name=name,
|
|
candidate_root=candidate_root,
|
|
declared_volumes=declared_volumes,
|
|
)
|
|
for port in _sequence(service.get("ports", []), f"{name}.ports"):
|
|
published = _published_port(_mapping(port, f"{name}.port"), name, public_tls_port)
|
|
if published and published in protected_ports:
|
|
raise IsolationError(f"{name} reuses protected host port {published}")
|
|
if published:
|
|
published_ports.add(published)
|
|
|
|
serialized = json.dumps(document, sort_keys=True)
|
|
referenced = sorted(name for name in protected_resource_names if name and name in serialized)
|
|
if referenced:
|
|
raise IsolationError(f"Compose plan references protected resources: {', '.join(referenced)}")
|
|
return {
|
|
"project": project,
|
|
"services": sorted(services),
|
|
"declared_volumes": sorted(declared_volumes),
|
|
"declared_networks": sorted(declared_networks),
|
|
"published_ports": sorted(published_ports),
|
|
}
|
|
|
|
|
|
def validate_observed_resources(
|
|
document: Mapping[str, Any], *, project: str
|
|
) -> dict[str, list[str]]:
|
|
"""Ensure resources created by Compose retain the exact validation project label and name."""
|
|
|
|
validate_project(project)
|
|
expected_label = project
|
|
prefixes = {"containers": f"{project}-", "volumes": f"{project}_", "networks": f"{project}_"}
|
|
result: dict[str, list[str]] = {}
|
|
for kind, prefix in prefixes.items():
|
|
items = _sequence(document.get(kind), kind)
|
|
if not items:
|
|
raise IsolationError(f"no observed {kind} for {project}")
|
|
names: list[str] = []
|
|
for item in items:
|
|
resource = _mapping(item, kind)
|
|
name = resource.get("name")
|
|
labels = _mapping(resource.get("labels"), f"{kind}.labels")
|
|
if not isinstance(name, str) or not name.startswith(prefix):
|
|
raise IsolationError(f"{kind} resource has a non-project name")
|
|
if labels.get("com.docker.compose.project") != expected_label:
|
|
raise IsolationError(f"{kind} resource has no exact project label")
|
|
names.append(name)
|
|
result[kind] = sorted(names)
|
|
return result
|
|
|
|
|
|
def _docker(*args: str) -> str:
|
|
completed = subprocess.run(
|
|
("docker", *args),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
if completed.returncode:
|
|
raise IsolationError(f"Docker inspection failed: {' '.join(args)}")
|
|
return completed.stdout
|
|
|
|
|
|
def collect_observed_resources(project: str) -> dict[str, list[dict[str, object]]]:
|
|
"""Collect only resources carrying the exact Compose project label.
|
|
|
|
``docker inspect`` is used instead of parsing ``docker * ls`` label strings: a label value can
|
|
itself contain commas or equals signs, and P12 must not mistake a truncated display value for
|
|
resource ownership evidence.
|
|
"""
|
|
|
|
validate_project(project)
|
|
kinds = {
|
|
"containers": ("container", ("ls", "--all", "--quiet")),
|
|
"volumes": ("volume", ("ls", "--quiet")),
|
|
"networks": ("network", ("ls", "--quiet")),
|
|
}
|
|
result: dict[str, list[dict[str, object]]] = {}
|
|
for plural, (singular, list_args) in kinds.items():
|
|
identifiers = [
|
|
value for value in _docker(
|
|
singular, *list_args, "--filter", f"label=com.docker.compose.project={project}"
|
|
).splitlines() if value
|
|
]
|
|
resources: list[dict[str, object]] = []
|
|
for identifier in identifiers:
|
|
try:
|
|
inspected = json.loads(_docker(singular, "inspect", identifier))
|
|
except json.JSONDecodeError as exc:
|
|
raise IsolationError(f"Docker returned invalid {singular} inspection JSON") from exc
|
|
if not isinstance(inspected, list) or len(inspected) != 1 or not isinstance(inspected[0], Mapping):
|
|
raise IsolationError(f"Docker returned ambiguous {singular} inspection data")
|
|
item = inspected[0]
|
|
labels = item.get("Labels")
|
|
if singular == "container":
|
|
config = _mapping(item.get("Config"), "container.Config")
|
|
labels = config.get("Labels")
|
|
resources.append(
|
|
{
|
|
"name": str(item.get("Name") or "").lstrip("/"),
|
|
"labels": dict(_mapping(labels or {}, f"{singular}.Labels")),
|
|
}
|
|
)
|
|
result[plural] = resources
|
|
return result
|
|
|
|
|
|
def _read_json(path: Path) -> Mapping[str, Any]:
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise IsolationError(f"cannot read JSON evidence {path}") from exc
|
|
return _mapping(value, str(path))
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--project", required=True)
|
|
parser.add_argument("--candidate-root", required=True, type=Path)
|
|
parser.add_argument("--compose-json", required=True, type=Path)
|
|
parser.add_argument("--observed-json", type=Path)
|
|
parser.add_argument("--inspect-docker", action="store_true")
|
|
parser.add_argument("--protected-port", action="append", default=[], type=int)
|
|
parser.add_argument("--public-tls-port", type=int)
|
|
parser.add_argument("--protected-resource", action="append", default=[])
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
if args.observed_json is not None and args.inspect_docker:
|
|
raise IsolationError("choose observed JSON or Docker inspection, not both")
|
|
result: dict[str, object] = {
|
|
"preflight": validate_compose(
|
|
_read_json(args.compose_json),
|
|
project=args.project,
|
|
candidate_root=args.candidate_root,
|
|
protected_ports=set(args.protected_port),
|
|
protected_resource_names=set(args.protected_resource),
|
|
public_tls_port=args.public_tls_port,
|
|
)
|
|
}
|
|
if args.observed_json is not None:
|
|
result["observed"] = validate_observed_resources(
|
|
_read_json(args.observed_json), project=args.project
|
|
)
|
|
elif args.inspect_docker:
|
|
result["observed"] = validate_observed_resources(
|
|
collect_observed_resources(args.project), project=args.project
|
|
)
|
|
except IsolationError as exc:
|
|
print(json.dumps({"result": "FAIL", "error": str(exc)}, sort_keys=True))
|
|
return 2
|
|
print(json.dumps({"result": "PASS", **result}, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|