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>
284 lines
9.6 KiB
Python
284 lines
9.6 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from unittest import TestCase, main, mock
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
|
|
def _load_script():
|
|
path = (
|
|
PROJECT_ROOT
|
|
/ "roles"
|
|
/ "sys-ctl-hlth-runtime"
|
|
/ "files"
|
|
/ "python"
|
|
/ "script.py"
|
|
)
|
|
spec = importlib.util.spec_from_file_location("hlth_runtime_script", path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec and spec.loader
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
SCRIPT = _load_script()
|
|
FLOORS = {"8.2": "8.2.35", "8.3": "8.3.36", "8.4": "8.4.27", "8.5": "8.5.12"}
|
|
MARKER = SCRIPT.PROBE_MARKER
|
|
|
|
|
|
def _container(name, stack=None, service=None):
|
|
return SCRIPT.Container(id=f"id-{name}", name=name, stack=stack, service=service)
|
|
|
|
|
|
def _probe(version, expose_php=False, upload_progress=False):
|
|
return SCRIPT.PhpProbe(
|
|
version=version,
|
|
settings={
|
|
"expose_php": expose_php,
|
|
"session.upload_progress.enabled": upload_progress,
|
|
},
|
|
)
|
|
|
|
|
|
def _check(containers, probes, hardened=None):
|
|
return SCRIPT.check(
|
|
containers, FLOORS, hardened or {}, lambda container_id: probes[container_id]
|
|
)
|
|
|
|
|
|
class TestFloorViolation(TestCase):
|
|
def test_a_version_at_its_floor_passes(self):
|
|
self.assertIsNone(SCRIPT.floor_violation("8.3.36", FLOORS))
|
|
|
|
def test_a_version_below_its_floor_fails(self):
|
|
self.assertIn(
|
|
"below the security floor 8.4.27", SCRIPT.floor_violation("8.4.26", FLOORS)
|
|
)
|
|
|
|
def test_php_8_1_is_end_of_life(self):
|
|
self.assertIn("end-of-life line 8.1", SCRIPT.floor_violation("8.1.34", FLOORS))
|
|
|
|
def test_php_7_4_is_end_of_life(self):
|
|
self.assertIn("end-of-life line 7.4", SCRIPT.floor_violation("7.4.33", FLOORS))
|
|
|
|
def test_a_line_newer_than_every_floor_passes(self):
|
|
self.assertIsNone(SCRIPT.floor_violation("8.6.0", FLOORS))
|
|
|
|
def test_a_distro_revision_suffix_is_ignored(self):
|
|
self.assertIsNone(SCRIPT.floor_violation("8.4.27-1+deb13u1", FLOORS))
|
|
|
|
def test_an_unparsable_version_fails(self):
|
|
self.assertIn("unparsable", SCRIPT.floor_violation("eight", FLOORS))
|
|
|
|
|
|
class TestParseProbe(TestCase):
|
|
def test_startup_warnings_before_the_probe_line_are_skipped(self):
|
|
stdout = f"PHP Warning: Module 'redis' already loaded\n{MARKER}|8.3.36||0\n"
|
|
self.assertEqual(SCRIPT.parse_probe(stdout), _probe("8.3.36"))
|
|
|
|
def test_enabled_settings_are_reported_on(self):
|
|
self.assertEqual(
|
|
SCRIPT.parse_probe(f"\n{MARKER}|8.4.27|1|1\n"),
|
|
_probe("8.4.27", expose_php=True, upload_progress=True),
|
|
)
|
|
|
|
def test_output_without_the_probe_line_raises(self):
|
|
with self.assertRaises(SCRIPT.ProbeError):
|
|
SCRIPT.parse_probe("Segmentation fault\n")
|
|
|
|
def test_a_probe_line_with_missing_fields_raises(self):
|
|
with self.assertRaises(SCRIPT.ProbeError):
|
|
SCRIPT.parse_probe(f"{MARKER}|8.4.27\n")
|
|
|
|
|
|
class TestIdentity(TestCase):
|
|
def test_compose_labels_give_project_and_service(self):
|
|
labels = {
|
|
"com.docker.compose.project": "nextcloud",
|
|
"com.docker.compose.service": "cron",
|
|
}
|
|
self.assertEqual(SCRIPT.identity(labels), ("nextcloud", "cron"))
|
|
|
|
def test_swarm_labels_strip_the_stack_prefix(self):
|
|
labels = {
|
|
"com.docker.stack.namespace": "nextcloud",
|
|
"com.docker.swarm.service.name": "nextcloud_nextcloud",
|
|
}
|
|
self.assertEqual(SCRIPT.identity(labels), ("nextcloud", "nextcloud"))
|
|
|
|
def test_a_container_without_labels_has_no_identity(self):
|
|
self.assertEqual(SCRIPT.identity(None), (None, None))
|
|
|
|
|
|
class TestCheck(TestCase):
|
|
def test_an_eol_runtime_outside_any_stack_fails(self):
|
|
failures, _infos = _check(
|
|
[_container("legacy")], {"id-legacy": _probe("8.1.34")}
|
|
)
|
|
self.assertEqual(failures, ["legacy: PHP 8.1.34 runs the end-of-life line 8.1"])
|
|
|
|
def test_a_patched_runtime_passes(self):
|
|
self.assertEqual(
|
|
_check([_container("app")], {"id-app": _probe("8.5.12")}), ([], [])
|
|
)
|
|
|
|
def test_a_hardened_service_with_expose_php_on_fails(self):
|
|
failures, _infos = _check(
|
|
[_container("wp", "wordpress", "wordpress")],
|
|
{"id-wp": _probe("8.4.27", expose_php=True)},
|
|
{"wordpress": ["wordpress"]},
|
|
)
|
|
self.assertEqual(
|
|
failures, ["wp: expose_php on, the php_hardening ini is not in effect"]
|
|
)
|
|
|
|
def test_a_hardened_service_with_both_settings_off_passes(self):
|
|
self.assertEqual(
|
|
_check(
|
|
[_container("wp", "wordpress", "wordpress")],
|
|
{"id-wp": _probe("8.4.27")},
|
|
{"wordpress": ["wordpress"]},
|
|
),
|
|
([], []),
|
|
)
|
|
|
|
def test_a_sidecar_of_a_hardened_stack_is_info_only(self):
|
|
failures, infos = _check(
|
|
[_container("nc-cron", "nextcloud", "cron")],
|
|
{"id-nc-cron": _probe("8.4.27", upload_progress=True)},
|
|
{"nextcloud": ["nextcloud"]},
|
|
)
|
|
self.assertEqual(failures, [])
|
|
self.assertEqual(
|
|
infos,
|
|
[
|
|
"nc-cron: session.upload_progress.enabled on (sidecar without php_hardening)"
|
|
],
|
|
)
|
|
|
|
def test_a_container_outside_hardened_stacks_is_not_judged_on_settings(self):
|
|
self.assertEqual(
|
|
_check(
|
|
[_container("app", "other", "app")],
|
|
{"id-app": _probe("8.4.27", expose_php=True)},
|
|
{"wordpress": ["wordpress"]},
|
|
),
|
|
([], []),
|
|
)
|
|
|
|
def test_a_hardened_service_without_php_fails(self):
|
|
failures, _infos = _check(
|
|
[_container("wp", "wordpress", "wordpress")],
|
|
{"id-wp": None},
|
|
{"wordpress": ["wordpress"]},
|
|
)
|
|
self.assertEqual(
|
|
failures, ["wp: declares php_hardening but has no php to verify it"]
|
|
)
|
|
|
|
def test_a_container_without_php_is_skipped(self):
|
|
self.assertEqual(_check([_container("db")], {"id-db": None}), ([], []))
|
|
|
|
def test_a_probe_error_is_a_failure(self):
|
|
def broken(_container_id):
|
|
raise SCRIPT.ProbeError("php exited 255: boom")
|
|
|
|
failures, _infos = SCRIPT.check([_container("app")], FLOORS, {}, broken)
|
|
self.assertEqual(failures, ["app: php exited 255: boom"])
|
|
|
|
|
|
class TestProbePhp(TestCase):
|
|
def _run(self, returncode, stdout="", stderr=""):
|
|
completed = SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
|
|
with mock.patch.object(SCRIPT.subprocess, "run", return_value=completed):
|
|
return SCRIPT.probe_php("abc")
|
|
|
|
def test_exit_127_means_no_php(self):
|
|
self.assertIsNone(self._run(127, stderr="executable file not found"))
|
|
|
|
def test_exit_126_means_no_php(self):
|
|
self.assertIsNone(self._run(126, stderr="OCI runtime exec failed"))
|
|
|
|
def test_another_non_zero_exit_raises(self):
|
|
with self.assertRaises(SCRIPT.ProbeError):
|
|
self._run(255, stderr="PHP Fatal error")
|
|
|
|
def test_a_timeout_raises(self):
|
|
with (
|
|
mock.patch.object(
|
|
SCRIPT.subprocess,
|
|
"run",
|
|
side_effect=subprocess.TimeoutExpired(cmd="php", timeout=60),
|
|
),
|
|
self.assertRaises(SCRIPT.ProbeError),
|
|
):
|
|
SCRIPT.probe_php("abc")
|
|
|
|
|
|
class TestMain(TestCase):
|
|
def _main(self, php_versions):
|
|
"""Run main() against a fake engine.
|
|
|
|
Args:
|
|
php_versions: ``{container name: PHP version or None}``.
|
|
"""
|
|
inspected = [
|
|
{
|
|
"Id": f"id-{name}",
|
|
"Name": f"/{name}",
|
|
"Config": {"Labels": None},
|
|
}
|
|
for name in php_versions
|
|
]
|
|
|
|
def fake_run(args, **_kwargs):
|
|
if args[:2] == ["container", "ps"]:
|
|
return SimpleNamespace(
|
|
returncode=0,
|
|
stdout="\n".join(i["Id"] for i in inspected),
|
|
stderr="",
|
|
)
|
|
if args[:2] == ["container", "inspect"]:
|
|
return SimpleNamespace(
|
|
returncode=0, stdout=json.dumps(inspected), stderr=""
|
|
)
|
|
if args[:2] == ["container", "exec"]:
|
|
version = php_versions[args[2].removeprefix("id-")]
|
|
if version is None:
|
|
return SimpleNamespace(
|
|
returncode=127, stdout="", stderr="not found"
|
|
)
|
|
return SimpleNamespace(
|
|
returncode=0, stdout=f"\n{MARKER}|{version}||0\n", stderr=""
|
|
)
|
|
raise AssertionError(f"unexpected command {args}")
|
|
|
|
argv = ["--floors", json.dumps({"php": FLOORS}), "--hardened", "{}"]
|
|
with (
|
|
mock.patch.object(SCRIPT.subprocess, "run", side_effect=fake_run),
|
|
contextlib.redirect_stdout(io.StringIO()) as out,
|
|
):
|
|
code = SCRIPT.main(argv)
|
|
return code, out.getvalue()
|
|
|
|
def test_main_fails_on_an_eol_runtime(self):
|
|
code, output = self._main({"akaunting": "8.1.34", "postgres": None})
|
|
self.assertEqual(code, 1)
|
|
self.assertIn("akaunting: PHP 8.1.34 runs the end-of-life line 8.1", output)
|
|
|
|
def test_main_passes_when_every_runtime_is_patched(self):
|
|
code, _output = self._main({"wordpress": "8.4.27", "postgres": None})
|
|
self.assertEqual(code, 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|