Review findings on the integrated scanner: - An OOM kill no longer wedges the loop: inFlight is persisted before each scan, and on restart that image is marked failed and sits out full runs for 20h. - One bad upstream string can no longer freeze the report: safe_url requires an exact lowercase scheme, acceptance reasons are bounded, /triage urls use the same rule, and report.build repairs or drops any entry the schema would refuse. lastCompleteRunAt advances only after a report is actually published. - The private registry reports credential-required, not failed: VULN_PRIVATE_REGISTRIES names it, and Trivy is not invoked. - loadError clears after a full run against a good inventory. - Every image under platform/vuln-scanner stays out of Renovate auto-merge. - The page labels status and target state per line, and mixed acceptance reads as mixed. The Keycloak client is still an operator action: its key must go in an existing encrypted file that an agent must not modify. platform/vuln-scanner/README.md has the exact steps. Co-Authored-By: Claude Opus 5.5 <[email protected]>
231 lines
12 KiB
Python
231 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""R-VULN-02/03/05/06: internal API, metrics, publish refusal, and HTTP during a scan."""
|
|
import copy
|
|
import http.client
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
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 metrics # noqa: E402
|
|
import report # noqa: E402
|
|
import server # noqa: E402
|
|
import state as st # noqa: E402
|
|
|
|
logging.disable(logging.CRITICAL)
|
|
FAILED = []
|
|
NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
|
|
A1 = "sha256:" + "a1" * 32
|
|
|
|
|
|
def check(name, condition):
|
|
print(("ok " if condition else "FAIL ") + name)
|
|
if not condition:
|
|
FAILED.append(name)
|
|
|
|
|
|
class Clock:
|
|
def __init__(self):
|
|
self.now = NOW
|
|
|
|
def __call__(self):
|
|
return self.now
|
|
|
|
|
|
class Client:
|
|
def list_all(self, path):
|
|
if path.endswith("pods"):
|
|
return [{"metadata": {"namespace": "apps", "name": "p"}, "spec": {"containers": [{"name": "c", "image": "nginx:1"}]},
|
|
"status": {"containerStatuses": [{"name": "c", "imageID": "docker.io/library/nginx@" + A1}]}}]
|
|
return []
|
|
|
|
|
|
class Runner:
|
|
"""Blocks on release while a scan is 'running', so a test can prove the HTTP path stays live."""
|
|
|
|
def __init__(self):
|
|
self.entered = threading.Event()
|
|
self.release = threading.Event()
|
|
self.release.set()
|
|
self.result = {"SchemaVersion": 2, "Results": [{"Vulnerabilities": [
|
|
{"VulnerabilityID": "CVE-1", "Severity": "CRITICAL", "PkgName": "openssl", "InstalledVersion": "1",
|
|
"FixedVersion": "2", "Title": "t", "PrimaryURL": "https://example.test/1"}]}]}
|
|
|
|
def download_db(self):
|
|
pass
|
|
|
|
def db_info(self):
|
|
return {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"}
|
|
|
|
def scan(self, _reference):
|
|
self.entered.set()
|
|
self.release.wait(10)
|
|
return self.result
|
|
|
|
def clean_cache(self):
|
|
pass
|
|
|
|
|
|
def request(port, method, path, body=None, headers=None):
|
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
|
conn.request(method, path, body, headers or {})
|
|
resp = conn.getresponse()
|
|
data = resp.read()
|
|
conn.close()
|
|
return resp.status, data, resp
|
|
|
|
|
|
def post(port, document, content_type="application/json"):
|
|
return request(port, "POST", "/triage", json.dumps(document), {"Content-Type": content_type})
|
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
accept = os.path.join(tmp, "a.yaml")
|
|
Path(accept).write_text("[]\n")
|
|
clock = Clock()
|
|
runner = Runner()
|
|
eng = main.Engine(tmp, Client(), runner, accept, clock=clock, publish_seconds=0)
|
|
servers = server.start_all(eng, ("127.0.0.1", 0), ("127.0.0.1", 0), ("127.0.0.1", 0))
|
|
page_port, internal, metrics_port = (s.server_address[1] for s in servers)
|
|
|
|
status, body, _ = request(internal, "GET", "/report.json")
|
|
check("report.json is 503 before any report", status == 503)
|
|
status, body, resp = request(page_port, "GET", "/")
|
|
check("page is 503 before any report, with the CSP", status == 503 and resp.getheader("Content-Security-Policy"))
|
|
text = request(metrics_port, "GET", "/metrics")[1].decode()
|
|
check("last-complete-run metric is absent before the first run", "last_complete_run" not in text)
|
|
check("triage heartbeat metric is absent before the first POST", "triage_heartbeat" not in text)
|
|
|
|
# HTTP must stay live while a scan is blocked mid-flight.
|
|
runner.release.clear()
|
|
worker = threading.Thread(target=eng.tick, daemon=True)
|
|
worker.start()
|
|
check("the scan is genuinely in flight", runner.entered.wait(10))
|
|
status_h, _, _ = request(internal, "GET", "/healthz")
|
|
status_p, _, _ = request(page_port, "GET", "/healthz")
|
|
status_m, _, _ = request(metrics_port, "GET", "/metrics")
|
|
check("listeners answer during a scan", (status_h, status_p, status_m) == (200, 200, 200))
|
|
runner.release.set()
|
|
worker.join(10)
|
|
check("the tick then completes", not worker.is_alive())
|
|
|
|
status, body, _ = request(internal, "GET", "/report.json")
|
|
check("report.json serves a schema-valid report after a tick", status == 200 and report.validate(json.loads(body)) == [])
|
|
|
|
# Page and CSP over HTTP
|
|
status, body, resp = request(page_port, "GET", "/?view=inventory")
|
|
check("page served over HTTP carries the CSP and nosniff",
|
|
status == 200 and resp.getheader("Content-Security-Policy").startswith("default-src 'none'") and resp.getheader("X-Content-Type-Options") == "nosniff")
|
|
status, body, resp = request(page_port, "GET", "/missing")
|
|
check("a 404 from the page listener also carries the CSP", status == 404 and resp.getheader("Content-Security-Policy"))
|
|
|
|
status, body, resp = request(page_port, "POST", "/")
|
|
check("an unsupported method on the page listener also carries the CSP", status == 501 and resp.getheader("Content-Security-Policy"))
|
|
|
|
# Triage API
|
|
valid = {"heartbeatAt": "2026-11-09T11:00:00Z", "remediation": [
|
|
{"image": "docker.io/library/nginx", "state": "pr-open", "url": "https://gitea.test/pulls/9", "updatedAt": "2026-11-09T11:00:00Z"}]}
|
|
status, body, _ = post(internal, valid)
|
|
check("valid triage is 204", status == 204 and body == b"")
|
|
check("remediation surfaces in the report", eng.report["remediation"][0]["state"] == "pr-open")
|
|
text = request(metrics_port, "GET", "/metrics")[1].decode()
|
|
check("heartbeat metric appears and uses server receipt time, not the bot's", f"triage_heartbeat_timestamp_seconds {int(NOW.timestamp())}" in text)
|
|
for name, mutate in (
|
|
("unknown top-level key", lambda d: d.update(extra=1)),
|
|
("missing heartbeat", lambda d: d.pop("heartbeatAt")),
|
|
("unknown remediation key", lambda d: d["remediation"][0].update(x=1)),
|
|
("bad remediation state", lambda d: d["remediation"][0].update(state="merged")),
|
|
("javascript url", lambda d: d["remediation"][0].update(url="javascript:x")),
|
|
("bad timestamp", lambda d: d.update(heartbeatAt="yesterday")),
|
|
):
|
|
document = copy.deepcopy(valid)
|
|
mutate(document)
|
|
status, _, _ = post(internal, document)
|
|
check("triage rejects " + name, status == 400)
|
|
for name, url in (("an uppercase scheme", "HTTP://gitea.test/pulls/1"), ("an empty url", ""), ("a javascript url", "javascript:alert(1)")):
|
|
bad = json.loads(json.dumps(valid))
|
|
bad["remediation"][0]["url"] = url
|
|
check("triage rejects " + name, post(internal, bad)[0] == 400)
|
|
check("triage rejects a wrong content type", post(internal, valid, "text/plain")[0] == 415)
|
|
status, _, _ = request(internal, "POST", "/triage", b"x" * 300000, {"Content-Type": "application/json"})
|
|
check("triage rejects an oversized body", status == 413)
|
|
many = {"heartbeatAt": "2026-11-09T11:00:00Z", "remediation": [valid["remediation"][0]] * 5001}
|
|
check("triage rejects more than 5000 entries", post(internal, many)[0] in (400, 413))
|
|
check("rejected triage leaves state untouched", eng.state["remediation"][0]["url"] == "https://gitea.test/pulls/9")
|
|
|
|
# Metrics labels and values
|
|
text = request(metrics_port, "GET", "/metrics")[1].decode()
|
|
allowed = {"state", "severity", "fixable"}
|
|
labels = {k for line in text.splitlines() for k in re.findall(r"([A-Za-z_]+)=\"", line)}
|
|
check("metric labels are within the allowlist", labels and labels <= allowed)
|
|
check("no metric carries a CVE, package, image or workload", not re.search(r"CVE-|openssl|nginx|apps", text))
|
|
for state in report.STATES:
|
|
check("targets gauge present for " + state, 'vuln_scanner_targets{state="' + state + '"}' in text)
|
|
check("critical fix findings counted with fixable true", 'vuln_scanner_findings{severity="CRITICAL",fixable="true"} 1' in text)
|
|
check("every severity and fixability appears", text.count("vuln_scanner_findings{") == 10)
|
|
check("failed-over-24h gauge exists", "vuln_scanner_failed_targets_over_24h 0" in text)
|
|
check("critical fix overdue is 0 inside the 72h grace", "vuln_scanner_critical_fix_overdue 0" in text)
|
|
clock.now += timedelta(hours=73)
|
|
text = request(metrics_port, "GET", "/metrics")[1].decode()
|
|
check("critical fix overdue is 1 after 72h", "vuln_scanner_critical_fix_overdue 1" in text)
|
|
check("last-complete-run metric present after a complete run", "vuln_scanner_last_complete_run_timestamp_seconds" in text)
|
|
for srv in servers:
|
|
srv.shutdown()
|
|
|
|
# Invalid report: refuse and keep the old one.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
good = {"schemaVersion": 1, "generatedAt": "2026-11-09T10:00:00Z", "database": {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"},
|
|
"run": {"startedAt": "2026-11-09T09:00:00Z", "finishedAt": "2026-11-09T09:30:00Z", "status": "complete"},
|
|
"targets": [], "findings": [], "remediation": []}
|
|
check("a valid report publishes", report.publish(good, tmp) is True)
|
|
before = Path(tmp, "report.json").read_bytes()
|
|
for name, mutate in (
|
|
("wrong schemaVersion", lambda r: r.update(schemaVersion=2)),
|
|
("extra top-level key", lambda r: r.update(extra=1)),
|
|
("bad timestamp", lambda r: r.update(generatedAt="soon")),
|
|
("unknown state", lambda r: r.update(targets=[{"id": "x", "type": "image", "image": "x", "digest": None, "state": "fine",
|
|
"reason": None, "lastScannedAt": None, "workloads": []}])),
|
|
("dangling targetId", lambda r: r.update(findings=[{"id": "CVE-1", "targetId": "nope", "severity": "LOW", "cvss": None,
|
|
"package": "p", "installedVersion": "1", "fixedVersion": None, "title": "t",
|
|
"url": "", "firstSeenAt": "2026-11-01T00:00:00Z", "status": "open"}])),
|
|
):
|
|
broken = copy.deepcopy(good)
|
|
mutate(broken)
|
|
check("invalid report refused: " + name, report.publish(broken, tmp) is False)
|
|
check("an invalid report writes nothing", Path(tmp, "report.json").read_bytes() == before)
|
|
check("accepted without acceptance is invalid", report.validate({**good, "targets": [{"id": "x", "type": "image", "image": "x", "digest": None,
|
|
"state": "scanned", "reason": None, "lastScannedAt": None, "workloads": []}], "findings": [{"id": "CVE-1", "targetId": "x", "severity": "LOW",
|
|
"cvss": None, "package": "p", "installedVersion": "1", "fixedVersion": None, "title": "t", "url": "", "firstSeenAt": "2026-11-01T00:00:00Z",
|
|
"status": "accepted"}]}) != [])
|
|
Path(tmp, "report.json").write_text("{not json")
|
|
check("a corrupt published report is not served", report.load_published(tmp) is None)
|
|
|
|
# Crash-consistency of state: the temp+rename leaves no temp files behind and the file parses.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
store = st.Store(tmp)
|
|
state = st.new_state()
|
|
state["triageHeartbeatAt"] = "2026-11-09T11:00:00Z"
|
|
store.save(state)
|
|
check("state saves with no temp file left behind", sorted(os.listdir(tmp)) == ["state.json"])
|
|
check("state survives a reload", st.Store(tmp).load()["triageHeartbeatAt"] == "2026-11-09T11:00:00Z")
|
|
Path(tmp, "state.json").write_text("not json")
|
|
loaded = st.Store(tmp).load()
|
|
check("corrupt state starts empty and marks degraded", loaded["loadError"] and loaded["scans"] == {})
|
|
check("corrupt state is preserved for inspection", Path(tmp, "state.json.corrupt").exists())
|
|
|
|
# Metrics module directly, without the report.
|
|
empty_state = st.new_state()
|
|
text = metrics.render(None, empty_state, NOW)
|
|
check("with no report only the state-derived gauge exists", text == "vuln_scanner_failed_targets_over_24h 0\n")
|
|
|
|
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
|
|
sys.exit(1 if FAILED else 0)
|