#!/usr/bin/env python3 """CVE triage bot (docs/17 "The triage bot", ADR-0027; R-VULN-05, R-VULN-07). Runs in .gitea/workflows/cve-triage.yaml, once a day, before Renovate. Each run: 1. reads report.json from the scanner's internal API; 2. creates or updates the single `CVE Dashboard` issue (aggregates only); 3. reads Renovate's Dependency Dashboard and open PRs, derives each image's remediation state; 4. ticks `approve-branch` boxes on that dashboard, for packages matching an image with an unaccepted CRITICAL that has a fix; 5. POSTs the heartbeat and the remediation states to the scanner. WHAT IT MUST NEVER DO. Renovate is the only author of version changes (R-UPD-02): this script never edits a version, opens a bump PR, merges anything, or posts `/approve-override`. It never ticks a `rebase-branch` row. The dashboard issue carries no CVE id, package, image or workload, because the repository is readable without authentication, and no label the in-cluster PM acts on (platform/pm-agent/configmap.yaml PM_REQUIRE_LABEL = `agent`). OUTPUT IS COUNTS ONLY. Job logs and the summary are as visible as the issue, so nothing here prints a report string. Every report value is untrusted: strings are validated, and only counts and timestamps that passed RFC 3339 validation ever reach markdown. HOW A JOB CONTAINER REACHES THE SCANNER, AND WHAT IS NOT PROVEN --------------------------------------------------------------- Jobs are containers on a per-job docker bridge network inside the DinD sidecar of the act-runner pod (apps/wave-1/gitea-actions.yaml). The runner config sets no `container.network` and dockerd no `--dns`, so name resolution is docker's embedded resolver forwarding to the resolvers of the dockerd container, which are that pod's: cluster DNS. Egress is masqueraded to the pod IP (measured 2026-10-29, platform/buildkit/networkpolicy.yaml header), so a ClusterIP is routed and policy-matched as the act-runner pod in gitea-actions. images.yaml relies on the same path to reach buildkitd.buildkit.svc.cluster.local, which is indirect evidence only. UNVERIFIED: in-cluster DNS from a DinD job container. UNVERIFIED: ClusterIP reachability from a DinD job container to vuln-scanner:8081. Hence VULN_API_FALLBACK_URL (the Service's pinned ClusterIP), used only when the name fails to resolve. Anything else, including refused and timed-out connections, fails the job: an unreachable API is a red run, never a skip. """ from __future__ import annotations import http.client import json import os import re import socket import sys import urllib.error import urllib.parse import urllib.request from datetime import datetime, timedelta, timezone from typing import Callable, Iterable, NamedTuple DASHBOARD_TITLE = "CVE Dashboard" DASHBOARD_LABEL = "cve-dashboard" DASHBOARD_LABEL_COLOR = "b60205" # Labels the in-cluster PM dispatcher selects work by. Never applied to anything here. FORBIDDEN_LABELS = frozenset({"agent"}) RENOVATE_DASHBOARD_TITLE = "Dependency Dashboard" SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN") FINDING_STATUSES = ("open", "fix-available", "accepted") TARGET_STATES = ("scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered") TARGET_TYPES = ("image", "vm", "node") RUN_STATUSES = ("complete", "degraded") REMEDIATION_STATES = ("pr-open", "approval-requested", "no-update-identified", "not-tracked") MAX_REPORT_BYTES = 128 * 1024 * 1024 MAX_API_BYTES = 8 * 1024 * 1024 MAX_FIELD_LEN = 4096 MAX_IMAGE_LEN = 512 MAX_ISSUE_BODY = 2 * 1024 * 1024 MAX_DASHBOARD_ROWS = 2000 MAX_DETECTED_DEPS = 20000 MAX_REMEDIATION = 5000 MAX_PAGES = 200 PAGE_SIZE = 50 REPORT_STALE_HOURS = 26 DATABASE_STALE_HOURS = 48 HTTP_TIMEOUT = 60 RFC3339_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})") IMAGE_RE = re.compile(r"[a-z0-9]([a-z0-9._:/-]*[a-z0-9])?") HTTP_URL_RE = re.compile(r"https?://[A-Za-z0-9.-]+(:\d{1,5})?(/[A-Za-z0-9._~%/-]*)?") REPO_RE = re.compile(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+") # Renovate's own row shape (lib/workers/repository/dependency-dashboard.ts, getListItem). The kind # is captured whole so `approvePr-branch` can never read as `approve-branch`. ROW_RE = re.compile(r" - \[(?P[ xX])\] (?P.*)") LINKED_TITLE_RE = re.compile(r"\[(?P.*)\]\((?P<target>[^()\s]*)\)(?P<tail>.*)") PR_LINK_RE = re.compile(r"(?:^|/)pulls?/(\d{1,9})$") PACKAGE_LIST_RE = re.compile(r"\s\(((?:`[^`\s]+`(?:, )?)+)\)\s*$") TITLE_PACKAGE_RE = re.compile(r"(?:^|\s)(?:Update|Pin)\s+(\S+)\s+Docker\s+(?:tag|digest)\b") SUMMARY_RE = re.compile(r"<details><summary>([^<]*?)(?: \(\d+\))?</summary>") DETECTED_RE = re.compile(r" - `(?P<name>[^\s`]+)(?: [^`]*)?`(?P<updates>\s*→ \[Updates:.*\])?\s*") # Detected-dependency managers whose names are charts or actions, never container images. NON_IMAGE_MANAGERS = frozenset({"flux", "helmv3", "github-actions"}) class BotError(Exception): """A failure that ends the run. The message must be safe to print: no report text, no secret.""" class DnsFailure(Exception): """The hostname did not resolve. The only condition that selects the fallback address.""" # ---------------------------------------------------------------- pure: normalisation + report def normalise_image(value: object) -> str: """Canonical form for exact comparison, or "" when the value is not an image reference. Lowercase; drop digest and tag; drop the Docker Hub registry and the `library/` namespace. `library/` is only Hub's implicit namespace, so ghcr.io/library/x keeps it. Comparison of the result is always string equality, never a substring test. """ if not isinstance(value, str) or not value or len(value) > MAX_IMAGE_LEN: return "" name = value.strip().lower().split("@", 1)[0] head, sep, last = name.rpartition("/") name = f"{head}{sep}{last.split(':', 1)[0]}" for hub in ("docker.io/", "index.docker.io/", "registry-1.docker.io/"): if name.startswith(hub): name = name[len(hub):] break if name.startswith("library/"): name = name[len("library/"):] return name if IMAGE_RE.fullmatch(name) else "" def parse_timestamp(value: object, field: str, nullable: bool = False) -> datetime | None: if value is None and nullable: return None if not isinstance(value, str) or not RFC3339_RE.fullmatch(value): raise BotError(f"report: {field} is not an RFC 3339 timestamp") text = re.sub(r"\.\d+", "", value).replace("Z", "+00:00") try: return datetime.fromisoformat(text) except ValueError as exc: raise BotError(f"report: {field} is not a valid timestamp") from exc def _object(value: object, field: str) -> dict: if not isinstance(value, dict): raise BotError(f"report: {field} is not an object") return value def _array(value: object, field: str) -> list: if not isinstance(value, list): raise BotError(f"report: {field} is not an array") return value def _string(value: object, field: str, nullable: bool = False) -> str | None: if value is None and nullable: return None if not isinstance(value, str) or len(value) > MAX_FIELD_LEN: raise BotError(f"report: {field} is not a bounded string") return value def _member(value: object, allowed: Iterable[str], field: str) -> str: if not isinstance(value, str) or value not in allowed: raise BotError(f"report: {field} has a value outside its enumeration") return value def parse_report(raw: bytes) -> dict: """Decode and validate report.json into the only shape the rest of this file reads.""" try: doc = json.loads(raw) except (ValueError, RecursionError) as exc: raise BotError("report: body is not valid JSON") from exc doc = _object(doc, "document") if doc.get("schemaVersion") != 1 or isinstance(doc.get("schemaVersion"), bool): raise BotError("report: unsupported schemaVersion") parse_timestamp(doc.get("generatedAt"), "generatedAt") database = _object(doc.get("database"), "database") run = _object(doc.get("run"), "run") parse_timestamp(database.get("updatedAt"), "database.updatedAt", nullable=True) parse_timestamp(run.get("startedAt"), "run.startedAt", nullable=True) parse_timestamp(run.get("finishedAt"), "run.finishedAt", nullable=True) if run.get("status") is not None: _member(run.get("status"), RUN_STATUSES, "run.status") targets = [] known_ids = {} for entry in _array(doc.get("targets"), "targets"): entry = _object(entry, "target") target_id = _string(entry.get("id"), "target.id") kind = _member(entry.get("type"), TARGET_TYPES, "target.type") state = _member(entry.get("state"), TARGET_STATES, "target.state") image = _string(entry.get("image"), "target.image", nullable=True) if kind == "image" and not normalise_image(image): raise BotError("report: an image target has an invalid image reference") known_ids[target_id] = image if kind == "image" else None targets.append({"id": target_id, "type": kind, "state": state, "image": image}) findings = [] for entry in _array(doc.get("findings"), "findings"): entry = _object(entry, "finding") target_id = _string(entry.get("targetId"), "finding.targetId") if target_id not in known_ids: raise BotError("report: a finding references an unknown target") findings.append({ "targetId": target_id, "image": known_ids[target_id], "severity": _member(entry.get("severity"), SEVERITIES, "finding.severity"), "status": _member(entry.get("status"), FINDING_STATUSES, "finding.status"), "fixedVersion": _string(entry.get("fixedVersion"), "finding.fixedVersion", nullable=True), }) return { "generatedAt": doc["generatedAt"], "database": {"updatedAt": database.get("updatedAt")}, "run": {"finishedAt": run.get("finishedAt"), "status": run.get("status")}, "targets": targets, "findings": findings, } def qualifies(finding: dict) -> bool: """An unaccepted CRITICAL with a fix: the only thing that may cause a tick.""" return ( finding["severity"] == "CRITICAL" and finding["status"] == "fix-available" and bool(finding["fixedVersion"]) ) def qualifying_images(report: dict) -> set[str]: return { norm for finding in report["findings"] if qualifies(finding) and (norm := normalise_image(finding["image"])) } def aggregate(report: dict) -> dict: severity = {sev: dict.fromkeys(FINDING_STATUSES, 0) for sev in SEVERITIES} for finding in report["findings"]: severity[finding["severity"]][finding["status"]] += 1 coverage = dict.fromkeys(TARGET_STATES, 0) for target in report["targets"]: coverage[target["state"]] += 1 return { "severity": severity, "coverage": coverage, "targets_total": len(report["targets"]), "critical_with_fix": sum(1 for f in report["findings"] if qualifies(f)), "generatedAt": report["generatedAt"], "databaseUpdatedAt": report["database"]["updatedAt"], "runFinishedAt": report["run"]["finishedAt"], "runStatus": report["run"]["status"], } def render_dashboard(agg: dict, page_url: str, now: datetime) -> str: """The CVE Dashboard issue body: a fixed template over counts and validated timestamps.""" if not HTTP_URL_RE.fullmatch(page_url): raise BotError("config: VULN_PAGE_URL is not a plain http(s) URL") warnings = [] generated = parse_timestamp(agg["generatedAt"], "generatedAt") if now - generated > timedelta(hours=REPORT_STALE_HOURS): warnings.append(f"The report is older than {REPORT_STALE_HOURS} hours.") updated = parse_timestamp(agg["databaseUpdatedAt"], "database.updatedAt", nullable=True) if updated is None or now - updated > timedelta(hours=DATABASE_STALE_HOURS): warnings.append(f"The vulnerability database is older than {DATABASE_STALE_HOURS} hours or unknown.") if agg["runStatus"] != "complete": warnings.append("The latest scan run did not complete cleanly.") lines = [ "Maintained by the CVE triage bot, once a day. **Counts only**: this repository is readable", "without authentication, so which CVE lands where is only on the", f"[CVE page]({page_url}) (SSO, platform-admins).", "", ] lines += [f"> **WARNING:** {text}" for text in warnings] if warnings: lines.append("") lines += [ "## Freshness", "", "| | |", "|---|---|", f"| Report generated | {agg['generatedAt']} |", f"| Database updated | {agg['databaseUpdatedAt'] or 'unknown'} |", f"| Last run finished | {agg['runFinishedAt'] or 'none yet'} |", f"| Last run status | {agg['runStatus'] or 'none yet'} |", f"| Dashboard rendered | {now.strftime('%Y-%m-%dT%H:%M:%SZ')} |", "", "## Findings by severity", "", "| Severity | Open | Fix available | Accepted |", "|---|---:|---:|---:|", ] for sev in SEVERITIES: row = agg["severity"][sev] lines.append(f"| {sev} | {row['open']} | {row['fix-available']} | {row['accepted']} |") lines += [ "", f"Unaccepted CRITICAL with a fix: **{agg['critical_with_fix']}**", "", "## Coverage", "", f"{agg['targets_total']} targets. A count in any state other than `scanned` is something" " that was not freshly examined, and is not a clean result.", "", "| State | Targets |", "|---|---:|", ] lines += [f"| {state} | {agg['coverage'][state]} |" for state in TARGET_STATES] return "\n".join(lines) + "\n" # ---------------------------------------------------------------- pure: Renovate's dashboard class Row(NamedTuple): kind: str mark: str branch: str packages: tuple[str, ...] pr_number: int | None offset: int # offset in the body of the first character of the line def _title_package(title: str) -> str: match = TITLE_PACKAGE_RE.search(title) return normalise_image(match.group(1)) if match else "" def _row_details(rest: str) -> tuple[tuple[str, ...], int | None]: """Packages and PR number of a row. No recognisable package means no packages, so no tick.""" rest = rest.rstrip("\r") number = None title = rest tail = rest linked = LINKED_TITLE_RE.fullmatch(rest) if linked: title, tail = linked.group("title"), linked.group("tail") link = PR_LINK_RE.search(linked.group("target")) number = int(link.group(1)) if link else None listed = PACKAGE_LIST_RE.search(tail) if listed: names = re.findall(r"`([^`]+)`", listed.group(1)) packages = tuple(n for n in map(normalise_image, names) if n) else: package = _title_package(title) packages = (package,) if package else () return packages, number def _is_detected_heading(line: str) -> bool: return line.startswith("## ") and line[3:].strip().lower().startswith("detected dependencies") def parse_rows(body: str) -> list[Row]: """Checkbox rows above `## Detected Dependencies`. Only a whole-line match counts.""" if len(body) > MAX_ISSUE_BODY: raise BotError("renovate dashboard: body exceeds its bound") rows: list[Row] = [] offset = 0 for line in body.split("\n"): if _is_detected_heading(line): break match = ROW_RE.fullmatch(line.rstrip("\r")) if match: packages, number = _row_details(match.group("rest")) rows.append(Row(match.group("kind"), match.group("mark"), match.group("branch"), packages, number, offset)) if len(rows) > MAX_DASHBOARD_ROWS: raise BotError("renovate dashboard: too many rows") offset += len(line) + 1 return rows def parse_detected(body: str) -> dict[str, bool]: """{normalised depName: has a pending update} from `## Detected Dependencies`.""" tracked: dict[str, bool] = {} lines = body.split("\n") in_section = False manager = "" count = 0 for index, raw in enumerate(lines): line = raw.rstrip("\r") if line.startswith("## "): in_section = _is_detected_heading(line) continue if not in_section: continue summary = SUMMARY_RE.fullmatch(line) if summary: # A manager heading is the summary directly followed by <blockquote>; a file heading # is not. if index + 1 < len(lines) and lines[index + 1].strip() == "<blockquote>": manager = summary.group(1) continue match = DETECTED_RE.fullmatch(line) if not match or manager in NON_IMAGE_MANAGERS: continue count += 1 if count > MAX_DETECTED_DEPS: raise BotError("renovate dashboard: too many detected dependencies") norm = normalise_image(match.group("name")) if norm: tracked[norm] = tracked.get(norm, False) or bool(match.group("updates")) return tracked def row_matches(row: Row, qualifying: set[str]) -> bool: return any(package in qualifying for package in row.packages) def select_ticks(rows: list[Row], qualifying: set[str]) -> list[Row]: return [ row for row in rows if row.kind == "approve" and row.mark == " " and row_matches(row, qualifying) ] def apply_ticks(body: str, ticks: list[Row]) -> str: """Replace only the `[ ]` of each row, re-verifying the row where it stands. Every byte other than the three of each marker is carried over unchanged. """ pieces = [] cursor = 0 for row in sorted(ticks, key=lambda r: r.offset): expected = f" - [ ] <!-- approve-branch={row.branch} -->" if row.kind != "approve" or not body.startswith(expected, row.offset): raise BotError("renovate dashboard: a row changed between parse and write") marker = row.offset + len(" - ") pieces += [body[cursor:marker], "[x]"] cursor = marker + len("[ ]") pieces.append(body[cursor:]) return "".join(pieces) def derive_remediation( images: Iterable[str], rows: list[Row], open_prs: list[dict], tracked: dict[str, bool], pr_url: Callable[[int], str], now_text: str, ) -> list[dict]: """One entry per report image. pr-open: an open-PR row or an open Renovate PR for that package. approval-requested: an approve row. no-update-identified: Renovate tracks it and lists no update. not-tracked: everything else, which deliberately includes "Renovate knows an update but no PR or approval row exists" (awaiting schedule, rate limited): saying no update was identified would be false, and the enumeration has no closer value, so it lands on the state that draws attention. """ by_branch = {pr["branch"]: pr["number"] for pr in open_prs} result = [] for image in sorted(set(images)): norm = normalise_image(image) state = "not-tracked" url = None rebase = next((r for r in rows if r.kind == "rebase" and norm in r.packages), None) pr = next((p for p in open_prs if p["package"] == norm), None) if rebase is not None or pr is not None: state = "pr-open" number = None if rebase is not None: number = rebase.pr_number or by_branch.get(rebase.branch) if number is None and pr is not None: number = pr["number"] url = pr_url(number) if number is not None else None elif any(r.kind == "approve" and norm in r.packages for r in rows): state = "approval-requested" elif norm in tracked and not tracked[norm]: state = "no-update-identified" result.append({"image": image, "state": state, "url": url, "updatedAt": now_text}) return result def count_states(remediation: list[dict]) -> dict[str, int]: counts = dict.fromkeys(REMEDIATION_STATES, 0) for entry in remediation: counts[entry["state"]] += 1 return counts # ---------------------------------------------------------------- I/O class _NoRedirect(urllib.request.HTTPRedirectHandler): """A redirect would carry the Authorization header to wherever it points.""" def redirect_request(self, *args, **kwargs): return None class UrllibTransport: """(method, url, headers, body, max_bytes) -> (status, bytes); never uses a proxy.""" def __init__(self) -> None: self._opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect()) def __call__(self, method: str, url: str, headers: dict, body: bytes | None, max_bytes: int): request = urllib.request.Request(url, data=body, method=method, headers=headers) try: with self._opener.open(request, timeout=HTTP_TIMEOUT) as response: data = response.read(max_bytes + 1) return response.status, data except urllib.error.HTTPError as exc: return exc.code, b"" except urllib.error.URLError as exc: if isinstance(exc.reason, socket.gaierror): raise DnsFailure() from exc raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc.reason).__name__}") from exc except (OSError, http.client.HTTPException) as exc: raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc).__name__}") from exc class GiteaClient: def __init__(self, base_url: str, repo: str, token: str, transport) -> None: self._api = f"{base_url.rstrip('/')}/api/v1/repos/{repo}" self._web = f"{base_url.rstrip('/')}/{repo}" self._token = token self._transport = transport def _call(self, method: str, path: str, query: dict | None = None, payload=None, expect=(200,)): url = self._api + path + (f"?{urllib.parse.urlencode(query)}" if query else "") headers = {"Authorization": f"token {self._token}", "Accept": "application/json"} data = None if payload is not None: data = json.dumps(payload).encode() headers["Content-Type"] = "application/json" try: status, raw = self._transport(method, url, headers, data, MAX_API_BYTES) except DnsFailure as exc: raise BotError(f"gitea: {method} {path}: name resolution failed") from exc if status not in expect: raise BotError(f"gitea: {method} {path} -> HTTP {status}") if len(raw) > MAX_API_BYTES: raise BotError(f"gitea: {method} {path}: response exceeds its bound") if not raw: return None try: return json.loads(raw) except ValueError as exc: raise BotError(f"gitea: {method} {path}: response is not JSON") from exc def _list(self, path: str, query: dict | None = None) -> list: items: list = [] for page in range(1, MAX_PAGES + 1): batch = self._call("GET", path, {**(query or {}), "limit": PAGE_SIZE, "page": page}) if not isinstance(batch, list): raise BotError(f"gitea: GET {path}: expected a list") items += batch if len(batch) < PAGE_SIZE: return items raise BotError(f"gitea: GET {path}: more than {MAX_PAGES} pages") def pr_url(self, number: int) -> str: return f"{self._web}/pulls/{int(number)}" def labels(self) -> list: return self._list("/labels") def create_label(self, name: str, color: str) -> dict: return self._call("POST", "/labels", payload={"name": name, "color": color}, expect=(201,)) def issues_with_label(self, label: str) -> list: return self._list("/issues", {"state": "all", "type": "issues", "labels": label}) def issues_matching(self, text: str) -> list: return self._list("/issues", {"state": "all", "type": "issues", "q": text}) def create_issue(self, title: str, body: str, label_id: int) -> dict: return self._call("POST", "/issues", payload={"title": title, "body": body, "labels": [label_id]}, expect=(201,)) def update_issue(self, number: int, fields: dict) -> None: self._call("PATCH", f"/issues/{int(number)}", payload=fields, expect=(200, 201)) def add_issue_label(self, number: int, label_id: int) -> None: self._call("POST", f"/issues/{int(number)}/labels", payload={"labels": [label_id]}, expect=(200,)) def get_issue(self, number: int) -> dict: issue = self._call("GET", f"/issues/{int(number)}") if not isinstance(issue, dict): raise BotError("gitea: issue response is not an object") return issue def open_renovate_prs(self) -> list[dict]: result = [] for pr in self._list("/pulls", {"state": "open"}): head = pr.get("head") if isinstance(pr, dict) else None ref = head.get("ref") if isinstance(head, dict) else None number = pr.get("number") if isinstance(pr, dict) else None title = pr.get("title") if isinstance(pr, dict) else None if not isinstance(ref, str) or not isinstance(number, int) or isinstance(number, bool): raise BotError("gitea: a pull request has an unexpected shape") if ref.startswith("renovate/"): package = _title_package(title) if isinstance(title, str) else "" result.append({"number": number, "branch": ref, "package": package}) return result class ScannerClient: """The scanner's internal API. Falls back to the pinned ClusterIP on a DNS failure only.""" def __init__(self, primary: str, fallback: str, transport) -> None: self._bases = [base.rstrip("/") for base in (primary, fallback) if base] self._transport = transport self.used_fallback = False def _call(self, method: str, path: str, body: bytes | None, headers: dict, max_bytes: int): last = len(self._bases) - 1 for index, base in enumerate(self._bases): try: result = self._transport(method, base + path, headers, body, max_bytes) except DnsFailure as exc: if index == last: raise BotError(f"scanner: {method} {path}: name resolution failed") from exc continue if index: self.used_fallback = True self._bases = self._bases[index:] return result raise BotError("scanner: no address configured") def report(self) -> dict: status, raw = self._call("GET", "/report.json", None, {"Accept": "application/json"}, MAX_REPORT_BYTES) if status != 200: raise BotError(f"scanner: GET /report.json -> HTTP {status}") if len(raw) > MAX_REPORT_BYTES: raise BotError("scanner: report exceeds its bound") return parse_report(raw) def triage(self, heartbeat: str, remediation: list[dict]) -> None: if len(remediation) > MAX_REMEDIATION: raise BotError("scanner: more remediation entries than POST /triage accepts") body = json.dumps({"heartbeatAt": heartbeat, "remediation": remediation}).encode() status, _ = self._call("POST", "/triage", body, {"Content-Type": "application/json"}, 4096) if status != 204: raise BotError(f"scanner: POST /triage -> HTTP {status}") # ---------------------------------------------------------------- orchestration def _label_names(issue: dict) -> set: return {label.get("name") for label in (issue.get("labels") or []) if isinstance(label, dict)} def find_dashboard_issues(gitea: GiteaClient) -> list[dict]: """Every issue that is the dashboard: by label, and by exact title in case a label was removed.""" found: dict[int, dict] = {} for issue in gitea.issues_with_label(DASHBOARD_LABEL) + gitea.issues_matching(DASHBOARD_TITLE): if ( isinstance(issue, dict) and issue.get("title") == DASHBOARD_TITLE and not issue.get("pull_request") and isinstance(issue.get("number"), int) ): found[issue["number"]] = issue return list(found.values()) def sync_dashboard_issue(gitea: GiteaClient, body: str, dry_run: bool) -> dict: """Create or update the one dashboard issue. Returns counts of what it did or would do.""" if DASHBOARD_LABEL in FORBIDDEN_LABELS: raise BotError("config: the dashboard label is one the PM agent acts on") plan = {"label_created": 0, "issue_created": 0, "issue_updated": 0, "issue_reopened": 0} existing = find_dashboard_issues(gitea) if len(existing) > 1: # Closing an issue on a guess is worse than a red run naming the numbers to look at. numbers = sorted(issue["number"] for issue in existing) raise BotError(f"dashboard: {len(numbers)} issues titled {DASHBOARD_TITLE!r}: {numbers}") label = next((x for x in gitea.labels() if isinstance(x, dict) and x.get("name") == DASHBOARD_LABEL), None) if label is None: plan["label_created"] = 1 if not dry_run: label = gitea.create_label(DASHBOARD_LABEL, DASHBOARD_LABEL_COLOR) if not existing: plan["issue_created"] = 1 if not dry_run: created = gitea.create_issue(DASHBOARD_TITLE, body, label["id"]) if not isinstance(created, dict) or not isinstance(created.get("number"), int): raise BotError("gitea: issue creation returned an unexpected shape") return plan issue = existing[0] fields: dict = {} if issue.get("body") != body: fields["body"] = body if issue.get("state") != "open": fields["state"] = "open" plan["issue_reopened"] = 1 if fields: plan["issue_updated"] = 1 if not dry_run: if DASHBOARD_LABEL not in _label_names(issue): gitea.add_issue_label(issue["number"], label["id"]) if fields: gitea.update_issue(issue["number"], fields) return plan def _renovate_issue(gitea: GiteaClient, number: int) -> dict: issue = gitea.get_issue(number) body = issue.get("body") if issue.get("title") != RENOVATE_DASHBOARD_TITLE or issue.get("state") != "open" or not isinstance(body, str): raise BotError(f"renovate dashboard: issue {number} is not the open Dependency Dashboard") return issue def report_images(report: dict) -> list[str]: return [t["image"] for t in report["targets"] if t["type"] == "image"] def run(cfg: dict, gitea: GiteaClient, scanner: ScannerClient, now: datetime, emit: Callable[[str], None]) -> dict: """One triage pass. Any failure raises, and the heartbeat is the last thing it writes.""" dry_run = cfg["dry_run"] report = scanner.report() agg = aggregate(report) now_text = now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") summary: dict = {"dry_run": dry_run, "targets": agg["targets_total"], "critical_with_fix": agg["critical_with_fix"]} summary["dashboard"] = sync_dashboard_issue(gitea, render_dashboard(agg, cfg["page_url"], now), dry_run) number = cfg["renovate_issue"] body = _renovate_issue(gitea, number)["body"] remediation = derive_remediation( report_images(report), parse_rows(body), gitea.open_renovate_prs(), parse_detected(body), gitea.pr_url, now_text, ) summary["remediation"] = count_states(remediation) # Re-read immediately before writing: Renovate rewrites this body, and a tick must apply to # the body as it is now. fresh = _renovate_issue(gitea, number)["body"] ticks = select_ticks(parse_rows(fresh), qualifying_images(report)) summary["ticks"] = len(ticks) if ticks and not dry_run: gitea.update_issue(number, {"body": apply_ticks(fresh, ticks)}) if not dry_run: scanner.triage(now_text, remediation) summary["heartbeat"] = 0 if dry_run else 1 summary["fallback_used"] = scanner.used_fallback emit(render_summary(summary)) return summary def render_summary(summary: dict) -> str: verb = "would be" if summary["dry_run"] else "were" dash = summary["dashboard"] states = ", ".join(f"{name} {count}" for name, count in summary["remediation"].items()) return "\n".join([ f"### CVE triage ({'DRY RUN, no writes' if summary['dry_run'] else 'live'})", "", f"- targets in report: {summary['targets']}", f"- unaccepted CRITICAL with a fix: {summary['critical_with_fix']}", f"- dashboard label created: {dash['label_created']}; issue created: {dash['issue_created']};" f" updated: {dash['issue_updated']}; reopened: {dash['issue_reopened']} ({verb} applied)", f"- remediation states: {states}", f"- approval boxes that {verb} ticked: {summary['ticks']}", f"- heartbeat posted: {summary['heartbeat']}", f"- scanner reached through the ClusterIP fallback: {summary['fallback_used']}", "", ]) def load_config(env: dict) -> dict: token = env.get("RENOVATE_TOKEN", "") if not token: raise BotError("RENOVATE_TOKEN is not set") repo = env.get("GITEA_REPOSITORY", "Home-Network/home-network-k3s-impl") if not REPO_RE.fullmatch(repo): raise BotError("config: GITEA_REPOSITORY is malformed") urls = { "gitea_url": env.get("GITEA_URL", "https://gitea.home.keithjackson.dev"), "vuln_url": env.get("VULN_API_URL", "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081"), "page_url": env.get("VULN_PAGE_URL", "https://cve.home.keithjackson.dev"), } fallback = env.get("VULN_API_FALLBACK_URL", "") for name, value in {**urls, **({"fallback": fallback} if fallback else {})}.items(): if not HTTP_URL_RE.fullmatch(value): raise BotError(f"config: {name} is not a plain http(s) URL") dry_run = env.get("DRY_RUN", "false") if dry_run not in ("true", "false"): raise BotError("config: DRY_RUN must be true or false") issue = env.get("RENOVATE_DASHBOARD_ISSUE", "91") if not issue.isdigit(): raise BotError("config: RENOVATE_DASHBOARD_ISSUE is not a number") return {**urls, "token": token, "repo": repo, "fallback": fallback, "dry_run": dry_run == "true", "renovate_issue": int(issue)} def main() -> int: def emit(text: str) -> None: print(text) path = os.environ.get("GITHUB_STEP_SUMMARY") if path: with open(path, "a", encoding="utf-8") as handle: handle.write(text + "\n") try: cfg = load_config(dict(os.environ)) transport = UrllibTransport() gitea = GiteaClient(cfg["gitea_url"], cfg["repo"], cfg["token"], transport) scanner = ScannerClient(cfg["vuln_url"], cfg["fallback"], transport) run(cfg, gitea, scanner, datetime.now(timezone.utc), emit) except BotError as exc: print(f"::error::CVE triage failed: {exc}", file=sys.stderr) emit(f"### CVE triage FAILED\n\n{exc}\n") return 1 return 0 if __name__ == "__main__": sys.exit(main())