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]>
215 lines
12 KiB
Python
215 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""R-VULN-03/04: the rendered page. Assertions run against the actual HTML output."""
|
|
import base64
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
|
|
import page # noqa: E402
|
|
|
|
FAILED = []
|
|
NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
|
|
REPORT = json.loads((HERE / "fixtures/report.json").read_text())
|
|
CLEAN_RE = re.compile(r"(?i)\bclean\b|no vulnerabilit")
|
|
|
|
|
|
def check(name, condition):
|
|
print(("ok " if condition else "FAIL ") + name)
|
|
if not condition:
|
|
FAILED.append(name)
|
|
|
|
|
|
def render(report=REPORT, query="", now=NOW):
|
|
status, headers, body = page.render(report, query, now)
|
|
return status, headers, body.decode("utf-8")
|
|
|
|
|
|
class Tags(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.tags = []
|
|
self.attrs = []
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
self.tags.append(tag)
|
|
self.attrs.append((tag, dict(attrs)))
|
|
|
|
|
|
def tags(body):
|
|
parser = Tags()
|
|
parser.feed(body)
|
|
return parser
|
|
|
|
|
|
def covers_everything(body, report):
|
|
"""Every fixture CVE id, workload name and container name appears in the document."""
|
|
missing = [f["id"] for f in report["findings"] if f["id"] not in body]
|
|
for target in report["targets"]:
|
|
for workload in target["workloads"]:
|
|
if workload["name"] not in body:
|
|
missing.append(workload["name"])
|
|
missing.extend(c for c in workload["containers"] if c not in body)
|
|
return missing
|
|
|
|
|
|
def workload_view_covers(body, report):
|
|
"""Every workload row with its containers, and every CVE in the expansions."""
|
|
missing = covers_everything(body, report)
|
|
return missing
|
|
|
|
|
|
for view in ("cves", "workloads", "inventory"):
|
|
status, headers, body = render(query="view=" + view)
|
|
check(view + " view returns 200", status == 200)
|
|
check(view + " view has the Content-Security-Policy with no script source", "script-src" not in headers["Content-Security-Policy"]
|
|
and headers["Content-Security-Policy"].startswith("default-src 'none'"))
|
|
check(view + " view sets nosniff", headers["X-Content-Type-Options"] == "nosniff")
|
|
check(view + " view has no script element", "script" not in tags(body).tags)
|
|
style_body = re.search(r"<style>(.*?)</style>", body, re.S).group(1)
|
|
expected = base64.b64encode(hashlib.sha256(style_body.encode()).digest()).decode()
|
|
check(view + " view's CSP hash matches its inline style", "'sha256-" + expected + "'" in headers["Content-Security-Policy"])
|
|
check(view + " view header shows every state count", all(state + ": <strong>" 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", "<script>" not in body)
|
|
hostile = copy.deepcopy(REPORT)
|
|
hostile["findings"][0]["url"] = "javascript:alert(1)"
|
|
hostile["findings"][0]["title"] = '"><img src=x onerror=alert(1)>'
|
|
hostile["targets"][0]["reason"] = "<b onmouseover=alert(1)>x</b>"
|
|
hostile["remediation"][0]["url"] = "javascript:evil()"
|
|
_, _, body = render(hostile, "view=cves")
|
|
parsed = tags(body)
|
|
check("a javascript: advisory URL never becomes a link", not any("javascript:" in (a.get("href") or "") for t, a in parsed.attrs if t == "a"))
|
|
check("no element carries a hostile inline handler", not any(k.startswith("on") for _, a in parsed.attrs for k in a))
|
|
check("no img element was injected", "img" not in parsed.tags)
|
|
_, _, body = render(hostile, "view=inventory")
|
|
check("hostile reason is escaped in inventory", "<b onmouseover" not in body and "<b onmouseover" in body)
|
|
_, _, body = render(query="view=cves&q=" + "%3Cscript%3E")
|
|
check("a hostile search term is escaped", "<script>" not in body)
|
|
check("a linked CVE points at its advisory", 'href="https://example.test/CVE-2026-1001"' in render(query="view=cves")[2])
|
|
|
|
stale = copy.deepcopy(REPORT)
|
|
stale["generatedAt"] = (NOW - timedelta(hours=27)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
_, _, body = render(stale)
|
|
check("stale report shows a warning", "STALE REPORT" in body)
|
|
check("stale report shows no normal state", "Status: ATTENTION NEEDED" in body and "Status: CURRENT" not in body)
|
|
check("mutation: a fresh report has no stale warning", "STALE REPORT" not in render(REPORT)[2])
|
|
degraded = copy.deepcopy(REPORT)
|
|
degraded["run"]["status"] = "degraded"
|
|
_, _, body = render(degraded)
|
|
check("degraded run shows a warning and no normal state", "DEGRADED RUN" in body and "Status: ATTENTION NEEDED" in body and "Status: CURRENT" not in body)
|
|
|
|
allgood = copy.deepcopy(REPORT)
|
|
allgood["targets"] = [t for t in allgood["targets"] if t["state"] == "scanned"]
|
|
allgood["findings"] = []
|
|
_, _, body = render(allgood)
|
|
check("the clean claim appears when every target is scanned with zero findings", "Clean: all 1 targets" in body)
|
|
check("mutation: an all-scanned report shows the current state", "Status: CURRENT" in body)
|
|
one_pending = copy.deepcopy(allgood)
|
|
one_pending["targets"].append({"id": "x@sha256:" + "0" * 64, "type": "image", "image": "x", "digest": "sha256:" + "0" * 64,
|
|
"state": "pending", "reason": None, "lastScannedAt": None, "workloads": []})
|
|
_, _, body = render(one_pending)
|
|
check("one pending target removes the clean claim", "Clean:" not in body and not CLEAN_RE.search(body))
|
|
for state in ("failed", "stale", "credential-required", "by-tag", "not-covered"):
|
|
variant = copy.deepcopy(allgood)
|
|
variant["targets"].append({**one_pending["targets"][-1], "state": state})
|
|
check("a " + state + " target removes the clean claim", not CLEAN_RE.search(render(variant)[2]))
|
|
empty = copy.deepcopy(allgood)
|
|
empty["targets"] = []
|
|
check("zero targets is never a clean claim", not CLEAN_RE.search(render(empty)[2]))
|
|
degraded_clean = copy.deepcopy(allgood)
|
|
degraded_clean["run"]["status"] = "degraded"
|
|
check("a degraded run never makes a clean claim", not CLEAN_RE.search(render(degraded_clean)[2]))
|
|
|
|
status, headers, body = render(None)
|
|
check("no report yet is a 503 with the CSP", status == 503 and "Content-Security-Policy" in headers)
|
|
check("no report yet makes no clean claim", not CLEAN_RE.search(body))
|
|
|
|
_, _, body = render(query="view=cves&severity=CRITICAL")
|
|
check("the severity filter narrows the list", "CVE-2026-1001" in body and "CVE-2026-1004" not in body)
|
|
check("a filter is carried in pagination and sort links", "severity=CRITICAL" in body)
|
|
_, _, body = render(query="view=cves&status=accepted")
|
|
check("accepted findings stay listed with their reason", "CVE-2026-1003" in body and "not reachable" in body)
|
|
_, _, body = render(query="view=cves&fix=no")
|
|
check("the fix filter excludes fixed CVEs", "CVE-2026-1001" not in body and "CVE-2026-1002" in body)
|
|
_, _, body = render(query="view=inventory&status=credential-required")
|
|
check("inventory filters on coverage state", "ghcr.io/org/private" in body and "postgres" not in body.split("<main>")[1])
|
|
_, _, body = render(query="view=workloads&namespace=ops")
|
|
check("the namespace filter narrows workloads", "nightly" in body and "<td>web</td>" not in body)
|
|
_, _, body = render(query="view=cves&q=openssl")
|
|
check("free text search matches packages", "CVE-2026-1001" in body and "CVE-2026-1004" not in body)
|
|
_, _, body = render()
|
|
check("default view excludes nothing", all(f["id"] in body for f in REPORT["findings"]))
|
|
check("remediation column shows pr-open with a link", 'href="https://gitea.test/pulls/1"' in body and "pr-open" in body)
|
|
|
|
# Status and state belong to the line they describe (R-VULN-04).
|
|
images = [t for t in REPORT["targets"] if t["type"] == "image" and t["workloads"]]
|
|
check("fixture has two image targets with workloads", len(images) >= 2)
|
|
mixed = copy.deepcopy(REPORT)
|
|
first, second = next(t for t in mixed["targets"] if t["id"] == images[0]["id"]), next(t for t in mixed["targets"] if t["id"] == images[1]["id"])
|
|
first["state"], first["reason"] = "scanned", None
|
|
second["state"], second["reason"] = "stale", "last success long ago"
|
|
base = dict(REPORT["findings"][0], id="CVE-MIXED")
|
|
base.pop("acceptance", None)
|
|
mixed["findings"] = [
|
|
dict(base, targetId=first["id"], status="accepted", acceptance={"reason": "only-on-the-first", "expires": "2026-12-01"}),
|
|
dict(base, targetId=second["id"], status="fix-available"),
|
|
]
|
|
_, _, body = render(mixed, "view=cves&q=CVE-MIXED")
|
|
check("a mixed CVE reads as mixed, with counts", "mixed: 1 fix-available, 1 accepted" in body)
|
|
check("a mixed CVE's row does not carry one image's acceptance", "<td>mixed: 1 fix-available, 1 accepted</td>" in body)
|
|
first_line = page.e(first["id"]) + " — target scanned — status accepted (only-on-the-first, until 2026-12-01)"
|
|
second_line = page.e(second["id"]) + " — target stale (last success long ago) — status fix-available"
|
|
check("the accepted image's line carries its own acceptance", first_line in body)
|
|
check("the other image's line carries its own status and state", second_line in body)
|
|
check("no line attaches the acceptance to the unaccepted status", "status fix-available (only-on-the-first" not in body)
|
|
check("workload lines inside the expansion carry state and status",
|
|
"target stale (last success long ago) — status fix-available</li>" in body
|
|
and "containers: " + page.e(", ".join(second["workloads"][0]["containers"]) or "none") + " — target stale" in body)
|
|
_, _, plain = render(query="view=cves&status=accepted")
|
|
check("mutation: a uniformly accepted CVE does not read as mixed", "mixed:" not in plain and "CVE-2026-1003" in plain)
|
|
_, _, body = render(mixed, "view=workloads")
|
|
check("the workloads images column labels each image's state", page.e(second["id"]) + " — target stale (last success long ago)" in body)
|
|
check("the workloads expansion labels each finding's status, image and state",
|
|
"status fix-available — " + page.e(second["id"]) + " — target stale (last success long ago)</li>" in body)
|
|
check("the workloads expansion keeps an acceptance on its own line",
|
|
"status accepted (only-on-the-first, until 2026-12-01) — " + page.e(first["id"]) + " — target scanned</li>" in body)
|
|
|
|
big = copy.deepcopy(REPORT)
|
|
big["findings"] = [dict(REPORT["findings"][0], id="CVE-2026-" + format(i, "05d")) for i in range(250)]
|
|
_, _, body = render(big, "view=cves&size=50&page=2")
|
|
check("pagination states the total", "of 250 CVEs (page 2 of 5)" in body)
|
|
check("pagination shows only its page", "CVE-2026-00049" not in body and "CVE-2026-00050" in body and "CVE-2026-00100" not in body)
|
|
check("every page's links include next and previous", ">next<" in body and ">previous<" in body)
|
|
|
|
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
|
|
sys.exit(1 if FAILED else 0)
|