Merge branch 'wip/vuln-bot' into feat/vuln-visibility
This commit is contained in:
commit
b265d08278
12 files changed
+2100
No files matched your search
@@ -0,0 +1,159 @@
|
||||
# CVE triage bot (R-VULN-05, R-VULN-07; docs/17 "The triage bot"; ADR-0027). Same shape as
|
||||
# renovate.yaml: scheduled, loud on a missing credential, never concurrent with itself, and it
|
||||
# records what it did in the job summary. The logic and its reasoning live in
|
||||
# scripts/cve_triage.py; this file only supplies the image, the code, the credential and the
|
||||
# schedule.
|
||||
#
|
||||
# WHY IT CANNOT SUCCEED SILENTLY. A skipped Gitea job reports `success` (CLAUDE.md §2), so the
|
||||
# token preflight exits 1 rather than skipping, and an unreachable scanner fails the script
|
||||
# rather than skipping. The heartbeat POST is the script's last action, so VulnTriageBotSilent
|
||||
# fires for every run that did not get all the way through, and when the schedule stops firing.
|
||||
#
|
||||
# THE ONE SECRET: RENOVATE_TOKEN, deliberately reused (ADR-0027). It acts as renovate-bot, which
|
||||
# is also what authored the dashboard this edits. It needs read:repository and write:issue.
|
||||
name: cve-triage
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 07:15 UTC, one hour before Renovate's 08:15 (renovate.yaml), so a tick is read the same
|
||||
# morning, and clear of its run so the bot's edit cannot race Renovate's rewrite of the body.
|
||||
- cron: "15 7 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Dry run: read everything, write nothing, print counts."
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
concurrency:
|
||||
group: cve-triage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
# THE TOOLBOX, the image gate.yml already runs, pinned by digest. A job here has no Docker
|
||||
# daemon, so a job container is the only way to choose an image. The toolbox provides
|
||||
# python3, git, curl and jq, pulls anonymously like the gate's, and is normally already in
|
||||
# the DinD store. It has no Node, so actions/checkout cannot run; a public python image has
|
||||
# neither Node nor git and would add a second image to keep pinned. The cost is a cold pull
|
||||
# after a runner restart, which the gate pays anyway.
|
||||
container:
|
||||
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main
|
||||
image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81
|
||||
# Without a bound, a wedged run would hold the concurrency group and block every later run.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Preflight - the token must exist, and its absence must be LOUD
|
||||
shell: bash
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${RENOVATE_TOKEN:-}" ]; then
|
||||
echo "::error::RENOVATE_TOKEN is not set on this repository." >&2
|
||||
echo "A skipped job reports success, so this fails on purpose: without the token no dashboard is maintained and no approval is requested. Add the Actions secret RENOVATE_TOKEN (scopes read:repository, write:issue)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "RENOVATE_TOKEN is present (value not printed)."
|
||||
|
||||
- name: Preflight - Gitea is reachable and the token can do this job
|
||||
shell: bash
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
# GETs only. The token reaches curl on stdin, never argv. A 401 is the token's value, a 403
|
||||
# its scope; the script's own errors would only show a bare status later.
|
||||
run: |
|
||||
set -uo pipefail
|
||||
GITEA=https://gitea.home.keithjackson.dev
|
||||
REPO=Home-Network/home-network-k3s-impl
|
||||
fail=0
|
||||
body=$(mktemp)
|
||||
fetch() {
|
||||
printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \
|
||||
| curl -sS --max-time 30 --config - -o "$body" -w '%{http_code}' "$GITEA$1" || true
|
||||
}
|
||||
probe() { # <what it proves> <path>
|
||||
local code
|
||||
code=$(fetch "$2")
|
||||
if [ "$code" = 200 ]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
fail=1
|
||||
echo "::error::$1: GET ${2%%\?*} -> $code"
|
||||
fi
|
||||
}
|
||||
probe "read:repository, Gitea reachable" "/api/v1/repos/$REPO"
|
||||
if [ "$(jq -r '.permissions.push // false' "$body" 2>/dev/null)" != "true" ]; then
|
||||
fail=1
|
||||
echo "::error::renovate-bot has no write access to $REPO, so it cannot create labels or edit issues."
|
||||
fi
|
||||
probe "read:issue" "/api/v1/repos/$REPO/labels?limit=1"
|
||||
probe "Renovate's dashboard readable" "/api/v1/repos/$REPO/issues/91"
|
||||
# A GET cannot prove a write scope; Gitea's scope refusal lists the token's scopes
|
||||
# (names only). Same technique as renovate.yaml, advisory if the message format changes.
|
||||
fetch "/api/v1/admin/orgs?limit=1" > /dev/null
|
||||
scopes=$(jq -r '.message // empty' "$body" 2>/dev/null | sed -n 's/.*token scope=//p')
|
||||
if [ -n "$scopes" ]; then
|
||||
case ",$scopes," in
|
||||
*",write:issue,"*|*",all,"*) echo "ok write:issue" ;;
|
||||
*) echo "::error::scope write:issue is missing (token scope=$scopes)"; fail=1 ;;
|
||||
esac
|
||||
else
|
||||
echo "::warning::could not read the token's scope list; write:issue is UNVERIFIED"
|
||||
fi
|
||||
rm -f "$body"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
# Plain git for the same reason as gate.yml: the toolbox has no Node. One commit is enough,
|
||||
# and the job token goes to the single fetch only, never into .git/config.
|
||||
- name: Checkout the script (git, one commit)
|
||||
shell: bash
|
||||
env:
|
||||
GIT_TOKEN: ${{ github.token }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 0022
|
||||
git config --global --add safe.directory "$PWD"
|
||||
git init -q .
|
||||
git remote add origin "${SERVER_URL}/${REPOSITORY}"
|
||||
auth="$(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)"
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \
|
||||
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \
|
||||
git -c protocol.version=2 fetch --quiet --depth=1 --no-tags origin \
|
||||
"+${SHA}:refs/remotes/checkout/head"
|
||||
unset auth GIT_TOKEN
|
||||
git checkout -q --force --detach "$SHA"
|
||||
[ "$(git rev-parse HEAD)" = "$SHA" ]
|
||||
|
||||
- name: Triage
|
||||
shell: bash
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
GITEA_URL: https://gitea.home.keithjackson.dev
|
||||
GITEA_REPOSITORY: Home-Network/home-network-k3s-impl
|
||||
RENOVATE_DASHBOARD_ISSUE: "91"
|
||||
VULN_API_URL: http://vuln-scanner.vuln-scanner.svc.cluster.local:8081
|
||||
# The Service's pinned spec.clusterIP (platform/vuln-scanner/service.yaml), used only
|
||||
# when the name above fails to resolve. Keep the two in step.
|
||||
VULN_API_FALLBACK_URL: http://10.43.210.10:8081
|
||||
VULN_PAGE_URL: https://cve.home.keithjackson.dev
|
||||
# Anything other than an explicit false on a dispatch is a dry run; a schedule is live.
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}
|
||||
run: python3 .gitea/workflows/scripts/cve_triage.py
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## CVE triage"
|
||||
echo
|
||||
echo "- trigger: \`${{ github.event_name }}\`"
|
||||
echo "- dry run requested: \`${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}\`"
|
||||
echo "- outcome: \`${{ job.status }}\`"
|
||||
echo
|
||||
echo "Counts only: which CVE lands where is on the CVE page, never here."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
Executable
+820
@@ -0,0 +1,820 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CVE triage bot (docs/17 "The triage bot", ADR-0027; R-VULN-05, R-VULN-07).
|
||||
|
||||
Runs in .gitea/workflows/cve-triage.yaml, once a day, before Renovate. Each run:
|
||||
1. reads report.json from the scanner's internal API;
|
||||
2. creates or updates the single `CVE Dashboard` issue (aggregates only);
|
||||
3. reads Renovate's Dependency Dashboard and open PRs, derives each image's remediation state;
|
||||
4. ticks `approve-branch` boxes on that dashboard, for packages matching an image with an
|
||||
unaccepted CRITICAL that has a fix;
|
||||
5. POSTs the heartbeat and the remediation states to the scanner.
|
||||
|
||||
WHAT IT MUST NEVER DO. Renovate is the only author of version changes (R-UPD-02): this script
|
||||
never edits a version, opens a bump PR, merges anything, or posts `/approve-override`. It never
|
||||
ticks a `rebase-branch` row. The dashboard issue carries no CVE id, package, image or workload,
|
||||
because the repository is readable without authentication, and no label the in-cluster PM acts
|
||||
on (platform/pm-agent/configmap.yaml PM_REQUIRE_LABEL = `agent`).
|
||||
|
||||
OUTPUT IS COUNTS ONLY. Job logs and the summary are as visible as the issue, so nothing here
|
||||
prints a report string. Every report value is untrusted: strings are validated, and only counts
|
||||
and timestamps that passed RFC 3339 validation ever reach markdown.
|
||||
|
||||
HOW A JOB CONTAINER REACHES THE SCANNER, AND WHAT IS NOT PROVEN
|
||||
---------------------------------------------------------------
|
||||
Jobs are containers on a per-job docker bridge network inside the DinD sidecar of the act-runner
|
||||
pod (apps/wave-1/gitea-actions.yaml). The runner config sets no `container.network` and dockerd no
|
||||
`--dns`, so name resolution is docker's embedded resolver forwarding to the resolvers of the
|
||||
dockerd container, which are that pod's: cluster DNS. Egress is masqueraded to the pod IP
|
||||
(measured 2026-10-29, platform/buildkit/networkpolicy.yaml header), so a ClusterIP is routed and
|
||||
policy-matched as the act-runner pod in gitea-actions. images.yaml relies on the same path to
|
||||
reach buildkitd.buildkit.svc.cluster.local, which is indirect evidence only.
|
||||
UNVERIFIED: in-cluster DNS from a DinD job container.
|
||||
UNVERIFIED: ClusterIP reachability from a DinD job container to vuln-scanner:8081.
|
||||
Hence VULN_API_FALLBACK_URL (the Service's pinned ClusterIP), used only when the name fails to
|
||||
resolve. Anything else, including refused and timed-out connections, fails the job: an
|
||||
unreachable API is a red run, never a skip.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable, Iterable, NamedTuple
|
||||
|
||||
DASHBOARD_TITLE = "CVE Dashboard"
|
||||
DASHBOARD_LABEL = "cve-dashboard"
|
||||
DASHBOARD_LABEL_COLOR = "b60205"
|
||||
# Labels the in-cluster PM dispatcher selects work by. Never applied to anything here.
|
||||
FORBIDDEN_LABELS = frozenset({"agent"})
|
||||
RENOVATE_DASHBOARD_TITLE = "Dependency Dashboard"
|
||||
|
||||
SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
|
||||
FINDING_STATUSES = ("open", "fix-available", "accepted")
|
||||
TARGET_STATES = ("scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered")
|
||||
TARGET_TYPES = ("image", "vm", "node")
|
||||
RUN_STATUSES = ("complete", "degraded")
|
||||
REMEDIATION_STATES = ("pr-open", "approval-requested", "no-update-identified", "not-tracked")
|
||||
|
||||
MAX_REPORT_BYTES = 128 * 1024 * 1024
|
||||
MAX_API_BYTES = 8 * 1024 * 1024
|
||||
MAX_FIELD_LEN = 4096
|
||||
MAX_IMAGE_LEN = 512
|
||||
MAX_ISSUE_BODY = 2 * 1024 * 1024
|
||||
MAX_DASHBOARD_ROWS = 2000
|
||||
MAX_DETECTED_DEPS = 20000
|
||||
MAX_REMEDIATION = 5000
|
||||
MAX_PAGES = 200
|
||||
PAGE_SIZE = 50
|
||||
REPORT_STALE_HOURS = 26
|
||||
DATABASE_STALE_HOURS = 48
|
||||
|
||||
HTTP_TIMEOUT = 60
|
||||
|
||||
RFC3339_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})")
|
||||
IMAGE_RE = re.compile(r"[a-z0-9]([a-z0-9._:/-]*[a-z0-9])?")
|
||||
HTTP_URL_RE = re.compile(r"https?://[A-Za-z0-9.-]+(:\d{1,5})?(/[A-Za-z0-9._~%/-]*)?")
|
||||
REPO_RE = re.compile(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+")
|
||||
|
||||
# Renovate's own row shape (lib/workers/repository/dependency-dashboard.ts, getListItem). The kind
|
||||
# is captured whole so `approvePr-branch` can never read as `approve-branch`.
|
||||
ROW_RE = re.compile(r" - \[(?P<mark>[ xX])\] <!-- (?P<kind>[A-Za-z]+)-branch=(?P<branch>\S+) -->(?P<rest>.*)")
|
||||
LINKED_TITLE_RE = re.compile(r"\[(?P<title>.*)\]\((?P<target>[^()\s]*)\)(?P<tail>.*)")
|
||||
PR_LINK_RE = re.compile(r"(?:^|/)pulls?/(\d{1,9})$")
|
||||
PACKAGE_LIST_RE = re.compile(r"\s\(((?:`[^`\s]+`(?:, )?)+)\)\s*$")
|
||||
TITLE_PACKAGE_RE = re.compile(r"(?:^|\s)(?:Update|Pin)\s+(\S+)\s+Docker\s+(?:tag|digest)\b")
|
||||
SUMMARY_RE = re.compile(r"<details><summary>([^<]*?)(?: \(\d+\))?</summary>")
|
||||
DETECTED_RE = re.compile(r" - `(?P<name>[^\s`]+)(?: [^`]*)?`(?P<updates>\s*→ \[Updates:.*\])?\s*")
|
||||
|
||||
# Detected-dependency managers whose names are charts or actions, never container images.
|
||||
NON_IMAGE_MANAGERS = frozenset({"flux", "helmv3", "github-actions"})
|
||||
|
||||
|
||||
class BotError(Exception):
|
||||
"""A failure that ends the run. The message must be safe to print: no report text, no secret."""
|
||||
|
||||
|
||||
class DnsFailure(Exception):
|
||||
"""The hostname did not resolve. The only condition that selects the fallback address."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pure: normalisation + report
|
||||
|
||||
|
||||
def normalise_image(value: object) -> str:
|
||||
"""Canonical form for exact comparison, or "" when the value is not an image reference.
|
||||
|
||||
Lowercase; drop digest and tag; drop the Docker Hub registry and the `library/` namespace.
|
||||
`library/` is only Hub's implicit namespace, so ghcr.io/library/x keeps it. Comparison of the
|
||||
result is always string equality, never a substring test.
|
||||
"""
|
||||
if not isinstance(value, str) or not value or len(value) > MAX_IMAGE_LEN:
|
||||
return ""
|
||||
name = value.strip().lower().split("@", 1)[0]
|
||||
head, sep, last = name.rpartition("/")
|
||||
name = f"{head}{sep}{last.split(':', 1)[0]}"
|
||||
for hub in ("docker.io/", "index.docker.io/", "registry-1.docker.io/"):
|
||||
if name.startswith(hub):
|
||||
name = name[len(hub):]
|
||||
break
|
||||
if name.startswith("library/"):
|
||||
name = name[len("library/"):]
|
||||
return name if IMAGE_RE.fullmatch(name) else ""
|
||||
|
||||
|
||||
def parse_timestamp(value: object, field: str, nullable: bool = False) -> datetime | None:
|
||||
if value is None and nullable:
|
||||
return None
|
||||
if not isinstance(value, str) or not RFC3339_RE.fullmatch(value):
|
||||
raise BotError(f"report: {field} is not an RFC 3339 timestamp")
|
||||
text = re.sub(r"\.\d+", "", value).replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise BotError(f"report: {field} is not a valid timestamp") from exc
|
||||
|
||||
|
||||
def _object(value: object, field: str) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise BotError(f"report: {field} is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _array(value: object, field: str) -> list:
|
||||
if not isinstance(value, list):
|
||||
raise BotError(f"report: {field} is not an array")
|
||||
return value
|
||||
|
||||
|
||||
def _string(value: object, field: str, nullable: bool = False) -> str | None:
|
||||
if value is None and nullable:
|
||||
return None
|
||||
if not isinstance(value, str) or len(value) > MAX_FIELD_LEN:
|
||||
raise BotError(f"report: {field} is not a bounded string")
|
||||
return value
|
||||
|
||||
|
||||
def _member(value: object, allowed: Iterable[str], field: str) -> str:
|
||||
if not isinstance(value, str) or value not in allowed:
|
||||
raise BotError(f"report: {field} has a value outside its enumeration")
|
||||
return value
|
||||
|
||||
|
||||
def parse_report(raw: bytes) -> dict:
|
||||
"""Decode and validate report.json into the only shape the rest of this file reads."""
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except (ValueError, RecursionError) as exc:
|
||||
raise BotError("report: body is not valid JSON") from exc
|
||||
doc = _object(doc, "document")
|
||||
if doc.get("schemaVersion") != 1 or isinstance(doc.get("schemaVersion"), bool):
|
||||
raise BotError("report: unsupported schemaVersion")
|
||||
parse_timestamp(doc.get("generatedAt"), "generatedAt")
|
||||
database = _object(doc.get("database"), "database")
|
||||
run = _object(doc.get("run"), "run")
|
||||
parse_timestamp(database.get("updatedAt"), "database.updatedAt", nullable=True)
|
||||
parse_timestamp(run.get("startedAt"), "run.startedAt", nullable=True)
|
||||
parse_timestamp(run.get("finishedAt"), "run.finishedAt", nullable=True)
|
||||
if run.get("status") is not None:
|
||||
_member(run.get("status"), RUN_STATUSES, "run.status")
|
||||
|
||||
targets = []
|
||||
known_ids = {}
|
||||
for entry in _array(doc.get("targets"), "targets"):
|
||||
entry = _object(entry, "target")
|
||||
target_id = _string(entry.get("id"), "target.id")
|
||||
kind = _member(entry.get("type"), TARGET_TYPES, "target.type")
|
||||
state = _member(entry.get("state"), TARGET_STATES, "target.state")
|
||||
image = _string(entry.get("image"), "target.image", nullable=True)
|
||||
if kind == "image" and not normalise_image(image):
|
||||
raise BotError("report: an image target has an invalid image reference")
|
||||
known_ids[target_id] = image if kind == "image" else None
|
||||
targets.append({"id": target_id, "type": kind, "state": state, "image": image})
|
||||
|
||||
findings = []
|
||||
for entry in _array(doc.get("findings"), "findings"):
|
||||
entry = _object(entry, "finding")
|
||||
target_id = _string(entry.get("targetId"), "finding.targetId")
|
||||
if target_id not in known_ids:
|
||||
raise BotError("report: a finding references an unknown target")
|
||||
findings.append({
|
||||
"targetId": target_id,
|
||||
"image": known_ids[target_id],
|
||||
"severity": _member(entry.get("severity"), SEVERITIES, "finding.severity"),
|
||||
"status": _member(entry.get("status"), FINDING_STATUSES, "finding.status"),
|
||||
"fixedVersion": _string(entry.get("fixedVersion"), "finding.fixedVersion", nullable=True),
|
||||
})
|
||||
return {
|
||||
"generatedAt": doc["generatedAt"],
|
||||
"database": {"updatedAt": database.get("updatedAt")},
|
||||
"run": {"finishedAt": run.get("finishedAt"), "status": run.get("status")},
|
||||
"targets": targets,
|
||||
"findings": findings,
|
||||
}
|
||||
|
||||
|
||||
def qualifies(finding: dict) -> bool:
|
||||
"""An unaccepted CRITICAL with a fix: the only thing that may cause a tick."""
|
||||
return (
|
||||
finding["severity"] == "CRITICAL"
|
||||
and finding["status"] == "fix-available"
|
||||
and bool(finding["fixedVersion"])
|
||||
)
|
||||
|
||||
|
||||
def qualifying_images(report: dict) -> set[str]:
|
||||
return {
|
||||
norm
|
||||
for finding in report["findings"]
|
||||
if qualifies(finding) and (norm := normalise_image(finding["image"]))
|
||||
}
|
||||
|
||||
|
||||
def aggregate(report: dict) -> dict:
|
||||
severity = {sev: dict.fromkeys(FINDING_STATUSES, 0) for sev in SEVERITIES}
|
||||
for finding in report["findings"]:
|
||||
severity[finding["severity"]][finding["status"]] += 1
|
||||
coverage = dict.fromkeys(TARGET_STATES, 0)
|
||||
for target in report["targets"]:
|
||||
coverage[target["state"]] += 1
|
||||
return {
|
||||
"severity": severity,
|
||||
"coverage": coverage,
|
||||
"targets_total": len(report["targets"]),
|
||||
"critical_with_fix": sum(1 for f in report["findings"] if qualifies(f)),
|
||||
"generatedAt": report["generatedAt"],
|
||||
"databaseUpdatedAt": report["database"]["updatedAt"],
|
||||
"runFinishedAt": report["run"]["finishedAt"],
|
||||
"runStatus": report["run"]["status"],
|
||||
}
|
||||
|
||||
|
||||
def render_dashboard(agg: dict, page_url: str, now: datetime) -> str:
|
||||
"""The CVE Dashboard issue body: a fixed template over counts and validated timestamps."""
|
||||
if not HTTP_URL_RE.fullmatch(page_url):
|
||||
raise BotError("config: VULN_PAGE_URL is not a plain http(s) URL")
|
||||
warnings = []
|
||||
generated = parse_timestamp(agg["generatedAt"], "generatedAt")
|
||||
if now - generated > timedelta(hours=REPORT_STALE_HOURS):
|
||||
warnings.append(f"The report is older than {REPORT_STALE_HOURS} hours.")
|
||||
updated = parse_timestamp(agg["databaseUpdatedAt"], "database.updatedAt", nullable=True)
|
||||
if updated is None or now - updated > timedelta(hours=DATABASE_STALE_HOURS):
|
||||
warnings.append(f"The vulnerability database is older than {DATABASE_STALE_HOURS} hours or unknown.")
|
||||
if agg["runStatus"] != "complete":
|
||||
warnings.append("The latest scan run did not complete cleanly.")
|
||||
|
||||
lines = [
|
||||
"Maintained by the CVE triage bot, once a day. **Counts only**: this repository is readable",
|
||||
"without authentication, so which CVE lands where is only on the",
|
||||
f"[CVE page]({page_url}) (SSO, platform-admins).",
|
||||
"",
|
||||
]
|
||||
lines += [f"> **WARNING:** {text}" for text in warnings]
|
||||
if warnings:
|
||||
lines.append("")
|
||||
lines += [
|
||||
"## Freshness",
|
||||
"",
|
||||
"| | |",
|
||||
"|---|---|",
|
||||
f"| Report generated | {agg['generatedAt']} |",
|
||||
f"| Database updated | {agg['databaseUpdatedAt'] or 'unknown'} |",
|
||||
f"| Last run finished | {agg['runFinishedAt'] or 'none yet'} |",
|
||||
f"| Last run status | {agg['runStatus'] or 'none yet'} |",
|
||||
f"| Dashboard rendered | {now.strftime('%Y-%m-%dT%H:%M:%SZ')} |",
|
||||
"",
|
||||
"## Findings by severity",
|
||||
"",
|
||||
"| Severity | Open | Fix available | Accepted |",
|
||||
"|---|---:|---:|---:|",
|
||||
]
|
||||
for sev in SEVERITIES:
|
||||
row = agg["severity"][sev]
|
||||
lines.append(f"| {sev} | {row['open']} | {row['fix-available']} | {row['accepted']} |")
|
||||
lines += [
|
||||
"",
|
||||
f"Unaccepted CRITICAL with a fix: **{agg['critical_with_fix']}**",
|
||||
"",
|
||||
"## Coverage",
|
||||
"",
|
||||
f"{agg['targets_total']} targets. A count in any state other than `scanned` is something"
|
||||
" that was not freshly examined, and is not a clean result.",
|
||||
"",
|
||||
"| State | Targets |",
|
||||
"|---|---:|",
|
||||
]
|
||||
lines += [f"| {state} | {agg['coverage'][state]} |" for state in TARGET_STATES]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pure: Renovate's dashboard
|
||||
|
||||
|
||||
class Row(NamedTuple):
|
||||
kind: str
|
||||
mark: str
|
||||
branch: str
|
||||
packages: tuple[str, ...]
|
||||
pr_number: int | None
|
||||
offset: int # offset in the body of the first character of the line
|
||||
|
||||
|
||||
def _title_package(title: str) -> str:
|
||||
match = TITLE_PACKAGE_RE.search(title)
|
||||
return normalise_image(match.group(1)) if match else ""
|
||||
|
||||
|
||||
def _row_details(rest: str) -> tuple[tuple[str, ...], int | None]:
|
||||
"""Packages and PR number of a row. No recognisable package means no packages, so no tick."""
|
||||
rest = rest.rstrip("\r")
|
||||
number = None
|
||||
title = rest
|
||||
tail = rest
|
||||
linked = LINKED_TITLE_RE.fullmatch(rest)
|
||||
if linked:
|
||||
title, tail = linked.group("title"), linked.group("tail")
|
||||
link = PR_LINK_RE.search(linked.group("target"))
|
||||
number = int(link.group(1)) if link else None
|
||||
listed = PACKAGE_LIST_RE.search(tail)
|
||||
if listed:
|
||||
names = re.findall(r"`([^`]+)`", listed.group(1))
|
||||
packages = tuple(n for n in map(normalise_image, names) if n)
|
||||
else:
|
||||
package = _title_package(title)
|
||||
packages = (package,) if package else ()
|
||||
return packages, number
|
||||
|
||||
|
||||
def _is_detected_heading(line: str) -> bool:
|
||||
return line.startswith("## ") and line[3:].strip().lower().startswith("detected dependencies")
|
||||
|
||||
|
||||
def parse_rows(body: str) -> list[Row]:
|
||||
"""Checkbox rows above `## Detected Dependencies`. Only a whole-line match counts."""
|
||||
if len(body) > MAX_ISSUE_BODY:
|
||||
raise BotError("renovate dashboard: body exceeds its bound")
|
||||
rows: list[Row] = []
|
||||
offset = 0
|
||||
for line in body.split("\n"):
|
||||
if _is_detected_heading(line):
|
||||
break
|
||||
match = ROW_RE.fullmatch(line.rstrip("\r"))
|
||||
if match:
|
||||
packages, number = _row_details(match.group("rest"))
|
||||
rows.append(Row(match.group("kind"), match.group("mark"), match.group("branch"),
|
||||
packages, number, offset))
|
||||
if len(rows) > MAX_DASHBOARD_ROWS:
|
||||
raise BotError("renovate dashboard: too many rows")
|
||||
offset += len(line) + 1
|
||||
return rows
|
||||
|
||||
|
||||
def parse_detected(body: str) -> dict[str, bool]:
|
||||
"""{normalised depName: has a pending update} from `## Detected Dependencies`."""
|
||||
tracked: dict[str, bool] = {}
|
||||
lines = body.split("\n")
|
||||
in_section = False
|
||||
manager = ""
|
||||
count = 0
|
||||
for index, raw in enumerate(lines):
|
||||
line = raw.rstrip("\r")
|
||||
if line.startswith("## "):
|
||||
in_section = _is_detected_heading(line)
|
||||
continue
|
||||
if not in_section:
|
||||
continue
|
||||
summary = SUMMARY_RE.fullmatch(line)
|
||||
if summary:
|
||||
# A manager heading is the summary directly followed by <blockquote>; a file heading
|
||||
# is not.
|
||||
if index + 1 < len(lines) and lines[index + 1].strip() == "<blockquote>":
|
||||
manager = summary.group(1)
|
||||
continue
|
||||
match = DETECTED_RE.fullmatch(line)
|
||||
if not match or manager in NON_IMAGE_MANAGERS:
|
||||
continue
|
||||
count += 1
|
||||
if count > MAX_DETECTED_DEPS:
|
||||
raise BotError("renovate dashboard: too many detected dependencies")
|
||||
norm = normalise_image(match.group("name"))
|
||||
if norm:
|
||||
tracked[norm] = tracked.get(norm, False) or bool(match.group("updates"))
|
||||
return tracked
|
||||
|
||||
|
||||
def row_matches(row: Row, qualifying: set[str]) -> bool:
|
||||
return any(package in qualifying for package in row.packages)
|
||||
|
||||
|
||||
def select_ticks(rows: list[Row], qualifying: set[str]) -> list[Row]:
|
||||
return [
|
||||
row for row in rows
|
||||
if row.kind == "approve" and row.mark == " " and row_matches(row, qualifying)
|
||||
]
|
||||
|
||||
|
||||
def apply_ticks(body: str, ticks: list[Row]) -> str:
|
||||
"""Replace only the `[ ]` of each row, re-verifying the row where it stands.
|
||||
|
||||
Every byte other than the three of each marker is carried over unchanged.
|
||||
"""
|
||||
pieces = []
|
||||
cursor = 0
|
||||
for row in sorted(ticks, key=lambda r: r.offset):
|
||||
expected = f" - [ ] <!-- approve-branch={row.branch} -->"
|
||||
if row.kind != "approve" or not body.startswith(expected, row.offset):
|
||||
raise BotError("renovate dashboard: a row changed between parse and write")
|
||||
marker = row.offset + len(" - ")
|
||||
pieces += [body[cursor:marker], "[x]"]
|
||||
cursor = marker + len("[ ]")
|
||||
pieces.append(body[cursor:])
|
||||
return "".join(pieces)
|
||||
|
||||
|
||||
def derive_remediation(
|
||||
images: Iterable[str],
|
||||
rows: list[Row],
|
||||
open_prs: list[dict],
|
||||
tracked: dict[str, bool],
|
||||
pr_url: Callable[[int], str],
|
||||
now_text: str,
|
||||
) -> list[dict]:
|
||||
"""One entry per report image.
|
||||
|
||||
pr-open: an open-PR row or an open Renovate PR for that package. approval-requested: an
|
||||
approve row. no-update-identified: Renovate tracks it and lists no update. not-tracked:
|
||||
everything else, which deliberately includes "Renovate knows an update but no PR or approval
|
||||
row exists" (awaiting schedule, rate limited): saying no update was identified would be
|
||||
false, and the enumeration has no closer value, so it lands on the state that draws attention.
|
||||
"""
|
||||
by_branch = {pr["branch"]: pr["number"] for pr in open_prs}
|
||||
result = []
|
||||
for image in sorted(set(images)):
|
||||
norm = normalise_image(image)
|
||||
state = "not-tracked"
|
||||
url = None
|
||||
rebase = next((r for r in rows if r.kind == "rebase" and norm in r.packages), None)
|
||||
pr = next((p for p in open_prs if p["package"] == norm), None)
|
||||
if rebase is not None or pr is not None:
|
||||
state = "pr-open"
|
||||
number = None
|
||||
if rebase is not None:
|
||||
number = rebase.pr_number or by_branch.get(rebase.branch)
|
||||
if number is None and pr is not None:
|
||||
number = pr["number"]
|
||||
url = pr_url(number) if number is not None else None
|
||||
elif any(r.kind == "approve" and norm in r.packages for r in rows):
|
||||
state = "approval-requested"
|
||||
elif norm in tracked and not tracked[norm]:
|
||||
state = "no-update-identified"
|
||||
result.append({"image": image, "state": state, "url": url, "updatedAt": now_text})
|
||||
return result
|
||||
|
||||
|
||||
def count_states(remediation: list[dict]) -> dict[str, int]:
|
||||
counts = dict.fromkeys(REMEDIATION_STATES, 0)
|
||||
for entry in remediation:
|
||||
counts[entry["state"]] += 1
|
||||
return counts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- I/O
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""A redirect would carry the Authorization header to wherever it points."""
|
||||
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class UrllibTransport:
|
||||
"""(method, url, headers, body, max_bytes) -> (status, bytes); never uses a proxy."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
|
||||
|
||||
def __call__(self, method: str, url: str, headers: dict, body: bytes | None, max_bytes: int):
|
||||
request = urllib.request.Request(url, data=body, method=method, headers=headers)
|
||||
try:
|
||||
with self._opener.open(request, timeout=HTTP_TIMEOUT) as response:
|
||||
data = response.read(max_bytes + 1)
|
||||
return response.status, data
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, b""
|
||||
except urllib.error.URLError as exc:
|
||||
if isinstance(exc.reason, socket.gaierror):
|
||||
raise DnsFailure() from exc
|
||||
raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc.reason).__name__}") from exc
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc).__name__}") from exc
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, base_url: str, repo: str, token: str, transport) -> None:
|
||||
self._api = f"{base_url.rstrip('/')}/api/v1/repos/{repo}"
|
||||
self._web = f"{base_url.rstrip('/')}/{repo}"
|
||||
self._token = token
|
||||
self._transport = transport
|
||||
|
||||
def _call(self, method: str, path: str, query: dict | None = None, payload=None, expect=(200,)):
|
||||
url = self._api + path + (f"?{urllib.parse.urlencode(query)}" if query else "")
|
||||
headers = {"Authorization": f"token {self._token}", "Accept": "application/json"}
|
||||
data = None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
try:
|
||||
status, raw = self._transport(method, url, headers, data, MAX_API_BYTES)
|
||||
except DnsFailure as exc:
|
||||
raise BotError(f"gitea: {method} {path}: name resolution failed") from exc
|
||||
if status not in expect:
|
||||
raise BotError(f"gitea: {method} {path} -> HTTP {status}")
|
||||
if len(raw) > MAX_API_BYTES:
|
||||
raise BotError(f"gitea: {method} {path}: response exceeds its bound")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError as exc:
|
||||
raise BotError(f"gitea: {method} {path}: response is not JSON") from exc
|
||||
|
||||
def _list(self, path: str, query: dict | None = None) -> list:
|
||||
items: list = []
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
batch = self._call("GET", path, {**(query or {}), "limit": PAGE_SIZE, "page": page})
|
||||
if not isinstance(batch, list):
|
||||
raise BotError(f"gitea: GET {path}: expected a list")
|
||||
items += batch
|
||||
if len(batch) < PAGE_SIZE:
|
||||
return items
|
||||
raise BotError(f"gitea: GET {path}: more than {MAX_PAGES} pages")
|
||||
|
||||
def pr_url(self, number: int) -> str:
|
||||
return f"{self._web}/pulls/{int(number)}"
|
||||
|
||||
def labels(self) -> list:
|
||||
return self._list("/labels")
|
||||
|
||||
def create_label(self, name: str, color: str) -> dict:
|
||||
return self._call("POST", "/labels", payload={"name": name, "color": color}, expect=(201,))
|
||||
|
||||
def issues_with_label(self, label: str) -> list:
|
||||
return self._list("/issues", {"state": "all", "type": "issues", "labels": label})
|
||||
|
||||
def issues_matching(self, text: str) -> list:
|
||||
return self._list("/issues", {"state": "all", "type": "issues", "q": text})
|
||||
|
||||
def create_issue(self, title: str, body: str, label_id: int) -> dict:
|
||||
return self._call("POST", "/issues", payload={"title": title, "body": body, "labels": [label_id]}, expect=(201,))
|
||||
|
||||
def update_issue(self, number: int, fields: dict) -> None:
|
||||
self._call("PATCH", f"/issues/{int(number)}", payload=fields, expect=(200, 201))
|
||||
|
||||
def add_issue_label(self, number: int, label_id: int) -> None:
|
||||
self._call("POST", f"/issues/{int(number)}/labels", payload={"labels": [label_id]}, expect=(200,))
|
||||
|
||||
def get_issue(self, number: int) -> dict:
|
||||
issue = self._call("GET", f"/issues/{int(number)}")
|
||||
if not isinstance(issue, dict):
|
||||
raise BotError("gitea: issue response is not an object")
|
||||
return issue
|
||||
|
||||
def open_renovate_prs(self) -> list[dict]:
|
||||
result = []
|
||||
for pr in self._list("/pulls", {"state": "open"}):
|
||||
head = pr.get("head") if isinstance(pr, dict) else None
|
||||
ref = head.get("ref") if isinstance(head, dict) else None
|
||||
number = pr.get("number") if isinstance(pr, dict) else None
|
||||
title = pr.get("title") if isinstance(pr, dict) else None
|
||||
if not isinstance(ref, str) or not isinstance(number, int) or isinstance(number, bool):
|
||||
raise BotError("gitea: a pull request has an unexpected shape")
|
||||
if ref.startswith("renovate/"):
|
||||
package = _title_package(title) if isinstance(title, str) else ""
|
||||
result.append({"number": number, "branch": ref, "package": package})
|
||||
return result
|
||||
|
||||
|
||||
class ScannerClient:
|
||||
"""The scanner's internal API. Falls back to the pinned ClusterIP on a DNS failure only."""
|
||||
|
||||
def __init__(self, primary: str, fallback: str, transport) -> None:
|
||||
self._bases = [base.rstrip("/") for base in (primary, fallback) if base]
|
||||
self._transport = transport
|
||||
self.used_fallback = False
|
||||
|
||||
def _call(self, method: str, path: str, body: bytes | None, headers: dict, max_bytes: int):
|
||||
last = len(self._bases) - 1
|
||||
for index, base in enumerate(self._bases):
|
||||
try:
|
||||
result = self._transport(method, base + path, headers, body, max_bytes)
|
||||
except DnsFailure as exc:
|
||||
if index == last:
|
||||
raise BotError(f"scanner: {method} {path}: name resolution failed") from exc
|
||||
continue
|
||||
if index:
|
||||
self.used_fallback = True
|
||||
self._bases = self._bases[index:]
|
||||
return result
|
||||
raise BotError("scanner: no address configured")
|
||||
|
||||
def report(self) -> dict:
|
||||
status, raw = self._call("GET", "/report.json", None, {"Accept": "application/json"}, MAX_REPORT_BYTES)
|
||||
if status != 200:
|
||||
raise BotError(f"scanner: GET /report.json -> HTTP {status}")
|
||||
if len(raw) > MAX_REPORT_BYTES:
|
||||
raise BotError("scanner: report exceeds its bound")
|
||||
return parse_report(raw)
|
||||
|
||||
def triage(self, heartbeat: str, remediation: list[dict]) -> None:
|
||||
if len(remediation) > MAX_REMEDIATION:
|
||||
raise BotError("scanner: more remediation entries than POST /triage accepts")
|
||||
body = json.dumps({"heartbeatAt": heartbeat, "remediation": remediation}).encode()
|
||||
status, _ = self._call("POST", "/triage", body, {"Content-Type": "application/json"}, 4096)
|
||||
if status != 204:
|
||||
raise BotError(f"scanner: POST /triage -> HTTP {status}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- orchestration
|
||||
|
||||
|
||||
def _label_names(issue: dict) -> set:
|
||||
return {label.get("name") for label in (issue.get("labels") or []) if isinstance(label, dict)}
|
||||
|
||||
|
||||
def find_dashboard_issues(gitea: GiteaClient) -> list[dict]:
|
||||
"""Every issue that is the dashboard: by label, and by exact title in case a label was removed."""
|
||||
found: dict[int, dict] = {}
|
||||
for issue in gitea.issues_with_label(DASHBOARD_LABEL) + gitea.issues_matching(DASHBOARD_TITLE):
|
||||
if (
|
||||
isinstance(issue, dict)
|
||||
and issue.get("title") == DASHBOARD_TITLE
|
||||
and not issue.get("pull_request")
|
||||
and isinstance(issue.get("number"), int)
|
||||
):
|
||||
found[issue["number"]] = issue
|
||||
return list(found.values())
|
||||
|
||||
|
||||
def sync_dashboard_issue(gitea: GiteaClient, body: str, dry_run: bool) -> dict:
|
||||
"""Create or update the one dashboard issue. Returns counts of what it did or would do."""
|
||||
if DASHBOARD_LABEL in FORBIDDEN_LABELS:
|
||||
raise BotError("config: the dashboard label is one the PM agent acts on")
|
||||
plan = {"label_created": 0, "issue_created": 0, "issue_updated": 0, "issue_reopened": 0}
|
||||
existing = find_dashboard_issues(gitea)
|
||||
if len(existing) > 1:
|
||||
# Closing an issue on a guess is worse than a red run naming the numbers to look at.
|
||||
numbers = sorted(issue["number"] for issue in existing)
|
||||
raise BotError(f"dashboard: {len(numbers)} issues titled {DASHBOARD_TITLE!r}: {numbers}")
|
||||
|
||||
label = next((x for x in gitea.labels() if isinstance(x, dict) and x.get("name") == DASHBOARD_LABEL), None)
|
||||
if label is None:
|
||||
plan["label_created"] = 1
|
||||
if not dry_run:
|
||||
label = gitea.create_label(DASHBOARD_LABEL, DASHBOARD_LABEL_COLOR)
|
||||
|
||||
if not existing:
|
||||
plan["issue_created"] = 1
|
||||
if not dry_run:
|
||||
created = gitea.create_issue(DASHBOARD_TITLE, body, label["id"])
|
||||
if not isinstance(created, dict) or not isinstance(created.get("number"), int):
|
||||
raise BotError("gitea: issue creation returned an unexpected shape")
|
||||
return plan
|
||||
|
||||
issue = existing[0]
|
||||
fields: dict = {}
|
||||
if issue.get("body") != body:
|
||||
fields["body"] = body
|
||||
if issue.get("state") != "open":
|
||||
fields["state"] = "open"
|
||||
plan["issue_reopened"] = 1
|
||||
if fields:
|
||||
plan["issue_updated"] = 1
|
||||
if not dry_run:
|
||||
if DASHBOARD_LABEL not in _label_names(issue):
|
||||
gitea.add_issue_label(issue["number"], label["id"])
|
||||
if fields:
|
||||
gitea.update_issue(issue["number"], fields)
|
||||
return plan
|
||||
|
||||
|
||||
def _renovate_issue(gitea: GiteaClient, number: int) -> dict:
|
||||
issue = gitea.get_issue(number)
|
||||
body = issue.get("body")
|
||||
if issue.get("title") != RENOVATE_DASHBOARD_TITLE or issue.get("state") != "open" or not isinstance(body, str):
|
||||
raise BotError(f"renovate dashboard: issue {number} is not the open Dependency Dashboard")
|
||||
return issue
|
||||
|
||||
|
||||
def report_images(report: dict) -> list[str]:
|
||||
return [t["image"] for t in report["targets"] if t["type"] == "image"]
|
||||
|
||||
|
||||
def run(cfg: dict, gitea: GiteaClient, scanner: ScannerClient, now: datetime, emit: Callable[[str], None]) -> dict:
|
||||
"""One triage pass. Any failure raises, and the heartbeat is the last thing it writes."""
|
||||
dry_run = cfg["dry_run"]
|
||||
report = scanner.report()
|
||||
agg = aggregate(report)
|
||||
now_text = now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
summary: dict = {"dry_run": dry_run, "targets": agg["targets_total"], "critical_with_fix": agg["critical_with_fix"]}
|
||||
summary["dashboard"] = sync_dashboard_issue(gitea, render_dashboard(agg, cfg["page_url"], now), dry_run)
|
||||
|
||||
number = cfg["renovate_issue"]
|
||||
body = _renovate_issue(gitea, number)["body"]
|
||||
remediation = derive_remediation(
|
||||
report_images(report), parse_rows(body), gitea.open_renovate_prs(), parse_detected(body),
|
||||
gitea.pr_url, now_text,
|
||||
)
|
||||
summary["remediation"] = count_states(remediation)
|
||||
|
||||
# Re-read immediately before writing: Renovate rewrites this body, and a tick must apply to
|
||||
# the body as it is now.
|
||||
fresh = _renovate_issue(gitea, number)["body"]
|
||||
ticks = select_ticks(parse_rows(fresh), qualifying_images(report))
|
||||
summary["ticks"] = len(ticks)
|
||||
if ticks and not dry_run:
|
||||
gitea.update_issue(number, {"body": apply_ticks(fresh, ticks)})
|
||||
|
||||
if not dry_run:
|
||||
scanner.triage(now_text, remediation)
|
||||
summary["heartbeat"] = 0 if dry_run else 1
|
||||
summary["fallback_used"] = scanner.used_fallback
|
||||
emit(render_summary(summary))
|
||||
return summary
|
||||
|
||||
|
||||
def render_summary(summary: dict) -> str:
|
||||
verb = "would be" if summary["dry_run"] else "were"
|
||||
dash = summary["dashboard"]
|
||||
states = ", ".join(f"{name} {count}" for name, count in summary["remediation"].items())
|
||||
return "\n".join([
|
||||
f"### CVE triage ({'DRY RUN, no writes' if summary['dry_run'] else 'live'})",
|
||||
"",
|
||||
f"- targets in report: {summary['targets']}",
|
||||
f"- unaccepted CRITICAL with a fix: {summary['critical_with_fix']}",
|
||||
f"- dashboard label created: {dash['label_created']}; issue created: {dash['issue_created']};"
|
||||
f" updated: {dash['issue_updated']}; reopened: {dash['issue_reopened']} ({verb} applied)",
|
||||
f"- remediation states: {states}",
|
||||
f"- approval boxes that {verb} ticked: {summary['ticks']}",
|
||||
f"- heartbeat posted: {summary['heartbeat']}",
|
||||
f"- scanner reached through the ClusterIP fallback: {summary['fallback_used']}",
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
def load_config(env: dict) -> dict:
|
||||
token = env.get("RENOVATE_TOKEN", "")
|
||||
if not token:
|
||||
raise BotError("RENOVATE_TOKEN is not set")
|
||||
repo = env.get("GITEA_REPOSITORY", "Home-Network/home-network-k3s-impl")
|
||||
if not REPO_RE.fullmatch(repo):
|
||||
raise BotError("config: GITEA_REPOSITORY is malformed")
|
||||
urls = {
|
||||
"gitea_url": env.get("GITEA_URL", "https://gitea.home.keithjackson.dev"),
|
||||
"vuln_url": env.get("VULN_API_URL", "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081"),
|
||||
"page_url": env.get("VULN_PAGE_URL", "https://cve.home.keithjackson.dev"),
|
||||
}
|
||||
fallback = env.get("VULN_API_FALLBACK_URL", "")
|
||||
for name, value in {**urls, **({"fallback": fallback} if fallback else {})}.items():
|
||||
if not HTTP_URL_RE.fullmatch(value):
|
||||
raise BotError(f"config: {name} is not a plain http(s) URL")
|
||||
dry_run = env.get("DRY_RUN", "false")
|
||||
if dry_run not in ("true", "false"):
|
||||
raise BotError("config: DRY_RUN must be true or false")
|
||||
issue = env.get("RENOVATE_DASHBOARD_ISSUE", "91")
|
||||
if not issue.isdigit():
|
||||
raise BotError("config: RENOVATE_DASHBOARD_ISSUE is not a number")
|
||||
return {**urls, "token": token, "repo": repo, "fallback": fallback,
|
||||
"dry_run": dry_run == "true", "renovate_issue": int(issue)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def emit(text: str) -> None:
|
||||
print(text)
|
||||
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if path:
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write(text + "\n")
|
||||
|
||||
try:
|
||||
cfg = load_config(dict(os.environ))
|
||||
transport = UrllibTransport()
|
||||
gitea = GiteaClient(cfg["gitea_url"], cfg["repo"], cfg["token"], transport)
|
||||
scanner = ScannerClient(cfg["vuln_url"], cfg["fallback"], transport)
|
||||
run(cfg, gitea, scanner, datetime.now(timezone.utc), emit)
|
||||
except BotError as exc:
|
||||
print(f"::error::CVE triage failed: {exc}", file=sys.stderr)
|
||||
emit(f"### CVE triage FAILED\n\n{exc}\n")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
# =============================================================================
|
||||
# L1 RENDERING — the bot ticks only qualifying approve rows (R-VULN-05, R-VULN-07)
|
||||
# Spec: docs/17 "The triage bot" and its L1 acceptance gates. GIVEN a Renovate dashboard in the
|
||||
# real issue #91 format plus a constructed Pending Approval section, WHEN the bot plans ticks,
|
||||
# THEN only `approve` rows exact-matching an image with an unaccepted CRITICAL with a fix change,
|
||||
# by exactly their marker, and every negative row stays byte-identical.
|
||||
#
|
||||
# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-triage-bot-approval-ticks
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-05: "true"
|
||||
conformance.home.k3s/R-VULN-07: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07"
|
||||
conformance.home.k3s/layer: "L1"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
# The script under test lives in .gitea/workflows/, which requirement-scoped selection does
|
||||
# not attribute to R-VULN-*, so a change to it must still select this test.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/intent: >-
|
||||
A tick is applied only to an approve row for an image with a qualifying finding, and dry
|
||||
runs, duplicate prevention and heartbeat ordering hold through the whole run.
|
||||
spec:
|
||||
steps:
|
||||
- name: plan-and-assert-ticks
|
||||
try:
|
||||
- script:
|
||||
content: bash ./render-check.sh
|
||||
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""L1: the bot ticks only qualifying approve rows, and a whole run behaves (R-VULN-05, R-VULN-07).
|
||||
|
||||
fixtures/renovate-dashboard.md follows issue #91's real format (`## Open` rebase rows, Detected
|
||||
Dependencies) plus a constructed Pending Approval section, because the live dashboard has none.
|
||||
Runs the real functions and the real run() over a fake Gitea and a fake scanner.
|
||||
|
||||
What this does NOT prove: that Renovate honours a tick; that the live dashboard keeps this format
|
||||
across Renovate upgrades (a format change yields no tick rather than a wrong one); that Gitea's
|
||||
API matches the fake; or anything about the runner's network path to the scanner.
|
||||
"""
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[3]
|
||||
spec = importlib.util.spec_from_file_location("cve_triage", ROOT / ".gitea/workflows/scripts/cve_triage.py")
|
||||
bot = importlib.util.module_from_spec(spec)
|
||||
sys.modules["cve_triage"] = bot
|
||||
spec.loader.exec_module(bot)
|
||||
|
||||
RAW = (HERE / "fixtures/report.json").read_bytes()
|
||||
FIXTURE = json.loads(RAW)
|
||||
BODY = (HERE / "fixtures/renovate-dashboard.md").read_text()
|
||||
OPEN_PRS = json.loads((HERE / "fixtures/open-prs.json").read_text())
|
||||
NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc)
|
||||
GITEA = "https://gitea.example.test"
|
||||
REPO = "o/r"
|
||||
VULN = "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081"
|
||||
FALLBACK = "http://10.43.0.99:8081"
|
||||
EXPECTED = {
|
||||
"renovate/nginx-2.x",
|
||||
"renovate/ghcr.io-example-app-2.x",
|
||||
"renovate/example-group",
|
||||
}
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
print(f"{'ok ' if condition else 'FAIL'} {name}")
|
||||
if not condition:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def fails(function):
|
||||
try:
|
||||
function()
|
||||
except AssertionError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def raises(function):
|
||||
try:
|
||||
function()
|
||||
except bot.BotError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def planned(report_doc, body):
|
||||
report = bot.parse_report(json.dumps(report_doc).encode())
|
||||
rows = bot.parse_rows(body)
|
||||
return bot.select_ticks(rows, bot.qualifying_images(report))
|
||||
|
||||
|
||||
def assert_ticks(report_doc, body, expected):
|
||||
"""The tick plan is exactly `expected`, and writing it changes exactly those markers."""
|
||||
ticks = planned(report_doc, body)
|
||||
assert {row.branch for row in ticks} == expected, "tick plan differs"
|
||||
after = bot.apply_ticks(body, ticks)
|
||||
want = body
|
||||
for branch in expected:
|
||||
line = f" - [ ] <!-- approve-branch={branch} -->"
|
||||
assert want.count(line) == 1, "expected row not found exactly once"
|
||||
want = want.replace(line, line.replace("[ ]", "[x]", 1))
|
||||
assert after == want, "output differs from a marker-only edit"
|
||||
|
||||
|
||||
check("exactly the three qualifying approve rows are ticked, and nothing else changes",
|
||||
not fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED)))
|
||||
|
||||
after = bot.apply_ticks(BODY, planned(FIXTURE, BODY))
|
||||
changed = [(a, b) for a, b in zip(BODY.split("\n"), after.split("\n")) if a != b]
|
||||
check("three lines differ, same line count, same length", len(changed) == 3 and len(BODY) == len(after))
|
||||
for label, needle in {
|
||||
"rebase row of a qualifying image": "rebase-branch=renovate/ghcr.io-example-has-pr",
|
||||
"approve row for a non-matching package": "approve-branch=renovate/ghcr.io-other-unrelated",
|
||||
"approve row matching only by substring": "approve-branch=renovate/ghcr.io-foo-bar-extras",
|
||||
"approve row for a HIGH-only image": "approve-branch=renovate/ghcr.io-example-high-only",
|
||||
"approve row for an accepted-CRITICAL image": "approve-branch=renovate/ghcr.io-example-accepted-only",
|
||||
"approve row for a CRITICAL with no fix": "approve-branch=renovate/ghcr.io-example-nofix",
|
||||
"approve row with no container package": "approve-branch=renovate/mychart",
|
||||
"approvePr row of a qualifying image": "approvePr-branch=renovate/ghcr.io-example-app-automerge",
|
||||
"unschedule row of a qualifying image": "unschedule-branch=renovate/ghcr.io-example-pending-sched",
|
||||
"decoy approve row below Detected Dependencies": "approve-branch=renovate/decoy",
|
||||
}.items():
|
||||
original = next(line for line in BODY.split("\n") if needle in line)
|
||||
check(f"untouched: {label}", original in after.split("\n"))
|
||||
check("untouched: the approve-all row", "- [ ] <!-- approve-all-pending-prs -->" in after)
|
||||
check("untouched: the already ticked row keeps its mark",
|
||||
" - [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.x -->" in after)
|
||||
|
||||
# Non-vacuity: each mutation must make the exact assertion above fail.
|
||||
def mutated_finding(image, **fields):
|
||||
doc = copy.deepcopy(FIXTURE)
|
||||
target = next(t for t in doc["targets"] if t["image"] == image)
|
||||
finding = next(f for f in doc["findings"] if f["targetId"] == target["id"])
|
||||
finding.update(fields)
|
||||
return doc
|
||||
|
||||
|
||||
for label, doc in {
|
||||
"HIGH-only image raised to CRITICAL": mutated_finding("ghcr.io/example/high-only", severity="CRITICAL"),
|
||||
"accepted CRITICAL un-accepted": mutated_finding("ghcr.io/example/accepted-only", status="fix-available"),
|
||||
"qualifying finding accepted": mutated_finding("ghcr.io/example/app", status="accepted"),
|
||||
"CRITICAL gains a fix": mutated_finding("ghcr.io/example/nofix", status="fix-available", fixedVersion="2"),
|
||||
}.items():
|
||||
check(f"non-vacuity: mutated fixture fails ({label})", fails(lambda: assert_ticks(doc, BODY, EXPECTED)))
|
||||
|
||||
mutated_body = BODY.replace("rebase-branch=renovate/ghcr.io-example-has-pr", "approve-branch=renovate/ghcr.io-example-has-pr")
|
||||
check("non-vacuity: a rebase row rewritten as approve is then ticked",
|
||||
fails(lambda: assert_ticks(FIXTURE, mutated_body, EXPECTED)))
|
||||
|
||||
real_match = bot.row_matches
|
||||
bot.row_matches = lambda row, qualifying: any(q in p for p in row.packages for q in qualifying)
|
||||
check("non-vacuity: a substring matcher fails the assertion", fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED)))
|
||||
bot.row_matches = real_match
|
||||
|
||||
for raw, want in {
|
||||
"docker.io/library/nginx": "nginx",
|
||||
"library/nginx": "nginx",
|
||||
"nginx:1.27": "nginx",
|
||||
"Docker.IO/Pihole/Pihole": "pihole/pihole",
|
||||
"ghcr.io/library/x": "ghcr.io/library/x",
|
||||
"registry.example:5000/a/b:v1@sha256:" + "0" * 64: "registry.example:5000/a/b",
|
||||
"bad image": "",
|
||||
"": "",
|
||||
}.items():
|
||||
check(f"normalise_image({raw[:40]!r})", bot.normalise_image(raw) == want)
|
||||
|
||||
ticks = planned(FIXTURE, BODY)
|
||||
moved = BODY.replace(" - [ ] <!-- approve-branch=renovate/nginx-2.x -->", " - [ ] <!-- rebase-branch=renovate/nginx-2.x -->")
|
||||
check("apply_ticks refuses a row that stopped being approve", raises(lambda: bot.apply_ticks(moved, ticks)))
|
||||
crlf = BODY.replace("\n", "\r\n")
|
||||
crlf_after = bot.apply_ticks(crlf, planned(FIXTURE, crlf))
|
||||
check("CRLF body keeps every line ending", crlf_after.count("\r\n") == crlf.count("\r\n") and crlf_after.count("[x]") == 4)
|
||||
|
||||
|
||||
class FakeWorld:
|
||||
"""A fake Gitea and scanner reached through the transport interface. Records every write."""
|
||||
|
||||
def __init__(self, renovate_body=BODY, report=RAW):
|
||||
self.report = report
|
||||
self.labels = []
|
||||
self.issues = {91: {"number": 91, "title": "Dependency Dashboard", "state": "open", "labels": [], "body": renovate_body, "pull_request": None}}
|
||||
self.next_issue = 200
|
||||
self.writes = []
|
||||
self.triage = None
|
||||
self.fail = None
|
||||
self.dns_down = set()
|
||||
self.seen_hosts = []
|
||||
|
||||
def __call__(self, method, url, headers, body, max_bytes):
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
self.seen_hosts.append(parts.netloc)
|
||||
if parts.netloc in self.dns_down:
|
||||
raise bot.DnsFailure()
|
||||
if self.fail and self.fail[0] == method and self.fail[1] in parts.path:
|
||||
return 500, b""
|
||||
payload = json.loads(body) if body else None
|
||||
if parts.netloc != "gitea.example.test":
|
||||
if method == "GET" and parts.path == "/report.json":
|
||||
return 200, self.report
|
||||
if method == "POST" and parts.path == "/triage":
|
||||
self.writes.append(("POST", "triage"))
|
||||
self.triage = payload
|
||||
return 204, b""
|
||||
return 404, b""
|
||||
path = parts.path.removeprefix(f"/api/v1/repos/{REPO}")
|
||||
query = dict(urllib.parse.parse_qsl(parts.query))
|
||||
page = int(query.get("page", "1"))
|
||||
if method != "GET":
|
||||
self.writes.append((method, path))
|
||||
if method == "GET" and path == "/labels":
|
||||
return 200, json.dumps(self.labels if page == 1 else []).encode()
|
||||
if method == "POST" and path == "/labels":
|
||||
label = {"id": 900 + len(self.labels), "name": payload["name"]}
|
||||
self.labels.append(label)
|
||||
return 201, json.dumps(label).encode()
|
||||
if method == "GET" and path == "/pulls":
|
||||
return 200, json.dumps(OPEN_PRS if page == 1 else []).encode()
|
||||
if method == "GET" and path == "/issues":
|
||||
items = list(self.issues.values())
|
||||
if "labels" in query:
|
||||
items = [i for i in items if query["labels"] in {x["name"] for x in i["labels"]}]
|
||||
if "q" in query:
|
||||
items = [i for i in items if query["q"] in i["title"]]
|
||||
return 200, json.dumps(items if page == 1 else []).encode()
|
||||
if method == "POST" and path == "/issues":
|
||||
number = self.next_issue
|
||||
self.next_issue += 1
|
||||
names = [x for x in self.labels if x["id"] in payload["labels"]]
|
||||
self.issues[number] = {"number": number, "title": payload["title"], "state": "open", "labels": names, "body": payload["body"], "pull_request": None}
|
||||
return 201, json.dumps(self.issues[number]).encode()
|
||||
if path.startswith("/issues/") and path.endswith("/labels") and method == "POST":
|
||||
number = int(path.split("/")[2])
|
||||
self.issues[number]["labels"] += [x for x in self.labels if x["id"] in payload["labels"]]
|
||||
return 200, b"[]"
|
||||
if path.startswith("/issues/"):
|
||||
number = int(path.split("/")[2])
|
||||
if method == "GET":
|
||||
return 200, json.dumps(self.issues[number]).encode()
|
||||
if method == "PATCH":
|
||||
self.issues[number].update(payload)
|
||||
return 201, json.dumps(self.issues[number]).encode()
|
||||
return 404, b""
|
||||
|
||||
|
||||
def execute(world, dry_run=False, fallback=FALLBACK):
|
||||
cfg = {"dry_run": dry_run, "page_url": "https://cve.example.test", "renovate_issue": 91}
|
||||
gitea = bot.GiteaClient(GITEA, REPO, "fixture-token", world)
|
||||
scanner = bot.ScannerClient(VULN, fallback, world)
|
||||
lines = []
|
||||
summary = bot.run(cfg, gitea, scanner, NOW, lines.append)
|
||||
return summary, "\n".join(lines)
|
||||
|
||||
|
||||
def dashboards(world):
|
||||
return [i for i in world.issues.values() if i["title"] == bot.DASHBOARD_TITLE]
|
||||
|
||||
|
||||
# Dry run: reads everything, writes nothing, reports the plan as counts.
|
||||
world = FakeWorld()
|
||||
summary, text = execute(world, dry_run=True)
|
||||
check("dry run performs no writes", world.writes == [] and world.triage is None and not dashboards(world))
|
||||
check("dry run plans the three ticks and the issue", summary["ticks"] == 3 and summary["dashboard"]["issue_created"] == 1)
|
||||
check("dry run leaves the dashboard body untouched", world.issues[91]["body"] == BODY)
|
||||
|
||||
NAMES = {"CVE-2026", "fixture-pkg", "fixture-ns-", "fixture-workload", "nginx", "ghcr.io"}
|
||||
|
||||
|
||||
def leaks(text_value):
|
||||
return [name for name in NAMES if name.lower() in text_value.lower()]
|
||||
|
||||
|
||||
check("dry-run summary is counts only", not leaks(text))
|
||||
|
||||
# Live run.
|
||||
world = FakeWorld()
|
||||
summary, text = execute(world)
|
||||
board = dashboards(world)
|
||||
check("live run creates one label and one dashboard issue", len(world.labels) == 1 and len(board) == 1)
|
||||
check("the dashboard issue carries only its own label", [x["name"] for x in board[0]["labels"]] == ["cve-dashboard"])
|
||||
check("issue body and summary are counts only", not leaks(board[0]["body"]) and not leaks(text))
|
||||
check("exactly one PATCH to issue #91", world.writes.count(("PATCH", "/issues/91")) == 1)
|
||||
want_body = BODY
|
||||
for branch in EXPECTED:
|
||||
line = f" - [ ] <!-- approve-branch={branch} -->"
|
||||
want_body = want_body.replace(line, line.replace("[ ]", "[x]", 1))
|
||||
check("the PATCHed body is the marker-only edit", world.issues[91]["body"] == want_body)
|
||||
check("the heartbeat is the last write", world.writes[-1] == ("POST", "triage"))
|
||||
check("heartbeat shape is exact", set(world.triage) == {"heartbeatAt", "remediation"} and world.triage["heartbeatAt"] == "2026-11-09T07:15:00Z")
|
||||
for entry in world.triage["remediation"]:
|
||||
if set(entry) != {"image", "state", "url", "updatedAt"} or entry["state"] not in bot.REMEDIATION_STATES:
|
||||
check("remediation entry shape", False)
|
||||
states = {e["image"]: (e["state"], e["url"]) for e in world.triage["remediation"]}
|
||||
for image, want in {
|
||||
"docker.io/library/nginx": ("approval-requested", None),
|
||||
"ghcr.io/example/app": ("approval-requested", None),
|
||||
"ghcr.io/example/grouped": ("approval-requested", None),
|
||||
"ghcr.io/example/already-ticked": ("approval-requested", None),
|
||||
"ghcr.io/example/high-only": ("approval-requested", None),
|
||||
"ghcr.io/example/has-pr": ("pr-open", f"{GITEA}/{REPO}/pulls/173"),
|
||||
"ghcr.io/example/pr-by-title": ("pr-open", f"{GITEA}/{REPO}/pulls/174"),
|
||||
"ghcr.io/example/tracked-clean": ("no-update-identified", None),
|
||||
"ghcr.io/foo/bar": ("no-update-identified", None),
|
||||
"ghcr.io/example/untracked": ("not-tracked", None),
|
||||
"ghcr.io/example/pending-sched": ("not-tracked", None),
|
||||
}.items():
|
||||
check(f"remediation for {image}", states.get(image) == want)
|
||||
check("one remediation entry per image", len(states) == len(FIXTURE["targets"]))
|
||||
|
||||
# A second run updates the same issue.
|
||||
before = len(world.issues)
|
||||
second = copy.deepcopy(FIXTURE)
|
||||
second["targets"][-1]["state"] = "stale"
|
||||
world.report = json.dumps(second).encode()
|
||||
summary, _ = execute(world)
|
||||
check("a second run creates no duplicate", len(dashboards(world)) == 1 and len(world.issues) == before)
|
||||
check("a second run updates the issue", summary["dashboard"]["issue_updated"] == 1 and "| stale | 1 |" in dashboards(world)[0]["body"])
|
||||
check("a second run has nothing left to tick", summary["ticks"] == 0)
|
||||
|
||||
# The dashboard issue is found even without its label, and a closed one is reopened.
|
||||
world = FakeWorld()
|
||||
world.labels.append({"id": 900, "name": "cve-dashboard"})
|
||||
world.issues[150] = {"number": 150, "title": "CVE Dashboard", "state": "closed", "labels": [], "body": "old", "pull_request": None}
|
||||
summary, _ = execute(world)
|
||||
check("an unlabelled closed dashboard is reused, relabelled and reopened",
|
||||
len(dashboards(world)) == 1 and world.issues[150]["state"] == "open"
|
||||
and [x["name"] for x in world.issues[150]["labels"]] == ["cve-dashboard"] and summary["dashboard"]["issue_created"] == 0)
|
||||
|
||||
world = FakeWorld()
|
||||
world.labels.append({"id": 900, "name": "cve-dashboard"})
|
||||
for number in (150, 151):
|
||||
world.issues[number] = {"number": number, "title": "CVE Dashboard", "state": "open", "labels": [world.labels[0]], "body": "x", "pull_request": None}
|
||||
check("two dashboard issues fail the run", raises(lambda: execute(world)))
|
||||
check("two dashboard issues write nothing", world.writes == [])
|
||||
|
||||
# Failures end the run before the heartbeat.
|
||||
world = FakeWorld()
|
||||
world.fail = ("PATCH", "/issues/91")
|
||||
check("a failed tick PATCH fails the run", raises(lambda: execute(world)))
|
||||
check("a failed run posts no heartbeat", world.triage is None)
|
||||
world = FakeWorld(report=b"{not json")
|
||||
check("an unreadable report fails the run", raises(lambda: execute(world)))
|
||||
check("an unreadable report writes nothing", world.writes == [])
|
||||
world = FakeWorld()
|
||||
world.issues[91]["title"] = "Something else"
|
||||
check("issue 91 being something else fails the run", raises(lambda: execute(world)))
|
||||
world = FakeWorld()
|
||||
world.issues[91]["state"] = "closed"
|
||||
check("a closed Renovate dashboard fails the run", raises(lambda: execute(world)))
|
||||
|
||||
# DNS fallback, and nothing but DNS failure selects it.
|
||||
world = FakeWorld()
|
||||
world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081")
|
||||
summary, _ = execute(world)
|
||||
check("DNS failure uses the fallback for report and heartbeat", summary["fallback_used"] and world.triage is not None)
|
||||
check("the fallback was actually contacted", "10.43.0.99:8081" in world.seen_hosts)
|
||||
world = FakeWorld()
|
||||
world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081")
|
||||
check("DNS failure with no fallback fails the run", raises(lambda: execute(world, fallback="")))
|
||||
|
||||
|
||||
def refused(method, url, headers, body, max_bytes):
|
||||
raise bot.BotError("GET /report.json: ConnectionRefusedError")
|
||||
|
||||
|
||||
check("a refused connection fails the run and does not fall back",
|
||||
raises(lambda: bot.ScannerClient(VULN, FALLBACK, refused).report()))
|
||||
|
||||
for label, env, expected_ok in (
|
||||
("missing token", {}, False),
|
||||
("present token", {"RENOVATE_TOKEN": "x"}, True),
|
||||
("bad DRY_RUN", {"RENOVATE_TOKEN": "x", "DRY_RUN": "maybe"}, False),
|
||||
("non-http API URL", {"RENOVATE_TOKEN": "x", "VULN_API_URL": "file:///etc/passwd"}, False),
|
||||
):
|
||||
try:
|
||||
bot.load_config(env)
|
||||
ok = True
|
||||
except bot.BotError:
|
||||
ok = False
|
||||
check(f"load_config: {label}", ok is expected_ok)
|
||||
|
||||
print(f"{len(failures)} failure(s)")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"number": 173, "title": "chore(deps): Update ghcr.io/example/has-pr Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-has-pr-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/173"},
|
||||
{"number": 174, "title": "chore(deps): Update ghcr.io/example/pr-by-title Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-pr-by-title-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/174"},
|
||||
{"number": 160, "title": "WIP: chore(spec): not Renovate work", "head": {"ref": "chore/adopt-spec-sync"}, "html_url": "https://gitea.example.test/o/r/pulls/160"}
|
||||
]
|
||||
+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