" in body for state in page.report_mod.STATES))
+ check(view + " view shows a coverage warning", "INCOMPLETE COVERAGE" in body)
+ check(view + " view makes no clean claim with unscanned targets", not CLEAN_RE.search(body))
+
+status, headers, cves = render(query="view=cves")
+check("CVE view contains every CVE", all(f["id"] in cves for f in REPORT["findings"]))
+affected = {f["targetId"] for f in REPORT["findings"]}
+expected_in_cves = {(t["id"], w["name"]) for t in REPORT["targets"] if t["id"] in affected for w in t["workloads"]}
+check("CVE view expansions name every workload of an affected target", all(name in cves for _, name in expected_in_cves))
+check("CVE view expansions name every container of an affected target",
+ all(c in cves for t in REPORT["targets"] if t["id"] in affected for w in t["workloads"] for c in w["containers"]))
+for name in ("web", "api", "app", "server"):
+ check("CVE view expansion names " + name, name in cves)
+status, headers, workloads = render(query="view=workloads")
+check("workloads view contains every workload and container, including zero-finding ones", covers_everything(workloads, REPORT) == [])
+status, headers, inventory = render(query="view=inventory")
+check("inventory view contains every target", all(t["id"] in inventory for t in REPORT["targets"]))
+check("inventory view contains every workload and container", covers_everything(inventory, REPORT) == [])
+mutated = copy.deepcopy(REPORT)
+mutated["findings"] = [f for f in mutated["findings"] if f["id"] != "CVE-2026-1003"]
+check("mutation: the coverage evaluator catches a dropped CVE", "CVE-2026-1003" in covers_everything(render(mutated, "view=workloads")[2], REPORT))
+
+_, _, body = render(query="view=cves")
+check("hostile title is escaped", "<script>alert(1)</script>" in body)
+check("hostile title is never live", "",
+ "url": "https://example.test/CVE-2026-1002"
+ },
+ {
+ "acceptance": {
+ "expires": "2027-01-01",
+ "reason": "not reachable"
+ },
+ "cvss": null,
+ "firstSeenAt": "2026-11-01T00:00:00Z",
+ "fixedVersion": "16.2",
+ "id": "CVE-2026-1003",
+ "installedVersion": "1.0",
+ "package": "libpq",
+ "severity": "HIGH",
+ "status": "accepted",
+ "targetId": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
+ "title": "libpq flaw (retained from a prior scan)",
+ "url": "https://example.test/CVE-2026-1003"
+ },
+ {
+ "cvss": null,
+ "firstSeenAt": "2026-11-01T00:00:00Z",
+ "fixedVersion": null,
+ "id": "CVE-2026-1004",
+ "installedVersion": "1.0",
+ "package": "curl",
+ "severity": "MEDIUM",
+ "status": "open",
+ "targetId": "ghcr.io/org/nightly:2.1",
+ "title": "curl flaw",
+ "url": "https://example.test/CVE-2026-1004"
+ }
+ ],
+ "generatedAt": "2026-11-09T10:00:00Z",
+ "remediation": [
+ {
+ "image": "docker.io/library/nginx",
+ "state": "pr-open",
+ "updatedAt": "2026-11-09T10:00:00Z",
+ "url": "https://gitea.test/pulls/1"
+ }
+ ],
+ "run": {
+ "finishedAt": "2026-11-09T09:30:00Z",
+ "startedAt": "2026-11-09T09:00:00Z",
+ "status": "complete"
+ },
+ "schemaVersion": 1,
+ "targets": [
+ {
+ "digest": "sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
+ "id": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
+ "image": "docker.io/library/nginx",
+ "lastScannedAt": "2026-11-09T10:00:00Z",
+ "reason": null,
+ "state": "scanned",
+ "type": "image",
+ "workloads": [
+ {
+ "containers": [
+ "app"
+ ],
+ "kind": "Deployment",
+ "name": "web",
+ "namespace": "apps"
+ },
+ {
+ "containers": [
+ "server"
+ ],
+ "kind": "Deployment",
+ "name": "api",
+ "namespace": "apps"
+ }
+ ]
+ },
+ {
+ "digest": "sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
+ "id": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
+ "image": "docker.io/library/postgres",
+ "lastScannedAt": "2026-11-07T09:00:00Z",
+ "reason": "trivy timed out",
+ "state": "failed",
+ "type": "image",
+ "workloads": [
+ {
+ "containers": [
+ "postgres"
+ ],
+ "kind": "StatefulSet",
+ "name": "db",
+ "namespace": "data"
+ }
+ ]
+ },
+ {
+ "digest": "sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
+ "id": "ghcr.io/org/private@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
+ "image": "ghcr.io/org/private",
+ "lastScannedAt": null,
+ "reason": "registry ghcr.io refused anonymous access",
+ "state": "credential-required",
+ "type": "image",
+ "workloads": [
+ {
+ "containers": [
+ "priv"
+ ],
+ "kind": "Deployment",
+ "name": "priv",
+ "namespace": "apps"
+ }
+ ]
+ },
+ {
+ "digest": null,
+ "id": "ghcr.io/org/nightly:2.1",
+ "image": "ghcr.io/org/nightly",
+ "lastScannedAt": "2026-11-09T10:00:00Z",
+ "reason": null,
+ "state": "by-tag",
+ "type": "image",
+ "workloads": [
+ {
+ "containers": [
+ "job"
+ ],
+ "kind": "CronJob",
+ "name": "nightly",
+ "namespace": "ops"
+ }
+ ]
+ },
+ {
+ "digest": null,
+ "id": "vm/kubevirt-vms/vm1",
+ "image": "",
+ "lastScannedAt": null,
+ "reason": "guest OS packages are not assessed by this scanner",
+ "state": "not-covered",
+ "type": "vm",
+ "workloads": [
+ {
+ "containers": [],
+ "kind": "VirtualMachineInstance",
+ "name": "vm1",
+ "namespace": "kubevirt-vms"
+ }
+ ]
+ },
+ {
+ "digest": null,
+ "id": "node/prod-1",
+ "image": "",
+ "lastScannedAt": null,
+ "reason": "node OS packages and the k3s binary are not assessed by this scanner",
+ "state": "not-covered",
+ "type": "node",
+ "workloads": [
+ {
+ "containers": [],
+ "kind": "Node",
+ "name": "prod-1",
+ "namespace": ""
+ }
+ ]
+ }
+ ]
+}
diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/chainsaw-test.yaml b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/chainsaw-test.yaml
new file mode 100644
index 0000000..68a3c56
--- /dev/null
+++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/chainsaw-test.yaml
@@ -0,0 +1,27 @@
+# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
+# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
+# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: vuln-scanner-scan-loop
+ labels:
+ conformance.home.k3s/R-VULN-02: "true"
+ conformance.home.k3s/R-VULN-03: "true"
+ conformance.home.k3s/R-VULN-05: "true"
+ annotations:
+ conformance.home.k3s/requirements: "R-VULN-02, R-VULN-03, R-VULN-05"
+ conformance.home.k3s/layer: "L1"
+ conformance.home.k3s/safety-critical: "false"
+ # Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
+ # a change to the scanner would select no vuln-scanner test.
+ conformance.home.k3s/scope: "whole-repo"
+ conformance.home.k3s/intent: >-
+ Given a fake Trivy runner and clock, When ticks run, Then shared digests scan once, failures isolate, and coverage states and first-seen persist.
+spec:
+ steps:
+ - name: run-offline-check
+ description: Run check.sh, the same assertions the offline harness runs.
+ try:
+ - script:
+ content: bash ./check.sh
diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py
new file mode 100644
index 0000000..f86ea2d
--- /dev/null
+++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py
@@ -0,0 +1,266 @@
+#!/usr/bin/env python3
+"""R-VULN-02/03/05: scan loop semantics with a fake Trivy runner and a controllable clock."""
+import json
+import logging
+import os
+import sys
+import tempfile
+import threading
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
+import main # noqa: E402
+import report # noqa: E402
+import trivy # noqa: E402
+
+logging.disable(logging.CRITICAL)
+FAILED = []
+A1 = "sha256:" + "a1" * 32
+D4 = "sha256:" + "d4" * 32
+E5 = "sha256:" + "e5" * 32
+NGINX = "docker.io/library/nginx@" + A1
+POSTGRES = "docker.io/library/postgres@" + D4
+PRIVATE = "ghcr.io/org/private@" + E5
+NIGHTLY = "ghcr.io/org/nightly:2.1"
+CLUSTER = json.loads((HERE / "fixtures/cluster.json").read_text())
+
+
+def fixture(name):
+ return json.loads((HERE / "fixtures" / name).read_text())
+
+
+def check(name, condition):
+ print(("ok " if condition else "FAIL ") + name)
+ if not condition:
+ FAILED.append(name)
+
+
+class Clock:
+ def __init__(self):
+ self.now = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
+
+ def __call__(self):
+ return self.now
+
+ def advance(self, **delta):
+ self.now += timedelta(**delta)
+
+
+class FakeClient:
+ def __init__(self, cluster):
+ self.cluster = cluster
+ self.fail = False
+
+ def list_all(self, path):
+ if self.fail:
+ raise RuntimeError("api down")
+ for name, item in (("pods", "/api/v1/pods"), ("replicasets", "replicasets"), ("jobs", "/jobs"),
+ ("cronjobs", "cronjobs"), ("nodes", "/nodes"), ("vmis", "virtualmachineinstances")):
+ if path.endswith(item):
+ return self.cluster[name]
+ return []
+
+
+class FakeRunner:
+ """Recorded results, no Trivy binary."""
+
+ def __init__(self):
+ self.scans = []
+ self.outcomes = {
+ NGINX: fixture("trivy-nginx.json"), POSTGRES: fixture("trivy-postgres.json"),
+ NIGHTLY: fixture("trivy-nightly.json"),
+ PRIVATE: trivy.classify_failure("ghcr.io", "FATAL: GET https://ghcr.io/: UNAUTHORIZED: authentication required"),
+ }
+ self.db_age_hours = 1
+ self.db_fails = False
+ self.clock = None
+
+ def download_db(self):
+ if self.db_fails:
+ raise trivy.ScanFailure("failed", "database update failed")
+
+ def db_info(self):
+ return {"updatedAt": (self.clock() - timedelta(hours=self.db_age_hours)).strftime("%Y-%m-%dT%H:%M:%SZ"), "version": "2"}
+
+ def scan(self, reference):
+ self.scans.append(reference)
+ outcome = self.outcomes[reference]
+ if isinstance(outcome, Exception):
+ raise outcome
+ return outcome
+
+ def clean_cache(self):
+ pass
+
+
+def engine(tmp, runner=None, clock=None, acc="[]\n", cluster=CLUSTER):
+ path = os.path.join(tmp, "acceptances.yaml")
+ if not os.path.exists(path):
+ Path(path).write_text(acc)
+ clock = clock or Clock()
+ runner = runner or FakeRunner()
+ runner.clock = clock
+ return main.Engine(tmp, FakeClient(cluster), runner, path, clock=clock, publish_seconds=0), runner, clock
+
+
+def targets(eng):
+ return {t["id"]: t for t in eng.report["targets"]}
+
+
+def findings(eng, target_id):
+ return [f for f in eng.report["findings"] if f["targetId"] == target_id]
+
+
+def one_scan_per_digest(scans):
+ return len(scans) == len(set(scans))
+
+
+with tempfile.TemporaryDirectory() as tmp:
+ eng, runner, clock = engine(tmp)
+ eng.tick()
+ t = targets(eng)
+ check("two workloads sharing a digest produce exactly one scan", runner.scans.count(NGINX) == 1 and one_scan_per_digest(runner.scans))
+ check("mutation: the digest-uniqueness evaluator rejects a double scan", not one_scan_per_digest(["a", "a"]))
+ check("the shared digest maps back to both workloads",
+ {(w["kind"], w["name"]) for w in t[NGINX]["workloads"]} == {("Deployment", "web"), ("Deployment", "api")})
+ check("shared digest findings appear once per target", len(findings(eng, NGINX)) == 2)
+ check("digest target is scanned", t[NGINX]["state"] == "scanned")
+ check("idle CronJob template is by-tag after scanning, not scanned", t[NIGHTLY]["state"] == "by-tag")
+ check("node is not-covered", t["node/prod-1"]["state"] == "not-covered")
+ check("report is schema-valid", report.validate(eng.report) == [])
+ check("credential-required is its own state, not failed", t[PRIVATE]["state"] == "credential-required")
+ check("credential-required reason names the registry", "ghcr.io" in t[PRIVATE]["reason"])
+ check("credential-required does not count toward failed_over_24h", report.failed_over_24h(eng.state, clock() + timedelta(hours=48)) == 0)
+ check("report is written to disk", json.loads(Path(tmp, "report.json").read_text())["generatedAt"])
+ first_seen = {f["id"]: f["firstSeenAt"] for f in findings(eng, NGINX)}
+
+ clock.advance(hours=21)
+ runner.outcomes[POSTGRES] = trivy.ScanFailure("failed", "pull error: manifest unknown")
+ eng.tick()
+ t = targets(eng)
+ check("a failed image leaves the run continuing", runner.scans.count(NGINX) == 2 and runner.scans.count(NIGHTLY) == 2)
+ check("the failed target is failed", t[POSTGRES]["state"] == "failed")
+ check("the failed target's reason is shown", "manifest unknown" in t[POSTGRES]["reason"])
+ check("the failed target keeps its prior findings", [f["id"] for f in findings(eng, POSTGRES)] == ["CVE-2026-0010"])
+ check("other targets stay scanned", t[NGINX]["state"] == "scanned")
+ check("first-seen survives a re-scan", {f["id"]: f["firstSeenAt"] for f in findings(eng, NGINX)} == first_seen)
+ check("run with one failed image is still complete (failure is per-target)", eng.report["run"]["status"] == "complete")
+
+ clock.advance(hours=25)
+ check("failed target is counted over 24h after 25h", report.failed_over_24h(eng.state, clock()) == 1)
+ eng.publish()
+ check("a target whose last success aged past 24h is stale", targets(eng)[NGINX]["state"] == "stale")
+ check("stale target keeps findings", len(findings(eng, NGINX)) == 2)
+
+ # Restart: a new Engine loads everything from disk.
+ eng2, runner2, clock2 = engine(tmp, runner=FakeRunner(), clock=clock)
+ check("report survives restart", eng2.report is not None and eng2.report_bytes() is not None)
+ check("last complete run survives restart", eng2.state["lastCompleteRunAt"] is not None)
+ eng2.publish()
+ check("first-seen survives restart", {f["id"]: f["firstSeenAt"] for f in findings(eng2, NGINX)} == first_seen)
+
+with tempfile.TemporaryDirectory() as tmp:
+ # Not-scanned is never zero findings: the first tick has not scanned anything yet.
+ eng, runner, clock = engine(tmp)
+ eng.refresh_inventory()
+ eng.publish()
+ t = targets(eng)
+ check("before scanning, digest targets are pending", t[NGINX]["state"] == "pending" and t[POSTGRES]["state"] == "pending")
+ check("run object exists and is degraded before the first run", eng.report["run"]["status"] == "degraded")
+ check("no fabricated findings before scanning", eng.report["findings"] == [])
+ check("zero findings report still counts every target", len(eng.report["targets"]) == 5)
+ clock.advance(hours=3)
+ eng.publish()
+ t = targets(eng)
+ check("pending beyond 2h becomes failed, never silently absent", t[NGINX]["state"] == "failed")
+ check("failed reason is the contract's text", t[NGINX]["reason"] == report.NOT_SCANNED_REASON)
+ check("a never-scanned failed target has lastScannedAt null", t[NGINX]["lastScannedAt"] is None)
+ clock.advance(hours=24)
+ check("never-scanned target counts as failed over 24h", report.failed_over_24h(eng.state, clock()) >= 1)
+
+with tempfile.TemporaryDirectory() as tmp:
+ # Database problems degrade the run but it still reports.
+ eng, runner, clock = engine(tmp)
+ runner.db_fails = True
+ eng.tick()
+ check("DB update failure degrades the run", eng.report["run"]["status"] == "degraded")
+ check("DB update failure still scans and reports", runner.scans and eng.report["findings"])
+ check("a degraded run is not recorded as complete", eng.state["lastCompleteRunAt"] is None)
+with tempfile.TemporaryDirectory() as tmp:
+ eng, runner, clock = engine(tmp)
+ runner.db_age_hours = 49
+ eng.tick()
+ check("a DB older than 48h degrades the run", eng.report["run"]["status"] == "degraded")
+ runner.db_age_hours = 1
+ clock.advance(hours=21)
+ eng.tick()
+ check("mutation: a fresh DB gives a complete run", eng.report["run"]["status"] == "complete" and eng.state["lastCompleteRunAt"])
+
+with tempfile.TemporaryDirectory() as tmp:
+ # A target leaving the inventory leaves the report, with its findings.
+ eng, runner, clock = engine(tmp)
+ eng.tick()
+ check("postgres is present first", POSTGRES in targets(eng))
+ gone = dict(CLUSTER)
+ gone["pods"] = [p for p in CLUSTER["pods"] if p["metadata"]["name"] != "db-0"]
+ eng.client.cluster = gone
+ clock.advance(minutes=15)
+ eng.tick()
+ check("a target leaving the inventory leaves the report", POSTGRES not in targets(eng))
+ check("its findings go with it", not findings(eng, POSTGRES))
+
+with tempfile.TemporaryDirectory() as tmp:
+ # Unexpected exceptions are contained at each level.
+ eng, runner, clock = engine(tmp)
+ runner.outcomes[POSTGRES] = RuntimeError("boom")
+ eng.tick()
+ check("an unexpected scan exception fails only that target", targets(eng)[POSTGRES]["state"] == "failed")
+ check("the run continued past it", runner.scans.count(NIGHTLY) == 1)
+ eng.client.fail = True
+ clock.advance(minutes=15)
+ eng.tick()
+ check("an inventory failure keeps the previous inventory", NGINX in targets(eng))
+ check("an inventory failure degrades the report", eng.report["run"]["status"] == "degraded")
+
+ calls = []
+
+ def crashing_tick():
+ calls.append(1)
+ if len(calls) == 1:
+ raise RuntimeError("cycle crash")
+ eng.stop.set()
+
+ eng.tick = crashing_tick
+ eng.tick_seconds = 0
+ thread = threading.Thread(target=eng.run_forever, daemon=True)
+ thread.start()
+ thread.join(10)
+ check("a crash in one cycle is logged and the loop continues", len(calls) == 2 and not thread.is_alive())
+
+# Recorded Trivy output through the normaliser: untrusted text.
+parsed = trivy.parse_results({"SchemaVersion": 2, "Results": [{"Vulnerabilities": [
+ {"VulnerabilityID": "CVE-1", "Severity": "weird", "PkgName": "p", "InstalledVersion": "1", "Title": "x\x07y",
+ "PrimaryURL": "javascript:alert(1)", "CVSS": {"nvd": {"V3Score": 11}}},
+ {"VulnerabilityID": "CVE-2", "Severity": "HIGH", "PkgName": "q", "InstalledVersion": "1", "FixedVersion": "2",
+ "PrimaryURL": "https://example.test/x", "CVSS": {"nvd": {"V3Score": 7.5}}},
+]}]})
+check("unknown severity becomes UNKNOWN", parsed[0]["severity"] == "UNKNOWN")
+check("control characters are stripped from upstream text", "\x07" not in parsed[0]["title"])
+check("non-http(s) advisory URL becomes empty", parsed[0]["url"] == "")
+check("out-of-range CVSS is dropped", parsed[0]["cvss"] is None)
+check("http(s) URL is kept", parsed[1]["url"] == "https://example.test/x")
+check("a recorded report with no Results is zero findings", trivy.parse_results(fixture("trivy-empty.json")) == [])
+try:
+ trivy.parse_results({"not": "a report"})
+ check("an unrecognised document is a failure, never zero findings", False)
+except trivy.ScanFailure as exc:
+ check("an unrecognised document is a failure, never zero findings", exc.kind == "failed")
+failure = trivy.classify_failure("ghcr.io", "GET failed: 401 Unauthorized")
+check("401 is credential-required", failure.kind == "credential-required" and "ghcr.io" in failure.reason)
+check("permission denied on local disk is a plain failure", trivy.classify_failure("r", "open /cache/x: permission denied").kind == "failed")
+check("a pull timeout is failed, not credential-required", trivy.classify_failure("r", "context deadline exceeded").kind == "failed")
+
+print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
+sys.exit(1 if FAILED else 0)
diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.sh b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.sh
new file mode 100755
index 0000000..77679ca
--- /dev/null
+++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
+# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+export PYTHONDONTWRITEBYTECODE=1
+exec python3 "${HERE}/check.py"
diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/cluster.json b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/cluster.json
new file mode 100644
index 0000000..5d4e491
--- /dev/null
+++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/cluster.json
@@ -0,0 +1,198 @@
+{
+ "cronjobs": [
+ {
+ "metadata": {
+ "name": "nightly",
+ "namespace": "ops"
+ },
+ "spec": {
+ "jobTemplate": {
+ "spec": {
+ "template": {
+ "spec": {
+ "containers": [
+ {
+ "image": "ghcr.io/org/nightly:2.1",
+ "name": "job"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ ],
+ "jobs": [],
+ "nodes": [
+ {
+ "metadata": {
+ "name": "prod-1"
+ }
+ }
+ ],
+ "pods": [
+ {
+ "metadata": {
+ "name": "web-1",
+ "namespace": "apps",
+ "ownerReferences": [
+ {
+ "controller": true,
+ "kind": "ReplicaSet",
+ "name": "web-5f"
+ }
+ ]
+ },
+ "spec": {
+ "containers": [
+ {
+ "image": "nginx:1.27",
+ "name": "app"
+ }
+ ]
+ },
+ "status": {
+ "containerStatuses": [
+ {
+ "imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
+ "name": "app"
+ }
+ ],
+ "phase": "Running"
+ }
+ },
+ {
+ "metadata": {
+ "name": "api-1",
+ "namespace": "apps",
+ "ownerReferences": [
+ {
+ "controller": true,
+ "kind": "ReplicaSet",
+ "name": "api-77"
+ }
+ ]
+ },
+ "spec": {
+ "containers": [
+ {
+ "image": "nginx:1.27",
+ "name": "server"
+ }
+ ]
+ },
+ "status": {
+ "containerStatuses": [
+ {
+ "imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
+ "name": "server"
+ }
+ ],
+ "phase": "Running"
+ }
+ },
+ {
+ "metadata": {
+ "name": "db-0",
+ "namespace": "data",
+ "ownerReferences": [
+ {
+ "controller": true,
+ "kind": "StatefulSet",
+ "name": "db"
+ }
+ ]
+ },
+ "spec": {
+ "containers": [
+ {
+ "image": "postgres:16",
+ "name": "postgres"
+ }
+ ]
+ },
+ "status": {
+ "containerStatuses": [
+ {
+ "imageID": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
+ "name": "postgres"
+ }
+ ],
+ "phase": "Running"
+ }
+ },
+ {
+ "metadata": {
+ "name": "priv-1",
+ "namespace": "apps",
+ "ownerReferences": [
+ {
+ "controller": true,
+ "kind": "ReplicaSet",
+ "name": "priv-1a"
+ }
+ ]
+ },
+ "spec": {
+ "containers": [
+ {
+ "image": "ghcr.io/org/private:1",
+ "name": "priv"
+ }
+ ]
+ },
+ "status": {
+ "containerStatuses": [
+ {
+ "imageID": "ghcr.io/org/private@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
+ "name": "priv"
+ }
+ ],
+ "phase": "Running"
+ }
+ }
+ ],
+ "replicasets": [
+ {
+ "metadata": {
+ "name": "web-5f",
+ "namespace": "apps",
+ "ownerReferences": [
+ {
+ "controller": true,
+ "kind": "Deployment",
+ "name": "web"
+ }
+ ]
+ }
+ },
+ {
+ "metadata": {
+ "name": "api-77",
+ "namespace": "apps",
+ "ownerReferences": [
+ {
+ "controller": true,
+ "kind": "Deployment",
+ "name": "api"
+ }
+ ]
+ }
+ },
+ {
+ "metadata": {
+ "name": "priv-1a",
+ "namespace": "apps",
+ "ownerReferences": [
+ {
+ "controller": true,
+ "kind": "Deployment",
+ "name": "priv"
+ }
+ ]
+ }
+ }
+ ],
+ "vmis": []
+}
diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-empty.json b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-empty.json
new file mode 100644
index 0000000..b81b42d
--- /dev/null
+++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-empty.json
@@ -0,0 +1,4 @@
+{
+ "ArtifactName": "x",
+ "SchemaVersion": 2
+}
diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nginx.json b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nginx.json
new file mode 100644
index 0000000..bbe13b4
--- /dev/null
+++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nginx.json
@@ -0,0 +1,33 @@
+{
+ "ArtifactName": "x",
+ "Results": [
+ {
+ "Target": "t",
+ "Vulnerabilities": [
+ {
+ "CVSS": {
+ "nvd": {
+ "V3Score": 9.8
+ }
+ },
+ "FixedVersion": "3.0.2",
+ "InstalledVersion": "1.0",
+ "PkgName": "openssl",
+ "PrimaryURL": "https://example.test/CVE-2026-0001",
+ "Severity": "CRITICAL",
+ "Title": "OpenSSL flaw",
+ "VulnerabilityID": "CVE-2026-0001"
+ },
+ {
+ "InstalledVersion": "1.0",
+ "PkgName": "zlib",
+ "PrimaryURL": "https://example.test/CVE-2026-0002",
+ "Severity": "LOW",
+ "Title": "t",
+ "VulnerabilityID": "CVE-2026-0002"
+ }
+ ]
+ }
+ ],
+ "SchemaVersion": 2
+}
diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nightly.json b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nightly.json
new file mode 100644
index 0000000..8d37a56
--- /dev/null
+++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nightly.json
@@ -0,0 +1,19 @@
+{
+ "ArtifactName": "x",
+ "Results": [
+ {
+ "Target": "t",
+ "Vulnerabilities": [
+ {
+ "InstalledVersion": "1.0",
+ "PkgName": "curl",
+ "PrimaryURL": "https://example.test/CVE-2026-0020",
+ "Severity": "MEDIUM",
+ "Title": "t",
+ "VulnerabilityID": "CVE-2026-0020"
+ }
+ ]
+ }
+ ],
+ "SchemaVersion": 2
+}
diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-postgres.json b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-postgres.json
new file mode 100644
index 0000000..efc3e3b
--- /dev/null
+++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-postgres.json
@@ -0,0 +1,25 @@
+{
+ "ArtifactName": "x",
+ "Results": [
+ {
+ "Target": "t",
+ "Vulnerabilities": [
+ {
+ "CVSS": {
+ "nvd": {
+ "V3Score": 7.5
+ }
+ },
+ "FixedVersion": "16.2",
+ "InstalledVersion": "1.0",
+ "PkgName": "libpq",
+ "PrimaryURL": "https://example.test/CVE-2026-0010",
+ "Severity": "HIGH",
+ "Title": "libpq flaw",
+ "VulnerabilityID": "CVE-2026-0010"
+ }
+ ]
+ }
+ ],
+ "SchemaVersion": 2
+}
diff --git a/platform/vuln-scanner/src/acceptances.py b/platform/vuln-scanner/src/acceptances.py
new file mode 100644
index 0000000..b8f4cd3
--- /dev/null
+++ b/platform/vuln-scanner/src/acceptances.py
@@ -0,0 +1,141 @@
+"""R-VULN-08 acceptances: a strict parser for exactly the spec's flat list, no YAML library.
+
+ - id: CVE-2026-00000
+ image: registry/repository # exact match; omit only for platform-wide
+ reason: not reachable - feature disabled
+ expires: 2027-02-01 # mandatory, at most 180 days out
+
+Fail-safe: a malformed entry is ignored and reported, so it can never hide a finding. Anything the
+parser cannot place (bad indentation, a stray line) invalidates the whole file for the same reason.
+"""
+import re
+import sys
+from datetime import date, timedelta
+
+from state import utcnow
+
+MAX_DAYS = 180
+_KEYS = ("id", "image", "reason", "expires")
+_ENTRY = re.compile(r"^- ([a-z]+):(?: (.*))?$")
+_FIELD = re.compile(r"^ ([a-z]+):(?: (.*))?$")
+_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
+_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
+_FORBIDDEN_START = tuple("[{&*!|>%@`")
+
+
+def _scalar(raw):
+ """(value, None) or (None, error) for an unquoted or simply-quoted YAML scalar."""
+ value = (raw or "").strip()
+ if value[:1] in ("'", '"'):
+ quote = value[0]
+ end = value.find(quote, 1)
+ if end == -1 or "\\" in value[:end]:
+ return None, "bad quoting"
+ rest = value[end + 1:].strip()
+ if rest and not rest.startswith("#"):
+ return None, "text after the closing quote"
+ return value[1:end], None
+ if value.startswith(_FORBIDDEN_START):
+ return None, "only plain scalars are allowed"
+ comment = re.search(r"\s#", value)
+ if comment:
+ value = value[:comment.start()].rstrip()
+ return value, None
+
+
+def parse_text(source, today=None):
+ """(valid entries, error strings). Entries carry id, reason, expires (date) and optional image."""
+ today = today or utcnow().date()
+ errors = []
+ raw_entries = []
+ current = None
+ for lineno, line in enumerate(source.splitlines(), 1):
+ if not line.strip() or line.lstrip().startswith("#"):
+ continue
+ entry = _ENTRY.match(line)
+ field = _FIELD.match(line)
+ if line.strip().split("#", 1)[0].strip() == "[]" and not raw_entries and current is None:
+ continue
+ if entry:
+ current = {"line": lineno, "fields": {}, "errors": []}
+ raw_entries.append(current)
+ key, raw = entry.group(1), entry.group(2)
+ elif field and current is not None:
+ key, raw = field.group(1), field.group(2)
+ else:
+ return [], ["line " + str(lineno) + ": not a flat acceptance entry; the whole file is ignored"]
+ value, problem = _scalar(raw)
+ if key not in _KEYS:
+ current["errors"].append("unknown key " + key)
+ elif problem:
+ current["errors"].append(key + ": " + problem)
+ elif key in current["fields"]:
+ current["errors"].append("duplicate key " + key)
+ else:
+ current["fields"][key] = value
+ entries = []
+ for raw in raw_entries:
+ problems = list(raw["errors"]) + _validate(raw["fields"], today)
+ if problems:
+ errors.append("entry at line " + str(raw["line"]) + ": " + "; ".join(problems))
+ continue
+ fields = raw["fields"]
+ entries.append({"id": fields["id"], "image": fields.get("image") or None, "reason": fields["reason"],
+ "expires": date.fromisoformat(fields["expires"])})
+ return entries, errors
+
+
+def _validate(fields, today):
+ problems = []
+ if not _ID.match(fields.get("id") or ""):
+ problems.append("id is missing or malformed")
+ if not fields.get("reason"):
+ problems.append("reason is mandatory")
+ if "image" in fields and not fields["image"]:
+ problems.append("image is empty")
+ expires = fields.get("expires") or ""
+ if not _DATE.match(expires):
+ problems.append("expires is mandatory and must be YYYY-MM-DD")
+ return problems
+ try:
+ when = date.fromisoformat(expires)
+ except ValueError:
+ problems.append("expires is not a real date")
+ return problems
+ if when > today + timedelta(days=MAX_DAYS):
+ problems.append("expires is more than " + str(MAX_DAYS) + " days out")
+ return problems
+
+
+def load(path, today=None):
+ """Entries and errors from a file. An unreadable file is an error, never silently empty."""
+ try:
+ with open(path, encoding="utf-8") as handle:
+ source = handle.read(1024 * 1024 + 1)
+ except (OSError, UnicodeDecodeError) as exc:
+ return [], ["acceptances file unreadable: " + type(exc).__name__]
+ if len(source) > 1024 * 1024:
+ return [], ["acceptances file exceeds 1 MiB"]
+ return parse_text(source, today)
+
+
+def match(entries, finding_id, image, today=None):
+ """The unexpired acceptance covering a finding, if any. Expiry day itself still counts."""
+ today = today or utcnow().date()
+ best = None
+ for entry in entries:
+ if entry["id"] != finding_id or entry["expires"] < today:
+ continue
+ if entry["image"] is not None and entry["image"] != image:
+ continue
+ if best is None or entry["expires"] > best["expires"]:
+ best = entry
+ return best
+
+
+if __name__ == "__main__":
+ found, problems = load(sys.argv[1])
+ for problem in problems:
+ print("INVALID: " + problem, file=sys.stderr)
+ print(str(len(found)) + " valid entries")
+ sys.exit(1 if problems else 0)
diff --git a/platform/vuln-scanner/src/inventory.py b/platform/vuln-scanner/src/inventory.py
new file mode 100644
index 0000000..5751d55
--- /dev/null
+++ b/platform/vuln-scanner/src/inventory.py
@@ -0,0 +1,248 @@
+"""Inventory from the Kubernetes API: what is actually running, and which workloads run it."""
+import json
+import logging
+import os
+import re
+import ssl
+import urllib.error
+import urllib.parse
+import urllib.request
+
+log = logging.getLogger("vuln-scanner.inventory")
+
+_ID_PREFIXES = ("docker-pullable://", "docker://", "containerd://", "cri-o://")
+_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
+_DOCKER_ALIASES = {"index.docker.io", "registry-1.docker.io", "docker.io"}
+_CONTAINER_FIELDS = (
+ ("containers", "containerStatuses"),
+ ("initContainers", "initContainerStatuses"),
+ ("ephemeralContainers", "ephemeralContainerStatuses"),
+)
+_VMI_PATH = "/apis/kubevirt.io/v1/virtualmachineinstances"
+PATHS = {
+ "pods": "/api/v1/pods",
+ "replicasets": "/apis/apps/v1/replicasets",
+ "jobs": "/apis/batch/v1/jobs",
+ "cronjobs": "/apis/batch/v1/cronjobs",
+ "nodes": "/api/v1/nodes",
+}
+
+
+class InventoryError(Exception):
+ """The API could not be listed; the previous inventory stands."""
+
+
+class NotFound(InventoryError):
+ pass
+
+
+class KubeClient:
+ """Paginated GET list against the API using the pod's service account."""
+
+ def __init__(self, base_url=None, token_file=None, ca_file=None):
+ sa = "/var/run/secrets/kubernetes.io/serviceaccount"
+ if base_url is None:
+ host = os.environ.get("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc")
+ port = os.environ.get("KUBERNETES_SERVICE_PORT", "443")
+ host = "[" + host + "]" if ":" in host else host
+ base_url = "https://" + host + ":" + port
+ self.base_url = base_url.rstrip("/")
+ self.token_file = token_file or os.path.join(sa, "token")
+ self.ca_file = ca_file or os.path.join(sa, "ca.crt")
+
+ def _get_json(self, path, params):
+ url = self.base_url + path + "?" + urllib.parse.urlencode(params)
+ with open(self.token_file, encoding="utf-8") as handle: # projected tokens rotate
+ token = handle.read().strip()
+ request = urllib.request.Request(url, headers={"Authorization": "Bearer " + token})
+ context = ssl.create_default_context(cafile=self.ca_file) if url.startswith("https") else None
+ try:
+ with urllib.request.urlopen(request, timeout=30, context=context) as response:
+ return json.loads(response.read())
+ except urllib.error.HTTPError as exc:
+ if exc.code == 404:
+ raise NotFound(path) from None
+ raise InventoryError(path + ": HTTP " + str(exc.code)) from None
+ except (urllib.error.URLError, OSError, ValueError) as exc:
+ raise InventoryError(path + ": " + type(exc).__name__) from None
+
+ def list_all(self, path):
+ items = []
+ params = {"limit": "500"}
+ while True:
+ page = self._get_json(path, params)
+ items.extend(page.get("items") or [])
+ token = (page.get("metadata") or {}).get("continue")
+ if not token:
+ return items
+ params = {"limit": "500", "continue": token}
+
+
+def collect(client):
+ """Every list the inventory needs. A VMI API that 404s means no KubeVirt, hence no VMs."""
+ lists = {name: client.list_all(path) for name, path in PATHS.items()}
+ try:
+ lists["vmis"] = client.list_all(_VMI_PATH)
+ except NotFound:
+ lists["vmis"] = []
+ return lists
+
+
+def normalise_reference(reference):
+ """(registry/repository, tag or None, digest or None) for an image reference."""
+ text = reference.strip()
+ for prefix in _ID_PREFIXES:
+ if text.startswith(prefix):
+ text = text[len(prefix):]
+ digest = None
+ if "@" in text:
+ text, _, raw = text.partition("@")
+ digest = raw if _DIGEST.fullmatch(raw) else None
+ tag = None
+ tail = text.rsplit("/", 1)[-1]
+ if ":" in tail:
+ text, _, tag = text.rpartition(":")
+ first, slash, rest = text.partition("/")
+ if slash and ("." in first or ":" in first or first == "localhost"):
+ registry, repository = first.lower(), rest
+ else:
+ registry, repository = "docker.io", text
+ if registry in _DOCKER_ALIASES:
+ registry = "docker.io"
+ if "/" not in repository:
+ repository = "library/" + repository
+ return registry + "/" + repository, tag, digest
+
+
+def digest_from_image_id(image_id):
+ """The repo digest in a container status imageID, or None.
+
+ A bare `sha256:...` with no repository is a local config id, not a pullable digest, so it is
+ treated as absent (the target is then by-tag) rather than scanned as something it is not.
+ """
+ text = image_id or ""
+ for prefix in _ID_PREFIXES:
+ if text.startswith(prefix):
+ text = text[len(prefix):]
+ if "@" not in text:
+ return None
+ candidate = text.rsplit("@", 1)[1]
+ return candidate if _DIGEST.fullmatch(candidate) else None
+
+
+def _controller(obj):
+ references = (obj.get("metadata") or {}).get("ownerReferences") or []
+ for ref in references:
+ if ref.get("controller"):
+ return ref
+ return references[0] if references else None
+
+
+def resolve_owner(pod, replicasets, jobs):
+ """The top-level workload (kind, name) a pod belongs to."""
+ meta = pod.get("metadata") or {}
+ namespace = meta.get("namespace", "")
+ owner = _controller(pod)
+ if owner is None:
+ return "Pod", meta.get("name", "")
+ kind, name = owner.get("kind", ""), owner.get("name", "")
+ index = {"ReplicaSet": replicasets, "Job": jobs}.get(kind)
+ if index is not None:
+ parent = index.get((namespace, name))
+ upstream = _controller(parent) if parent else None
+ if upstream is not None:
+ return upstream.get("kind", kind), upstream.get("name", name)
+ return kind, name
+
+
+def _index(items):
+ return {((i.get("metadata") or {}).get("namespace", ""), (i.get("metadata") or {}).get("name", "")): i
+ for i in items}
+
+
+class _Builder:
+ def __init__(self):
+ self.targets = {}
+
+ def add(self, target_id, image, digest, ref, workload, container):
+ target = self.targets.get(target_id)
+ if target is None:
+ target = self.targets[target_id] = {
+ "id": target_id, "type": "image", "image": image, "digest": digest, "ref": ref,
+ "reason": None, "workloads": {},
+ }
+ if digest and not target["digest"]:
+ target["digest"] = digest
+ target["workloads"].setdefault(workload, set()).add(container)
+
+ def add_image(self, spec_image, image_id, workload, container):
+ image, tag, spec_digest = normalise_reference(spec_image)
+ digest = digest_from_image_id(image_id)
+ if digest:
+ self.add(image + "@" + digest, image, digest, image + "@" + digest, workload, container)
+ else:
+ ref = image + "@" + spec_digest if spec_digest else image + ":" + (tag or "latest")
+ target_id = image + ":" + tag if tag else (ref if spec_digest else image + ":latest")
+ self.add(target_id, image, None, ref, workload, container)
+
+ def add_uncovered(self, target_id, kind, reason, namespace, name, node_kind):
+ self.targets[target_id] = {
+ "id": target_id, "type": kind, "image": "", "digest": None, "ref": None, "reason": reason,
+ "workloads": {(namespace, node_kind, name): set()},
+ }
+
+ def finish(self):
+ result = []
+ for target in sorted(self.targets.values(), key=lambda t: t["id"]):
+ target["workloads"] = [
+ {"namespace": ns, "kind": kind, "name": name, "containers": sorted(containers)}
+ for (ns, kind, name), containers in sorted(target["workloads"].items())
+ ]
+ result.append(target)
+ return result
+
+
+def build(lists):
+ """Resolve raw API lists into target dicts."""
+ replicasets = _index(lists.get("replicasets") or [])
+ jobs = _index(lists.get("jobs") or [])
+ builder = _Builder()
+ running = set()
+ for pod in lists.get("pods") or []:
+ status = pod.get("status") or {}
+ # A Succeeded/Failed pod runs nothing; counting it would let a stale evicted pod pin a digest.
+ if status.get("phase") in ("Succeeded", "Failed"):
+ continue
+ namespace = (pod.get("metadata") or {}).get("namespace", "")
+ kind, name = resolve_owner(pod, replicasets, jobs)
+ workload = (namespace, kind, name)
+ if kind == "CronJob":
+ running.add((namespace, name))
+ spec = pod.get("spec") or {}
+ for spec_field, status_field in _CONTAINER_FIELDS:
+ statuses = {s.get("name"): s for s in status.get(status_field) or []}
+ for container in spec.get(spec_field) or []:
+ image_id = (statuses.get(container.get("name")) or {}).get("imageID", "")
+ builder.add_image(container.get("image", ""), image_id, workload, container.get("name", ""))
+ for cronjob in lists.get("cronjobs") or []:
+ meta = cronjob.get("metadata") or {}
+ namespace, name = meta.get("namespace", ""), meta.get("name", "")
+ if (namespace, name) in running:
+ continue
+ job_spec = ((cronjob.get("spec") or {}).get("jobTemplate") or {}).get("spec") or {}
+ pod_spec = (job_spec.get("template") or {}).get("spec") or {}
+ for field in ("containers", "initContainers"):
+ for container in pod_spec.get(field) or []:
+ builder.add_image(container.get("image", ""), "", (namespace, "CronJob", name), container.get("name", ""))
+ for vmi in lists.get("vmis") or []:
+ meta = vmi.get("metadata") or {}
+ namespace, name = meta.get("namespace", ""), meta.get("name", "")
+ builder.add_uncovered("vm/" + namespace + "/" + name, "vm",
+ "guest OS packages are not assessed by this scanner",
+ namespace, name, "VirtualMachineInstance")
+ for node in lists.get("nodes") or []:
+ name = (node.get("metadata") or {}).get("name", "")
+ builder.add_uncovered("node/" + name, "node",
+ "node OS packages and the k3s binary are not assessed by this scanner",
+ "", name, "Node")
+ return builder.finish()
diff --git a/platform/vuln-scanner/src/main.py b/platform/vuln-scanner/src/main.py
new file mode 100644
index 0000000..6e0f3e3
--- /dev/null
+++ b/platform/vuln-scanner/src/main.py
@@ -0,0 +1,276 @@
+"""Scanner entrypoint and the scan loop. Import has no side effects; main() runs the service."""
+import collections
+import logging
+import os
+import signal
+import sys
+import threading
+import time
+from datetime import timedelta
+
+import acceptances
+import inventory
+import metrics
+import page
+import report as report_mod
+import server
+import state as st
+import trivy
+from state import fmt_ts, parse_ts
+
+log = logging.getLogger("vuln-scanner")
+
+FULL_RUN_INTERVAL = timedelta(hours=20)
+DB_MAX_AGE = timedelta(hours=48)
+DB_REFRESH_AGE = timedelta(hours=24)
+
+
+class Engine:
+ def __init__(self, data_dir, client, runner, acceptances_path, clock=st.utcnow, tick_seconds=900, publish_seconds=120):
+ self.store = st.Store(data_dir)
+ self.data_dir = data_dir
+ self.client = client
+ self.runner = runner
+ self.acceptances_path = acceptances_path
+ self.clock = clock
+ self.tick_seconds = tick_seconds
+ self.publish_seconds = publish_seconds
+ self.lock = threading.RLock()
+ self.stop = threading.Event()
+ self.state = self.store.load()
+ self.report = report_mod.load_published(data_dir)
+ self.report_json = report_mod.serialise(self.report) if self.report else None
+ self.loop_thread = None
+ self._last_publish = 0.0
+ self._last_inventory = None
+
+ # --- views used by the HTTP servers; none of them waits for a scan --------------------------
+ def report_bytes(self):
+ return self.report_json
+
+ def render_page(self, query):
+ return page.render(self.report, query, self.clock())
+
+ def metrics_text(self):
+ with self.lock:
+ return metrics.render(self.report, self.state, self.clock())
+
+ def healthy(self):
+ return self.loop_thread is None or self.loop_thread.is_alive()
+
+ def record_triage(self, document):
+ with self.lock:
+ self.state["remediation"] = document["remediation"]
+ self.state["triageHeartbeatAt"] = fmt_ts(self.clock()) # server receipt time, per contract
+ self.store.save(self.state)
+ self.publish()
+
+ # --- persistence -----------------------------------------------------------------------------
+ def publish(self):
+ with self.lock:
+ now = self.clock()
+ entries, errors = acceptances.load(self.acceptances_path, now.date())
+ for problem in errors:
+ log.error("invalid acceptance ignored: %s", problem)
+ built = report_mod.build(self.state, entries, errors, now)
+ if report_mod.publish(built, self.data_dir):
+ self.report = built
+ self.report_json = report_mod.serialise(built)
+ self._last_publish = time.monotonic()
+
+ # --- inventory -------------------------------------------------------------------------------
+ def refresh_inventory(self):
+ """Install a fresh inventory. A failure keeps the previous one and degrades the report."""
+ now = self.clock()
+ self._last_inventory = time.monotonic()
+ try:
+ targets = inventory.build(inventory.collect(self.client))
+ except Exception as exc: # pylint: disable=broad-except
+ log.exception("inventory failed; keeping the previous inventory")
+ with self.lock:
+ self.state["inventoryError"] = type(exc).__name__
+ self.store.save(self.state)
+ return False
+ with self.lock:
+ st.merge_inventory(self.state, targets, now)
+ self.store.save(self.state)
+ return True
+
+ def _units(self):
+ """{scan key: reference}. Two targets sharing a digest are one unit, scanned once."""
+ units = {}
+ for target in sorted(self.state["inventory"], key=lambda t: t["id"]):
+ key = st.scan_key(target)
+ if key is not None and key not in units:
+ units[key] = target["image"] + "@" + target["digest"] if target["digest"] else target["ref"]
+ return units
+
+ def _never_attempted(self):
+ units = self._units()
+ return [k for k in units if self.state["scans"][k]["lastAttemptAt"] is None]
+
+ def _full_run_due(self):
+ """Keyed on the last ATTEMPT, not the last success: a permanently failing image would
+ otherwise look 'older than 20h' forever and restart a full run every tick."""
+ if self.state["run"] is None:
+ return True
+ now = self.clock()
+ for key in self._units():
+ attempted = parse_ts(self.state["scans"][key]["lastAttemptAt"])
+ if attempted is not None and now - attempted >= FULL_RUN_INTERVAL:
+ return True
+ return False
+
+ # --- scanning --------------------------------------------------------------------------------
+ def _refresh_database(self, always):
+ """True when a usable database is present. Failures are logged, never raised."""
+ info = self._db_info()
+ if always or info is None or self._db_age(info) >= DB_REFRESH_AGE:
+ try:
+ self.runner.download_db()
+ except Exception: # pylint: disable=broad-except
+ log.exception("vulnerability database update failed")
+ return False
+ info = self._db_info()
+ if info is None:
+ return False
+ with self.lock:
+ self.state["database"] = info
+ self.store.save(self.state)
+ return True
+
+ def _db_info(self):
+ try:
+ info = self.runner.db_info()
+ except Exception: # pylint: disable=broad-except
+ log.exception("could not read vulnerability database information")
+ return None
+ return dict(info) if info else None
+
+ def _db_age(self, info):
+ moment = parse_ts(info.get("updatedAt"))
+ return timedelta.max if moment is None else self.clock() - moment
+
+ def _scan_unit(self, key, reference):
+ try:
+ found = trivy.parse_results(self.runner.scan(reference))
+ except trivy.ScanFailure as exc:
+ self._record_failure(key, exc.kind, exc.reason)
+ except Exception as exc: # pylint: disable=broad-except
+ log.exception("scan of %s failed unexpectedly", reference)
+ self._record_failure(key, "failed", "scan error: " + type(exc).__name__)
+ else:
+ self._record_success(key, found)
+ try:
+ self.runner.clean_cache()
+ except Exception: # pylint: disable=broad-except
+ log.exception("scan cache cleanup failed")
+
+ def _record_failure(self, key, kind, reason):
+ now = fmt_ts(self.clock())
+ with self.lock:
+ record = self.state["scans"].get(key)
+ if record is None:
+ return
+ record["lastAttemptAt"] = now
+ record["outcome"] = kind
+ record["reason"] = reason
+ record["failedSince"] = (record["failedSince"] or now) if kind == "failed" else None
+ self.store.save(self.state)
+
+ def _record_success(self, key, found):
+ now = fmt_ts(self.clock())
+ with self.lock:
+ record = self.state["scans"].get(key)
+ if record is None:
+ return
+ record["lastAttemptAt"] = now
+ record["lastSuccessAt"] = now
+ record["outcome"] = "ok"
+ record["reason"] = None
+ record["failedSince"] = None
+ record["findings"] = found
+ record["firstSeen"] = {f["id"]: record["firstSeen"].get(f["id"], now) for f in found}
+ self.store.save(self.state)
+
+ def _scan_queue(self, keys):
+ """Scan sequentially, keeping the inventory and report fresh while a long run proceeds."""
+ queue = collections.deque(keys)
+ while queue and not self.stop.is_set():
+ if self._last_inventory is None or time.monotonic() - self._last_inventory >= self.tick_seconds:
+ if self.refresh_inventory():
+ queue.extendleft(k for k in self._never_attempted() if k not in queue)
+ key = queue.popleft()
+ reference = self._units().get(key)
+ if reference is None:
+ continue # it left the inventory while queued
+ try:
+ self._scan_unit(key, reference)
+ except Exception: # pylint: disable=broad-except
+ log.exception("scan bookkeeping failed for %s", key)
+ if time.monotonic() - self._last_publish >= self.publish_seconds:
+ self.publish()
+
+ def run_full(self):
+ started = self.clock()
+ log.info("full run starting")
+ database_ok = self._refresh_database(always=True)
+ self._scan_queue(sorted(self._units()))
+ info = self.state["database"]
+ now = self.clock()
+ degraded = not database_ok or info is None or self._db_age(info) > DB_MAX_AGE
+ _, errors = acceptances.load(self.acceptances_path, now.date())
+ if errors or self.state.get("inventoryError") or self.state.get("loadError"):
+ degraded = True
+ status = "degraded" if degraded else "complete"
+ with self.lock:
+ self.state["run"] = {"startedAt": fmt_ts(started), "finishedAt": fmt_ts(now), "status": status}
+ if not degraded:
+ self.state["lastCompleteRunAt"] = fmt_ts(now)
+ self.store.save(self.state)
+ log.info("full run finished: %s", status)
+
+ def tick(self):
+ self.refresh_inventory()
+ self.publish()
+ if self._full_run_due():
+ self.run_full()
+ else:
+ never = self._never_attempted()
+ if never:
+ self._refresh_database(always=False)
+ self._scan_queue(never)
+ self.publish()
+
+ def run_forever(self):
+ while not self.stop.is_set():
+ try:
+ self.tick()
+ except Exception: # pylint: disable=broad-except
+ log.exception("scan cycle crashed; continuing with the next cycle")
+ self.stop.wait(self.tick_seconds)
+
+ def start(self):
+ self.loop_thread = threading.Thread(target=self.run_forever, name="scan-loop", daemon=True)
+ self.loop_thread.start()
+
+
+def main():
+ logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
+ data_dir = os.environ.get("VULN_DATA_DIR", "/data")
+ cache_dir = os.environ.get("VULN_CACHE_DIR", "/cache")
+ tools_dir = os.environ.get("VULN_TOOLS_DIR", "/tools")
+ trivy_bin = os.environ.get("TRIVY_BIN", os.path.join(tools_dir, "trivy"))
+ runner = trivy.Runner(trivy_bin, cache_dir, int(os.environ.get("VULN_SCAN_TIMEOUT_SECONDS", "600")))
+ engine = Engine(data_dir, inventory.KubeClient(), runner, os.environ.get("VULN_ACCEPTANCES", "/config/acceptances.yaml"))
+ server.start_all(engine, ("127.0.0.1", 8080), ("0.0.0.0", 8081), ("0.0.0.0", 9090))
+ finished = threading.Event()
+ signal.signal(signal.SIGTERM, lambda *_: finished.set())
+ signal.signal(signal.SIGINT, lambda *_: finished.set())
+ engine.start()
+ finished.wait()
+ engine.stop.set()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/platform/vuln-scanner/src/metrics.py b/platform/vuln-scanner/src/metrics.py
new file mode 100644
index 0000000..c8fe7c6
--- /dev/null
+++ b/platform/vuln-scanner/src/metrics.py
@@ -0,0 +1,44 @@
+"""Prometheus text exposition: aggregates only (R-VULN-06). The label allowlist is state, severity, fixable."""
+from datetime import timedelta
+
+import report as report_mod
+from state import parse_ts
+
+_CRITICAL_GRACE = timedelta(hours=72)
+_SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
+
+
+def _sample(lines, name, value, labels=""):
+ lines.append(name + labels + " " + format(value, ".0f" if float(value).is_integer() else "g"))
+
+
+def render(report, state, now):
+ lines = []
+ if report is not None:
+ _sample(lines, "vuln_scanner_report_generated_timestamp_seconds", parse_ts(report["generatedAt"]).timestamp())
+ _sample(lines, "vuln_scanner_database_updated_timestamp_seconds", parse_ts(report["database"]["updatedAt"]).timestamp())
+ counts = {name: 0 for name in report_mod.STATES}
+ for target in report["targets"]:
+ counts[target["state"]] += 1
+ for name in report_mod.STATES:
+ _sample(lines, "vuln_scanner_targets", counts[name], '{state="' + name + '"}')
+ findings = {(sev, fix): 0 for sev in _SEVERITIES for fix in ("true", "false")}
+ overdue = 0
+ for finding in report["findings"]:
+ if finding["status"] == "accepted":
+ continue
+ findings[(finding["severity"], "true" if finding["fixedVersion"] else "false")] += 1
+ if finding["severity"] == "CRITICAL" and finding["fixedVersion"]:
+ first_seen = parse_ts(finding["firstSeenAt"])
+ if first_seen and now - first_seen > _CRITICAL_GRACE:
+ overdue += 1
+ for (severity, fixable), value in sorted(findings.items()):
+ _sample(lines, "vuln_scanner_findings", value, '{severity="' + severity + '",fixable="' + fixable + '"}')
+ _sample(lines, "vuln_scanner_critical_fix_overdue", overdue)
+ _sample(lines, "vuln_scanner_failed_targets_over_24h", report_mod.failed_over_24h(state, now))
+ for name, key in (("vuln_scanner_last_complete_run_timestamp_seconds", "lastCompleteRunAt"),
+ ("vuln_scanner_triage_heartbeat_timestamp_seconds", "triageHeartbeatAt")):
+ value = parse_ts(state.get(key))
+ if value is not None:
+ _sample(lines, name, value.timestamp())
+ return "\n".join(lines) + "\n"
diff --git a/platform/vuln-scanner/src/page.py b/platform/vuln-scanner/src/page.py
new file mode 100644
index 0000000..a2cd1a3
--- /dev/null
+++ b/platform/vuln-scanner/src/page.py
@@ -0,0 +1,482 @@
+"""The CVE page: server-rendered HTML with no script at all.
+
+Every string that reaches the document goes through e(), the single escape path; links additionally
+go through safe_url(). The style block is the only inline resource and is allowed by hash.
+"""
+import base64
+import hashlib
+import html
+import math
+from datetime import timedelta
+from urllib.parse import parse_qs, urlencode
+
+import report as report_mod
+import trivy
+from state import parse_ts
+
+REPORT_STALE = timedelta(hours=26)
+DB_STALE = timedelta(hours=48)
+SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
+SEV_RANK = {name: i for i, name in enumerate(SEVERITIES)}
+STATUS_RANK = {"fix-available": 0, "open": 1, "accepted": 2}
+STATE_RANK = {name: i for i, name in enumerate(report_mod.STATES)}
+PAGE_SIZES = (25, 50, 100, 200, 500)
+DEFAULT_SIZE = 100
+_MAX_PARAM = 200
+_EXPANSION_LIMIT = 200 # per-target CVE list in the inventory view; the remainder is linked
+
+STYLE = """
+body{font:14px/1.45 system-ui,sans-serif;margin:0;background:#fff;color:#1a1a1a}
+main{padding:0 16px 32px}
+header{padding:12px 16px;background:#f4f4f6;border-bottom:1px solid #ccc}
+h1{font-size:18px;margin:0 0 8px}
+nav a{margin-right:12px}
+.warn{background:#fff0d6;border:2px solid #b35900;padding:8px 12px;margin:8px 0;font-weight:600}
+.status{font-weight:700}
+.tiles span{display:inline-block;margin:2px 10px 2px 0}
+table{border-collapse:collapse;width:100%;margin-top:8px}
+th,td{border-bottom:1px solid #ddd;padding:4px 6px;text-align:left;vertical-align:top}
+th a{text-decoration:none}
+.sev-CRITICAL{color:#a00;font-weight:700}
+.sev-HIGH{color:#b35900;font-weight:700}
+form{margin:8px 0}
+form label{margin-right:8px}
+code{word-break:break-all}
+@media (prefers-color-scheme:dark){body{background:#16181c;color:#e6e6e6}
+header{background:#22252b;border-color:#3a3d44}
+th,td{border-color:#33363c}
+a{color:#7fb4ff}
+.warn{background:#3d2a12;border-color:#c98422}}
+"""
+STYLE_HASH = base64.b64encode(hashlib.sha256(STYLE.encode("utf-8")).digest()).decode("ascii")
+CSP = ("default-src 'none'; style-src 'sha256-" + STYLE_HASH + "'; base-uri 'none'; "
+ "form-action 'self'; frame-ancestors 'none'")
+SECURITY_HEADERS = {
+ "Content-Security-Policy": CSP,
+ "X-Content-Type-Options": "nosniff",
+ "Content-Type": "text/html; charset=utf-8",
+ "Cache-Control": "no-store",
+}
+
+
+def e(value):
+ """The one escape path for anything placed in the document."""
+ return "" if value is None else html.escape(str(value), quote=True)
+
+
+def safe_url(value):
+ return trivy.safe_url(value) if isinstance(value, str) else ""
+
+
+def _link(url, label):
+ url = safe_url(url)
+ return '' + e(label) + " " if url else e(label)
+
+
+def _parse(query):
+ raw = parse_qs(query or "", keep_blank_values=False)
+ params = {k: v[0][:_MAX_PARAM] for k, v in raw.items() if v}
+ view = params.get("view", "cves")
+ params["view"] = view if view in ("cves", "workloads", "inventory") else "cves"
+ try:
+ params["page"] = max(1, int(params.get("page", "1")))
+ except ValueError:
+ params["page"] = 1
+ try:
+ size = int(params.get("size", str(DEFAULT_SIZE)))
+ except ValueError:
+ size = DEFAULT_SIZE
+ params["size"] = size if size in PAGE_SIZES else DEFAULT_SIZE
+ return params
+
+
+def _qs(params, **overrides):
+ merged = dict(params)
+ merged.update(overrides)
+ pairs = sorted((k, str(v)) for k, v in merged.items() if v not in (None, ""))
+ return "?" + urlencode(pairs)
+
+
+def _age(moment, now):
+ if moment is None:
+ return "unknown"
+ seconds = int((now - moment).total_seconds())
+ if seconds < 0:
+ return "in the future"
+ hours, remainder = divmod(seconds, 3600)
+ minutes = remainder // 60
+ return str(hours) + "h " + str(minutes) + "m ago"
+
+
+def _finding_text(finding, target):
+ parts = [finding["id"], finding["package"], finding["title"], target["id"], target["image"]]
+ for workload in target["workloads"]:
+ parts.extend((workload["namespace"], workload["name"], *workload["containers"]))
+ return " ".join(parts).lower()
+
+
+def _match_workload(target, params):
+ namespace, workload = params.get("namespace"), params.get("workload")
+ if not namespace and not workload:
+ return True
+ for item in target["workloads"]:
+ if namespace and item["namespace"] != namespace:
+ continue
+ if workload and workload not in (item["name"], item["kind"] + "/" + item["name"]):
+ continue
+ return True
+ return False
+
+
+def _match_finding(finding, target, params):
+ if params.get("severity") and finding["severity"] != params["severity"].upper():
+ return False
+ if params.get("status") and finding["status"] != params["status"]:
+ return False
+ fix = params.get("fix")
+ if fix == "yes" and not finding["fixedVersion"]:
+ return False
+ if fix == "no" and finding["fixedVersion"]:
+ return False
+ if not _match_workload(target, params):
+ return False
+ text = params.get("q", "").lower()
+ return not text or text in _finding_text(finding, target)
+
+
+def _paginate(rows, params):
+ total = len(rows)
+ size = params["size"]
+ pages = max(1, math.ceil(total / size))
+ page = min(params["page"], pages)
+ start = (page - 1) * size
+ return rows[start:start + size], total, page, pages, start
+
+
+def _pager(params, total, page, pages, start, shown, noun):
+ first = start + 1 if total else 0
+ summary = "Showing " + str(first) + "–" + str(start + shown) + " of " + str(total) + " " + noun \
+ + " (page " + str(page) + " of " + str(pages) + ")"
+ links = []
+ if page > 1:
+ links.append('previous ')
+ if page < pages:
+ links.append('next ')
+ return "" + e(summary) + " " + " ".join(links) + "
"
+
+
+def _th(params, label, key):
+ direction = "desc" if params.get("sort") == key and params.get("dir", "asc") == "asc" else "asc"
+ return '' + e(label) + " "
+
+
+def _table(head, rows, empty):
+ if not rows:
+ return "" + e(empty) + "
"
+ return "" + "".join(head) + " " + "".join(rows) + "
"
+
+
+def _sev(severity):
+ return '' + e(severity) + " "
+
+
+def _workload_lines(target):
+ items = []
+ for workload in target["workloads"]:
+ items.append("" + e(workload["namespace"]) + " / " + e(workload["kind"]) + " / " + e(workload["name"])
+ + " — containers: " + e(", ".join(workload["containers"]) or "none") + " ")
+ return ""
+
+
+def _remediation_cell(images, remediation):
+ if not images:
+ return ""
+ out = []
+ for image in sorted(images):
+ entry = remediation.get(image)
+ if entry is None:
+ out.append(e(image) + ": no data")
+ elif entry["state"] == "pr-open" and safe_url(entry.get("url")):
+ out.append(e(image) + ": " + _link(entry["url"], "pr-open"))
+ else:
+ out.append(e(image) + ": " + e(entry["state"]))
+ return " ".join(out)
+
+
+# --- views ------------------------------------------------------------------------------------------
+
+def _cves(report, targets, params, remediation):
+ groups = {}
+ for finding in report["findings"]:
+ target = targets[finding["targetId"]]
+ if not _match_finding(finding, target, params):
+ continue
+ group = groups.setdefault(finding["id"], {
+ "id": finding["id"], "severity": finding["severity"], "cvss": None, "packages": set(), "fixed": set(),
+ "firstSeen": finding["firstSeenAt"], "statuses": set(), "url": "", "title": "", "items": [],
+ })
+ if SEV_RANK[finding["severity"]] < SEV_RANK[group["severity"]]:
+ group["severity"] = finding["severity"]
+ if finding["cvss"] is not None and (group["cvss"] is None or finding["cvss"] > group["cvss"]):
+ group["cvss"] = finding["cvss"]
+ group["packages"].add(finding["package"])
+ if finding["fixedVersion"]:
+ group["fixed"].add(finding["fixedVersion"])
+ group["firstSeen"] = min(group["firstSeen"], finding["firstSeenAt"])
+ group["statuses"].add(finding["status"])
+ group["url"] = group["url"] or finding["url"]
+ group["title"] = group["title"] or finding["title"]
+ group["items"].append((finding, target))
+ rows = []
+ for group in groups.values():
+ group["status"] = min(group["statuses"], key=STATUS_RANK.get)
+ group["imageCount"] = len({t["id"] for _, t in group["items"]})
+ group["workloadCount"] = len({(w["namespace"], w["kind"], w["name"]) for _, t in group["items"] for w in t["workloads"]})
+ group["package"] = ", ".join(sorted(group["packages"]))
+ group["fix"] = ", ".join(sorted(group["fixed"]))
+ rows.append(group)
+ sorts = {
+ "id": lambda r: r["id"], "severity": lambda r: SEV_RANK[r["severity"]], "cvss": lambda r: r["cvss"] if r["cvss"] is not None else -1,
+ "package": lambda r: r["package"], "fixed": lambda r: r["fix"], "firstSeen": lambda r: r["firstSeen"],
+ "status": lambda r: STATUS_RANK[r["status"]], "images": lambda r: r["imageCount"], "workloads": lambda r: r["workloadCount"],
+ }
+ rows.sort(key=lambda r: r["id"])
+ rows.sort(key=sorts.get(params.get("sort"), sorts["severity"]), reverse=params.get("dir") == "desc")
+ page_rows, total, page, pages, start = _paginate(rows, params)
+ html_rows = []
+ for row in page_rows:
+ lines = []
+ fix_images = set()
+ for finding, target in row["items"]:
+ if finding["status"] == "fix-available":
+ fix_images.add(target["image"])
+ lines.append("" + e(target["id"]) + " [" + e(target["state"]) + "] — " + e(finding["package"]) + " "
+ + e(finding["installedVersion"]) + " → " + e(finding["fixedVersion"] or "no fix")
+ + _workload_lines(target) + " ")
+ acceptance = next((f.get("acceptance") for f, _ in row["items"] if f.get("acceptance")), None)
+ status_text = e(row["status"])
+ if acceptance:
+ status_text += " (" + e(acceptance["reason"]) + ", until " + e(acceptance["expires"]) + ")"
+ html_rows.append(
+ "" + _link(row["url"], row["id"]) + " " + _sev(row["severity"]) + " "
+ + e("" if row["cvss"] is None else row["cvss"]) + " " + e(row["package"]) + " " + e(row["fix"])
+ + " " + e(row["firstSeen"]) + " " + status_text + " " + _remediation_cell(fix_images, remediation)
+ + " " + e(row["imageCount"]) + " images, " + e(row["workloadCount"])
+ + " workloads " + e(row["title"]) + "
")
+ head = [_th(params, "CVE", "id"), _th(params, "Severity", "severity"), _th(params, "CVSS", "cvss"), _th(params, "Package", "package"),
+ _th(params, "Fixed in", "fixed"), _th(params, "First seen", "firstSeen"), _th(params, "Status", "status"),
+ "Remediation ", _th(params, "Affected", "images")]
+ return _pager(params, total, page, pages, start, len(page_rows), "CVEs") \
+ + _table(head, html_rows, "No CVE rows match these filters.")
+
+
+def _workload_key(item):
+ return item["namespace"], item["kind"], item["name"]
+
+
+def _workloads(report, targets, params):
+ by_target = {}
+ for finding in report["findings"]:
+ target = targets[finding["targetId"]]
+ if _match_finding(finding, target, params):
+ by_target.setdefault(target["id"], []).append(finding)
+ rows = {}
+ finding_filter = params.get("severity") or params.get("status") or params.get("fix")
+ needle = params.get("q", "").lower()
+ for target in report["targets"]:
+ matching = by_target.get(target["id"], [])
+ for workload in target["workloads"]:
+ key = _workload_key(workload)
+ scope = {"namespace": params.get("namespace"), "workload": params.get("workload")}
+ if not _match_workload({"workloads": [workload]}, scope):
+ continue
+ text = " ".join((workload["namespace"], workload["name"], *workload["containers"], target["image"])).lower()
+ if not matching and (finding_filter or (needle and needle not in text)):
+ continue
+ row = rows.setdefault(key, {"key": key, "containers": set(), "images": set(), "findings": {}, "counts": dict.fromkeys(SEVERITIES, 0)})
+ row["containers"].update(workload["containers"])
+ row["images"].add(target["id"])
+ for finding in matching:
+ row["findings"].setdefault((finding["id"], target["id"], finding["package"]), finding)
+ for row in rows.values():
+ for finding in row["findings"].values():
+ row["counts"][finding["severity"]] += 1
+ items = list(rows.values())
+ sorts = {
+ "namespace": lambda r: r["key"][0], "kind": lambda r: r["key"][1], "name": lambda r: r["key"][2],
+ "images": lambda r: len(r["images"]), "total": lambda r: -len(r["findings"]),
+ }
+ for severity in SEVERITIES:
+ sorts[severity.lower()] = (lambda s: lambda r: -r["counts"][s])(severity)
+ items.sort(key=lambda r: r["key"])
+ if params.get("sort") in sorts:
+ items.sort(key=sorts[params["sort"]], reverse=params.get("dir") == "desc")
+ else:
+ items.sort(key=lambda r: tuple(-r["counts"][s] for s in SEVERITIES))
+ page_rows, total, page, pages, start = _paginate(items, params)
+ html_rows = []
+ for row in page_rows:
+ lines = "".join(
+ "" + _link(f["url"], f["id"]) + " " + _sev(f["severity"]) + " " + e(f["package"]) + " — " + e(f["status"]) + " "
+ for f in sorted(row["findings"].values(), key=lambda f: (SEV_RANK[f["severity"]], f["id"])))
+ counts = "".join("" + e(row["counts"][s]) + " " for s in SEVERITIES)
+ html_rows.append(
+ "" + e(row["key"][0]) + " " + e(row["key"][1]) + " " + e(row["key"][2]) + " "
+ + e(", ".join(sorted(row["containers"])) or "none") + " " + " ".join(e(i) for i in sorted(row["images"])) + " "
+ + counts + "" + e(len(row["findings"])) + " findings ")
+ head = [_th(params, "Namespace", "namespace"), _th(params, "Kind", "kind"), _th(params, "Workload", "name"), "Containers ",
+ _th(params, "Images", "images")] + [_th(params, s, s.lower()) for s in SEVERITIES] + [_th(params, "Findings", "total")]
+ return _pager(params, total, page, pages, start, len(page_rows), "workloads") \
+ + _table(head, html_rows, "No workload rows match these filters.")
+
+
+def _inventory(report, params):
+ counts = {}
+ by_target = {}
+ for finding in report["findings"]:
+ counts[finding["targetId"]] = counts.get(finding["targetId"], 0) + 1
+ by_target.setdefault(finding["targetId"], []).append(finding)
+ needle = params.get("q", "").lower()
+ wanted_state = params.get("status")
+ rows = []
+ for target in report["targets"]:
+ if wanted_state and target["state"] != wanted_state:
+ continue
+ if not _match_workload(target, params):
+ continue
+ if needle:
+ text = " ".join((target["id"], target["image"], target["state"], *[w["namespace"] + " " + w["name"] for w in target["workloads"]])).lower()
+ if needle not in text:
+ continue
+ rows.append(target)
+ sorts = {
+ "id": lambda t: t["id"], "type": lambda t: t["type"], "state": lambda t: STATE_RANK[t["state"]],
+ "lastScanned": lambda t: t["lastScannedAt"] or "", "findings": lambda t: counts.get(t["id"], 0),
+ }
+ rows.sort(key=lambda t: t["id"])
+ rows.sort(key=sorts.get(params.get("sort"), sorts["state"]), reverse=params.get("dir") == "desc")
+ page_rows, total, page, pages, start = _paginate(rows, params)
+ html_rows = []
+ for target in page_rows:
+ found = sorted(by_target.get(target["id"], []), key=lambda f: (SEV_RANK[f["severity"]], f["id"]))
+ listed = "".join("" + _link(f["url"], f["id"]) + " " + _sev(f["severity"]) + " " + e(f["package"]) + " — " + e(f["status"]) + " "
+ for f in found[:_EXPANSION_LIMIT])
+ if len(found) > _EXPANSION_LIMIT:
+ listed += '' + e(len(found) - _EXPANSION_LIMIT) + " more in the CVE view "
+ html_rows.append(
+ "" + e(target["id"]) + "" + e(target["type"]) + " " + e(target["state"]) + " "
+ + e(target["reason"]) + " " + e(target["lastScannedAt"] or "never") + " " + e(counts.get(target["id"], 0))
+ + " " + e(len(target["workloads"])) + " workloads, " + e(len(found)) + " findings "
+ + _workload_lines(target) + ("" if listed else "") + " ")
+ head = [_th(params, "Target", "id"), _th(params, "Type", "type"), _th(params, "State", "state"), "Reason ",
+ _th(params, "Last scanned", "lastScanned"), _th(params, "Findings", "findings"), "Workloads "]
+ return _pager(params, total, page, pages, start, len(page_rows), "targets") \
+ + _table(head, html_rows, "No targets match these filters.")
+
+
+# --- header -----------------------------------------------------------------------------------------
+
+def _header(report, now):
+ generated = parse_ts(report["generatedAt"])
+ database = parse_ts(report["database"]["updatedAt"])
+ states = dict.fromkeys(report_mod.STATES, 0)
+ for target in report["targets"]:
+ states[target["state"]] += 1
+ open_counts = dict.fromkeys(SEVERITIES, 0)
+ accepted = 0
+ for finding in report["findings"]:
+ if finding["status"] == "accepted":
+ accepted += 1
+ else:
+ open_counts[finding["severity"]] += 1
+ warnings = []
+ report_stale = generated is None or now - generated > REPORT_STALE
+ if report_stale:
+ warnings.append("STALE REPORT: generated " + _age(generated, now) + "; the expected refresh is within 26 hours.")
+ if report["run"]["status"] == "degraded":
+ warnings.append("DEGRADED RUN: the last run did not complete normally (database, acceptances file or inventory problem). Do not treat these results as current.")
+ db_epoch = report["database"]["version"] == "unavailable"
+ if db_epoch:
+ warnings.append("DATABASE UNAVAILABLE: no vulnerability database has been downloaded.")
+ elif database is None or now - database > DB_STALE:
+ warnings.append("DATABASE STALE: the vulnerability database is older than 48 hours.")
+ total_targets = len(report["targets"])
+ not_scanned = total_targets - states["scanned"]
+ if total_targets == 0:
+ warnings.append("NO TARGETS: the inventory is empty; nothing has been evaluated.")
+ if not_scanned:
+ warnings.append("INCOMPLETE COVERAGE: " + str(not_scanned) + " of " + str(total_targets)
+ + " targets are not in the scanned state; findings for them are missing or may be out of date.")
+ open_total = sum(open_counts.values())
+ if warnings:
+ status = "Status: ATTENTION NEEDED"
+ elif open_total or accepted:
+ status = "Status: CURRENT, findings present"
+ else:
+ status = "Status: CURRENT"
+ tiles = "".join("" + e(name) + ": " + e(states[name]) + " " for name in report_mod.STATES)
+ sev_tiles = "".join("" + _sev(s) + ": " + e(open_counts[s]) + " " for s in SEVERITIES)
+ claim = ""
+ if not warnings and not report["findings"] and total_targets > 0 and not_scanned == 0:
+ claim = "Clean: all " + e(total_targets) + " targets were evaluated within 24 hours with zero findings.
"
+ return ("CVE visibility "
+ "" + e(status) + "
"
+ + "".join('' + e(w) + "
" for w in warnings) + claim
+ + "Report age: " + e(_age(generated, now)) + " · Database age: "
+ + e("unavailable" if db_epoch else _age(database, now)) + " · Run: " + e(report["run"]["status"]) + "
"
+ + 'Targets (' + e(total_targets) + "): " + tiles + "
"
+ + 'Open findings by severity: ' + sev_tiles + "accepted: " + e(accepted) + "
"
+ + " ")
+
+
+def _nav(params):
+ links = []
+ for view, label in (("cves", "CVEs"), ("workloads", "Workloads"), ("inventory", "Inventory")):
+ marker = ' aria-current="page"' if params["view"] == view else ""
+ target = {k: v for k, v in params.items() if k not in ("page", "sort", "dir")}
+ target["view"] = view
+ links.append('" + e(label) + " ")
+ return "" + "".join(links) + " "
+
+
+def _filters(params):
+ def field(name, label):
+ return "" + e(label) + ' '
+ selected = params.get("fix", "")
+ fix_options = "".join(
+ '" + e(t) + " "
+ for v, t in (("", "any"), ("yes", "yes"), ("no", "no")))
+ return ('')
+
+
+def _document(body):
+ return ("CVE visibility "
+ ' "
+ "" + body + "").encode("utf-8")
+
+
+def render(report, query, now):
+ """(status code, header dict, body bytes) for one page request."""
+ if report is None:
+ body = ("CVE visibility NO REPORT YET: nothing has been evaluated. "
+ "Nothing can be said about this cluster until the first report is published.
")
+ return 503, dict(SECURITY_HEADERS), _document(body)
+ params = _parse(query)
+ targets = {t["id"]: t for t in report["targets"]}
+ remediation = {r["image"]: r for r in report["remediation"]}
+ view = params["view"]
+ if view == "workloads":
+ content = _workloads(report, targets, params)
+ elif view == "inventory":
+ content = _inventory(report, params)
+ else:
+ content = _cves(report, targets, params, remediation)
+ return 200, dict(SECURITY_HEADERS), _document(_header(report, now) + "" + _nav(params) + _filters(params) + content + " ")
+
+
+def not_found():
+ return 404, dict(SECURITY_HEADERS), _document("Not found.
")
diff --git a/platform/vuln-scanner/src/report.py b/platform/vuln-scanner/src/report.py
new file mode 100644
index 0000000..bb5e213
--- /dev/null
+++ b/platform/vuln-scanner/src/report.py
@@ -0,0 +1,222 @@
+"""Build, validate and publish report.json, the one interface to the page and the bot."""
+import json
+import logging
+import os
+import re
+from datetime import date, timedelta
+
+import acceptances as acceptances_mod
+import state as st
+from state import fmt_ts, parse_ts
+
+log = logging.getLogger("vuln-scanner.report")
+
+STATES = ("scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered")
+SCAN_WINDOW = timedelta(hours=24)
+PENDING_WINDOW = timedelta(hours=2)
+DB_STALE = timedelta(hours=48)
+NOT_SCANNED_REASON = "not scanned within 2h of first appearing"
+EPOCH = "1970-01-01T00:00:00Z" # database.updatedAt when no database has ever been downloaded
+
+_SCHEMA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "report.schema.json")
+with open(_SCHEMA_PATH, encoding="utf-8") as _handle:
+ SCHEMA = json.load(_handle)
+
+
+def coverage(target, record, now):
+ """(state, reason, failed-since datetime or None) for one target. The single place states come from."""
+ if target["type"] != "image":
+ return "not-covered", target["reason"], None
+ record = record or st.new_record(now)
+ outcome = record["outcome"]
+ if outcome == "credential-required":
+ return "credential-required", record["reason"], None
+ if outcome == "failed":
+ return "failed", record["reason"], parse_ts(record["failedSince"]) or now
+ if record["lastAttemptAt"] is None:
+ appeared = parse_ts(record["firstDiscoveredAt"]) or now
+ if now - appeared > PENDING_WINDOW:
+ return "failed", NOT_SCANNED_REASON, appeared + PENDING_WINDOW
+ return "pending", None, None
+ last_success = parse_ts(record["lastSuccessAt"]) or now
+ if now - last_success > SCAN_WINDOW:
+ return "stale", "last successful scan was at " + record["lastSuccessAt"], None
+ return ("by-tag" if target["digest"] is None else "scanned"), None, None
+
+
+def failed_over_24h(state, now):
+ count = 0
+ for target in state["inventory"]:
+ status, _, since = coverage(target, state["scans"].get(st.scan_key(target)), now)
+ if status == "failed" and since is not None and now - since > SCAN_WINDOW:
+ count += 1
+ return count
+
+
+def build(state, accepted, acceptance_errors, now):
+ """The report for `state` at `now`; acceptance_errors degrades the run."""
+ today = now.date()
+ targets = []
+ findings = []
+ for target in state["inventory"]:
+ record = state["scans"].get(st.scan_key(target))
+ status, reason, _ = coverage(target, record, now)
+ last_scanned = record["lastSuccessAt"] if record else None
+ targets.append({
+ "id": target["id"], "type": target["type"], "image": target["image"], "digest": target["digest"],
+ "state": status, "reason": reason, "lastScannedAt": last_scanned, "workloads": target["workloads"],
+ })
+ for item in (record or {}).get("findings", []):
+ finding = {key: item[key] for key in
+ ("id", "severity", "cvss", "package", "installedVersion", "fixedVersion", "title", "url")}
+ finding["targetId"] = target["id"]
+ finding["firstSeenAt"] = record["firstSeen"].get(item["id"]) or fmt_ts(now)
+ acceptance = acceptances_mod.match(accepted, item["id"], target["image"], today)
+ if acceptance:
+ finding["status"] = "accepted"
+ finding["acceptance"] = {"reason": acceptance["reason"], "expires": acceptance["expires"].isoformat()}
+ else:
+ finding["status"] = "fix-available" if item["fixedVersion"] else "open"
+ findings.append(finding)
+ findings.sort(key=lambda f: (f["id"], f["targetId"], f["package"], f["installedVersion"]))
+ database = dict(state["database"]) if state["database"] else {"updatedAt": EPOCH, "version": "unavailable"}
+ run = dict(state["run"]) if state["run"] else {"startedAt": fmt_ts(now), "finishedAt": fmt_ts(now), "status": "degraded"}
+ db_time = parse_ts(database["updatedAt"])
+ if acceptance_errors or state.get("inventoryError") or state.get("loadError") \
+ or db_time is None or now - db_time > DB_STALE:
+ run["status"] = "degraded"
+ return {
+ "schemaVersion": 1, "generatedAt": fmt_ts(now), "database": database, "run": run,
+ "targets": targets, "findings": findings, "remediation": list(state["remediation"]),
+ }
+
+
+# --- hand validator for the JSON-Schema subset report.schema.json uses -----------------------------
+
+def _type_ok(value, name):
+ if name == "object":
+ return isinstance(value, dict)
+ if name == "array":
+ return isinstance(value, list)
+ if name == "string":
+ return isinstance(value, str)
+ if name == "null":
+ return value is None
+ if name == "boolean":
+ return isinstance(value, bool)
+ if name == "number":
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
+ if name == "integer":
+ return isinstance(value, int) and not isinstance(value, bool)
+ return False
+
+
+def _format_ok(value, name):
+ if name == "date-time":
+ return parse_ts(value) is not None
+ if name == "date":
+ try:
+ date.fromisoformat(value)
+ return True
+ except (ValueError, TypeError):
+ return False
+ return True
+
+
+def _check(schema, value, path, errors):
+ if len(errors) >= 50:
+ return
+ if "type" in schema:
+ names = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
+ if not any(_type_ok(value, n) for n in names):
+ errors.append(path + ": wrong type")
+ return
+ if "const" in schema and value != schema["const"]:
+ errors.append(path + ": not the required constant")
+ if "enum" in schema and value not in schema["enum"]:
+ errors.append(path + ": not an allowed value")
+ if isinstance(value, str):
+ if "minLength" in schema and len(value) < schema["minLength"]:
+ errors.append(path + ": too short")
+ if "maxLength" in schema and len(value) > schema["maxLength"]:
+ errors.append(path + ": too long")
+ if "pattern" in schema and not re.search(schema["pattern"], value):
+ errors.append(path + ": does not match the pattern")
+ if "format" in schema and not _format_ok(value, schema["format"]):
+ errors.append(path + ": not a valid " + schema["format"])
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
+ if "minimum" in schema and value < schema["minimum"]:
+ errors.append(path + ": below the minimum")
+ if "maximum" in schema and value > schema["maximum"]:
+ errors.append(path + ": above the maximum")
+ if isinstance(value, dict):
+ for key in schema.get("required", []):
+ if key not in value:
+ errors.append(path + ": missing " + key)
+ properties = schema.get("properties", {})
+ for key, item in value.items():
+ if key in properties:
+ _check(properties[key], item, path + "." + key, errors)
+ elif schema.get("additionalProperties") is False:
+ errors.append(path + ": unexpected key " + key)
+ if isinstance(value, list) and "items" in schema:
+ for index, item in enumerate(value):
+ _check(schema["items"], item, path + "[" + str(index) + "]", errors)
+ if "not" in schema:
+ trial = []
+ _check(schema["not"], value, path, trial)
+ if not trial:
+ errors.append(path + ": matches a forbidden shape")
+ if "if" in schema:
+ trial = []
+ _check(schema["if"], value, path, trial)
+ branch = schema.get("then") if not trial else schema.get("else")
+ if branch:
+ _check(branch, value, path, errors)
+
+
+def validate(report):
+ """Schema and referential errors; an empty list means the report may be served."""
+ errors = []
+ _check(SCHEMA, report, "$", errors)
+ if errors:
+ return errors
+ ids = [t["id"] for t in report["targets"]]
+ if len(set(ids)) != len(ids):
+ errors.append("$.targets: duplicate target id")
+ known = set(ids)
+ for finding in report["findings"]:
+ if finding["targetId"] not in known:
+ errors.append("$.findings: targetId has no target")
+ break
+ return errors
+
+
+def serialise(report):
+ return json.dumps(report, sort_keys=True, separators=(",", ":")).encode("utf-8")
+
+
+def publish(report, data_dir):
+ """Write report.json. An invalid report writes nothing and is logged loudly."""
+ errors = validate(report)
+ if errors:
+ log.critical("REFUSING to publish an invalid report; the previous one stands: %s", errors[:10])
+ return False
+ st.atomic_write(os.path.join(data_dir, "report.json"), serialise(report))
+ return True
+
+
+def load_published(data_dir):
+ """The published report if present and still valid, else None (never a corrupt report)."""
+ try:
+ with open(os.path.join(data_dir, "report.json"), "rb") as handle:
+ report = json.loads(handle.read())
+ except FileNotFoundError:
+ return None
+ except (OSError, ValueError):
+ log.critical("published report.json is unreadable; serving no report until the next publish")
+ return None
+ if validate(report):
+ log.critical("published report.json is invalid; serving no report until the next publish")
+ return None
+ return report
diff --git a/platform/vuln-scanner/src/report.schema.json b/platform/vuln-scanner/src/report.schema.json
new file mode 100644
index 0000000..cebeba7
--- /dev/null
+++ b/platform/vuln-scanner/src/report.schema.json
@@ -0,0 +1,110 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "report.schema.json",
+ "title": "vuln-scanner report (docs/17, schemaVersion 1)",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["schemaVersion", "generatedAt", "database", "run", "targets", "findings", "remediation"],
+ "properties": {
+ "schemaVersion": {"const": 1},
+ "generatedAt": {"type": "string", "format": "date-time"},
+ "database": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["updatedAt", "version"],
+ "properties": {
+ "updatedAt": {"type": "string", "format": "date-time"},
+ "version": {"type": "string", "minLength": 1, "maxLength": 128}
+ }
+ },
+ "run": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["startedAt", "finishedAt", "status"],
+ "properties": {
+ "startedAt": {"type": "string", "format": "date-time"},
+ "finishedAt": {"type": "string", "format": "date-time"},
+ "status": {"enum": ["complete", "degraded"]}
+ }
+ },
+ "targets": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "type", "image", "digest", "state", "reason", "lastScannedAt", "workloads"],
+ "properties": {
+ "id": {"type": "string", "minLength": 1, "maxLength": 2048},
+ "type": {"enum": ["image", "vm", "node"]},
+ "image": {"type": "string", "maxLength": 2048},
+ "digest": {"type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$"},
+ "state": {"enum": ["scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered"]},
+ "reason": {"type": ["string", "null"], "maxLength": 1024},
+ "lastScannedAt": {"type": ["string", "null"], "format": "date-time"},
+ "workloads": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["namespace", "kind", "name", "containers"],
+ "properties": {
+ "namespace": {"type": "string", "maxLength": 253},
+ "kind": {"type": "string", "minLength": 1, "maxLength": 253},
+ "name": {"type": "string", "minLength": 1, "maxLength": 253},
+ "containers": {"type": "array", "items": {"type": "string", "maxLength": 253}}
+ }
+ }
+ }
+ }
+ }
+ },
+ "findings": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "targetId", "severity", "cvss", "package", "installedVersion", "fixedVersion",
+ "title", "url", "firstSeenAt", "status"],
+ "properties": {
+ "id": {"type": "string", "minLength": 1, "maxLength": 256},
+ "targetId": {"type": "string", "minLength": 1, "maxLength": 2048},
+ "severity": {"enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]},
+ "cvss": {"type": ["number", "null"], "minimum": 0, "maximum": 10},
+ "package": {"type": "string", "maxLength": 256},
+ "installedVersion": {"type": "string", "maxLength": 256},
+ "fixedVersion": {"type": ["string", "null"], "maxLength": 512},
+ "title": {"type": "string", "maxLength": 512},
+ "url": {"type": "string", "maxLength": 2048, "pattern": "^(https?://.+)?$"},
+ "firstSeenAt": {"type": "string", "format": "date-time"},
+ "status": {"enum": ["open", "fix-available", "accepted"]},
+ "acceptance": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["reason", "expires"],
+ "properties": {
+ "reason": {"type": "string", "minLength": 1, "maxLength": 2048},
+ "expires": {"type": "string", "format": "date"}
+ }
+ }
+ },
+ "if": {"properties": {"status": {"const": "accepted"}}},
+ "then": {"required": ["acceptance"]},
+ "else": {"not": {"required": ["acceptance"]}}
+ }
+ },
+ "remediation": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["image", "state", "url", "updatedAt"],
+ "properties": {
+ "image": {"type": "string", "minLength": 1, "maxLength": 512},
+ "state": {"enum": ["pr-open", "approval-requested", "no-update-identified", "not-tracked"]},
+ "url": {"type": ["string", "null"], "maxLength": 2048, "pattern": "^https?://.+$"},
+ "updatedAt": {"type": "string", "format": "date-time"}
+ }
+ }
+ }
+ }
+}
diff --git a/platform/vuln-scanner/src/server.py b/platform/vuln-scanner/src/server.py
new file mode 100644
index 0000000..7746bcf
--- /dev/null
+++ b/platform/vuln-scanner/src/server.py
@@ -0,0 +1,150 @@
+"""The three HTTP listeners: page (loopback, behind oauth2-proxy), internal API, metrics."""
+import json
+import logging
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import urlsplit
+
+import page
+import trivy
+from state import parse_ts
+
+log = logging.getLogger("vuln-scanner.server")
+
+MAX_TRIAGE_BYTES = 256 * 1024
+MAX_REMEDIATION = 5000
+REMEDIATION_STATES = ("pr-open", "approval-requested", "no-update-identified", "not-tracked")
+
+
+class _Handler(BaseHTTPRequestHandler):
+ server_version = "vuln-scanner"
+ sys_version = ""
+ timeout = 30
+ engine = None
+
+ def log_message(self, fmt, *args): # request lines carry filters; keep them out of the log
+ return
+
+ def _send(self, status, headers, body):
+ self.send_response(status)
+ for name, value in headers.items():
+ self.send_header(name, value)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _text(self, status, body, content_type="text/plain; charset=utf-8"):
+ self._send(status, {"Content-Type": content_type}, body.encode("utf-8"))
+
+
+class _PageHandler(_Handler):
+ def send_error(self, code, message=None, explain=None): # keep the CSP on error responses too
+ status, headers, body = page.not_found()
+ self._send(code, headers, body)
+
+ def do_GET(self):
+ parts = urlsplit(self.path)
+ if parts.path == "/":
+ self._send(*self.engine.render_page(parts.query))
+ elif parts.path == "/healthz":
+ self._text(200, "ok\n")
+ else:
+ self._send(*page.not_found())
+
+
+class _MetricsHandler(_Handler):
+ def do_GET(self):
+ path = urlsplit(self.path).path
+ if path == "/metrics":
+ self._text(200, self.engine.metrics_text(), "text/plain; version=0.0.4; charset=utf-8")
+ elif path == "/healthz":
+ healthy = self.engine.healthy()
+ self._text(200 if healthy else 503, "ok\n" if healthy else "scan loop stopped\n")
+ else:
+ self._text(404, "not found\n")
+
+
+class _InternalHandler(_Handler):
+ def do_GET(self):
+ path = urlsplit(self.path).path
+ if path == "/report.json":
+ body = self.engine.report_bytes()
+ if body is None:
+ self._text(503, "no report yet\n")
+ else:
+ self._send(200, {"Content-Type": "application/json"}, body)
+ elif path == "/healthz":
+ self._text(200, "ok\n")
+ else:
+ self._text(404, "not found\n")
+
+ def do_POST(self):
+ if urlsplit(self.path).path != "/triage":
+ self._text(404, "not found\n")
+ return
+ content_type = (self.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
+ if content_type != "application/json":
+ self._text(415, "content type must be application/json\n")
+ return
+ try:
+ length = int(self.headers.get("Content-Length", ""))
+ except ValueError:
+ self._text(411, "a content length is required\n")
+ return
+ if length < 0 or length > MAX_TRIAGE_BYTES:
+ self._text(413, "body too large\n")
+ return
+ raw = self.rfile.read(length)
+ try:
+ document = json.loads(raw)
+ except ValueError:
+ self._text(400, "body is not JSON\n")
+ return
+ errors = validate_triage(document)
+ if errors:
+ self._text(400, errors[0] + "\n")
+ return
+ self.engine.record_triage(document)
+ self._send(204, {}, b"")
+
+
+def validate_triage(document):
+ """Error strings for a POST /triage body; empty means valid. Exact shape, nothing extra."""
+ if not isinstance(document, dict) or set(document) != {"heartbeatAt", "remediation"}:
+ return ["body must have exactly heartbeatAt and remediation"]
+ if parse_ts(document["heartbeatAt"]) is None or len(document["heartbeatAt"]) > 64:
+ return ["heartbeatAt must be RFC 3339"]
+ entries = document["remediation"]
+ if not isinstance(entries, list) or len(entries) > MAX_REMEDIATION:
+ return ["remediation must be a list of at most " + str(MAX_REMEDIATION) + " entries"]
+ for index, entry in enumerate(entries):
+ prefix = "remediation[" + str(index) + "]: "
+ if not isinstance(entry, dict) or set(entry) != {"image", "state", "url", "updatedAt"}:
+ return [prefix + "must have exactly image, state, url, updatedAt"]
+ if not isinstance(entry["image"], str) or not 0 < len(entry["image"]) <= 512:
+ return [prefix + "image must be a string of at most 512 characters"]
+ if entry["state"] not in REMEDIATION_STATES:
+ return [prefix + "unknown state"]
+ url = entry["url"]
+ if url is not None and (not isinstance(url, str) or len(url) > 2048 or trivy.safe_url(url) != url):
+ return [prefix + "url must be null or an http(s) URL"]
+ if parse_ts(entry["updatedAt"]) is None or len(entry["updatedAt"]) > 64:
+ return [prefix + "updatedAt must be RFC 3339"]
+ return []
+
+
+def serve(handler_type, address, engine):
+ handler = type(handler_type.__name__, (handler_type,), {"engine": engine})
+ server = ThreadingHTTPServer(address, handler)
+ server.daemon_threads = True
+ thread = threading.Thread(target=server.serve_forever, name="http-" + str(address[1]), daemon=True)
+ thread.start()
+ return server
+
+
+def start_all(engine, page_address, internal_address, metrics_address):
+ return [
+ serve(_PageHandler, page_address, engine),
+ serve(_InternalHandler, internal_address, engine),
+ serve(_MetricsHandler, metrics_address, engine),
+ ]
diff --git a/platform/vuln-scanner/src/state.py b/platform/vuln-scanner/src/state.py
new file mode 100644
index 0000000..56912db
--- /dev/null
+++ b/platform/vuln-scanner/src/state.py
@@ -0,0 +1,147 @@
+"""Persisted scanner state and the time helpers every module shares.
+
+state.json holds what must survive a restart: the last inventory, one record per unit of scan
+(a digest, or a by-tag target id), the database info, the last run, remediation and heartbeat.
+Every write is temp-file + fsync + rename, so a crash leaves the previous file intact.
+"""
+import json
+import logging
+import os
+import re
+import tempfile
+from datetime import datetime, timedelta, timezone
+
+log = logging.getLogger("vuln-scanner.state")
+
+STATE_VERSION = 1
+RETENTION = timedelta(days=7) # how long a scan record outlives its target in the inventory
+
+_RFC3339 = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$")
+
+
+def utcnow():
+ return datetime.now(timezone.utc)
+
+
+def fmt_ts(value):
+ return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def parse_ts(value):
+ """A timezone-aware datetime for an RFC 3339 string, or None for anything else."""
+ if not isinstance(value, str) or not _RFC3339.match(value):
+ return None
+ try:
+ return datetime.fromisoformat(value)
+ except ValueError:
+ return None
+
+
+def atomic_write(path, data):
+ directory = os.path.dirname(path) or "."
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-")
+ try:
+ with os.fdopen(fd, "wb") as handle:
+ handle.write(data)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(tmp, path)
+ except BaseException:
+ try:
+ os.unlink(tmp)
+ except OSError:
+ pass
+ raise
+ dir_fd = os.open(directory, os.O_RDONLY)
+ try:
+ os.fsync(dir_fd)
+ finally:
+ os.close(dir_fd)
+
+
+def new_state():
+ return {
+ "version": STATE_VERSION,
+ "inventory": [],
+ "inventoryAt": None,
+ "inventoryError": None,
+ "scans": {},
+ "database": None,
+ "run": None,
+ "lastCompleteRunAt": None,
+ "remediation": [],
+ "triageHeartbeatAt": None,
+ "loadError": None,
+ }
+
+
+def new_record(now):
+ return {
+ "firstDiscoveredAt": fmt_ts(now),
+ "lastInventoriedAt": fmt_ts(now),
+ "lastAttemptAt": None,
+ "lastSuccessAt": None,
+ "outcome": None,
+ "reason": None,
+ "failedSince": None,
+ "findings": [],
+ "firstSeen": {},
+ }
+
+
+class Store:
+ """state.json on the data volume."""
+
+ def __init__(self, data_dir):
+ self.data_dir = data_dir
+ self.path = os.path.join(data_dir, "state.json")
+
+ def load(self):
+ try:
+ with open(self.path, "rb") as handle:
+ loaded = json.loads(handle.read())
+ if not isinstance(loaded, dict) or loaded.get("version") != STATE_VERSION:
+ raise ValueError("unrecognised state version")
+ except FileNotFoundError:
+ return new_state()
+ except (OSError, ValueError) as exc:
+ log.critical("state.json is unreadable (%s); starting empty and marking the run degraded", exc)
+ try:
+ os.replace(self.path, self.path + ".corrupt")
+ except OSError:
+ pass
+ fresh = new_state()
+ fresh["loadError"] = "state.json was unreadable at startup"
+ return fresh
+ merged = new_state()
+ merged.update(loaded)
+ return merged
+
+ def save(self, state):
+ atomic_write(self.path, json.dumps(state, sort_keys=True).encode("utf-8"))
+
+
+def merge_inventory(state, targets, now):
+ """Install a fresh inventory; create a record per new unit; forget long-gone units."""
+ state["inventory"] = targets
+ state["inventoryAt"] = fmt_ts(now)
+ state["inventoryError"] = None
+ stamp = fmt_ts(now)
+ for target in targets:
+ key = scan_key(target)
+ if key is None:
+ continue
+ record = state["scans"].get(key)
+ if record is None:
+ record = state["scans"][key] = new_record(now)
+ record["lastInventoriedAt"] = stamp
+ cutoff = now - RETENTION
+ for key in [k for k, r in state["scans"].items() if (parse_ts(r["lastInventoriedAt"]) or now) < cutoff]:
+ del state["scans"][key]
+
+
+def scan_key(target):
+ """The unit of scan: the digest, else the by-tag target id. VMs and nodes have none."""
+ if target["type"] != "image":
+ return None
+ return target["digest"] or target["id"]
diff --git a/platform/vuln-scanner/src/trivy.py b/platform/vuln-scanner/src/trivy.py
new file mode 100644
index 0000000..2481965
--- /dev/null
+++ b/platform/vuln-scanner/src/trivy.py
@@ -0,0 +1,153 @@
+"""Run Trivy and normalise its output. Everything Trivy emits is untrusted upstream text."""
+import json
+import logging
+import re
+import subprocess
+from urllib.parse import urlsplit
+
+log = logging.getLogger("vuln-scanner.trivy")
+
+SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
+_CREDENTIAL = re.compile(
+ r"unauthorized|authentication required|\bdenied:|access to the resource is denied|forbidden"
+ r"|status(?: code)?:? ?(?:401|403)\b|\b40[13] (?:unauthorized|forbidden)",
+ re.IGNORECASE,
+)
+_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
+
+
+class ScanFailure(Exception):
+ """A scan did not produce a result. kind is `failed` or `credential-required`."""
+
+ def __init__(self, kind, reason):
+ super().__init__(reason)
+ self.kind = kind
+ self.reason = reason
+
+
+def text(value, limit=512):
+ """A bounded, control-character-free string; non-strings become empty."""
+ if not isinstance(value, str):
+ return ""
+ return _CONTROL.sub(" ", value).strip()[:limit]
+
+
+def safe_url(value):
+ """value if it is a plain http(s) URL, else the empty string."""
+ url = text(value, 2048)
+ if not url or url != value or re.search(r"\s", url):
+ return ""
+ try:
+ parts = urlsplit(url)
+ except ValueError:
+ return ""
+ return url if parts.scheme.lower() in ("http", "https") and parts.netloc else ""
+
+
+def _cvss(vuln):
+ best = None
+ for vendor in (vuln.get("CVSS") or {}).values() if isinstance(vuln.get("CVSS"), dict) else ():
+ if not isinstance(vendor, dict):
+ continue
+ for key in ("V40Score", "V3Score", "V2Score"):
+ score = vendor.get(key)
+ if isinstance(score, (int, float)) and not isinstance(score, bool) and 0 <= score <= 10:
+ best = max(best, float(score)) if best is not None else float(score)
+ break
+ return best
+
+
+def parse_results(document):
+ """Findings (id, severity, cvss, package, installedVersion, fixedVersion, title, url), deduped."""
+ if not isinstance(document, dict) or "SchemaVersion" not in document:
+ raise ScanFailure("failed", "trivy produced an unrecognised report")
+ results = document.get("Results")
+ if results is None:
+ return []
+ if not isinstance(results, list):
+ raise ScanFailure("failed", "trivy report has a malformed Results field")
+ seen = {}
+ for result in results:
+ vulnerabilities = result.get("Vulnerabilities") if isinstance(result, dict) else None
+ if vulnerabilities is None:
+ continue
+ if not isinstance(vulnerabilities, list):
+ raise ScanFailure("failed", "trivy report has a malformed Vulnerabilities field")
+ for vuln in vulnerabilities:
+ if not isinstance(vuln, dict) or not text(vuln.get("VulnerabilityID")):
+ raise ScanFailure("failed", "trivy report has a finding with no identifier")
+ severity = text(vuln.get("Severity")).upper()
+ fixed = text(vuln.get("FixedVersion"))
+ finding = {
+ "id": text(vuln.get("VulnerabilityID"), 256),
+ "severity": severity if severity in SEVERITIES else "UNKNOWN",
+ "cvss": _cvss(vuln),
+ "package": text(vuln.get("PkgName"), 256),
+ "installedVersion": text(vuln.get("InstalledVersion"), 256),
+ "fixedVersion": fixed or None,
+ "title": text(vuln.get("Title") or vuln.get("Description"), 512),
+ "url": safe_url(vuln.get("PrimaryURL")),
+ }
+ seen[(finding["id"], finding["package"], finding["installedVersion"])] = finding
+ return sorted(seen.values(), key=lambda f: (f["id"], f["package"], f["installedVersion"]))
+
+
+def classify_failure(registry, stderr):
+ """Sort a non-zero Trivy exit into credential-required or failed. Reasons name the registry."""
+ if _CREDENTIAL.search(stderr or ""):
+ return ScanFailure("credential-required", "registry " + registry + " refused anonymous access")
+ lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
+ return ScanFailure("failed", text(lines[-1], 400) if lines else "trivy exited with an error and no message")
+
+
+class Runner:
+ """The real Trivy binary. Tests inject a runner with the same four methods."""
+
+ def __init__(self, trivy_bin, cache_dir, timeout=600):
+ self.bin = trivy_bin
+ self.cache_dir = cache_dir
+ self.timeout = timeout
+
+ def _run(self, args, timeout):
+ return subprocess.run([self.bin] + args, capture_output=True, text=True, timeout=timeout, check=False)
+
+ def download_db(self):
+ result = self._run(["image", "--download-db-only", "--no-progress", "--cache-dir", self.cache_dir], 900)
+ if result.returncode != 0:
+ raise ScanFailure("failed", "database update failed: " + text(result.stderr, 300))
+
+ def db_info(self):
+ """{updatedAt, version} of the DB on disk, or None when no DB is present."""
+ result = self._run(["version", "--format", "json", "--cache-dir", self.cache_dir], 60)
+ if result.returncode != 0:
+ return None
+ try:
+ db = json.loads(result.stdout).get("VulnerabilityDB")
+ except (ValueError, AttributeError):
+ return None
+ if not isinstance(db, dict) or not db.get("UpdatedAt"):
+ return None
+ return {"updatedAt": db["UpdatedAt"], "version": str(db.get("Version", "unknown"))}
+
+ def scan(self, reference):
+ args = [
+ "image", "--skip-db-update", "--image-src", "remote", "--scanners", "vuln", "--format", "json",
+ "--no-progress", "--cache-dir", self.cache_dir, "--timeout", str(self.timeout) + "s", reference,
+ ]
+ try:
+ result = self._run(args, self.timeout + 30)
+ except subprocess.TimeoutExpired:
+ raise ScanFailure("failed", "timed out after " + str(self.timeout) + "s") from None
+ except OSError as exc:
+ raise ScanFailure("failed", "could not start trivy: " + type(exc).__name__) from None
+ if result.returncode < 0:
+ raise ScanFailure("failed", "trivy killed by signal " + str(-result.returncode) + " (out of memory?)")
+ if result.returncode != 0:
+ raise classify_failure(reference.split("/", 1)[0], result.stderr)
+ try:
+ return json.loads(result.stdout)
+ except ValueError:
+ raise ScanFailure("failed", "trivy output was not valid JSON") from None
+
+ def clean_cache(self):
+ self._run(["clean", "--scan-cache", "--cache-dir", self.cache_dir], 120)