feat(sys-ctl-hlth-runtime): alert on EOL or unpatched PHP and missing PHP hardening

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>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-29 00:12:25 +01:00
1 parent e8cb250c51
commit 3c743736a8
28 files changed
+1461 -3

No files matched your search

+6 -1
View File
@@ -701,6 +701,11 @@ security-apparmor-teardown:
echo "[apparmor] AppArmor module is not loaded — skipping teardown"; \
fi
.PHONY: security-runtimes
# List the PHP runtime of every pinned role image from registry metadata, without pulling, and fail on one below its security floor.
security-runtimes:
@"$${PYTHON}" -m cli.contributing.security.runtimes
.PHONY: setup
# Run the setup step after generating .dockerignore.
setup: fix-dockerignore dotenv
@@ -945,4 +950,4 @@ wsl2-systemd-check:
.PHONY: wsl2-trust-windows
# Trust Windows certificates in WSL2.
wsl2-trust-windows:
@bash scripts/system/tls/trust/wsl2.sh
@bash scripts/system/tls/trust/wsl2.sh
+1
View File
@@ -143,6 +143,7 @@ alias i8se2='m setup'
alias i8sece='m setup-clean'
alias i8sepa='m security-apparmor-teardown'
alias i8sepu='m system-purge'
alias i8seru='m security-runtimes'
alias i8source='source scripts/meta/env/load.sh' # load the project .env + venv python into the current shell
alias i8ta='m autoformat'
alias i8tare='m autoformat-restage'
+3
View File
@@ -0,0 +1,3 @@
# Security 🛡️
Repository-side security reports over the declared role artefacts, such as the language runtimes the pinned images ship, read from registry metadata without pulling.
Whitespace-only changes.
@@ -0,0 +1,5 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[4]
__all__ = ["PROJECT_ROOT"]
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor
from utils.docker.image.discovery import ImageRef, iter_role_images
from utils.docker.image.runtime import (
build_instructions,
load_php_floors,
php_finding,
php_runtime,
)
from . import PROJECT_ROOT
WORKERS = 4
OS_NAME = "linux"
ARCHITECTURE = "amd64"
def _instructions(ref: ImageRef) -> list[str] | None:
return build_instructions(
f"{ref.registry}/{ref.name}",
ref.version,
os_name=OS_NAME,
architecture=ARCHITECTURE,
)
def report(refs: list[ImageRef], probed: list, floors: dict[str, str]) -> int:
"""Print one line per image that ships PHP and return the exit code.
Args:
refs: the pinned role images.
probed: :func:`build_instructions` result per ref, in the same order.
floors: see :func:`load_php_floors`.
Returns:
``1`` when an image is below its floor or the registry gave no answer
for one, ``0`` otherwise.
"""
findings = 0
unknown = []
with_php = 0
for ref, instructions in zip(refs, probed, strict=True):
if instructions is None:
unknown.append(f"{ref.role}:{ref.service} ({ref.source})")
continue
runtime = php_runtime(instructions)
if runtime is None:
continue
with_php += 1
php = runtime.version or f"line {', '.join(runtime.lines)}"
finding = php_finding(runtime, floors)
if finding:
findings += 1
print(f"❌ {ref.role}:{ref.service} {ref.source} PHP {php}: {finding}")
elif runtime.version:
print(f"✅ {ref.role}:{ref.service} {ref.source} PHP {php}")
else:
print(
f"❔ {ref.role}:{ref.service} {ref.source} PHP {php}: "
"patch level not in the registry metadata"
)
for entry in unknown:
print(f"⚠️ {entry}: the registry gave no answer")
print(
f"\n{len(refs)} image(s): {with_php} ship PHP, {findings} below the "
f"floor, {len(unknown)} unanswered"
)
return 1 if findings or unknown else 0
def main(argv=None) -> int:
argparse.ArgumentParser(
description=(
"List the PHP runtime of every pinned role image from registry "
"metadata, without pulling, and fail on one below its security floor."
)
).parse_args(argv)
floors = load_php_floors()
refs = sorted(iter_role_images(PROJECT_ROOT), key=lambda r: (r.role, r.service))
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
probed = list(pool.map(_instructions, refs))
return report(refs, probed, floors)
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -23,6 +23,7 @@ SYS_SCHEDULE_HEALTH_DOCKER_VOLUMES: "*-*-* {{ HOURS_SERVER_AWAKE }}:00:00" # Che
SYS_SCHEDULE_HEALTH_CSP_CRAWLER: "*-*-* {{ HOURS_SERVER_AWAKE }}:00:00" # Check once per hour if all CSP are fullfilled available
SYS_SCHEDULE_HEALTH_NGINX: "*-*-* {{ HOURS_SERVER_AWAKE }}:00:00" # Check once per hour if all webservices are available
SYS_SCHEDULE_HEALTH_MSMTP: "*-*-* 00:00:00" # Check once per day SMTP Server
SYS_SCHEDULE_HEALTH_RUNTIME: "*-*-* 00:00:00" # Check once per day the PHP version and hardening of every running container
SYS_SCHEDULE_CLEANUP_CERTS: "*-*-* 20:00" # Deletes and revokes unused certs once per day
SYS_SCHEDULE_CLEANUP_BACKUPS: "*-*-* 21:00" # Cleanup backups once per day, MUST be called before disc space cleanup
+35
View File
@@ -0,0 +1,35 @@
# Container Runtime Health Check
## Description
This role checks the language runtime inside every running Docker container. It reads the PHP version of each container that ships a `php` binary and the effective PHP hardening of each service that mounts the shared hardening ini from [`sys-svc-compose-php`](../sys-svc-compose-php/README.md).
## Overview
The role installs a check script with a `systemd` service and timer. The check fails, and the failure notifier from [`sys-ctl-alm-compose`](../sys-ctl-alm-compose/README.md) alerts, when a container runs PHP below the security floor of its release line or on an end-of-life line, or when a service that declares `php_hardening` in its `meta/volumes.yml` still reports `expose_php` or `session.upload_progress.enabled` as on. The floors live in `HEALTH_RUNTIME_FLOORS` in [main.yml](./vars/main.yml).
## Cosmos
The diagram places Container Runtime Health Check in the Infinito.Nexus cosmos: the components it deploys (capabilities), the central services it consumes (dependencies), and its outward reach (federation and bridged external networks).
```mermaid
flowchart LR
subgraph role [sys-ctl-hlth-runtime 💻]
svc_runtime["runtime"]
end
```
Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (enabled only in matching deployments); red `0..0` edges are turned off in this role. Node markers show the role's deploy modes (💻 host, 🐳 compose, 🐝 swarm); ❌ marks a service that is explicitly turned off, and ⚙️ an Ansible role dependency declared in `meta/main.yml`.
## Features
- **Runtime floor:** Fails for every running container whose PHP is below the floor of its `major.minor` line, or on a line older than every listed floor (end of life). A line newer than every floor passes.
- **Hardening verification:** Runs `php` inside every service that mounts `php_hardening` and fails while `expose_php` or `session.upload_progress.enabled` is on, which catches a wrong scan dir, a later ini that overrides the hardening, and an image change.
- **Sidecar report:** Lists other PHP containers of a hardened stack, such as cron or worker sidecars, as info without failing.
- **Registry report:** `make security-runtimes` lists the PHP runtime of every pinned image from registry metadata, without pulling, against the same floors.
## Credits
Implemented by **[Kevin Veen-Birkenbach](https://www.veen.world)**.
Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code) and maintained by [Kevin Veen-Birkenbach](https://www.veen.world).
Licensed under the [Infinito.Nexus Community License (Non-Commercial)](https://s.infinito.nexus/license).
+6
View File
@@ -0,0 +1,6 @@
# To-dos
- Extend `HEALTH_RUNTIME_FLOORS`, the container probe and `make security-runtimes` to `node`, `python`, `ruby` and `java`.
- Verify the web SAPI where an image splits it from the CLI. `web-app-lam` mounts the hardening ini into `/etc/php/8.4/apache2/conf.d`, which the Debian CLI does not scan, so `php` in that container reads the CLI configuration instead of the one Apache loads.
- Run `make security-runtimes` in a scheduled CI workflow.
- Scan the pinned images for CVEs (Trivy or Grype) in the scheduled CI and report new criticals as issues.
+269
View File
@@ -0,0 +1,269 @@
#!/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())
@@ -0,0 +1,70 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from utils.roles.entity.name import get_entity_name
HARDENING_VOLUME = "php_hardening"
def hardened_services(volumes: Any) -> list[str]:
"""Return the services that mount the shared PHP hardening ini.
Args:
volumes: the role's canonical ``meta/volumes.yml`` mapping.
Returns:
The sorted service keys listed under ``php_hardening.mounts``.
"""
if not isinstance(volumes, Mapping):
return []
entry = volumes.get(HARDENING_VOLUME)
if not isinstance(entry, Mapping):
return []
return sorted(
{
str(mount["service"])
for mount in entry.get("mounts") or []
if isinstance(mount, Mapping) and mount.get("service")
}
)
class LookupModule(LookupBase):
"""``lookup('php_hardened_services', group_names=<role ids>)``.
Args:
group_names: the deployed role ids.
Returns:
``{entity: [service, ...]}`` for every role whose ``meta/volumes.yml``
declares ``php_hardening``; the entity is the compose project and
swarm stack name.
"""
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[dict[str, list[str]]]:
group_names = kwargs.get("group_names")
if not isinstance(group_names, (list, tuple)):
raise AnsibleError(
"php_hardened_services: group_names must be a list of role ids"
)
volume = lookup_loader.get(
"volume", loader=self._loader, templar=getattr(self, "_templar", None)
)
result: dict[str, list[str]] = {}
for role_id in sorted({str(name) for name in group_names}):
services = hardened_services(volume.run([role_id], variables=variables)[0])
if services:
result[get_entity_name(role_id)] = services
return [result]
+31
View File
@@ -0,0 +1,31 @@
---
galaxy_info:
author: Kevin Veen-Birkenbach
description: Systemd-timed monitor for containers running PHP below its security floor or without the shared PHP hardening
company: |
Kevin Veen-Birkenbach
https://www.veen.world
license: Infinito.Nexus Community License (Non-Commercial)
min_ansible_version: "2.9"
platforms:
- name: ArchLinux
versions:
- all
- name: Debian
versions:
- all
- name: EL
versions:
- all
- name: Fedora
versions:
- all
- name: Ubuntu
versions:
- all
galaxy_tags:
- monitor
- docker
- php
- security
- systemd
@@ -0,0 +1,8 @@
---
runtime:
lifecycle: beta
required_by:
compose:
categories: [web]
swarm:
categories: [web]
@@ -0,0 +1,19 @@
---
- name: 📂 Include dependency 'sys-ctl-alm-compose'
include_role:
name: sys-ctl-alm-compose
when: run_once_sys_ctl_alm_compose is not defined
- include_role:
name: sys-service
vars:
system_service_on_calendar: "{{ SYS_SCHEDULE_HEALTH_RUNTIME }}"
system_service_timer_enabled: true
system_service_tpl_on_failure: "{{ SYS_SERVICE_ON_FAILURE_COMPOSE }}"
system_service_tpl_exec_start: >-
{{ system_service_script_exec }}
--floors '{{ HEALTH_RUNTIME_FLOORS | to_json }}'
--hardened '{{ HEALTH_RUNTIME_PHP_HARDENED | to_json }}'
system_service_force_flush_final: false
- include_tasks: utils/once/flag.yml
@@ -0,0 +1,3 @@
---
- include_tasks: 00_core.yml
when: run_once_sys_ctl_hlth_runtime is not defined
+10
View File
@@ -0,0 +1,10 @@
---
system_service_id: sys-ctl-hlth-runtime
# Lowest patched release per runtime line; a line older than every listed one is end of life
HEALTH_RUNTIME_FLOORS:
php:
"8.2": "8.2.35"
"8.3": "8.3.36"
"8.4": "8.4.27"
"8.5": "8.5.12"
HEALTH_RUNTIME_PHP_HARDENED: "{{ lookup('php_hardened_services', group_names=lookup('deployment').deployed) }}"
+1
View File
@@ -10,6 +10,7 @@
list_or_shoot_roles:
- sys-ctl-cln-docker
- sys-ctl-hlth-container
- sys-ctl-hlth-runtime
- sys-ctl-hlth-volumes
- sys-ctl-rpr-container-hard
Whitespace-only changes.
@@ -0,0 +1,71 @@
from __future__ import annotations
import contextlib
import io
import unittest
import cli.contributing.security.runtimes.__main__ as runtimes_main
from utils.docker.image.discovery import ImageRef
FLOORS = {"8.2": "8.2.35", "8.3": "8.3.36", "8.4": "8.4.27", "8.5": "8.5.12"}
def _ref(role, service):
return ImageRef(
role=role,
service=service,
name=f"acme/{service}",
version="1.0",
source=f"docker.io/acme/{service}:1.0",
)
class TestReport(unittest.TestCase):
def _report(self, refs, probed):
with contextlib.redirect_stdout(io.StringIO()) as out:
code = runtimes_main.report(refs, probed, FLOORS)
return code, out.getvalue()
def test_an_eol_image_fails_the_report(self):
code, output = self._report(
[_ref("web-app-akaunting", "akaunting")], [["ENV PHP_VERSION=8.1.34"]]
)
self.assertEqual(code, 1)
self.assertIn("runs the end-of-life line 8.1", output)
def test_patched_images_and_images_without_php_pass(self):
code, _output = self._report(
[
_ref("web-app-wordpress", "wordpress"),
_ref("svc-db-postgres", "postgres"),
],
[["ENV PHP_VERSION=8.4.27"], ["ENV PG_VERSION=17.2"]],
)
self.assertEqual(code, 0)
def test_an_unanswered_image_fails_the_report(self):
code, output = self._report([_ref("web-app-lam", "lam")], [None])
self.assertEqual(code, 1)
self.assertIn(
"web-app-lam:lam (docker.io/acme/lam:1.0): the registry gave no answer",
output,
)
def test_a_distro_line_without_patch_level_is_listed_but_passes(self):
code, output = self._report(
[_ref("web-app-lam", "lam")], [["RUN apt-get install -y php8.4-ldap"]]
)
self.assertEqual(code, 0)
self.assertIn("PHP line 8.4: patch level not in the registry metadata", output)
def test_an_eol_php_base_argument_fails_the_report(self):
code, output = self._report(
[_ref("web-app-fusiondirectory", "fusiondirectory")],
[["ARG PHP_BASE=7.4", "RUN |1 PHP_BASE=7.4 /bin/bash -c install"]],
)
self.assertEqual(code, 1)
self.assertIn("PHP line 7.4: line 7.4 is end of life", output)
if __name__ == "__main__":
unittest.main()
Whitespace-only changes.
@@ -0,0 +1,5 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[6]
__all__ = ["PROJECT_ROOT"]
@@ -0,0 +1,283 @@
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()
@@ -0,0 +1,5 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[6]
__all__ = ["PROJECT_ROOT"]
@@ -0,0 +1,90 @@
from __future__ import annotations
import importlib.util
import sys
import unittest
from unittest import mock
from ansible.errors import AnsibleError
from . import PROJECT_ROOT
def _load_module():
path = (
PROJECT_ROOT
/ "roles"
/ "sys-ctl-hlth-runtime"
/ "lookup_plugins"
/ "php_hardened_services.py"
)
spec = importlib.util.spec_from_file_location("php_hardened_services_lookup", 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
MODULE = _load_module()
HARDENING = {
"type": "config",
"source": "{{ FILE_PHP_HARDENING_INI }}",
"mounts": [
{"service": "nextcloud", "target": "/usr/local/etc/php/conf.d/zz.ini"},
],
}
class TestHardenedServices(unittest.TestCase):
def test_lists_every_mounting_service_once(self):
entry = dict(
HARDENING,
mounts=[
{"service": "web", "target": "/a"},
{"service": "php", "target": "/b"},
{"service": "web", "target": "/c"},
],
)
self.assertEqual(
MODULE.hardened_services({"php_hardening": entry}), ["php", "web"]
)
def test_a_role_without_php_hardening_has_none(self):
self.assertEqual(MODULE.hardened_services({"data": {"type": "volume"}}), [])
def test_the_fpm_drop_in_does_not_count(self):
self.assertEqual(MODULE.hardened_services({"php_fpm_hardening": HARDENING}), [])
class TestLookup(unittest.TestCase):
def _run(self, volumes_by_role, **kwargs):
lookup = MODULE.LookupModule()
lookup._loader = mock.MagicMock()
lookup._templar = mock.MagicMock()
volume = mock.MagicMock()
volume.run.side_effect = lambda terms, **_kw: [volumes_by_role[terms[0]]]
with mock.patch.object(MODULE, "lookup_loader") as loader:
loader.get.return_value = volume
return lookup.run([], variables={}, **kwargs)[0]
def test_maps_hardened_roles_to_their_entity(self):
result = self._run(
{"web-app-nextcloud": {"php_hardening": HARDENING}, "svc-db-postgres": {}},
group_names=["web-app-nextcloud", "svc-db-postgres"],
)
self.assertEqual(result, {"nextcloud": ["nextcloud"]})
def test_no_deployed_php_role_yields_an_empty_map(self):
self.assertEqual(
self._run({"svc-db-postgres": {}}, group_names=["svc-db-postgres"]), {}
)
def test_missing_group_names_raises(self):
with self.assertRaises(AnsibleError):
self._run({})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,215 @@
from __future__ import annotations
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from utils.docker.image import runtime
FLOORS = {"8.2": "8.2.35", "8.3": "8.3.36", "8.4": "8.4.27", "8.5": "8.5.12"}
def _php(version=None, lines=()):
return runtime.PhpRuntime(version=version, lines=tuple(lines))
class TestPhpRuntime(unittest.TestCase):
def test_reads_php_version_from_an_env_instruction(self):
self.assertEqual(
runtime.php_runtime(
["ENV PHP_INI_DIR=/usr/local/etc/php", "ENV PHP_VERSION=8.4.15"]
),
_php("8.4.15"),
)
def test_the_last_php_version_wins(self):
self.assertEqual(
runtime.php_runtime(
["ENV PHP_VERSION=8.3.1", "ENV PHP_VERSION=8.4.27"]
).version,
"8.4.27",
)
def test_alpine_packages_name_their_line(self):
found = runtime.php_runtime(
["RUN /bin/sh -c apk add php83 php83-fpm php83-ldap"]
)
self.assertEqual(found, _php(lines=["8.3"]))
def test_debian_packages_name_their_line(self):
found = runtime.php_runtime(
["RUN apt-get install -y php8.4-cli libapache2-mod-php8.4 php8.4-ldap"]
)
self.assertEqual(found, _php(lines=["8.4"]))
def test_an_unversioned_alpine_php7_package_names_its_major(self):
self.assertEqual(
runtime.php_runtime(["RUN apk add php7 php7-fpm"]), _php(lines=["7"])
)
def test_a_php_base_build_argument_names_its_line(self):
self.assertEqual(
runtime.php_runtime(
["ARG PHP_BASE=7.4", "RUN |1 PHP_BASE=7.4 /bin/bash -c build"]
),
_php(lines=["7.4"]),
)
def test_a_php_directory_outside_a_package_install_is_not_php(self):
self.assertIsNone(
runtime.php_runtime(
["COPY /app/snuffleupagus.so /usr/lib/php83/modules/ # buildkit"]
)
)
def test_a_pinned_alpine_package_names_its_line(self):
found = runtime.php_runtime(["RUN apk add --no-cache php84=8.4.27-r0 nginx;"])
self.assertEqual(found, _php(lines=["8.4"]))
def test_php_paths_are_not_packages(self):
self.assertIsNone(
runtime.php_runtime(
['RUN mkdir -p "$PHP_INI_DIR/conf.d" /usr/local/etc/php']
)
)
def test_an_image_without_php_has_no_runtime(self):
self.assertIsNone(
runtime.php_runtime(
["ENV PG_VERSION=17.2", "RUN apt-get install -y postgresql"]
)
)
class TestPhpFinding(unittest.TestCase):
def test_a_version_below_its_floor_is_a_finding(self):
self.assertIn(
"below the security floor 8.4.27",
runtime.php_finding(_php("8.4.15"), FLOORS),
)
def test_a_version_at_its_floor_is_fine(self):
self.assertIsNone(runtime.php_finding(_php("8.2.35"), FLOORS))
def test_php_8_1_is_end_of_life(self):
self.assertIn(
"end-of-life line 8.1", runtime.php_finding(_php("8.1.34"), FLOORS)
)
def test_a_line_newer_than_every_floor_is_fine(self):
self.assertIsNone(runtime.php_finding(_php("8.6.0"), FLOORS))
def test_a_distro_php7_is_end_of_life(self):
self.assertEqual(
runtime.php_finding(_php(lines=["7"]), FLOORS), "line 7 is end of life"
)
def test_a_distro_php_8_1_is_end_of_life(self):
self.assertEqual(
runtime.php_finding(_php(lines=["8.1"]), FLOORS), "line 8.1 is end of life"
)
def test_a_supported_distro_line_is_no_finding(self):
self.assertIsNone(runtime.php_finding(_php(lines=["8.3"]), FLOORS))
class TestLoadPhpFloors(unittest.TestCase):
def test_reads_the_php_floors_from_the_vars_file(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "main.yml"
path.write_text(
"HEALTH_RUNTIME_FLOORS:\n php:\n '8.4': '8.4.27'\n",
encoding="utf-8",
)
with mock.patch.object(runtime, "FLOORS_FILE", path):
self.assertEqual(runtime.load_php_floors(), {"8.4": "8.4.27"})
class TestBuildInstructions(unittest.TestCase):
def test_a_docker_hub_tag_is_read_through_the_hub_api(self):
with (
mock.patch.object(
runtime, "_hub_instructions", return_value=["ENV A=1"]
) as hub,
mock.patch.object(runtime, "config_blob") as blob,
):
found = runtime.build_instructions(
"docker.io/wordpress", "6.8", os_name="linux", architecture="amd64"
)
self.assertEqual(found, ["ENV A=1"])
hub.assert_called_once_with("library/wordpress", "6.8", "linux", "amd64")
blob.assert_not_called()
def test_another_registry_is_read_through_the_config_blob(self):
blob = {
"history": [{"created_by": "RUN apk add php84"}],
"config": {"Env": ["PHP_VERSION=8.4.27"]},
}
with mock.patch.object(runtime, "config_blob", return_value=blob):
found = runtime.build_instructions(
"ghcr.io/acme/app", "1.0", os_name="linux", architecture="amd64"
)
self.assertEqual(found, ["RUN apk add php84", "ENV PHP_VERSION=8.4.27"])
def test_a_docker_hub_digest_pin_is_read_through_the_config_blob(self):
with (
mock.patch.object(runtime, "_hub_instructions") as hub,
mock.patch.object(runtime, "config_blob", return_value=None),
):
found = runtime.build_instructions(
"docker.io/acme/app",
"sha256:" + "a" * 64,
os_name="linux",
architecture="amd64",
)
self.assertIsNone(found)
hub.assert_not_called()
class TestHubInstructions(unittest.TestCase):
def _respond(self, payload):
response = mock.MagicMock()
response.__enter__.return_value = io.BytesIO(json.dumps(payload).encode())
return mock.patch.object(
runtime.urllib.request, "urlopen", return_value=response
)
def test_picks_the_layers_of_the_requested_platform(self):
payload = [
{
"os": "linux",
"architecture": "arm64",
"layers": [{"instruction": "ENV PHP_VERSION=1"}],
},
{
"os": "linux",
"architecture": "amd64",
"layers": [{"instruction": "ENV PHP_VERSION=2"}],
},
]
with self._respond(payload):
found = runtime._hub_instructions("library/php", "8.4", "linux", "amd64")
self.assertEqual(found, ["ENV PHP_VERSION=2"])
def test_a_tag_without_the_platform_is_unanswered(self):
with self._respond([{"os": "linux", "architecture": "arm64", "layers": []}]):
self.assertIsNone(
runtime._hub_instructions("library/php", "8.4", "linux", "amd64")
)
def test_a_network_error_is_unanswered(self):
with mock.patch.object(
runtime.urllib.request,
"urlopen",
side_effect=runtime.urllib.error.URLError("down"),
):
self.assertIsNone(
runtime._hub_instructions("library/php", "8.4", "linux", "amd64")
)
if __name__ == "__main__":
unittest.main()
@@ -120,5 +120,24 @@ class TestProbeSource(_Isolated, unittest.TestCase):
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()
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
import http.client
import json
import re
import urllib.error
import urllib.request
from dataclasses import dataclass
from urllib.parse import quote
from utils.cache import PROJECT_ROOT
from utils.cache.yaml import load_yaml_any
from utils.docker.image.pin import is_digest
from utils.docker.image.ref import DOCKER_HUB_REGISTRIES, split_registry_and_name
from utils.docker.registry import config_blob
from utils.roles.mapping import ROLE_FILE_VARS_MAIN
FLOORS_FILE = PROJECT_ROOT / "roles" / "sys-ctl-hlth-runtime" / ROLE_FILE_VARS_MAIN
FLOORS_KEY = "HEALTH_RUNTIME_FLOORS"
HUB_API = "https://hub.docker.com/v2"
USER_AGENT = "infinito-nexus-runtime-report"
PHP_VERSION_PATTERN = re.compile(r"\bPHP_VERSION=([0-9][^\s\"';]*)")
PHP_BASE_PATTERN = re.compile(r"\bPHP_BASE=(\d+\.\d+)(?![\d.])")
PACKAGE_INSTALL_PATTERN = re.compile(
r"\b(?:apk\s+add|(?:apt-get|apt|dnf|microdnf|yum)\s+(?:-\S+\s+)*install)\b"
)
PHP_PACKAGE_PATTERN = re.compile(
r"(?<!\S)(?:[\w.+]+-)*php(\d)(?:\.?(\d))?(?:-[\w.+-]+)?(?:=\S*?)?(?=[;&|\\]*(?:\s|$))"
)
VERSION_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)")
@dataclass(frozen=True)
class PhpRuntime:
"""PHP found in an image's build record.
Args:
version: ``PHP_VERSION`` the image sets, ``None`` when it sets none.
lines: ``major[.minor]`` lines named by distro PHP packages or a
``PHP_BASE`` build argument.
"""
version: str | None
lines: tuple[str, ...]
def load_php_floors() -> dict[str, str]:
"""Return ``{"<major>.<minor>": "<lowest patched version>"}`` from the SPOT."""
return load_yaml_any(str(FLOORS_FILE))[FLOORS_KEY]["php"]
def _hub_instructions(
repository: str, tag: str, os_name: str, architecture: str
) -> list[str] | None:
url = (
f"{HUB_API}/repositories/{quote(repository, safe='/')}"
f"/tags/{quote(tag, safe='')}/images"
)
request = urllib.request.Request( # noqa: S310 - https request to hub.docker.com
url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}
)
try:
with urllib.request.urlopen(request, timeout=15) as response: # noqa: S310 - https request to hub.docker.com
images = json.loads(response.read().decode())
except (
urllib.error.URLError,
OSError,
http.client.HTTPException,
json.JSONDecodeError,
UnicodeDecodeError,
):
return None
if not isinstance(images, list):
return None
for image in images:
if (
isinstance(image, dict)
and image.get("os") == os_name
and image.get("architecture") == architecture
):
return [
str(layer.get("instruction") or "")
for layer in image.get("layers") or []
if isinstance(layer, dict)
]
return None
def _oci_instructions(
image: str, reference: str, os_name: str, architecture: str
) -> list[str] | None:
blob = config_blob(image, reference, os_name, architecture)
if blob is None:
return None
history = [
str(step.get("created_by") or "")
for step in blob.get("history") or []
if isinstance(step, dict)
]
env = [f"ENV {item}" for item in (blob.get("config") or {}).get("Env") or []]
return history + env
def build_instructions(
image: str,
reference: str,
*,
os_name: str,
architecture: str,
) -> list[str] | None:
"""Return the build instructions of ``image:reference`` without pulling it.
Args:
image: image name, with or without registry host.
reference: tag or digest.
os_name: platform to resolve a multi-arch index to.
architecture: platform to resolve a multi-arch index to.
Returns:
The image's history plus its final ``ENV`` lines, or ``None`` when the
registry gave no answer. A Docker Hub tag is read through the Hub API,
which a pull rate limit does not meter; every other image through its
OCI config blob.
"""
parsed = split_registry_and_name(image)
if parsed is None:
return None
registry, name = parsed
on_hub = registry is None or registry in DOCKER_HUB_REGISTRIES
if on_hub and not is_digest(reference):
repository = name if "/" in name else f"library/{name}"
return _hub_instructions(repository, reference, os_name, architecture)
return _oci_instructions(image, reference, os_name, architecture)
def php_runtime(instructions: list[str]) -> PhpRuntime | None:
"""Read the PHP an image ships from its build instructions.
Args:
instructions: see :func:`build_instructions`.
Returns:
The last ``PHP_VERSION`` set and the lines of the distro PHP packages
and ``PHP_BASE`` arguments, or ``None`` when none of them appears.
"""
version = None
lines: set[str] = set()
for instruction in instructions:
for match in PHP_VERSION_PATTERN.finditer(instruction):
version = match.group(1)
lines.update(PHP_BASE_PATTERN.findall(instruction))
if not PACKAGE_INSTALL_PATTERN.search(instruction):
continue
for major, minor in PHP_PACKAGE_PATTERN.findall(instruction):
lines.add(f"{major}.{minor}" if minor else major)
if version is None and not lines:
return None
return PhpRuntime(version=version, lines=tuple(sorted(lines)))
def _numbers(text: str) -> tuple[int, ...]:
return tuple(int(part) for part in text.split("."))
def php_finding(runtime: PhpRuntime, floors: dict[str, str]) -> str | None:
"""Judge an image's PHP against the floors.
Args:
runtime: see :func:`php_runtime`.
floors: see :func:`load_php_floors`.
Returns:
``None`` when nothing is below its floor, otherwise the reason. A
version found only as a line is judged by that line.
"""
oldest = min(_numbers(line) for line in floors)
newest = max(_numbers(line) for line in floors)
if runtime.version is not None:
match = VERSION_PATTERN.match(runtime.version)
if match is None:
return f"unparsable PHP_VERSION {runtime.version!r}"
current = tuple(int(part) for part in match.groups())
line = f"{current[0]}.{current[1]}"
if line in floors:
if current < _numbers(floors[line]):
return f"{runtime.version} is below the security floor {floors[line]}"
return None
if current[:2] > newest:
return None
return f"{runtime.version} runs the end-of-life line {line}"
eol = [line for line in runtime.lines if _numbers(line) < oldest]
if eol:
return f"line {', '.join(eol)} is end of life"
return None
+21 -2
View File
@@ -307,9 +307,22 @@ def _fetch_blob(image: str, digest: str) -> dict | None:
return parsed if isinstance(parsed, dict) else None
def _healthcheck_at(
def config_blob(
image: str, reference: str, os_name: str, architecture: str
) -> dict | None:
"""Return the OCI config blob of ``image:reference`` from its registry.
Args:
image: image name, with or without registry host.
reference: tag or digest.
os_name: platform to resolve a multi-arch index to.
architecture: platform to resolve a multi-arch index to.
Returns:
The parsed blob with ``config`` and ``history``, or ``None`` on any
indeterminate outcome (see :func:`fetch_manifest`) and on an index
without a matching platform.
"""
doc = fetch_manifest(image, reference)
if doc is None:
return None
@@ -324,7 +337,13 @@ def _healthcheck_at(
config_digest = (doc.get("config") or {}).get("digest")
if not isinstance(config_digest, str) or not config_digest:
return None
blob = _fetch_blob(image, config_digest)
return _fetch_blob(image, config_digest)
def _healthcheck_at(
image: str, reference: str, os_name: str, architecture: str
) -> dict | None:
blob = config_blob(image, reference, os_name, architecture)
if blob is None:
return None
return (blob.get("config") or {}).get("Healthcheck") or {}