Merge branch 'wip/vuln-bot' into feat/vuln-visibility

This commit is contained in:
jakman85 committed 2026-11-09 10:29:05 -06:00
commit b265d08278
12 files changed
+2100

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
# 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() { # <what it proves> <path>
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. 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' }}
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"
+820
View File
@@ -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<mark>[ xX])\] <!-- (?P<kind>[A-Za-z]+)-branch=(?P<branch>\S+) -->(?P<rest>.*)")
LINKED_TITLE_RE = re.compile(r"\[(?P<title>.*)\]\((?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())
@@ -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
@@ -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)
@@ -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"}
]
@@ -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>
@@ -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": []
}
@@ -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"
@@ -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
@@ -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)
@@ -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": []
}
@@ -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"