From c3c72c47bbb15afec2c6c3c635f0e5a53602cddf Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 9 Nov 2026 09:39:24 -0600 Subject: [PATCH 1/2] feat(vuln): CVE triage bot workflow, script and L1 tests 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 --- .gitea/workflows/cve-triage.yaml | 160 ++++ .gitea/workflows/scripts/cve_triage.py | 820 ++++++++++++++++++ .../chainsaw-test.yaml | 32 + .../vuln-triage-bot-approval-ticks/check.py | 363 ++++++++ .../fixtures/open-prs.json | 5 + .../fixtures/renovate-dashboard.md | 71 ++ .../fixtures/report.json | 425 +++++++++ .../render-check.sh | 6 + .../chainsaw-test.yaml | 31 + .../check.py | 162 ++++ .../fixtures/report.json | 20 + .../render-check.sh | 6 + 12 files changed, 2101 insertions(+) create mode 100644 .gitea/workflows/cve-triage.yaml create mode 100755 .gitea/workflows/scripts/cve_triage.py create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/chainsaw-test.yaml create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/check.py create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/open-prs.json create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/renovate-dashboard.md create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/report.json create mode 100755 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/render-check.sh create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/chainsaw-test.yaml create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/check.py create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/fixtures/report.json create mode 100755 conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/render-check.sh diff --git a/.gitea/workflows/cve-triage.yaml b/.gitea/workflows/cve-triage.yaml new file mode 100644 index 0000000..ab1b843 --- /dev/null +++ b/.gitea/workflows/cve-triage.yaml @@ -0,0 +1,160 @@ +# CVE triage bot (R-VULN-05, R-VULN-07; docs/17 "The triage bot"; ADR-0027). Same shape as +# renovate.yaml: scheduled, loud on a missing credential, never concurrent with itself, and it +# records what it did in the job summary. The logic and its reasoning live in +# scripts/cve_triage.py; this file only supplies the image, the code, the credential and the +# schedule. +# +# WHY IT CANNOT SUCCEED SILENTLY. A skipped Gitea job reports `success` (CLAUDE.md ยง2), so the +# token preflight exits 1 rather than skipping, and an unreachable scanner fails the script +# rather than skipping. The heartbeat POST is the script's last action, so VulnTriageBotSilent +# fires for every run that did not get all the way through, and when the schedule stops firing. +# +# THE ONE SECRET: RENOVATE_TOKEN, deliberately reused (ADR-0027). It acts as renovate-bot, which +# is also what authored the dashboard this edits. It needs read:repository and write:issue. +name: cve-triage + +on: + schedule: + # 07:15 UTC, one hour before Renovate's 08:15 (renovate.yaml), so a tick is read the same + # morning, and clear of its run so the bot's edit cannot race Renovate's rewrite of the body. + - cron: "15 7 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "Dry run: read everything, write nothing, print counts." + required: false + default: "true" + +concurrency: + group: cve-triage + cancel-in-progress: false + +jobs: + triage: + runs-on: ubuntu-latest + # THE TOOLBOX, the image gate.yml already runs, pinned by digest. A job here has no Docker + # daemon, so a job container is the only way to choose an image. The toolbox provides + # python3, git, curl and jq, pulls anonymously like the gate's, and is normally already in + # the DinD store. It has no Node, so actions/checkout cannot run; a public python image has + # neither Node nor git and would add a second image to keep pinned. The cost is a cold pull + # after a runner restart, which the gate pays anyway. + container: + # renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main + image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81 + # Without a bound, a wedged run would hold the concurrency group and block every later run. + timeout-minutes: 15 + steps: + - name: Preflight - the token must exist, and its absence must be LOUD + shell: bash + env: + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} + run: | + set -euo pipefail + if [ -z "${RENOVATE_TOKEN:-}" ]; then + echo "::error::RENOVATE_TOKEN is not set on this repository." >&2 + echo "A skipped job reports success, so this fails on purpose: without the token no dashboard is maintained and no approval is requested. Add the Actions secret RENOVATE_TOKEN (scopes read:repository, write:issue)." >&2 + exit 1 + fi + echo "RENOVATE_TOKEN is present (value not printed)." + + - name: Preflight - Gitea is reachable and the token can do this job + shell: bash + env: + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} + # GETs only. The token reaches curl on stdin, never argv. A 401 is the token's value, a 403 + # its scope; the script's own errors would only show a bare status later. + run: | + set -uo pipefail + GITEA=https://gitea.home.keithjackson.dev + REPO=Home-Network/home-network-k3s-impl + fail=0 + body=$(mktemp) + fetch() { + printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \ + | curl -sS --max-time 30 --config - -o "$body" -w '%{http_code}' "$GITEA$1" || true + } + probe() { # + local code + code=$(fetch "$2") + if [ "$code" = 200 ]; then + echo "ok $1" + else + fail=1 + echo "::error::$1: GET ${2%%\?*} -> $code" + fi + } + probe "read:repository, Gitea reachable" "/api/v1/repos/$REPO" + if [ "$(jq -r '.permissions.push // false' "$body" 2>/dev/null)" != "true" ]; then + fail=1 + echo "::error::renovate-bot has no write access to $REPO, so it cannot create labels or edit issues." + fi + probe "read:issue" "/api/v1/repos/$REPO/labels?limit=1" + probe "Renovate's dashboard readable" "/api/v1/repos/$REPO/issues/91" + # A GET cannot prove a write scope; Gitea's scope refusal lists the token's scopes + # (names only). Same technique as renovate.yaml, advisory if the message format changes. + fetch "/api/v1/admin/orgs?limit=1" > /dev/null + scopes=$(jq -r '.message // empty' "$body" 2>/dev/null | sed -n 's/.*token scope=//p') + if [ -n "$scopes" ]; then + case ",$scopes," in + *",write:issue,"*|*",all,"*) echo "ok write:issue" ;; + *) echo "::error::scope write:issue is missing (token scope=$scopes)"; fail=1 ;; + esac + else + echo "::warning::could not read the token's scope list; write:issue is UNVERIFIED" + fi + rm -f "$body" + [ "$fail" -eq 0 ] + + # Plain git for the same reason as gate.yml: the toolbox has no Node. One commit is enough, + # and the job token goes to the single fetch only, never into .git/config. + - name: Checkout the script (git, one commit) + shell: bash + env: + GIT_TOKEN: ${{ github.token }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + umask 0022 + git config --global --add safe.directory "$PWD" + git init -q . + git remote add origin "${SERVER_URL}/${REPOSITORY}" + auth="$(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)" + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \ + git -c protocol.version=2 fetch --quiet --depth=1 --no-tags origin \ + "+${SHA}:refs/remotes/checkout/head" + unset auth GIT_TOKEN + git checkout -q --force --detach "$SHA" + [ "$(git rev-parse HEAD)" = "$SHA" ] + + - name: Triage + shell: bash + env: + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} + GITEA_URL: https://gitea.home.keithjackson.dev + GITEA_REPOSITORY: Home-Network/home-network-k3s-impl + RENOVATE_DASHBOARD_ISSUE: "91" + VULN_API_URL: http://vuln-scanner.vuln-scanner.svc.cluster.local:8081 + # The Service's pinned spec.clusterIP (platform/vuln-scanner/service.yaml), used only + # when the name above fails to resolve. Empty disables the fallback, and a DNS failure + # then fails the job. + VULN_API_FALLBACK_URL: "" + VULN_PAGE_URL: https://cve.home.keithjackson.dev + # Anything other than an explicit false on a dispatch is a dry run; a schedule is live. + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }} + run: python3 .gitea/workflows/scripts/cve_triage.py + + - name: Summary + if: always() + run: | + { + echo "## CVE triage" + echo + echo "- trigger: \`${{ github.event_name }}\`" + echo "- dry run requested: \`${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}\`" + echo "- outcome: \`${{ job.status }}\`" + echo + echo "Counts only: which CVE lands where is on the CVE page, never here." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitea/workflows/scripts/cve_triage.py b/.gitea/workflows/scripts/cve_triage.py new file mode 100755 index 0000000..8465898 --- /dev/null +++ b/.gitea/workflows/scripts/cve_triage.py @@ -0,0 +1,820 @@ +#!/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()) diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/chainsaw-test.yaml b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/chainsaw-test.yaml new file mode 100644 index 0000000..9a0faff --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/chainsaw-test.yaml @@ -0,0 +1,32 @@ +# ============================================================================= +# L1 RENDERING โ€” the bot ticks only qualifying approve rows (R-VULN-05, R-VULN-07) +# Spec: docs/17 "The triage bot" and its L1 acceptance gates. GIVEN a Renovate dashboard in the +# real issue #91 format plus a constructed Pending Approval section, WHEN the bot plans ticks, +# THEN only `approve` rows exact-matching an image with an unaccepted CRITICAL with a fix change, +# by exactly their marker, and every negative row stays byte-identical. +# +# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-triage-bot-approval-ticks + labels: + conformance.home.k3s/R-VULN-05: "true" + conformance.home.k3s/R-VULN-07: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07" + conformance.home.k3s/layer: "L1" + conformance.home.k3s/safety-critical: "false" + # The script under test lives in .gitea/workflows/, which requirement-scoped selection does + # not attribute to R-VULN-*, so a change to it must still select this test. + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/intent: >- + A tick is applied only to an approve row for an image with a qualifying finding, and dry + runs, duplicate prevention and heartbeat ordering hold through the whole run. +spec: + steps: + - name: plan-and-assert-ticks + try: + - script: + content: bash ./render-check.sh diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/check.py b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/check.py new file mode 100644 index 0000000..53bc42b --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/check.py @@ -0,0 +1,363 @@ +#!/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) diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/open-prs.json b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/open-prs.json new file mode 100644 index 0000000..b7771a1 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/open-prs.json @@ -0,0 +1,5 @@ +[ + {"number": 173, "title": "chore(deps): Update ghcr.io/example/has-pr Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-has-pr-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/173"}, + {"number": 174, "title": "chore(deps): Update ghcr.io/example/pr-by-title Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-pr-by-title-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/174"}, + {"number": 160, "title": "WIP: chore(spec): not Renovate work", "head": {"ref": "chore/adopt-spec-sync"}, "html_url": "https://gitea.example.test/o/r/pulls/160"} +] diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/renovate-dashboard.md b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/renovate-dashboard.md new file mode 100644 index 0000000..372cc5d --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/renovate-dashboard.md @@ -0,0 +1,71 @@ +This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. + +## Pending Approval + +The following branches are pending approval. To create them, click on a checkbox below. + + - [ ] <!-- approve-branch=renovate/nginx-2.x -->chore(deps): Update nginx Docker tag to v2 + - [ ] <!-- approve-branch=renovate/ghcr.io-example-app-2.x -->chore(deps): Update ghcr.io/example/app Docker tag to v2 + - [ ] <!-- approve-branch=renovate/example-group -->chore(deps): Update example group (`ghcr.io/example/grouped`, `ghcr.io/example/other`) + - [ ] <!-- approve-branch=renovate/ghcr.io-foo-bar-extras-2.x -->chore(deps): Update ghcr.io/foo/bar-extras Docker tag to v2 + - [ ] <!-- approve-branch=renovate/ghcr.io-other-unrelated-2.x -->chore(deps): Update ghcr.io/other/unrelated Docker tag to v2 + - [ ] <!-- approve-branch=renovate/ghcr.io-example-high-only-2.x -->chore(deps): Update ghcr.io/example/high-only Docker tag to v2 + - [ ] <!-- approve-branch=renovate/ghcr.io-example-accepted-only-2.x -->chore(deps): Update ghcr.io/example/accepted-only Docker tag to v2 + - [ ] <!-- approve-branch=renovate/ghcr.io-example-nofix-2.x -->chore(deps): Update ghcr.io/example/nofix Docker tag to v2 + - [ ] <!-- approve-branch=renovate/mychart-2.x -->chore(deps): Update Helm release mychart to v2 + - [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.x -->chore(deps): Update ghcr.io/example/already-ticked Docker tag to v2 + - [ ] <!-- approve-all-pending-prs -->๐Ÿ” **Create all pending approval PRs at once** ๐Ÿ” + +## Awaiting Schedule + +The following updates are awaiting their schedule. To get an update now, click on a checkbox below. + + - [ ] <!-- unschedule-branch=renovate/ghcr.io-example-pending-sched-2.x -->chore(deps): Update ghcr.io/example/pending-sched Docker tag to v2 + +## Pending Branch Automerge + +The following branches have automerge enabled. To abort a branch automerge, click on a checkbox below. + + - [ ] <!-- approvePr-branch=renovate/ghcr.io-example-app-automerge -->chore(deps): Update ghcr.io/example/app Docker tag to v2 + +## Open + +The following updates have all been created. To force a retry/rebase of any, click on a checkbox below. + + - [ ] <!-- rebase-branch=renovate/gitea.home.keithjackson.dev-home-network-toolbox-main -->[chore(deps): Update gitea.home.keithjackson.dev/home-network/toolbox:main Docker digest to 83a3501](pulls/161) + - [ ] <!-- rebase-branch=renovate/kube-prometheus-stack-93.x -->[chore(deps): Update Helm release kube-prometheus-stack to v93](pulls/164) + - [ ] <!-- rebase-branch=renovate/ghcr.io-example-has-pr-2.x -->[chore(deps): Update ghcr.io/example/has-pr Docker tag to v2](pulls/173) + - [ ] <!-- rebase-all-open-prs -->**Click on this checkbox to rebase all open PRs at once** + +## Detected Dependencies + +<details><summary>flux (13)</summary> +<blockquote> + +<details><summary>platform/cert-manager/helmrelease.yaml (1)</summary> + + - `cert-manager >=1.21.0 <1.22.0` โ†’ [Updates: `>=1.21.3 <1.22.0`] + +</details> + +</blockquote> +</details> + +<details><summary>kubernetes (69)</summary> +<blockquote> + +<details><summary>apps/wave-0/jackett.yaml (4)</summary> + + - `lscr.io/linuxserver/jackett v0.24.3025-ls29` โ†’ [Updates: `v0.24.3030-ls30`] + - `quay.io/oauth2-proxy/oauth2-proxy v7.15.6` + - `NetworkPolicy networking.k8s.io/v1` + - `ghcr.io/example/tracked-clean 1.0` + - `ghcr.io/foo/bar 1.0` + - `ghcr.io/example/pending-sched 1.0` โ†’ [Updates: `2.0`] + - `ghcr.io/example/app 1.0` โ†’ [Updates: `2.0`] + - [ ] <!-- approve-branch=renovate/decoy-below-detected -->chore(deps): Update nginx Docker tag to v9 + +</details> + +</blockquote> +</details> diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/report.json b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/report.json new file mode 100644 index 0000000..860443d --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/report.json @@ -0,0 +1,425 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-11-09T06:00:00Z", + "database": { + "updatedAt": "2026-11-09T01:00:00Z", + "version": "fixture-db" + }, + "run": { + "startedAt": "2026-11-09T05:00:00Z", + "finishedAt": "2026-11-09T05:30:00Z", + "status": "complete" + }, + "targets": [ + { + "id": "docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "type": "image", + "image": "docker.io/library/nginx", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-0", + "kind": "Deployment", + "name": "fixture-workload-0", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/app@sha256:0000000000000000000000000000000000000000000000000000000000000001", + "type": "image", + "image": "ghcr.io/example/app", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000001", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-1", + "kind": "Deployment", + "name": "fixture-workload-1", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/grouped@sha256:0000000000000000000000000000000000000000000000000000000000000002", + "type": "image", + "image": "ghcr.io/example/grouped", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000002", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-2", + "kind": "Deployment", + "name": "fixture-workload-2", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/foo/bar@sha256:0000000000000000000000000000000000000000000000000000000000000003", + "type": "image", + "image": "ghcr.io/foo/bar", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000003", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-3", + "kind": "Deployment", + "name": "fixture-workload-3", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/has-pr@sha256:0000000000000000000000000000000000000000000000000000000000000004", + "type": "image", + "image": "ghcr.io/example/has-pr", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000004", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-4", + "kind": "Deployment", + "name": "fixture-workload-4", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/pr-by-title@sha256:0000000000000000000000000000000000000000000000000000000000000005", + "type": "image", + "image": "ghcr.io/example/pr-by-title", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000005", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-5", + "kind": "Deployment", + "name": "fixture-workload-5", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/already-ticked@sha256:0000000000000000000000000000000000000000000000000000000000000006", + "type": "image", + "image": "ghcr.io/example/already-ticked", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000006", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-6", + "kind": "Deployment", + "name": "fixture-workload-6", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/untracked@sha256:0000000000000000000000000000000000000000000000000000000000000007", + "type": "image", + "image": "ghcr.io/example/untracked", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000007", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-7", + "kind": "Deployment", + "name": "fixture-workload-7", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/pending-sched@sha256:0000000000000000000000000000000000000000000000000000000000000008", + "type": "image", + "image": "ghcr.io/example/pending-sched", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000008", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-8", + "kind": "Deployment", + "name": "fixture-workload-8", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/high-only@sha256:0000000000000000000000000000000000000000000000000000000000000009", + "type": "image", + "image": "ghcr.io/example/high-only", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000009", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-9", + "kind": "Deployment", + "name": "fixture-workload-9", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/accepted-only@sha256:000000000000000000000000000000000000000000000000000000000000000a", + "type": "image", + "image": "ghcr.io/example/accepted-only", + "digest": "sha256:000000000000000000000000000000000000000000000000000000000000000a", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-10", + "kind": "Deployment", + "name": "fixture-workload-10", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/nofix@sha256:000000000000000000000000000000000000000000000000000000000000000b", + "type": "image", + "image": "ghcr.io/example/nofix", + "digest": "sha256:000000000000000000000000000000000000000000000000000000000000000b", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-11", + "kind": "Deployment", + "name": "fixture-workload-11", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/tracked-clean@sha256:000000000000000000000000000000000000000000000000000000000000000c", + "type": "image", + "image": "ghcr.io/example/tracked-clean", + "digest": "sha256:000000000000000000000000000000000000000000000000000000000000000c", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-12", + "kind": "Deployment", + "name": "fixture-workload-12", + "containers": [ + "main" + ] + } + ] + } + ], + "findings": [ + { + "id": "CVE-2026-1000", + "targetId": "docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-0", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1010", + "targetId": "ghcr.io/example/app@sha256:0000000000000000000000000000000000000000000000000000000000000001", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-1", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1020", + "targetId": "ghcr.io/example/grouped@sha256:0000000000000000000000000000000000000000000000000000000000000002", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-2", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1030", + "targetId": "ghcr.io/foo/bar@sha256:0000000000000000000000000000000000000000000000000000000000000003", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-3", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1040", + "targetId": "ghcr.io/example/has-pr@sha256:0000000000000000000000000000000000000000000000000000000000000004", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-4", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1050", + "targetId": "ghcr.io/example/pr-by-title@sha256:0000000000000000000000000000000000000000000000000000000000000005", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-5", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1060", + "targetId": "ghcr.io/example/already-ticked@sha256:0000000000000000000000000000000000000000000000000000000000000006", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-6", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1070", + "targetId": "ghcr.io/example/untracked@sha256:0000000000000000000000000000000000000000000000000000000000000007", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-7", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1080", + "targetId": "ghcr.io/example/pending-sched@sha256:0000000000000000000000000000000000000000000000000000000000000008", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-8", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1090", + "targetId": "ghcr.io/example/high-only@sha256:0000000000000000000000000000000000000000000000000000000000000009", + "severity": "HIGH", + "cvss": 9.0, + "package": "fixture-pkg-9", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1100", + "targetId": "ghcr.io/example/accepted-only@sha256:000000000000000000000000000000000000000000000000000000000000000a", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-10", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "accepted", + "acceptance": { + "reason": "fixture", + "expires": "2027-01-01" + } + }, + { + "id": "CVE-2026-1110", + "targetId": "ghcr.io/example/nofix@sha256:000000000000000000000000000000000000000000000000000000000000000b", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-11", + "installedVersion": "1.0", + "fixedVersion": null, + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "open" + } + ], + "remediation": [] +} diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/render-check.sh b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/render-check.sh new file mode 100755 index 0000000..d5b02de --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/render-check.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# L1 offline check for the CVE triage bot: fixture-driven, no cluster, no network. Assertions and +# their limits are in check.py. rc 0 pass, 1 fail. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHONDONTWRITEBYTECODE=1 exec python3 "${here}/check.py" diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/chainsaw-test.yaml b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/chainsaw-test.yaml new file mode 100644 index 0000000..ff8f327 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/chainsaw-test.yaml @@ -0,0 +1,31 @@ +# ============================================================================= +# L1 RENDERING โ€” CVE Dashboard issue carries aggregates only (R-VULN-05, R-VULN-07) +# Spec: docs/17 "The triage bot". GIVEN a report fixture with hostile and distinctive strings, +# WHEN the bot renders the dashboard issue, THEN no CVE id, package, image, workload, namespace or +# other report string appears, the counts are exact, and a deliberately leaking body is caught. +# +# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-triage-bot-dashboard-aggregates + labels: + conformance.home.k3s/R-VULN-05: "true" + conformance.home.k3s/R-VULN-07: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07" + conformance.home.k3s/layer: "L1" + conformance.home.k3s/safety-critical: "false" + # The script under test lives in .gitea/workflows/, which requirement-scoped selection does + # not attribute to R-VULN-*, so a change to it must still select this test. + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/intent: >- + A dashboard issue rendered from a fixture contains counts and timestamps only, never a CVE id, + package, image or workload name. +spec: + steps: + - name: render-and-assert-aggregates-only + try: + - script: + content: bash ./render-check.sh diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/check.py b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/check.py new file mode 100644 index 0000000..10e1fcc --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/check.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""L1: the CVE Dashboard issue is aggregates only (R-VULN-05, R-VULN-07). + +Exercises the real renderer in .gitea/workflows/scripts/cve_triage.py over fixtures/report.json, +whose every name is distinctive so a leak cannot be confused with template text. + +What this does NOT prove: that Gitea accepts the issue, or that the issue is the one the live bot +wrote. Those need a real run and are not exercised by any L1 test. +""" +import copy +import importlib.util +import json +import re +import sys +from datetime import datetime, timedelta, 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) +NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc) +PAGE = "https://cve.example.test" +failures = [] + + +def check(name, condition): + print(f"{'ok ' if condition else 'FAIL'} {name}") + if not condition: + failures.append(name) + + +def sensitive_names(doc): + """Every string in the fixture that must never reach the issue.""" + names = set() + for target in doc["targets"]: + for field in ("id", "image", "digest", "reason"): + if target.get(field): + names.add(target[field]) + if target.get("image"): + names.add(bot.normalise_image(target["image"])) + names.add(target["image"].rsplit("/", 1)[-1]) + for workload in target["workloads"]: + names.update((workload["namespace"], workload["name"])) + names.update(workload["containers"]) + for finding in doc["findings"]: + for field in ("id", "package", "installedVersion", "fixedVersion", "title", "url"): + if finding.get(field): + names.add(finding[field]) + if finding.get("acceptance"): + names.add(finding["acceptance"]["reason"]) + names.add(doc["database"]["version"]) + return {name for name in names if name} + + +def assert_no_names(body, names): + lowered = body.lower() + leaked = sorted(name for name in names if name.lower() in lowered) + assert not leaked, f"{len(leaked)} fixture names leaked into the issue body" + + +def rendered(raw=RAW, now=NOW): + return bot.render_dashboard(bot.aggregate(bot.parse_report(raw)), PAGE, now) + + +def fails(function): + try: + function() + except AssertionError: + return True + return False + + +def rejected(doc): + try: + bot.parse_report(json.dumps(doc).encode()) + except bot.BotError: + return True + return False + + +body = rendered() +names = sensitive_names(FIXTURE) +check("fixture carries a non-trivial set of names", len(names) >= 25) +check("rendered body contains no fixture name", not fails(lambda: assert_no_names(body, names))) +check("body has no CVE or GHSA identifier at all", not re.search(r"CVE-\d|GHSA-", body, re.I)) +check("hostile title's markup is absent", "onerror" not in body and "evil.example" not in body and "@keith" not in body) + +# Non-vacuity: the assertion above must detect each class of leak. +for label, leak in { + "CVE id": "CVE-2026-11111", + "package": "fixture-libcrit", + "image": "ghcr.io/fixture-org/fixture-api", + "workload": "fixture-workload-db", + "namespace": "fixture-ns-gamma", +}.items(): + check(f"non-vacuity: a leaked {label} is caught", fails(lambda: assert_no_names(body + leak, names))) + +for row in ( + "| CRITICAL | 1 | 1 | 1 |", + "| HIGH | 0 | 1 | 0 |", + "| MEDIUM | 0 | 0 | 0 |", + "| LOW | 1 | 0 | 0 |", + "| UNKNOWN | 0 | 0 | 0 |", + "| scanned | 1 |", + "| pending | 0 |", + "| stale | 1 |", + "| failed | 0 |", + "| credential-required | 1 |", + "| by-tag | 0 |", + "| not-covered | 1 |", + "Unaccepted CRITICAL with a fix: **1**", + "4 targets.", + "| Report generated | 2026-11-09T06:00:00Z |", + f"[CVE page]({PAGE})", +): + check(f"body states {row!r}", row in body) +check("a fresh report carries no warning", "WARNING" not in body) + +mutated = copy.deepcopy(FIXTURE) +extra = copy.deepcopy(mutated["findings"][0]) +extra["id"] = "CVE-2026-55555" +mutated["findings"].append(extra) +changed = rendered(json.dumps(mutated).encode()) +check("non-vacuity: a mutated fixture changes the counts", "| CRITICAL | 1 | 2 | 1 |" in changed and changed != body) + +check("stale report warns", "older than 26 hours" in rendered(now=NOW + timedelta(hours=30))) +degraded = copy.deepcopy(FIXTURE) +degraded["run"]["status"] = "degraded" +check("degraded run warns", "did not complete cleanly" in rendered(json.dumps(degraded).encode())) +old_db = copy.deepcopy(FIXTURE) +old_db["database"]["updatedAt"] = "2026-11-01T00:00:00Z" +check("stale database warns", "database is older" in rendered(json.dumps(old_db).encode())) + +for label, mutate in { + "wrong schemaVersion": lambda d: d.update(schemaVersion=2), + "unknown severity": lambda d: d["findings"][0].update(severity="SEVERE"), + "unknown status": lambda d: d["findings"][0].update(status="ignored"), + "unknown target state": lambda d: d["targets"][0].update(state="clean"), + "finding on an unknown target": lambda d: d["findings"][0].update(targetId="nope"), + "bad timestamp": lambda d: d.update(generatedAt="yesterday <b>"), + "invalid image": lambda d: d["targets"][0].update(image="bad image <x>"), +}.items(): + doc = copy.deepcopy(FIXTURE) + mutate(doc) + check(f"parse rejects {label}", rejected(doc)) + +check("dashboard label is not one the PM acts on", bot.DASHBOARD_LABEL not in bot.FORBIDDEN_LABELS) +configmap = (ROOT / "platform/pm-agent/configmap.yaml").read_text() +match = re.search(r'^\s*PM_REQUIRE_LABEL:\s*"([^"]*)"', configmap, re.M) +check("PM_REQUIRE_LABEL is readable", match is not None) +check("the PM's label is in FORBIDDEN_LABELS and is not ours", + match is not None and match.group(1) in bot.FORBIDDEN_LABELS and match.group(1) != bot.DASHBOARD_LABEL) + +print(f"{len(failures)} failure(s)") +sys.exit(1 if failures else 0) diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/fixtures/report.json b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/fixtures/report.json new file mode 100644 index 0000000..b74c092 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/fixtures/report.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-11-09T06:00:00Z", + "database": {"updatedAt": "2026-11-09T01:00:00Z", "version": "fixture-db-v2-20261109"}, + "run": {"startedAt": "2026-11-09T05:00:00Z", "finishedAt": "2026-11-09T05:30:00Z", "status": "complete"}, + "targets": [ + {"id": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "type": "image", "image": "ghcr.io/fixture-org/fixture-api", "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "state": "scanned", "reason": null, "lastScannedAt": "2026-11-09T05:10:00Z", "workloads": [{"namespace": "fixture-ns-alpha", "kind": "Deployment", "name": "fixture-workload-api", "containers": ["fixture-container-api"]}]}, + {"id": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "type": "image", "image": "docker.io/library/fixturedb", "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "state": "stale", "reason": "fixture-reason-stale-text", "lastScannedAt": "2026-11-07T05:10:00Z", "workloads": [{"namespace": "fixture-ns-beta", "kind": "StatefulSet", "name": "fixture-workload-db", "containers": ["fixture-container-db"]}]}, + {"id": "fixture.example:5000/priv/fixture-private:v1", "type": "image", "image": "fixture.example:5000/priv/fixture-private", "digest": null, "state": "credential-required", "reason": "fixture.example:5000 refused anonymous access", "lastScannedAt": null, "workloads": [{"namespace": "fixture-ns-gamma", "kind": "CronJob", "name": "fixture-workload-cron", "containers": ["fixture-container-cron"]}]}, + {"id": "vm/fixture-ns-vm/fixture-vm-name", "type": "vm", "image": null, "digest": null, "state": "not-covered", "reason": null, "lastScannedAt": null, "workloads": [{"namespace": "fixture-ns-vm", "kind": "VirtualMachineInstance", "name": "fixture-vm-name", "containers": []}]} + ], + "findings": [ + {"id": "CVE-2026-11111", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "CRITICAL", "cvss": 9.8, "package": "fixture-libcrit", "installedVersion": "1.0.1-installed", "fixedVersion": "1.0.2-fixed", "title": "<img src=x onerror=alert(1)> @keith [click](http://evil.example/hook) | injected |", "url": "https://advisory.example/CVE-2026-11111", "firstSeenAt": "2026-11-01T00:00:00Z", "status": "fix-available"}, + {"id": "GHSA-fixt-ure0-0001", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "CRITICAL", "cvss": 9.1, "package": "fixture-libopen", "installedVersion": "2.0.0-installed", "fixedVersion": null, "title": "fixture title without a fix", "url": "https://advisory.example/ghsa", "firstSeenAt": "2026-11-02T00:00:00Z", "status": "open"}, + {"id": "CVE-2026-22222", "targetId": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "severity": "CRITICAL", "cvss": 9.0, "package": "fixture-libaccepted", "installedVersion": "3.0.0-installed", "fixedVersion": "3.0.1-fixed", "title": "fixture accepted title", "url": "https://advisory.example/accepted", "firstSeenAt": "2026-10-01T00:00:00Z", "status": "accepted", "acceptance": {"reason": "fixture-acceptance-reason", "expires": "2027-01-01"}}, + {"id": "CVE-2026-33333", "targetId": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "severity": "HIGH", "cvss": 7.5, "package": "fixture-libhigh", "installedVersion": "4.0.0-installed", "fixedVersion": "4.0.1-fixed", "title": "fixture high title", "url": "https://advisory.example/high", "firstSeenAt": "2026-11-03T00:00:00Z", "status": "fix-available"}, + {"id": "CVE-2026-44444", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "LOW", "cvss": null, "package": "fixture-liblow", "installedVersion": "5.0.0-installed", "fixedVersion": null, "title": "fixture low title", "url": "", "firstSeenAt": "2026-11-04T00:00:00Z", "status": "open"} + ], + "remediation": [] +} diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/render-check.sh b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/render-check.sh new file mode 100755 index 0000000..d5b02de --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/render-check.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# L1 offline check for the CVE triage bot: fixture-driven, no cluster, no network. Assertions and +# their limits are in check.py. rc 0 pass, 1 fail. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHONDONTWRITEBYTECODE=1 exec python3 "${here}/check.py" From ec6b39bb919d8f67a1993a35efd8b7f8c1c89b97 Mon Sep 17 00:00:00 2001 From: Keith Jackson <keithallenjackson@gmail.com> Date: Mon, 9 Nov 2026 09:39:55 -0600 Subject: [PATCH 2/2] feat(vuln): point the triage bot's fallback at the pinned scanner ClusterIP Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .gitea/workflows/cve-triage.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/cve-triage.yaml b/.gitea/workflows/cve-triage.yaml index ab1b843..5de9f2c 100644 --- a/.gitea/workflows/cve-triage.yaml +++ b/.gitea/workflows/cve-triage.yaml @@ -138,9 +138,8 @@ jobs: RENOVATE_DASHBOARD_ISSUE: "91" VULN_API_URL: http://vuln-scanner.vuln-scanner.svc.cluster.local:8081 # The Service's pinned spec.clusterIP (platform/vuln-scanner/service.yaml), used only - # when the name above fails to resolve. Empty disables the fallback, and a DNS failure - # then fails the job. - VULN_API_FALLBACK_URL: "" + # when the name above fails to resolve. Keep the two in step. + VULN_API_FALLBACK_URL: http://10.43.210.10:8081 VULN_PAGE_URL: https://cve.home.keithjackson.dev # Anything other than an explicit false on a dispatch is a dry run; a schedule is live. DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}