070 - sys-ctl-hlth-runtime: Vulnerability Engines Instead of Version Floors¶
User Story¶
As an operator, I want every node to scan its host and its running container images with Trivy, Grype and OSV-Scanner over a Syft SBOM, so that a real, fixable vulnerability is reported, and the daily mirror scan fails on it, instead of a deploy failing on a distribution build whose upstream version number looks old.
Issue: #1324.
Acceptance Criteria¶
[ ]
HEALTH_RUNTIME_FLOORS,floor_violation,make security-runtimes,cli/contributing/security/runtimesandutils/docker/image/runtime.pyare gone, and thephp_hardeningcheck ofexpose_phpandsession.upload_progress.enabledstill runs for every service that mountsphp_hardening.[ ]
roles/sys-ctl-hlth-runtime/meta/services.ymlpins Trivy, Grype, OSV-Scanner and Syft as services withimageand a semverversion, so the GHCR mirror copies them.[ ] The same file holds the scan configuration:
targets[host, container],engines[trivy, grype, osv, syft], theseveritymapping{CRITICAL: fail, HIGH: warn},fixed_only: true,workers,db_update,cache_diranddatabase_dir, with no default fallback in the role. The script rejects aseveritylist, an empty mapping, an unknown severity and any action other thanfailorwarn.[ ] The
containertarget is the image of every container running on the node, read through the read-only Docker socket; thehosttarget is the host root filesystem mounted read-only, without/proc,/sys,/dev,/run,/tmp, the Docker root, the backups, the NFS export and the vulnerability cache.[ ] In swarm every node scans its own containers and its own host.
[ ] Syft builds one SBOM per target; Grype and OSV-Scanner read that SBOM and Trivy reads the target itself; without
syftinengines, Grype and OSV-Scanner read the target directly.[ ] OSV-Scanner reads a CycloneDX copy whose
debandapkpurls name the source package, solibssl3is matched asopensslandphp8.1-cliasphp8.1.[ ] A configuration with
osvand thehosttarget but withoutsyftis rejected, because OSV-Scanner reads no OS packages of a plain directory.[ ] A runtime built from source, such as PHP 8.4.26 in an official
phpimage, fails the scan with the CRITICAL CVE-2026-109618 and warns on the HIGH CVE-2026-109619, both fixed in 8.4.27: only Grype sees it, through the CPE of Syft’s binary classifier, so withsyftinenginesandgrypenot, Grype still scans the binary-classified packages of every SBOM, and a configuration with neithergrypenorsyftis rejected.[ ] Targets times engines run in parallel, bounded by
workers; Trivy runs one target at a time and OSV-Scanner reads every SBOM in one run.[ ] Each engine’s JSON becomes findings of engine, target, image or host, package, installed version, fixed version, vulnerability ids and severity; OSV maps the group’s CVSS score to a severity and takes the fixed version from the affected ranges of the same package.
[ ] An unsuppressed finding whose severity maps to
failis a❌line naming engine, target and CVE and failssys-ctl-hlth-runtime.service, the deploy throughsys-service-terminatorand the OnFailure alert; one that maps towarnis a⚠️line and does not fail the service; a suppressed one is anℹ️line. The mapping is the same in production and CI, and no deploy variant overrides it. An engine error, a missing database or a PHP hardening violation fails the service too. The dailyscanjob ofcron-images-mirror-all.ymlfails on every unsuppressed mapped finding,failandwarnalike.[ ] Every finding at the threshold becomes one record per owner with
run_id,timestamp(RFC 3339, UTC),host,environment,target,kind,image(<image name>@<image id>for a container, the host name for the host),scanner,id,severity,package,installed_version,fixed_version,suppressed,suppressed_untilandverdict(fail,warnorsuppressed), and the four outputs below are built from these records.[ ]
report.jsoninDIR_HEALTH_RUNTIME_REPORT(/var/lib/infinito/hlth-runtime) holds the summary and the records of the last run and is replaced atomically.[ ]
findings.jsonlinDIR_HEALTH_RUNTIME_LOG(/var/log/infinito/hlth-runtime) gets one JSON line per record on every run, appended; the role installs logrotate and a drop-in that rotates it weekly, keeps 12 compressed generations and skips a missing or empty file.[ ] The journal gets one prefix-free JSON line per target,
{"event": "hlth_runtime.target", "run_id", "target", "kind", "image", "counts": {"fail", "warn", "suppressed"}, "verdict": "fail|warn|ok"}, and last{"event": "hlth_runtime.summary", "run_id", "counts", "errors", "verdict"}, next to the human-readable lines.[ ] While
web-app-prometheusis deployed, the role passes--metrics-fileand the check replacesinfinito_hlth_runtime.prominDIR_METRICS_TEXTFILEatomically withinfinito_hlth_runtime_findings{target,kind,image,severity,scanner,verdict}(a count, no CVE label),infinito_hlth_runtime_last_run_timestamp_seconds,infinito_hlth_runtime_last_run_success(1 when the run did not fail) andinfinito_hlth_runtime_scanner_errors{scanner}, each with# HELPand# TYPEand escaped label values; without the flag no textfile is written.[ ] A database refresh or a provided database that is missing fails the service loudly, and OSV-Scanner reporting no database for an ecosystem is an error.
[ ] The timer runs daily at 00:00 UTC and the deploy-time run stays.
[ ] A suppression is
services.<service>.security.suppressionsof the owning role, each entry eitherid: CVE-YYYY-NNNNor a quotedimage_tag, plusuntil: YYYY-MM-DD, with a# nocheck: cve <reason>marker directly above it.[ ] An
identry suppresses that CVE; animage_tagentry suppresses every finding of the service’s container while the scanned image runs that tag, and a container running any other tag is judged unsuppressed. The Docker image updater removes a service’simage_tagentries, with their markers, when it moves that service to another version.[ ] A role’s suppressions apply only to the containers of its own stack or compose project and service; host suppressions are the
identries under the engine entries ofsys-ctl-hlth-runtimeand apply to that engine’s host findings, while animage_tagentry there covers the engine’s own image.[ ]
docs/contributing/actions/testing/suppression.mdlists thecverule.[ ] The lint under
tests/lint/rejects a marker without a reason, a malformed id, an entry with both or neither ofidandimage_tag, animage_tagthat differs from the service’sversion, a missing or pastuntiland an entry without a marker.[ ] Each CI deploy job draws one engine (trivy, grype or osv) by rotation; its job name carries 🔺, 🍇 or 🐙, the axis glyph is 🔬, and
utils/symbol_glossary.pyregisters all four.[ ] A CI deploy pins
enginesto[syft, <engine>], reads provided databases and never downloads one; thevulnerability-dbjob ofcall-test-deploy.ymldownloads the Trivy, Grype and OSV databases once per day into the Actions cache, every deploy job restores the Grype database plus the drawn engine’s, and a missing one fails the job.[ ] A local development deploy (
make compose-deploy, swarm through act ormake swarm-zombie) draws one engine at random, runs it behind Syft plus the Grype runtime pass, refreshes the databases it reads once in the shared host directoryINFINITO_VULNERABILITY_DB_HOST_PATH, and every stack and swarm node reads that directory read-only atINFINITO_VULNERABILITY_DB_DIR; no stack and no node downloads a database.[ ] Production hosts run every engine of the list and refresh their databases daily in their own cache.
[ ] The deploy-time run fails on its first failure:
sys-service-terminatordoes not retry a unit that setssystem_service_run_once, and only the database refresh inside the scan is retried, three attempts 30 s apart.[ ] The threshold is strict from the first run: a CRITICAL with a fix fails the check, a HIGH with a fix warns, and both fail the daily mirror scan.
[ ]
make test-securitybuilds a Syft SBOM of every pinned image of all roles straight from the GHCR mirror refs, cached by image digest, and scans that SBOM tree with Trivy, Grype and OSV at the same threshold and suppressions; it is not part ofmake test, and the aliasi8seruruns it.[ ] The
scanjob ofcron-images-mirror-all.ymlruns aftersync, runsmake test-security, keeps exactly one openvulnerabilityissue per image that lists its CVEs (unsuppressed first, then CRITICAL first, suppressed ones with theirsuppressed_untilday so the issue stays open while a suppression runs, capped below GitHub’s body limit with a pointer to the artifact) and closes any further open one as a duplicate, comments the still unresolved CVEs into it and updates its body whenever the image’s CVE table changed, leaves it alone while the table is unchanged, closes it once no engine reports any CVE for it, holdsissues: writeonly on that job and pins its actions like the existing jobs.[ ] Every run of that job writes the failing findings per image and per CVE into the job summary and the
vulnerability-findingsartifact, and theworkflow_dispatchinputissues: falsemakes it a dry run without issue changes; the schedule keeps the issues on.[ ] The measured database sizes are: Grype v6 3.2 GB unpacked, Trivy 1.6 GB without the Java database, OSV offline zips Ubuntu 806 MB, npm 220 MB, Debian 80 MB, PyPI 38 MB, and 1.24 GB for the ecosystem list in
meta/services.yml.[ ] Unit tests cover the parsers of all three engines with trimmed real output, the severity mapping and its validation, the fix-available switch, suppression scoping and expiry, the bounded parallel run with a fake runner, the CI engine pin, the exit code per verdict and each of the four outputs.
[ ] A negative control shows the removed floor flagging
8.1.2-1ubuntu2.28while Trivy, Grype and OSV fixtures of that package pass, and a fixture with a fixable CRITICAL fails.