mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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:
1 parent
e8cb250c51
commit
3c743736a8
28 files changed
+1461
-3
No files matched your search
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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]
|
||||
@@ -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
|
||||
@@ -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) }}"
|
||||
@@ -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"]
|
||||
+90
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
|
||||
Reference in new issue
Block a user