Scheduled Gitea Actions bot in the Renovate shape: maintains a counts-only CVE Dashboard issue, derives remediation states from Renovate's dashboard and PRs, ticks approve rows for exact-matched images with an unaccepted CRITICAL with a fix, and posts the heartbeat. Co-Authored-By: Claude Opus 5.5 <[email protected]>
364 lines
17 KiB
Python
364 lines
17 KiB
Python
#!/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" - [ ] <!-- approve-branch={branch} -->"
|
|
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", "- [ ] <!-- approve-all-pending-prs -->" in after)
|
|
check("untouched: the already ticked row keeps its mark",
|
|
" - [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.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(" - [ ] <!-- approve-branch=renovate/nginx-2.x -->", " - [ ] <!-- rebase-branch=renovate/nginx-2.x -->")
|
|
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" - [ ] <!-- approve-branch={branch} -->"
|
|
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)
|