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:
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"}
|
||||
]
|
||||
+71
@@ -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": []
|
||||
}
|
||||
+6
@@ -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"
|
||||
+31
@@ -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)
|
||||
+20
@@ -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": []
|
||||
}
|
||||
+6
@@ -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"
|
||||
Reference in new issue
Block a user