Files
computer-playbook/utils/docker/image/runtime.py
kevinveenbirkenbachandClaude Opus 5.5 284b0873dd fix(sys-ctl-hlth-runtime): harden LAM CLI, catch apk options, drop vars comment
Addresses three CodeRabbit findings on PR #1265.

1. LAM hardening invisible to the probe (thread 4413764929): the runtime
   check probes PHP with `php -r`, the CLI SAPI. web-app-lam mounted the
   hardening ini only into /etc/php/8.4/apache2/conf.d, which the Debian
   CLI does not scan, so the probe read unhardened CLI settings and the
   check failed for LAM. The php_hardening entry now also mounts the ini
   into /etc/php/8.4/cli/conf.d. No other role mounts it into a Debian
   per-SAPI directory; the rest use the shared scan dir of the official
   image or of Alpine. The TODO entry now names the remaining gap: the
   probe reads only the CLI copy.

2. Explanatory comment above HEALTH_RUNTIME_FLOORS (thread 4413764949):
   removed. The README already states the end-of-life rule and now also
   defines a floor as the lowest patched release of each line.

3. `apk --no-cache add` missed (thread 4415018662): PACKAGE_INSTALL_PATTERN
   required `add` right after `apk`, so `RUN apk --no-cache add php7`
   passed as an image without PHP. The apk branch now accepts options
   before `add`, like the apt branch. New tests cover the option form and
   guard that `apk` in a path or a comment is no package install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:57:21 +01:00

208 lines
7.4 KiB
Python

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"
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+(?:-\S+\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|$))"
)
PHP_UNVERSIONED_PACKAGE_PATTERN = re.compile(
r"(?<!\S)(php|php-(?:cli|fpm|cgi|phpdbg)|libapache2-mod-php)(?:=\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.
packages: unversioned distro packages that are the interpreter itself
(``php``, ``php-cli``, ``php-fpm``, ``php-cgi``, ``php-phpdbg``,
``libapache2-mod-php``); they name no line. Extension and library
packages such as ``php-mysql`` or ``php-pear`` do not count.
"""
version: str | None
lines: tuple[str, ...]
packages: 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"https://hub.docker.com/v2/repositories/{quote(repository, safe='/')}"
f"/tags/{quote(tag, safe='')}/images"
)
request = urllib.request.Request(
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, the lines of the distro PHP packages
and ``PHP_BASE`` arguments, and the unversioned interpreter packages,
or ``None`` when none of them appears.
"""
version = None
lines: set[str] = set()
packages: 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)
packages.update(PHP_UNVERSIONED_PACKAGE_PATTERN.findall(instruction))
if version is None and not lines and not packages:
return None
return PhpRuntime(
version=version,
lines=tuple(sorted(lines)),
packages=tuple(sorted(packages)),
)
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. The
lines are judged next to ``PHP_VERSION``, so an end-of-life package
line fails an image whose ``PHP_VERSION`` is patched. Unversioned
packages alone are no finding: their patch level is unknown.
"""
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 and current < _numbers(floors[line]):
return f"{runtime.version} is below the security floor {floors[line]}"
if line not in floors and current[:2] <= newest:
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