#!/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)