mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
Answering "which deployed containers run a vulnerable PHP?" after CVE-2026-109618/109619 took a manual inventory of registry metadata, and nothing noticed a hardening ini that an image never loads. - New role sys-ctl-hlth-runtime, routed through sys-svc-compose next to sys-ctl-hlth-container. A daily timer runs `php` in every running container and fails, alerting through sys-ctl-alm-compose, when PHP is below the floor of its line or on a line older than every floor (7.4, 8.1). Every service that mounts php_hardening in meta/volumes.yml fails while expose_php or session.upload_progress.enabled is on; other PHP containers of such a stack are listed as info. Compose and swarm containers are matched by their project/stack and service labels. The check is not run at deploy time, so a finding never fails a deploy. - The floors (8.2.35, 8.3.36, 8.4.27, 8.5.12) live once, in HEALTH_RUNTIME_FLOORS of the role's vars/main.yml. - `make security-runtimes` lists the PHP of every pinned role image without pulling: Docker Hub tags through the Hub API, which no pull rate limit meters, other registries through the OCI config blob (new registry.config_blob, which the healthcheck probe now reuses). PHP_VERSION, distro PHP packages and a PHP_BASE build argument count. It exits 1 on an image below its floor or one the registry did not answer. Today it flags nfrastack/fusiondirectory:1.5 (line 7.4) and shopware/docker-base:8.3 (8.3.25). The role TODO.md keeps the follow-ups: further runtimes, the split web SAPI of web-app-lam, a scheduled CI run of the report and the CVE scan. Refs #1127 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
8.1 KiB
Python
Executable File
270 lines
8.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from dataclasses import dataclass
|
||
|
||
PROBE_MARKER = "INFINITO-RUNTIME-PROBE"
|
||
HARDENED_SETTINGS = ("expose_php", "session.upload_progress.enabled")
|
||
PHP_PROBE = (
|
||
f'echo "\\n{PROBE_MARKER}|", PHP_VERSION, "|", ini_get("expose_php"), "|", '
|
||
'ini_get("session.upload_progress.enabled"), "\\n";'
|
||
)
|
||
NO_BINARY_EXIT_CODES = frozenset({126, 127})
|
||
EXEC_TIMEOUT_SECONDS = 60
|
||
VERSION_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)")
|
||
ENABLED_VALUES = frozenset({"1", "on", "yes", "true"})
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Container:
|
||
id: str
|
||
name: str
|
||
stack: str | None
|
||
service: str | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PhpProbe:
|
||
version: str
|
||
settings: dict
|
||
|
||
|
||
class ProbeError(RuntimeError):
|
||
pass
|
||
|
||
|
||
def parse_args(argv=None):
|
||
parser = argparse.ArgumentParser(
|
||
description="Check PHP versions and hardening of running containers."
|
||
)
|
||
parser.add_argument(
|
||
"--floors",
|
||
required=True,
|
||
help='JSON: {"php": {"<major>.<minor>": "<lowest patched version>"}}',
|
||
)
|
||
parser.add_argument(
|
||
"--hardened",
|
||
required=True,
|
||
help='JSON: {"<stack>": ["<service mounting php_hardening>", ...]}',
|
||
)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def identity(labels: dict | None) -> tuple[str | None, str | None]:
|
||
"""Return the ``(stack, service)`` a container belongs to.
|
||
|
||
Args:
|
||
labels: the container's ``Config.Labels``, ``None`` when it has none.
|
||
|
||
Returns:
|
||
The swarm stack and service or the compose project and service;
|
||
``(None, None)`` for a container started outside both.
|
||
"""
|
||
if not labels:
|
||
return None, None
|
||
stack = labels.get("com.docker.stack.namespace")
|
||
if stack:
|
||
service = labels["com.docker.swarm.service.name"]
|
||
return stack, service.removeprefix(f"{stack}_")
|
||
project = labels.get("com.docker.compose.project")
|
||
if project:
|
||
return project, labels["com.docker.compose.service"]
|
||
return None, None
|
||
|
||
|
||
def running_containers() -> list[Container]:
|
||
ids = subprocess.run(
|
||
["container", "ps", "-q", "--no-trunc"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=True,
|
||
).stdout.split()
|
||
if not ids:
|
||
return []
|
||
inspected = json.loads(
|
||
subprocess.run(
|
||
["container", "inspect", "--type", "container", *ids],
|
||
capture_output=True,
|
||
text=True,
|
||
check=True,
|
||
).stdout
|
||
)
|
||
containers = []
|
||
for entry in inspected:
|
||
stack, service = identity(entry["Config"]["Labels"])
|
||
containers.append(
|
||
Container(
|
||
id=entry["Id"],
|
||
name=entry["Name"].lstrip("/"),
|
||
stack=stack,
|
||
service=service,
|
||
)
|
||
)
|
||
return containers
|
||
|
||
|
||
def parse_probe(stdout: str) -> PhpProbe:
|
||
"""Read the probe line out of the ``php -r`` output.
|
||
|
||
Args:
|
||
stdout: everything php printed, startup warnings included.
|
||
|
||
Returns:
|
||
The PHP version and whether each of HARDENED_SETTINGS is on.
|
||
"""
|
||
for line in stdout.splitlines():
|
||
if not line.startswith(f"{PROBE_MARKER}|"):
|
||
continue
|
||
fields = line.split("|")[1:]
|
||
if len(fields) != 1 + len(HARDENED_SETTINGS):
|
||
raise ProbeError(f"malformed probe line: {line!r}")
|
||
version, *values = fields
|
||
return PhpProbe(
|
||
version=version,
|
||
settings={
|
||
name: value.strip().lower() in ENABLED_VALUES
|
||
for name, value in zip(HARDENED_SETTINGS, values, strict=True)
|
||
},
|
||
)
|
||
raise ProbeError(f"php printed no probe line: {stdout.strip()!r}")
|
||
|
||
|
||
def probe_php(container_id: str) -> PhpProbe | None:
|
||
"""Run the PHP probe inside a container.
|
||
|
||
Args:
|
||
container_id: the container to exec into.
|
||
|
||
Returns:
|
||
The probe result, or ``None`` when the container has no ``php``.
|
||
"""
|
||
try:
|
||
result = subprocess.run(
|
||
["container", "exec", container_id, "php", "-r", PHP_PROBE],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
timeout=EXEC_TIMEOUT_SECONDS,
|
||
)
|
||
except subprocess.TimeoutExpired as exc:
|
||
raise ProbeError(f"php did not answer within {exc.timeout}s") from exc
|
||
if result.returncode in NO_BINARY_EXIT_CODES:
|
||
return None
|
||
if result.returncode != 0:
|
||
output = result.stderr.strip() or result.stdout.strip()
|
||
raise ProbeError(f"php exited {result.returncode}: {output}")
|
||
return parse_probe(result.stdout)
|
||
|
||
|
||
def _version_tuple(version: str) -> tuple[int, int, int] | None:
|
||
match = VERSION_PATTERN.match(version)
|
||
if match is None:
|
||
return None
|
||
major, minor, patch = (int(part) for part in match.groups())
|
||
return major, minor, patch
|
||
|
||
|
||
def _line_tuple(line: str) -> tuple[int, int]:
|
||
major, minor = line.split(".")
|
||
return int(major), int(minor)
|
||
|
||
|
||
def floor_violation(version: str, floors: dict) -> str | None:
|
||
"""Judge a runtime version against the floors of its runtime.
|
||
|
||
Args:
|
||
version: the version the runtime reports, e.g. ``8.3.12`` or
|
||
``8.1.2-1ubuntu2.14``.
|
||
floors: ``{"<major>.<minor>": "<lowest patched version>"}``.
|
||
|
||
Returns:
|
||
``None`` when the version is at or above its line's floor or on a line
|
||
newer than every floor, otherwise the reason it fails.
|
||
"""
|
||
current = _version_tuple(version)
|
||
if current is None:
|
||
return f"unparsable version {version!r}"
|
||
line = f"{current[0]}.{current[1]}"
|
||
if line in floors:
|
||
floor = floors[line]
|
||
if current < _version_tuple(floor):
|
||
return f"{version} is below the security floor {floor}"
|
||
return None
|
||
if current[:2] > max(_line_tuple(key) for key in floors):
|
||
return None
|
||
return f"{version} runs the end-of-life line {line}"
|
||
|
||
|
||
def check(
|
||
containers: list[Container],
|
||
php_floors: dict,
|
||
hardened: dict,
|
||
probe,
|
||
) -> tuple[list[str], list[str]]:
|
||
"""Evaluate every container.
|
||
|
||
Args:
|
||
containers: the running containers.
|
||
php_floors: the PHP floors, see :func:`floor_violation`.
|
||
hardened: ``{stack: [service, ...]}`` that must run hardened PHP.
|
||
probe: callable returning a :class:`PhpProbe` or ``None`` per id.
|
||
|
||
Returns:
|
||
``(failures, infos)`` as printable lines.
|
||
"""
|
||
failures: list[str] = []
|
||
infos: list[str] = []
|
||
for container in sorted(containers, key=lambda c: c.name):
|
||
in_hardened_stack = container.stack in hardened
|
||
must_harden = (
|
||
in_hardened_stack and container.service in hardened[container.stack]
|
||
)
|
||
try:
|
||
result = probe(container.id)
|
||
except ProbeError as exc:
|
||
failures.append(f"{container.name}: {exc}")
|
||
continue
|
||
if result is None:
|
||
if must_harden:
|
||
failures.append(
|
||
f"{container.name}: declares php_hardening but has no php to verify it"
|
||
)
|
||
continue
|
||
violation = floor_violation(result.version, php_floors)
|
||
if violation:
|
||
failures.append(f"{container.name}: PHP {violation}")
|
||
enabled = [name for name, on in result.settings.items() if on]
|
||
if not enabled:
|
||
continue
|
||
message = f"{container.name}: {', '.join(enabled)} on"
|
||
if must_harden:
|
||
failures.append(f"{message}, the php_hardening ini is not in effect")
|
||
elif in_hardened_stack:
|
||
infos.append(f"{message} (sidecar without php_hardening)")
|
||
return failures, infos
|
||
|
||
|
||
def main(argv=None) -> int:
|
||
args = parse_args(argv)
|
||
php_floors = json.loads(args.floors)["php"]
|
||
hardened = json.loads(args.hardened)
|
||
failures, infos = check(running_containers(), php_floors, hardened, probe_php)
|
||
for line in infos:
|
||
print(f"ℹ️ {line}")
|
||
for line in failures:
|
||
print(f"❌ {line}")
|
||
if failures:
|
||
print(f"🚨 {len(failures)} runtime finding(s).")
|
||
return 1
|
||
print("✅ Every PHP runtime is at or above its floor and hardened where declared.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|