Files
computer-playbook/cli/contributing/security/runtimes/__main__.py
kevinveenbirkenbachandClaude Opus 5.5 d116dde659 fix(sys-ctl-hlth-runtime): bound the engine calls and catch hidden PHP runtimes
Three CodeRabbit findings on PR #1265:

- script.py listed the running containers with `container ps` and
  `container inspect` without a timeout, so a hung engine kept the daily
  unit active forever and blocked the next timer run. Both calls now use
  EXEC_TIMEOUT_SECONDS; main turns a timeout or engine error into a
  one-line failure and exit 1 instead of a traceback.

- runtime.py's PHP_PACKAGE_PATTERN requires a digit after "php", so an
  image installing an unversioned interpreter package (`php-cli`) and
  setting no PHP_VERSION was reported as PHP-free. The interpreter
  packages php, php-cli, php-fpm, php-cgi, php-phpdbg and
  libapache2-mod-php are now recorded as PhpRuntime.packages and reported
  with an unknown patch level; extension and library packages
  (php-mysql, php-pear, php-common, phpmyadmin) and paths do not count.

- php_finding returned early for a patched PHP_VERSION, so an end-of-life
  package line installed next to it (e.g. php7) was never reported. The
  version only returns a finding now; otherwise the lines are judged too.

Every finding has unit tests that fail on the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:32:57 +01:00

96 lines
2.9 KiB
Python
Executable File

#!/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
if runtime.version:
php = runtime.version
elif runtime.lines:
php = f"line {', '.join(runtime.lines)}"
else:
php = f"package {', '.join(runtime.packages)}"
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())