mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 20:26:33 +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>
144 lines
5.4 KiB
Python
144 lines
5.4 KiB
Python
"""image_healthcheck must separate "declares none" from "could not tell".
|
|
|
|
A sweep that reads an indeterminate answer as an absent probe reports every
|
|
image as a gap the moment the network hiccups.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
import unittest.mock as mock
|
|
from pathlib import Path
|
|
|
|
from utils.docker import registry
|
|
|
|
MANIFEST = {"config": {"digest": "sha256:cfg"}, "layers": [{"size": 1}]}
|
|
INDEX = {
|
|
"manifests": [
|
|
{"platform": {"os": "linux", "architecture": "amd64"}, "digest": "sha256:amd"}
|
|
]
|
|
}
|
|
PROBE = {"Test": ["CMD-SHELL", "dig @127.0.0.1 || exit 1"]}
|
|
|
|
|
|
class _Isolated:
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
cache = tempfile.TemporaryDirectory()
|
|
self.addCleanup(cache.cleanup)
|
|
for patcher in (
|
|
mock.patch.object(registry, "_CACHE_ROOT", Path(cache.name)),
|
|
mock.patch.dict(os.environ, {"INFINITO_GHCR_MIRROR_PREFIX": ""}),
|
|
):
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def serve(self, manifest, config):
|
|
"""Answer manifest requests with *manifest* and blob requests with *config*."""
|
|
|
|
def _request(url, repo, method, accept):
|
|
if "/blobs/" in url:
|
|
return (200, {}, json.dumps(config).encode()) if config else None
|
|
return (200, {}, json.dumps(manifest).encode()) if manifest else None
|
|
|
|
return mock.patch.object(registry, "_request", side_effect=_request)
|
|
|
|
|
|
class TestImageHealthcheck(_Isolated, unittest.TestCase):
|
|
def test_a_declared_probe_comes_back(self) -> None:
|
|
with self.serve(MANIFEST, {"config": {"Healthcheck": PROBE}}):
|
|
self.assertEqual(registry.image_healthcheck("redis", "alpine"), PROBE)
|
|
|
|
def test_an_image_without_one_answers_with_an_empty_mapping(self) -> None:
|
|
with self.serve(MANIFEST, {"config": {}}):
|
|
self.assertEqual(registry.image_healthcheck("redis", "alpine"), {})
|
|
|
|
def test_an_unreachable_registry_is_indeterminate_not_absent(self) -> None:
|
|
with mock.patch.object(registry, "_request", return_value=None):
|
|
self.assertIsNone(registry.image_healthcheck("redis", "alpine"))
|
|
|
|
def test_a_multi_platform_index_is_resolved_to_the_platform(self) -> None:
|
|
seen = []
|
|
|
|
def _request(url, repo, method, accept):
|
|
seen.append(url)
|
|
if "/blobs/" in url:
|
|
return (
|
|
200,
|
|
{},
|
|
json.dumps({"config": {"Healthcheck": PROBE}}).encode(),
|
|
)
|
|
body = MANIFEST if "sha256%3Aamd" in url else INDEX
|
|
return (200, {}, json.dumps(body).encode())
|
|
|
|
with mock.patch.object(registry, "_request", side_effect=_request):
|
|
self.assertEqual(registry.image_healthcheck("redis", "alpine"), PROBE)
|
|
self.assertTrue(any("sha256%3Aamd" in url for url in seen))
|
|
|
|
def test_an_index_without_the_platform_is_indeterminate(self) -> None:
|
|
with (
|
|
self.serve(INDEX, {"config": {}}),
|
|
mock.patch.object(registry, "_platform_digest", return_value=None),
|
|
):
|
|
self.assertIsNone(registry.image_healthcheck("redis", "alpine"))
|
|
|
|
def test_a_determinate_answer_is_cached(self) -> None:
|
|
calls = []
|
|
|
|
def _request(url, repo, method, accept):
|
|
calls.append(url)
|
|
if "/blobs/" in url:
|
|
return (200, {}, json.dumps({"config": {}}).encode())
|
|
return (200, {}, json.dumps(MANIFEST).encode())
|
|
|
|
with mock.patch.object(registry, "_request", side_effect=_request):
|
|
registry.image_healthcheck("redis", "alpine")
|
|
before = len(calls)
|
|
registry.image_healthcheck("redis", "alpine")
|
|
self.assertEqual(len(calls), before, "the second read must come from cache")
|
|
|
|
|
|
class TestProbeSource(_Isolated, unittest.TestCase):
|
|
def test_the_source_says_where_the_answer_came_from(self) -> None:
|
|
with self.serve(MANIFEST, {"config": {}}):
|
|
_found, source = registry.image_healthcheck_probed("redis", "alpine")
|
|
self.assertEqual(source, "upstream")
|
|
|
|
def test_a_cached_answer_says_so(self) -> None:
|
|
with self.serve(MANIFEST, {"config": {}}):
|
|
registry.image_healthcheck_probed("redis", "alpine")
|
|
_found, source = registry.image_healthcheck_probed("redis", "alpine")
|
|
self.assertEqual(source, "cache")
|
|
|
|
def test_an_unreachable_registry_reports_none(self) -> None:
|
|
with mock.patch.object(registry, "_request", return_value=None):
|
|
found, source = registry.image_healthcheck_probed("redis", "alpine")
|
|
self.assertIsNone(found)
|
|
self.assertEqual(source, "none")
|
|
|
|
|
|
class TestConfigBlob(_Isolated, unittest.TestCase):
|
|
def test_the_whole_blob_comes_back_with_its_history(self) -> None:
|
|
blob = {
|
|
"config": {"Env": ["PHP_VERSION=8.4.27"]},
|
|
"history": [{"created_by": "x"}],
|
|
}
|
|
with (
|
|
self.serve(INDEX, blob),
|
|
mock.patch.object(
|
|
registry, "fetch_manifest", side_effect=[INDEX, MANIFEST]
|
|
),
|
|
):
|
|
self.assertEqual(registry.config_blob("php", "8.4", "linux", "amd64"), blob)
|
|
|
|
def test_an_index_without_the_platform_is_indeterminate(self) -> None:
|
|
with self.serve(INDEX, {"config": {}}):
|
|
self.assertIsNone(registry.config_blob("php", "8.4", "linux", "riscv64"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|