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 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5.5 committed 2026-11-09 09:39:24 -06:00
1 parent 4c3a284cad
commit c3c72c47bb
12 files changed
+2101

No files matched your search

@@ -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"