#!/usr/bin/env python3 """L1: the bot ticks only qualifying approve rows, and a whole run behaves (R-VULN-05, R-VULN-07). fixtures/renovate-dashboard.md follows issue #91's real format (`## Open` rebase rows, Detected Dependencies) plus a constructed Pending Approval section, because the live dashboard has none. Runs the real functions and the real run() over a fake Gitea and a fake scanner. What this does NOT prove: that Renovate honours a tick; that the live dashboard keeps this format across Renovate upgrades (a format change yields no tick rather than a wrong one); that Gitea's API matches the fake; or anything about the runner's network path to the scanner. """ import copy import importlib.util import json import sys import urllib.parse from datetime import datetime, timezone from pathlib import Path sys.dont_write_bytecode = True HERE = Path(__file__).resolve().parent ROOT = HERE.parents[3] spec = importlib.util.spec_from_file_location("cve_triage", ROOT / ".gitea/workflows/scripts/cve_triage.py") bot = importlib.util.module_from_spec(spec) sys.modules["cve_triage"] = bot spec.loader.exec_module(bot) RAW = (HERE / "fixtures/report.json").read_bytes() FIXTURE = json.loads(RAW) BODY = (HERE / "fixtures/renovate-dashboard.md").read_text() OPEN_PRS = json.loads((HERE / "fixtures/open-prs.json").read_text()) NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc) GITEA = "https://gitea.example.test" REPO = "o/r" VULN = "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081" FALLBACK = "http://10.43.0.99:8081" EXPECTED = { "renovate/nginx-2.x", "renovate/ghcr.io-example-app-2.x", "renovate/example-group", } failures = [] def check(name, condition): print(f"{'ok ' if condition else 'FAIL'} {name}") if not condition: failures.append(name) def fails(function): try: function() except AssertionError: return True return False def raises(function): try: function() except bot.BotError: return True return False def planned(report_doc, body): report = bot.parse_report(json.dumps(report_doc).encode()) rows = bot.parse_rows(body) return bot.select_ticks(rows, bot.qualifying_images(report)) def assert_ticks(report_doc, body, expected): """The tick plan is exactly `expected`, and writing it changes exactly those markers.""" ticks = planned(report_doc, body) assert {row.branch for row in ticks} == expected, "tick plan differs" after = bot.apply_ticks(body, ticks) want = body for branch in expected: line = f" - [ ] " assert want.count(line) == 1, "expected row not found exactly once" want = want.replace(line, line.replace("[ ]", "[x]", 1)) assert after == want, "output differs from a marker-only edit" check("exactly the three qualifying approve rows are ticked, and nothing else changes", not fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED))) after = bot.apply_ticks(BODY, planned(FIXTURE, BODY)) changed = [(a, b) for a, b in zip(BODY.split("\n"), after.split("\n")) if a != b] check("three lines differ, same line count, same length", len(changed) == 3 and len(BODY) == len(after)) for label, needle in { "rebase row of a qualifying image": "rebase-branch=renovate/ghcr.io-example-has-pr", "approve row for a non-matching package": "approve-branch=renovate/ghcr.io-other-unrelated", "approve row matching only by substring": "approve-branch=renovate/ghcr.io-foo-bar-extras", "approve row for a HIGH-only image": "approve-branch=renovate/ghcr.io-example-high-only", "approve row for an accepted-CRITICAL image": "approve-branch=renovate/ghcr.io-example-accepted-only", "approve row for a CRITICAL with no fix": "approve-branch=renovate/ghcr.io-example-nofix", "approve row with no container package": "approve-branch=renovate/mychart", "approvePr row of a qualifying image": "approvePr-branch=renovate/ghcr.io-example-app-automerge", "unschedule row of a qualifying image": "unschedule-branch=renovate/ghcr.io-example-pending-sched", "decoy approve row below Detected Dependencies": "approve-branch=renovate/decoy", }.items(): original = next(line for line in BODY.split("\n") if needle in line) check(f"untouched: {label}", original in after.split("\n")) check("untouched: the approve-all row", "- [ ] " in after) check("untouched: the already ticked row keeps its mark", " - [x] " in after) # Non-vacuity: each mutation must make the exact assertion above fail. def mutated_finding(image, **fields): doc = copy.deepcopy(FIXTURE) target = next(t for t in doc["targets"] if t["image"] == image) finding = next(f for f in doc["findings"] if f["targetId"] == target["id"]) finding.update(fields) return doc for label, doc in { "HIGH-only image raised to CRITICAL": mutated_finding("ghcr.io/example/high-only", severity="CRITICAL"), "accepted CRITICAL un-accepted": mutated_finding("ghcr.io/example/accepted-only", status="fix-available"), "qualifying finding accepted": mutated_finding("ghcr.io/example/app", status="accepted"), "CRITICAL gains a fix": mutated_finding("ghcr.io/example/nofix", status="fix-available", fixedVersion="2"), }.items(): check(f"non-vacuity: mutated fixture fails ({label})", fails(lambda: assert_ticks(doc, BODY, EXPECTED))) mutated_body = BODY.replace("rebase-branch=renovate/ghcr.io-example-has-pr", "approve-branch=renovate/ghcr.io-example-has-pr") check("non-vacuity: a rebase row rewritten as approve is then ticked", fails(lambda: assert_ticks(FIXTURE, mutated_body, EXPECTED))) real_match = bot.row_matches bot.row_matches = lambda row, qualifying: any(q in p for p in row.packages for q in qualifying) check("non-vacuity: a substring matcher fails the assertion", fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED))) bot.row_matches = real_match for raw, want in { "docker.io/library/nginx": "nginx", "library/nginx": "nginx", "nginx:1.27": "nginx", "Docker.IO/Pihole/Pihole": "pihole/pihole", "ghcr.io/library/x": "ghcr.io/library/x", "registry.example:5000/a/b:v1@sha256:" + "0" * 64: "registry.example:5000/a/b", "bad image": "", "": "", }.items(): check(f"normalise_image({raw[:40]!r})", bot.normalise_image(raw) == want) ticks = planned(FIXTURE, BODY) moved = BODY.replace(" - [ ] ", " - [ ] ") check("apply_ticks refuses a row that stopped being approve", raises(lambda: bot.apply_ticks(moved, ticks))) crlf = BODY.replace("\n", "\r\n") crlf_after = bot.apply_ticks(crlf, planned(FIXTURE, crlf)) check("CRLF body keeps every line ending", crlf_after.count("\r\n") == crlf.count("\r\n") and crlf_after.count("[x]") == 4) class FakeWorld: """A fake Gitea and scanner reached through the transport interface. Records every write.""" def __init__(self, renovate_body=BODY, report=RAW): self.report = report self.labels = [] self.issues = {91: {"number": 91, "title": "Dependency Dashboard", "state": "open", "labels": [], "body": renovate_body, "pull_request": None}} self.next_issue = 200 self.writes = [] self.triage = None self.fail = None self.dns_down = set() self.seen_hosts = [] def __call__(self, method, url, headers, body, max_bytes): parts = urllib.parse.urlsplit(url) self.seen_hosts.append(parts.netloc) if parts.netloc in self.dns_down: raise bot.DnsFailure() if self.fail and self.fail[0] == method and self.fail[1] in parts.path: return 500, b"" payload = json.loads(body) if body else None if parts.netloc != "gitea.example.test": if method == "GET" and parts.path == "/report.json": return 200, self.report if method == "POST" and parts.path == "/triage": self.writes.append(("POST", "triage")) self.triage = payload return 204, b"" return 404, b"" path = parts.path.removeprefix(f"/api/v1/repos/{REPO}") query = dict(urllib.parse.parse_qsl(parts.query)) page = int(query.get("page", "1")) if method != "GET": self.writes.append((method, path)) if method == "GET" and path == "/labels": return 200, json.dumps(self.labels if page == 1 else []).encode() if method == "POST" and path == "/labels": label = {"id": 900 + len(self.labels), "name": payload["name"]} self.labels.append(label) return 201, json.dumps(label).encode() if method == "GET" and path == "/pulls": return 200, json.dumps(OPEN_PRS if page == 1 else []).encode() if method == "GET" and path == "/issues": items = list(self.issues.values()) if "labels" in query: items = [i for i in items if query["labels"] in {x["name"] for x in i["labels"]}] if "q" in query: items = [i for i in items if query["q"] in i["title"]] return 200, json.dumps(items if page == 1 else []).encode() if method == "POST" and path == "/issues": number = self.next_issue self.next_issue += 1 names = [x for x in self.labels if x["id"] in payload["labels"]] self.issues[number] = {"number": number, "title": payload["title"], "state": "open", "labels": names, "body": payload["body"], "pull_request": None} return 201, json.dumps(self.issues[number]).encode() if path.startswith("/issues/") and path.endswith("/labels") and method == "POST": number = int(path.split("/")[2]) self.issues[number]["labels"] += [x for x in self.labels if x["id"] in payload["labels"]] return 200, b"[]" if path.startswith("/issues/"): number = int(path.split("/")[2]) if method == "GET": return 200, json.dumps(self.issues[number]).encode() if method == "PATCH": self.issues[number].update(payload) return 201, json.dumps(self.issues[number]).encode() return 404, b"" def execute(world, dry_run=False, fallback=FALLBACK): cfg = {"dry_run": dry_run, "page_url": "https://cve.example.test", "renovate_issue": 91} gitea = bot.GiteaClient(GITEA, REPO, "fixture-token", world) scanner = bot.ScannerClient(VULN, fallback, world) lines = [] summary = bot.run(cfg, gitea, scanner, NOW, lines.append) return summary, "\n".join(lines) def dashboards(world): return [i for i in world.issues.values() if i["title"] == bot.DASHBOARD_TITLE] # Dry run: reads everything, writes nothing, reports the plan as counts. world = FakeWorld() summary, text = execute(world, dry_run=True) check("dry run performs no writes", world.writes == [] and world.triage is None and not dashboards(world)) check("dry run plans the three ticks and the issue", summary["ticks"] == 3 and summary["dashboard"]["issue_created"] == 1) check("dry run leaves the dashboard body untouched", world.issues[91]["body"] == BODY) NAMES = {"CVE-2026", "fixture-pkg", "fixture-ns-", "fixture-workload", "nginx", "ghcr.io"} def leaks(text_value): return [name for name in NAMES if name.lower() in text_value.lower()] check("dry-run summary is counts only", not leaks(text)) # Live run. world = FakeWorld() summary, text = execute(world) board = dashboards(world) check("live run creates one label and one dashboard issue", len(world.labels) == 1 and len(board) == 1) check("the dashboard issue carries only its own label", [x["name"] for x in board[0]["labels"]] == ["cve-dashboard"]) check("issue body and summary are counts only", not leaks(board[0]["body"]) and not leaks(text)) check("exactly one PATCH to issue #91", world.writes.count(("PATCH", "/issues/91")) == 1) want_body = BODY for branch in EXPECTED: line = f" - [ ] " want_body = want_body.replace(line, line.replace("[ ]", "[x]", 1)) check("the PATCHed body is the marker-only edit", world.issues[91]["body"] == want_body) check("the heartbeat is the last write", world.writes[-1] == ("POST", "triage")) check("heartbeat shape is exact", set(world.triage) == {"heartbeatAt", "remediation"} and world.triage["heartbeatAt"] == "2026-11-09T07:15:00Z") for entry in world.triage["remediation"]: if set(entry) != {"image", "state", "url", "updatedAt"} or entry["state"] not in bot.REMEDIATION_STATES: check("remediation entry shape", False) states = {e["image"]: (e["state"], e["url"]) for e in world.triage["remediation"]} for image, want in { "docker.io/library/nginx": ("approval-requested", None), "ghcr.io/example/app": ("approval-requested", None), "ghcr.io/example/grouped": ("approval-requested", None), "ghcr.io/example/already-ticked": ("approval-requested", None), "ghcr.io/example/high-only": ("approval-requested", None), "ghcr.io/example/has-pr": ("pr-open", f"{GITEA}/{REPO}/pulls/173"), "ghcr.io/example/pr-by-title": ("pr-open", f"{GITEA}/{REPO}/pulls/174"), "ghcr.io/example/tracked-clean": ("no-update-identified", None), "ghcr.io/foo/bar": ("no-update-identified", None), "ghcr.io/example/untracked": ("not-tracked", None), "ghcr.io/example/pending-sched": ("not-tracked", None), }.items(): check(f"remediation for {image}", states.get(image) == want) check("one remediation entry per image", len(states) == len(FIXTURE["targets"])) # A second run updates the same issue. before = len(world.issues) second = copy.deepcopy(FIXTURE) second["targets"][-1]["state"] = "stale" world.report = json.dumps(second).encode() summary, _ = execute(world) check("a second run creates no duplicate", len(dashboards(world)) == 1 and len(world.issues) == before) check("a second run updates the issue", summary["dashboard"]["issue_updated"] == 1 and "| stale | 1 |" in dashboards(world)[0]["body"]) check("a second run has nothing left to tick", summary["ticks"] == 0) # The dashboard issue is found even without its label, and a closed one is reopened. world = FakeWorld() world.labels.append({"id": 900, "name": "cve-dashboard"}) world.issues[150] = {"number": 150, "title": "CVE Dashboard", "state": "closed", "labels": [], "body": "old", "pull_request": None} summary, _ = execute(world) check("an unlabelled closed dashboard is reused, relabelled and reopened", len(dashboards(world)) == 1 and world.issues[150]["state"] == "open" and [x["name"] for x in world.issues[150]["labels"]] == ["cve-dashboard"] and summary["dashboard"]["issue_created"] == 0) world = FakeWorld() world.labels.append({"id": 900, "name": "cve-dashboard"}) for number in (150, 151): world.issues[number] = {"number": number, "title": "CVE Dashboard", "state": "open", "labels": [world.labels[0]], "body": "x", "pull_request": None} check("two dashboard issues fail the run", raises(lambda: execute(world))) check("two dashboard issues write nothing", world.writes == []) # Failures end the run before the heartbeat. world = FakeWorld() world.fail = ("PATCH", "/issues/91") check("a failed tick PATCH fails the run", raises(lambda: execute(world))) check("a failed run posts no heartbeat", world.triage is None) world = FakeWorld(report=b"{not json") check("an unreadable report fails the run", raises(lambda: execute(world))) check("an unreadable report writes nothing", world.writes == []) world = FakeWorld() world.issues[91]["title"] = "Something else" check("issue 91 being something else fails the run", raises(lambda: execute(world))) world = FakeWorld() world.issues[91]["state"] = "closed" check("a closed Renovate dashboard fails the run", raises(lambda: execute(world))) # DNS fallback, and nothing but DNS failure selects it. world = FakeWorld() world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081") summary, _ = execute(world) check("DNS failure uses the fallback for report and heartbeat", summary["fallback_used"] and world.triage is not None) check("the fallback was actually contacted", "10.43.0.99:8081" in world.seen_hosts) world = FakeWorld() world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081") check("DNS failure with no fallback fails the run", raises(lambda: execute(world, fallback=""))) def refused(method, url, headers, body, max_bytes): raise bot.BotError("GET /report.json: ConnectionRefusedError") check("a refused connection fails the run and does not fall back", raises(lambda: bot.ScannerClient(VULN, FALLBACK, refused).report())) for label, env, expected_ok in ( ("missing token", {}, False), ("present token", {"RENOVATE_TOKEN": "x"}, True), ("bad DRY_RUN", {"RENOVATE_TOKEN": "x", "DRY_RUN": "maybe"}, False), ("non-http API URL", {"RENOVATE_TOKEN": "x", "VULN_API_URL": "file:///etc/passwd"}, False), ): try: bot.load_config(env) ok = True except bot.BotError: ok = False check(f"load_config: {label}", ok is expected_ok) print(f"{len(failures)} failure(s)") sys.exit(1 if failures else 0)