From c3c72c47bbb15afec2c6c3c635f0e5a53602cddf Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 9 Nov 2026 09:39:24 -0600 Subject: [PATCH 01/10] feat(vuln): CVE triage bot workflow, script and L1 tests Scheduled Gitea Actions bot in the Renovate shape: maintains a counts-only CVE Dashboard issue, derives remediation states from Renovate's dashboard and PRs, ticks approve rows for exact-matched images with an unaccepted CRITICAL with a fix, and posts the heartbeat. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/cve-triage.yaml | 160 ++++ .gitea/workflows/scripts/cve_triage.py | 820 ++++++++++++++++++ .../chainsaw-test.yaml | 32 + .../vuln-triage-bot-approval-ticks/check.py | 363 ++++++++ .../fixtures/open-prs.json | 5 + .../fixtures/renovate-dashboard.md | 71 ++ .../fixtures/report.json | 425 +++++++++ .../render-check.sh | 6 + .../chainsaw-test.yaml | 31 + .../check.py | 162 ++++ .../fixtures/report.json | 20 + .../render-check.sh | 6 + 12 files changed, 2101 insertions(+) create mode 100644 .gitea/workflows/cve-triage.yaml create mode 100755 .gitea/workflows/scripts/cve_triage.py create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/chainsaw-test.yaml create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/check.py create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/open-prs.json create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/renovate-dashboard.md create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/report.json create mode 100755 conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/render-check.sh create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/chainsaw-test.yaml create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/check.py create mode 100644 conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/fixtures/report.json create mode 100755 conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/render-check.sh diff --git a/.gitea/workflows/cve-triage.yaml b/.gitea/workflows/cve-triage.yaml new file mode 100644 index 0000000..ab1b843 --- /dev/null +++ b/.gitea/workflows/cve-triage.yaml @@ -0,0 +1,160 @@ +# CVE triage bot (R-VULN-05, R-VULN-07; docs/17 "The triage bot"; ADR-0027). Same shape as +# renovate.yaml: scheduled, loud on a missing credential, never concurrent with itself, and it +# records what it did in the job summary. The logic and its reasoning live in +# scripts/cve_triage.py; this file only supplies the image, the code, the credential and the +# schedule. +# +# WHY IT CANNOT SUCCEED SILENTLY. A skipped Gitea job reports `success` (CLAUDE.md §2), so the +# token preflight exits 1 rather than skipping, and an unreachable scanner fails the script +# rather than skipping. The heartbeat POST is the script's last action, so VulnTriageBotSilent +# fires for every run that did not get all the way through, and when the schedule stops firing. +# +# THE ONE SECRET: RENOVATE_TOKEN, deliberately reused (ADR-0027). It acts as renovate-bot, which +# is also what authored the dashboard this edits. It needs read:repository and write:issue. +name: cve-triage + +on: + schedule: + # 07:15 UTC, one hour before Renovate's 08:15 (renovate.yaml), so a tick is read the same + # morning, and clear of its run so the bot's edit cannot race Renovate's rewrite of the body. + - cron: "15 7 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "Dry run: read everything, write nothing, print counts." + required: false + default: "true" + +concurrency: + group: cve-triage + cancel-in-progress: false + +jobs: + triage: + runs-on: ubuntu-latest + # THE TOOLBOX, the image gate.yml already runs, pinned by digest. A job here has no Docker + # daemon, so a job container is the only way to choose an image. The toolbox provides + # python3, git, curl and jq, pulls anonymously like the gate's, and is normally already in + # the DinD store. It has no Node, so actions/checkout cannot run; a public python image has + # neither Node nor git and would add a second image to keep pinned. The cost is a cold pull + # after a runner restart, which the gate pays anyway. + container: + # renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main + image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81 + # Without a bound, a wedged run would hold the concurrency group and block every later run. + timeout-minutes: 15 + steps: + - name: Preflight - the token must exist, and its absence must be LOUD + shell: bash + env: + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} + run: | + set -euo pipefail + if [ -z "${RENOVATE_TOKEN:-}" ]; then + echo "::error::RENOVATE_TOKEN is not set on this repository." >&2 + echo "A skipped job reports success, so this fails on purpose: without the token no dashboard is maintained and no approval is requested. Add the Actions secret RENOVATE_TOKEN (scopes read:repository, write:issue)." >&2 + exit 1 + fi + echo "RENOVATE_TOKEN is present (value not printed)." + + - name: Preflight - Gitea is reachable and the token can do this job + shell: bash + env: + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} + # GETs only. The token reaches curl on stdin, never argv. A 401 is the token's value, a 403 + # its scope; the script's own errors would only show a bare status later. + run: | + set -uo pipefail + GITEA=https://gitea.home.keithjackson.dev + REPO=Home-Network/home-network-k3s-impl + fail=0 + body=$(mktemp) + fetch() { + printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \ + | curl -sS --max-time 30 --config - -o "$body" -w '%{http_code}' "$GITEA$1" || true + } + probe() { # + local code + code=$(fetch "$2") + if [ "$code" = 200 ]; then + echo "ok $1" + else + fail=1 + echo "::error::$1: GET ${2%%\?*} -> $code" + fi + } + probe "read:repository, Gitea reachable" "/api/v1/repos/$REPO" + if [ "$(jq -r '.permissions.push // false' "$body" 2>/dev/null)" != "true" ]; then + fail=1 + echo "::error::renovate-bot has no write access to $REPO, so it cannot create labels or edit issues." + fi + probe "read:issue" "/api/v1/repos/$REPO/labels?limit=1" + probe "Renovate's dashboard readable" "/api/v1/repos/$REPO/issues/91" + # A GET cannot prove a write scope; Gitea's scope refusal lists the token's scopes + # (names only). Same technique as renovate.yaml, advisory if the message format changes. + fetch "/api/v1/admin/orgs?limit=1" > /dev/null + scopes=$(jq -r '.message // empty' "$body" 2>/dev/null | sed -n 's/.*token scope=//p') + if [ -n "$scopes" ]; then + case ",$scopes," in + *",write:issue,"*|*",all,"*) echo "ok write:issue" ;; + *) echo "::error::scope write:issue is missing (token scope=$scopes)"; fail=1 ;; + esac + else + echo "::warning::could not read the token's scope list; write:issue is UNVERIFIED" + fi + rm -f "$body" + [ "$fail" -eq 0 ] + + # Plain git for the same reason as gate.yml: the toolbox has no Node. One commit is enough, + # and the job token goes to the single fetch only, never into .git/config. + - name: Checkout the script (git, one commit) + shell: bash + env: + GIT_TOKEN: ${{ github.token }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + umask 0022 + git config --global --add safe.directory "$PWD" + git init -q . + git remote add origin "${SERVER_URL}/${REPOSITORY}" + auth="$(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)" + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \ + git -c protocol.version=2 fetch --quiet --depth=1 --no-tags origin \ + "+${SHA}:refs/remotes/checkout/head" + unset auth GIT_TOKEN + git checkout -q --force --detach "$SHA" + [ "$(git rev-parse HEAD)" = "$SHA" ] + + - name: Triage + shell: bash + env: + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} + GITEA_URL: https://gitea.home.keithjackson.dev + GITEA_REPOSITORY: Home-Network/home-network-k3s-impl + RENOVATE_DASHBOARD_ISSUE: "91" + VULN_API_URL: http://vuln-scanner.vuln-scanner.svc.cluster.local:8081 + # The Service's pinned spec.clusterIP (platform/vuln-scanner/service.yaml), used only + # when the name above fails to resolve. Empty disables the fallback, and a DNS failure + # then fails the job. + VULN_API_FALLBACK_URL: "" + VULN_PAGE_URL: https://cve.home.keithjackson.dev + # Anything other than an explicit false on a dispatch is a dry run; a schedule is live. + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }} + run: python3 .gitea/workflows/scripts/cve_triage.py + + - name: Summary + if: always() + run: | + { + echo "## CVE triage" + echo + echo "- trigger: \`${{ github.event_name }}\`" + echo "- dry run requested: \`${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}\`" + echo "- outcome: \`${{ job.status }}\`" + echo + echo "Counts only: which CVE lands where is on the CVE page, never here." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitea/workflows/scripts/cve_triage.py b/.gitea/workflows/scripts/cve_triage.py new file mode 100755 index 0000000..8465898 --- /dev/null +++ b/.gitea/workflows/scripts/cve_triage.py @@ -0,0 +1,820 @@ +#!/usr/bin/env python3 +"""CVE triage bot (docs/17 "The triage bot", ADR-0027; R-VULN-05, R-VULN-07). + +Runs in .gitea/workflows/cve-triage.yaml, once a day, before Renovate. Each run: + 1. reads report.json from the scanner's internal API; + 2. creates or updates the single `CVE Dashboard` issue (aggregates only); + 3. reads Renovate's Dependency Dashboard and open PRs, derives each image's remediation state; + 4. ticks `approve-branch` boxes on that dashboard, for packages matching an image with an + unaccepted CRITICAL that has a fix; + 5. POSTs the heartbeat and the remediation states to the scanner. + +WHAT IT MUST NEVER DO. Renovate is the only author of version changes (R-UPD-02): this script +never edits a version, opens a bump PR, merges anything, or posts `/approve-override`. It never +ticks a `rebase-branch` row. The dashboard issue carries no CVE id, package, image or workload, +because the repository is readable without authentication, and no label the in-cluster PM acts +on (platform/pm-agent/configmap.yaml PM_REQUIRE_LABEL = `agent`). + +OUTPUT IS COUNTS ONLY. Job logs and the summary are as visible as the issue, so nothing here +prints a report string. Every report value is untrusted: strings are validated, and only counts +and timestamps that passed RFC 3339 validation ever reach markdown. + +HOW A JOB CONTAINER REACHES THE SCANNER, AND WHAT IS NOT PROVEN +--------------------------------------------------------------- +Jobs are containers on a per-job docker bridge network inside the DinD sidecar of the act-runner +pod (apps/wave-1/gitea-actions.yaml). The runner config sets no `container.network` and dockerd no +`--dns`, so name resolution is docker's embedded resolver forwarding to the resolvers of the +dockerd container, which are that pod's: cluster DNS. Egress is masqueraded to the pod IP +(measured 2026-10-29, platform/buildkit/networkpolicy.yaml header), so a ClusterIP is routed and +policy-matched as the act-runner pod in gitea-actions. images.yaml relies on the same path to +reach buildkitd.buildkit.svc.cluster.local, which is indirect evidence only. + UNVERIFIED: in-cluster DNS from a DinD job container. + UNVERIFIED: ClusterIP reachability from a DinD job container to vuln-scanner:8081. +Hence VULN_API_FALLBACK_URL (the Service's pinned ClusterIP), used only when the name fails to +resolve. Anything else, including refused and timed-out connections, fails the job: an +unreachable API is a red run, never a skip. +""" +from __future__ import annotations + +import http.client +import json +import os +import re +import socket +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timedelta, timezone +from typing import Callable, Iterable, NamedTuple + +DASHBOARD_TITLE = "CVE Dashboard" +DASHBOARD_LABEL = "cve-dashboard" +DASHBOARD_LABEL_COLOR = "b60205" +# Labels the in-cluster PM dispatcher selects work by. Never applied to anything here. +FORBIDDEN_LABELS = frozenset({"agent"}) +RENOVATE_DASHBOARD_TITLE = "Dependency Dashboard" + +SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN") +FINDING_STATUSES = ("open", "fix-available", "accepted") +TARGET_STATES = ("scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered") +TARGET_TYPES = ("image", "vm", "node") +RUN_STATUSES = ("complete", "degraded") +REMEDIATION_STATES = ("pr-open", "approval-requested", "no-update-identified", "not-tracked") + +MAX_REPORT_BYTES = 128 * 1024 * 1024 +MAX_API_BYTES = 8 * 1024 * 1024 +MAX_FIELD_LEN = 4096 +MAX_IMAGE_LEN = 512 +MAX_ISSUE_BODY = 2 * 1024 * 1024 +MAX_DASHBOARD_ROWS = 2000 +MAX_DETECTED_DEPS = 20000 +MAX_REMEDIATION = 5000 +MAX_PAGES = 200 +PAGE_SIZE = 50 +REPORT_STALE_HOURS = 26 +DATABASE_STALE_HOURS = 48 + +HTTP_TIMEOUT = 60 + +RFC3339_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})") +IMAGE_RE = re.compile(r"[a-z0-9]([a-z0-9._:/-]*[a-z0-9])?") +HTTP_URL_RE = re.compile(r"https?://[A-Za-z0-9.-]+(:\d{1,5})?(/[A-Za-z0-9._~%/-]*)?") +REPO_RE = re.compile(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+") + +# Renovate's own row shape (lib/workers/repository/dependency-dashboard.ts, getListItem). The kind +# is captured whole so `approvePr-branch` can never read as `approve-branch`. +ROW_RE = re.compile(r" - \[(?P[ xX])\] (?P.*)") +LINKED_TITLE_RE = re.compile(r"\[(?P.*)\]\((?P<target>[^()\s]*)\)(?P<tail>.*)") +PR_LINK_RE = re.compile(r"(?:^|/)pulls?/(\d{1,9})$") +PACKAGE_LIST_RE = re.compile(r"\s\(((?:`[^`\s]+`(?:, )?)+)\)\s*$") +TITLE_PACKAGE_RE = re.compile(r"(?:^|\s)(?:Update|Pin)\s+(\S+)\s+Docker\s+(?:tag|digest)\b") +SUMMARY_RE = re.compile(r"<details><summary>([^<]*?)(?: \(\d+\))?</summary>") +DETECTED_RE = re.compile(r" - `(?P<name>[^\s`]+)(?: [^`]*)?`(?P<updates>\s*→ \[Updates:.*\])?\s*") + +# Detected-dependency managers whose names are charts or actions, never container images. +NON_IMAGE_MANAGERS = frozenset({"flux", "helmv3", "github-actions"}) + + +class BotError(Exception): + """A failure that ends the run. The message must be safe to print: no report text, no secret.""" + + +class DnsFailure(Exception): + """The hostname did not resolve. The only condition that selects the fallback address.""" + + +# ---------------------------------------------------------------- pure: normalisation + report + + +def normalise_image(value: object) -> str: + """Canonical form for exact comparison, or "" when the value is not an image reference. + + Lowercase; drop digest and tag; drop the Docker Hub registry and the `library/` namespace. + `library/` is only Hub's implicit namespace, so ghcr.io/library/x keeps it. Comparison of the + result is always string equality, never a substring test. + """ + if not isinstance(value, str) or not value or len(value) > MAX_IMAGE_LEN: + return "" + name = value.strip().lower().split("@", 1)[0] + head, sep, last = name.rpartition("/") + name = f"{head}{sep}{last.split(':', 1)[0]}" + for hub in ("docker.io/", "index.docker.io/", "registry-1.docker.io/"): + if name.startswith(hub): + name = name[len(hub):] + break + if name.startswith("library/"): + name = name[len("library/"):] + return name if IMAGE_RE.fullmatch(name) else "" + + +def parse_timestamp(value: object, field: str, nullable: bool = False) -> datetime | None: + if value is None and nullable: + return None + if not isinstance(value, str) or not RFC3339_RE.fullmatch(value): + raise BotError(f"report: {field} is not an RFC 3339 timestamp") + text = re.sub(r"\.\d+", "", value).replace("Z", "+00:00") + try: + return datetime.fromisoformat(text) + except ValueError as exc: + raise BotError(f"report: {field} is not a valid timestamp") from exc + + +def _object(value: object, field: str) -> dict: + if not isinstance(value, dict): + raise BotError(f"report: {field} is not an object") + return value + + +def _array(value: object, field: str) -> list: + if not isinstance(value, list): + raise BotError(f"report: {field} is not an array") + return value + + +def _string(value: object, field: str, nullable: bool = False) -> str | None: + if value is None and nullable: + return None + if not isinstance(value, str) or len(value) > MAX_FIELD_LEN: + raise BotError(f"report: {field} is not a bounded string") + return value + + +def _member(value: object, allowed: Iterable[str], field: str) -> str: + if not isinstance(value, str) or value not in allowed: + raise BotError(f"report: {field} has a value outside its enumeration") + return value + + +def parse_report(raw: bytes) -> dict: + """Decode and validate report.json into the only shape the rest of this file reads.""" + try: + doc = json.loads(raw) + except (ValueError, RecursionError) as exc: + raise BotError("report: body is not valid JSON") from exc + doc = _object(doc, "document") + if doc.get("schemaVersion") != 1 or isinstance(doc.get("schemaVersion"), bool): + raise BotError("report: unsupported schemaVersion") + parse_timestamp(doc.get("generatedAt"), "generatedAt") + database = _object(doc.get("database"), "database") + run = _object(doc.get("run"), "run") + parse_timestamp(database.get("updatedAt"), "database.updatedAt", nullable=True) + parse_timestamp(run.get("startedAt"), "run.startedAt", nullable=True) + parse_timestamp(run.get("finishedAt"), "run.finishedAt", nullable=True) + if run.get("status") is not None: + _member(run.get("status"), RUN_STATUSES, "run.status") + + targets = [] + known_ids = {} + for entry in _array(doc.get("targets"), "targets"): + entry = _object(entry, "target") + target_id = _string(entry.get("id"), "target.id") + kind = _member(entry.get("type"), TARGET_TYPES, "target.type") + state = _member(entry.get("state"), TARGET_STATES, "target.state") + image = _string(entry.get("image"), "target.image", nullable=True) + if kind == "image" and not normalise_image(image): + raise BotError("report: an image target has an invalid image reference") + known_ids[target_id] = image if kind == "image" else None + targets.append({"id": target_id, "type": kind, "state": state, "image": image}) + + findings = [] + for entry in _array(doc.get("findings"), "findings"): + entry = _object(entry, "finding") + target_id = _string(entry.get("targetId"), "finding.targetId") + if target_id not in known_ids: + raise BotError("report: a finding references an unknown target") + findings.append({ + "targetId": target_id, + "image": known_ids[target_id], + "severity": _member(entry.get("severity"), SEVERITIES, "finding.severity"), + "status": _member(entry.get("status"), FINDING_STATUSES, "finding.status"), + "fixedVersion": _string(entry.get("fixedVersion"), "finding.fixedVersion", nullable=True), + }) + return { + "generatedAt": doc["generatedAt"], + "database": {"updatedAt": database.get("updatedAt")}, + "run": {"finishedAt": run.get("finishedAt"), "status": run.get("status")}, + "targets": targets, + "findings": findings, + } + + +def qualifies(finding: dict) -> bool: + """An unaccepted CRITICAL with a fix: the only thing that may cause a tick.""" + return ( + finding["severity"] == "CRITICAL" + and finding["status"] == "fix-available" + and bool(finding["fixedVersion"]) + ) + + +def qualifying_images(report: dict) -> set[str]: + return { + norm + for finding in report["findings"] + if qualifies(finding) and (norm := normalise_image(finding["image"])) + } + + +def aggregate(report: dict) -> dict: + severity = {sev: dict.fromkeys(FINDING_STATUSES, 0) for sev in SEVERITIES} + for finding in report["findings"]: + severity[finding["severity"]][finding["status"]] += 1 + coverage = dict.fromkeys(TARGET_STATES, 0) + for target in report["targets"]: + coverage[target["state"]] += 1 + return { + "severity": severity, + "coverage": coverage, + "targets_total": len(report["targets"]), + "critical_with_fix": sum(1 for f in report["findings"] if qualifies(f)), + "generatedAt": report["generatedAt"], + "databaseUpdatedAt": report["database"]["updatedAt"], + "runFinishedAt": report["run"]["finishedAt"], + "runStatus": report["run"]["status"], + } + + +def render_dashboard(agg: dict, page_url: str, now: datetime) -> str: + """The CVE Dashboard issue body: a fixed template over counts and validated timestamps.""" + if not HTTP_URL_RE.fullmatch(page_url): + raise BotError("config: VULN_PAGE_URL is not a plain http(s) URL") + warnings = [] + generated = parse_timestamp(agg["generatedAt"], "generatedAt") + if now - generated > timedelta(hours=REPORT_STALE_HOURS): + warnings.append(f"The report is older than {REPORT_STALE_HOURS} hours.") + updated = parse_timestamp(agg["databaseUpdatedAt"], "database.updatedAt", nullable=True) + if updated is None or now - updated > timedelta(hours=DATABASE_STALE_HOURS): + warnings.append(f"The vulnerability database is older than {DATABASE_STALE_HOURS} hours or unknown.") + if agg["runStatus"] != "complete": + warnings.append("The latest scan run did not complete cleanly.") + + lines = [ + "Maintained by the CVE triage bot, once a day. **Counts only**: this repository is readable", + "without authentication, so which CVE lands where is only on the", + f"[CVE page]({page_url}) (SSO, platform-admins).", + "", + ] + lines += [f"> **WARNING:** {text}" for text in warnings] + if warnings: + lines.append("") + lines += [ + "## Freshness", + "", + "| | |", + "|---|---|", + f"| Report generated | {agg['generatedAt']} |", + f"| Database updated | {agg['databaseUpdatedAt'] or 'unknown'} |", + f"| Last run finished | {agg['runFinishedAt'] or 'none yet'} |", + f"| Last run status | {agg['runStatus'] or 'none yet'} |", + f"| Dashboard rendered | {now.strftime('%Y-%m-%dT%H:%M:%SZ')} |", + "", + "## Findings by severity", + "", + "| Severity | Open | Fix available | Accepted |", + "|---|---:|---:|---:|", + ] + for sev in SEVERITIES: + row = agg["severity"][sev] + lines.append(f"| {sev} | {row['open']} | {row['fix-available']} | {row['accepted']} |") + lines += [ + "", + f"Unaccepted CRITICAL with a fix: **{agg['critical_with_fix']}**", + "", + "## Coverage", + "", + f"{agg['targets_total']} targets. A count in any state other than `scanned` is something" + " that was not freshly examined, and is not a clean result.", + "", + "| State | Targets |", + "|---|---:|", + ] + lines += [f"| {state} | {agg['coverage'][state]} |" for state in TARGET_STATES] + return "\n".join(lines) + "\n" + + +# ---------------------------------------------------------------- pure: Renovate's dashboard + + +class Row(NamedTuple): + kind: str + mark: str + branch: str + packages: tuple[str, ...] + pr_number: int | None + offset: int # offset in the body of the first character of the line + + +def _title_package(title: str) -> str: + match = TITLE_PACKAGE_RE.search(title) + return normalise_image(match.group(1)) if match else "" + + +def _row_details(rest: str) -> tuple[tuple[str, ...], int | None]: + """Packages and PR number of a row. No recognisable package means no packages, so no tick.""" + rest = rest.rstrip("\r") + number = None + title = rest + tail = rest + linked = LINKED_TITLE_RE.fullmatch(rest) + if linked: + title, tail = linked.group("title"), linked.group("tail") + link = PR_LINK_RE.search(linked.group("target")) + number = int(link.group(1)) if link else None + listed = PACKAGE_LIST_RE.search(tail) + if listed: + names = re.findall(r"`([^`]+)`", listed.group(1)) + packages = tuple(n for n in map(normalise_image, names) if n) + else: + package = _title_package(title) + packages = (package,) if package else () + return packages, number + + +def _is_detected_heading(line: str) -> bool: + return line.startswith("## ") and line[3:].strip().lower().startswith("detected dependencies") + + +def parse_rows(body: str) -> list[Row]: + """Checkbox rows above `## Detected Dependencies`. Only a whole-line match counts.""" + if len(body) > MAX_ISSUE_BODY: + raise BotError("renovate dashboard: body exceeds its bound") + rows: list[Row] = [] + offset = 0 + for line in body.split("\n"): + if _is_detected_heading(line): + break + match = ROW_RE.fullmatch(line.rstrip("\r")) + if match: + packages, number = _row_details(match.group("rest")) + rows.append(Row(match.group("kind"), match.group("mark"), match.group("branch"), + packages, number, offset)) + if len(rows) > MAX_DASHBOARD_ROWS: + raise BotError("renovate dashboard: too many rows") + offset += len(line) + 1 + return rows + + +def parse_detected(body: str) -> dict[str, bool]: + """{normalised depName: has a pending update} from `## Detected Dependencies`.""" + tracked: dict[str, bool] = {} + lines = body.split("\n") + in_section = False + manager = "" + count = 0 + for index, raw in enumerate(lines): + line = raw.rstrip("\r") + if line.startswith("## "): + in_section = _is_detected_heading(line) + continue + if not in_section: + continue + summary = SUMMARY_RE.fullmatch(line) + if summary: + # A manager heading is the summary directly followed by <blockquote>; a file heading + # is not. + if index + 1 < len(lines) and lines[index + 1].strip() == "<blockquote>": + manager = summary.group(1) + continue + match = DETECTED_RE.fullmatch(line) + if not match or manager in NON_IMAGE_MANAGERS: + continue + count += 1 + if count > MAX_DETECTED_DEPS: + raise BotError("renovate dashboard: too many detected dependencies") + norm = normalise_image(match.group("name")) + if norm: + tracked[norm] = tracked.get(norm, False) or bool(match.group("updates")) + return tracked + + +def row_matches(row: Row, qualifying: set[str]) -> bool: + return any(package in qualifying for package in row.packages) + + +def select_ticks(rows: list[Row], qualifying: set[str]) -> list[Row]: + return [ + row for row in rows + if row.kind == "approve" and row.mark == " " and row_matches(row, qualifying) + ] + + +def apply_ticks(body: str, ticks: list[Row]) -> str: + """Replace only the `[ ]` of each row, re-verifying the row where it stands. + + Every byte other than the three of each marker is carried over unchanged. + """ + pieces = [] + cursor = 0 + for row in sorted(ticks, key=lambda r: r.offset): + expected = f" - [ ] <!-- approve-branch={row.branch} -->" + if row.kind != "approve" or not body.startswith(expected, row.offset): + raise BotError("renovate dashboard: a row changed between parse and write") + marker = row.offset + len(" - ") + pieces += [body[cursor:marker], "[x]"] + cursor = marker + len("[ ]") + pieces.append(body[cursor:]) + return "".join(pieces) + + +def derive_remediation( + images: Iterable[str], + rows: list[Row], + open_prs: list[dict], + tracked: dict[str, bool], + pr_url: Callable[[int], str], + now_text: str, +) -> list[dict]: + """One entry per report image. + + pr-open: an open-PR row or an open Renovate PR for that package. approval-requested: an + approve row. no-update-identified: Renovate tracks it and lists no update. not-tracked: + everything else, which deliberately includes "Renovate knows an update but no PR or approval + row exists" (awaiting schedule, rate limited): saying no update was identified would be + false, and the enumeration has no closer value, so it lands on the state that draws attention. + """ + by_branch = {pr["branch"]: pr["number"] for pr in open_prs} + result = [] + for image in sorted(set(images)): + norm = normalise_image(image) + state = "not-tracked" + url = None + rebase = next((r for r in rows if r.kind == "rebase" and norm in r.packages), None) + pr = next((p for p in open_prs if p["package"] == norm), None) + if rebase is not None or pr is not None: + state = "pr-open" + number = None + if rebase is not None: + number = rebase.pr_number or by_branch.get(rebase.branch) + if number is None and pr is not None: + number = pr["number"] + url = pr_url(number) if number is not None else None + elif any(r.kind == "approve" and norm in r.packages for r in rows): + state = "approval-requested" + elif norm in tracked and not tracked[norm]: + state = "no-update-identified" + result.append({"image": image, "state": state, "url": url, "updatedAt": now_text}) + return result + + +def count_states(remediation: list[dict]) -> dict[str, int]: + counts = dict.fromkeys(REMEDIATION_STATES, 0) + for entry in remediation: + counts[entry["state"]] += 1 + return counts + + +# ---------------------------------------------------------------- I/O + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """A redirect would carry the Authorization header to wherever it points.""" + + def redirect_request(self, *args, **kwargs): + return None + + +class UrllibTransport: + """(method, url, headers, body, max_bytes) -> (status, bytes); never uses a proxy.""" + + def __init__(self) -> None: + self._opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect()) + + def __call__(self, method: str, url: str, headers: dict, body: bytes | None, max_bytes: int): + request = urllib.request.Request(url, data=body, method=method, headers=headers) + try: + with self._opener.open(request, timeout=HTTP_TIMEOUT) as response: + data = response.read(max_bytes + 1) + return response.status, data + except urllib.error.HTTPError as exc: + return exc.code, b"" + except urllib.error.URLError as exc: + if isinstance(exc.reason, socket.gaierror): + raise DnsFailure() from exc + raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc.reason).__name__}") from exc + except (OSError, http.client.HTTPException) as exc: + raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc).__name__}") from exc + + +class GiteaClient: + def __init__(self, base_url: str, repo: str, token: str, transport) -> None: + self._api = f"{base_url.rstrip('/')}/api/v1/repos/{repo}" + self._web = f"{base_url.rstrip('/')}/{repo}" + self._token = token + self._transport = transport + + def _call(self, method: str, path: str, query: dict | None = None, payload=None, expect=(200,)): + url = self._api + path + (f"?{urllib.parse.urlencode(query)}" if query else "") + headers = {"Authorization": f"token {self._token}", "Accept": "application/json"} + data = None + if payload is not None: + data = json.dumps(payload).encode() + headers["Content-Type"] = "application/json" + try: + status, raw = self._transport(method, url, headers, data, MAX_API_BYTES) + except DnsFailure as exc: + raise BotError(f"gitea: {method} {path}: name resolution failed") from exc + if status not in expect: + raise BotError(f"gitea: {method} {path} -> HTTP {status}") + if len(raw) > MAX_API_BYTES: + raise BotError(f"gitea: {method} {path}: response exceeds its bound") + if not raw: + return None + try: + return json.loads(raw) + except ValueError as exc: + raise BotError(f"gitea: {method} {path}: response is not JSON") from exc + + def _list(self, path: str, query: dict | None = None) -> list: + items: list = [] + for page in range(1, MAX_PAGES + 1): + batch = self._call("GET", path, {**(query or {}), "limit": PAGE_SIZE, "page": page}) + if not isinstance(batch, list): + raise BotError(f"gitea: GET {path}: expected a list") + items += batch + if len(batch) < PAGE_SIZE: + return items + raise BotError(f"gitea: GET {path}: more than {MAX_PAGES} pages") + + def pr_url(self, number: int) -> str: + return f"{self._web}/pulls/{int(number)}" + + def labels(self) -> list: + return self._list("/labels") + + def create_label(self, name: str, color: str) -> dict: + return self._call("POST", "/labels", payload={"name": name, "color": color}, expect=(201,)) + + def issues_with_label(self, label: str) -> list: + return self._list("/issues", {"state": "all", "type": "issues", "labels": label}) + + def issues_matching(self, text: str) -> list: + return self._list("/issues", {"state": "all", "type": "issues", "q": text}) + + def create_issue(self, title: str, body: str, label_id: int) -> dict: + return self._call("POST", "/issues", payload={"title": title, "body": body, "labels": [label_id]}, expect=(201,)) + + def update_issue(self, number: int, fields: dict) -> None: + self._call("PATCH", f"/issues/{int(number)}", payload=fields, expect=(200, 201)) + + def add_issue_label(self, number: int, label_id: int) -> None: + self._call("POST", f"/issues/{int(number)}/labels", payload={"labels": [label_id]}, expect=(200,)) + + def get_issue(self, number: int) -> dict: + issue = self._call("GET", f"/issues/{int(number)}") + if not isinstance(issue, dict): + raise BotError("gitea: issue response is not an object") + return issue + + def open_renovate_prs(self) -> list[dict]: + result = [] + for pr in self._list("/pulls", {"state": "open"}): + head = pr.get("head") if isinstance(pr, dict) else None + ref = head.get("ref") if isinstance(head, dict) else None + number = pr.get("number") if isinstance(pr, dict) else None + title = pr.get("title") if isinstance(pr, dict) else None + if not isinstance(ref, str) or not isinstance(number, int) or isinstance(number, bool): + raise BotError("gitea: a pull request has an unexpected shape") + if ref.startswith("renovate/"): + package = _title_package(title) if isinstance(title, str) else "" + result.append({"number": number, "branch": ref, "package": package}) + return result + + +class ScannerClient: + """The scanner's internal API. Falls back to the pinned ClusterIP on a DNS failure only.""" + + def __init__(self, primary: str, fallback: str, transport) -> None: + self._bases = [base.rstrip("/") for base in (primary, fallback) if base] + self._transport = transport + self.used_fallback = False + + def _call(self, method: str, path: str, body: bytes | None, headers: dict, max_bytes: int): + last = len(self._bases) - 1 + for index, base in enumerate(self._bases): + try: + result = self._transport(method, base + path, headers, body, max_bytes) + except DnsFailure as exc: + if index == last: + raise BotError(f"scanner: {method} {path}: name resolution failed") from exc + continue + if index: + self.used_fallback = True + self._bases = self._bases[index:] + return result + raise BotError("scanner: no address configured") + + def report(self) -> dict: + status, raw = self._call("GET", "/report.json", None, {"Accept": "application/json"}, MAX_REPORT_BYTES) + if status != 200: + raise BotError(f"scanner: GET /report.json -> HTTP {status}") + if len(raw) > MAX_REPORT_BYTES: + raise BotError("scanner: report exceeds its bound") + return parse_report(raw) + + def triage(self, heartbeat: str, remediation: list[dict]) -> None: + if len(remediation) > MAX_REMEDIATION: + raise BotError("scanner: more remediation entries than POST /triage accepts") + body = json.dumps({"heartbeatAt": heartbeat, "remediation": remediation}).encode() + status, _ = self._call("POST", "/triage", body, {"Content-Type": "application/json"}, 4096) + if status != 204: + raise BotError(f"scanner: POST /triage -> HTTP {status}") + + +# ---------------------------------------------------------------- orchestration + + +def _label_names(issue: dict) -> set: + return {label.get("name") for label in (issue.get("labels") or []) if isinstance(label, dict)} + + +def find_dashboard_issues(gitea: GiteaClient) -> list[dict]: + """Every issue that is the dashboard: by label, and by exact title in case a label was removed.""" + found: dict[int, dict] = {} + for issue in gitea.issues_with_label(DASHBOARD_LABEL) + gitea.issues_matching(DASHBOARD_TITLE): + if ( + isinstance(issue, dict) + and issue.get("title") == DASHBOARD_TITLE + and not issue.get("pull_request") + and isinstance(issue.get("number"), int) + ): + found[issue["number"]] = issue + return list(found.values()) + + +def sync_dashboard_issue(gitea: GiteaClient, body: str, dry_run: bool) -> dict: + """Create or update the one dashboard issue. Returns counts of what it did or would do.""" + if DASHBOARD_LABEL in FORBIDDEN_LABELS: + raise BotError("config: the dashboard label is one the PM agent acts on") + plan = {"label_created": 0, "issue_created": 0, "issue_updated": 0, "issue_reopened": 0} + existing = find_dashboard_issues(gitea) + if len(existing) > 1: + # Closing an issue on a guess is worse than a red run naming the numbers to look at. + numbers = sorted(issue["number"] for issue in existing) + raise BotError(f"dashboard: {len(numbers)} issues titled {DASHBOARD_TITLE!r}: {numbers}") + + label = next((x for x in gitea.labels() if isinstance(x, dict) and x.get("name") == DASHBOARD_LABEL), None) + if label is None: + plan["label_created"] = 1 + if not dry_run: + label = gitea.create_label(DASHBOARD_LABEL, DASHBOARD_LABEL_COLOR) + + if not existing: + plan["issue_created"] = 1 + if not dry_run: + created = gitea.create_issue(DASHBOARD_TITLE, body, label["id"]) + if not isinstance(created, dict) or not isinstance(created.get("number"), int): + raise BotError("gitea: issue creation returned an unexpected shape") + return plan + + issue = existing[0] + fields: dict = {} + if issue.get("body") != body: + fields["body"] = body + if issue.get("state") != "open": + fields["state"] = "open" + plan["issue_reopened"] = 1 + if fields: + plan["issue_updated"] = 1 + if not dry_run: + if DASHBOARD_LABEL not in _label_names(issue): + gitea.add_issue_label(issue["number"], label["id"]) + if fields: + gitea.update_issue(issue["number"], fields) + return plan + + +def _renovate_issue(gitea: GiteaClient, number: int) -> dict: + issue = gitea.get_issue(number) + body = issue.get("body") + if issue.get("title") != RENOVATE_DASHBOARD_TITLE or issue.get("state") != "open" or not isinstance(body, str): + raise BotError(f"renovate dashboard: issue {number} is not the open Dependency Dashboard") + return issue + + +def report_images(report: dict) -> list[str]: + return [t["image"] for t in report["targets"] if t["type"] == "image"] + + +def run(cfg: dict, gitea: GiteaClient, scanner: ScannerClient, now: datetime, emit: Callable[[str], None]) -> dict: + """One triage pass. Any failure raises, and the heartbeat is the last thing it writes.""" + dry_run = cfg["dry_run"] + report = scanner.report() + agg = aggregate(report) + now_text = now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + summary: dict = {"dry_run": dry_run, "targets": agg["targets_total"], "critical_with_fix": agg["critical_with_fix"]} + summary["dashboard"] = sync_dashboard_issue(gitea, render_dashboard(agg, cfg["page_url"], now), dry_run) + + number = cfg["renovate_issue"] + body = _renovate_issue(gitea, number)["body"] + remediation = derive_remediation( + report_images(report), parse_rows(body), gitea.open_renovate_prs(), parse_detected(body), + gitea.pr_url, now_text, + ) + summary["remediation"] = count_states(remediation) + + # Re-read immediately before writing: Renovate rewrites this body, and a tick must apply to + # the body as it is now. + fresh = _renovate_issue(gitea, number)["body"] + ticks = select_ticks(parse_rows(fresh), qualifying_images(report)) + summary["ticks"] = len(ticks) + if ticks and not dry_run: + gitea.update_issue(number, {"body": apply_ticks(fresh, ticks)}) + + if not dry_run: + scanner.triage(now_text, remediation) + summary["heartbeat"] = 0 if dry_run else 1 + summary["fallback_used"] = scanner.used_fallback + emit(render_summary(summary)) + return summary + + +def render_summary(summary: dict) -> str: + verb = "would be" if summary["dry_run"] else "were" + dash = summary["dashboard"] + states = ", ".join(f"{name} {count}" for name, count in summary["remediation"].items()) + return "\n".join([ + f"### CVE triage ({'DRY RUN, no writes' if summary['dry_run'] else 'live'})", + "", + f"- targets in report: {summary['targets']}", + f"- unaccepted CRITICAL with a fix: {summary['critical_with_fix']}", + f"- dashboard label created: {dash['label_created']}; issue created: {dash['issue_created']};" + f" updated: {dash['issue_updated']}; reopened: {dash['issue_reopened']} ({verb} applied)", + f"- remediation states: {states}", + f"- approval boxes that {verb} ticked: {summary['ticks']}", + f"- heartbeat posted: {summary['heartbeat']}", + f"- scanner reached through the ClusterIP fallback: {summary['fallback_used']}", + "", + ]) + + +def load_config(env: dict) -> dict: + token = env.get("RENOVATE_TOKEN", "") + if not token: + raise BotError("RENOVATE_TOKEN is not set") + repo = env.get("GITEA_REPOSITORY", "Home-Network/home-network-k3s-impl") + if not REPO_RE.fullmatch(repo): + raise BotError("config: GITEA_REPOSITORY is malformed") + urls = { + "gitea_url": env.get("GITEA_URL", "https://gitea.home.keithjackson.dev"), + "vuln_url": env.get("VULN_API_URL", "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081"), + "page_url": env.get("VULN_PAGE_URL", "https://cve.home.keithjackson.dev"), + } + fallback = env.get("VULN_API_FALLBACK_URL", "") + for name, value in {**urls, **({"fallback": fallback} if fallback else {})}.items(): + if not HTTP_URL_RE.fullmatch(value): + raise BotError(f"config: {name} is not a plain http(s) URL") + dry_run = env.get("DRY_RUN", "false") + if dry_run not in ("true", "false"): + raise BotError("config: DRY_RUN must be true or false") + issue = env.get("RENOVATE_DASHBOARD_ISSUE", "91") + if not issue.isdigit(): + raise BotError("config: RENOVATE_DASHBOARD_ISSUE is not a number") + return {**urls, "token": token, "repo": repo, "fallback": fallback, + "dry_run": dry_run == "true", "renovate_issue": int(issue)} + + +def main() -> int: + def emit(text: str) -> None: + print(text) + path = os.environ.get("GITHUB_STEP_SUMMARY") + if path: + with open(path, "a", encoding="utf-8") as handle: + handle.write(text + "\n") + + try: + cfg = load_config(dict(os.environ)) + transport = UrllibTransport() + gitea = GiteaClient(cfg["gitea_url"], cfg["repo"], cfg["token"], transport) + scanner = ScannerClient(cfg["vuln_url"], cfg["fallback"], transport) + run(cfg, gitea, scanner, datetime.now(timezone.utc), emit) + except BotError as exc: + print(f"::error::CVE triage failed: {exc}", file=sys.stderr) + emit(f"### CVE triage FAILED\n\n{exc}\n") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/chainsaw-test.yaml b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/chainsaw-test.yaml new file mode 100644 index 0000000..9a0faff --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/chainsaw-test.yaml @@ -0,0 +1,32 @@ +# ============================================================================= +# L1 RENDERING — the bot ticks only qualifying approve rows (R-VULN-05, R-VULN-07) +# Spec: docs/17 "The triage bot" and its L1 acceptance gates. GIVEN a Renovate dashboard in the +# real issue #91 format plus a constructed Pending Approval section, WHEN the bot plans ticks, +# THEN only `approve` rows exact-matching an image with an unaccepted CRITICAL with a fix change, +# by exactly their marker, and every negative row stays byte-identical. +# +# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-triage-bot-approval-ticks + labels: + conformance.home.k3s/R-VULN-05: "true" + conformance.home.k3s/R-VULN-07: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07" + conformance.home.k3s/layer: "L1" + conformance.home.k3s/safety-critical: "false" + # The script under test lives in .gitea/workflows/, which requirement-scoped selection does + # not attribute to R-VULN-*, so a change to it must still select this test. + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/intent: >- + A tick is applied only to an approve row for an image with a qualifying finding, and dry + runs, duplicate prevention and heartbeat ordering hold through the whole run. +spec: + steps: + - name: plan-and-assert-ticks + try: + - script: + content: bash ./render-check.sh diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/check.py b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/check.py new file mode 100644 index 0000000..53bc42b --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/check.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""L1: the bot ticks only qualifying approve rows, and a whole run behaves (R-VULN-05, R-VULN-07). + +fixtures/renovate-dashboard.md follows issue #91's real format (`## Open` rebase rows, Detected +Dependencies) plus a constructed Pending Approval section, because the live dashboard has none. +Runs the real functions and the real run() over a fake Gitea and a fake scanner. + +What this does NOT prove: that Renovate honours a tick; that the live dashboard keeps this format +across Renovate upgrades (a format change yields no tick rather than a wrong one); that Gitea's +API matches the fake; or anything about the runner's network path to the scanner. +""" +import copy +import importlib.util +import json +import sys +import urllib.parse +from datetime import datetime, timezone +from pathlib import Path + +sys.dont_write_bytecode = True +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[3] +spec = importlib.util.spec_from_file_location("cve_triage", ROOT / ".gitea/workflows/scripts/cve_triage.py") +bot = importlib.util.module_from_spec(spec) +sys.modules["cve_triage"] = bot +spec.loader.exec_module(bot) + +RAW = (HERE / "fixtures/report.json").read_bytes() +FIXTURE = json.loads(RAW) +BODY = (HERE / "fixtures/renovate-dashboard.md").read_text() +OPEN_PRS = json.loads((HERE / "fixtures/open-prs.json").read_text()) +NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc) +GITEA = "https://gitea.example.test" +REPO = "o/r" +VULN = "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081" +FALLBACK = "http://10.43.0.99:8081" +EXPECTED = { + "renovate/nginx-2.x", + "renovate/ghcr.io-example-app-2.x", + "renovate/example-group", +} +failures = [] + + +def check(name, condition): + print(f"{'ok ' if condition else 'FAIL'} {name}") + if not condition: + failures.append(name) + + +def fails(function): + try: + function() + except AssertionError: + return True + return False + + +def raises(function): + try: + function() + except bot.BotError: + return True + return False + + +def planned(report_doc, body): + report = bot.parse_report(json.dumps(report_doc).encode()) + rows = bot.parse_rows(body) + return bot.select_ticks(rows, bot.qualifying_images(report)) + + +def assert_ticks(report_doc, body, expected): + """The tick plan is exactly `expected`, and writing it changes exactly those markers.""" + ticks = planned(report_doc, body) + assert {row.branch for row in ticks} == expected, "tick plan differs" + after = bot.apply_ticks(body, ticks) + want = body + for branch in expected: + line = f" - [ ] <!-- approve-branch={branch} -->" + assert want.count(line) == 1, "expected row not found exactly once" + want = want.replace(line, line.replace("[ ]", "[x]", 1)) + assert after == want, "output differs from a marker-only edit" + + +check("exactly the three qualifying approve rows are ticked, and nothing else changes", + not fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED))) + +after = bot.apply_ticks(BODY, planned(FIXTURE, BODY)) +changed = [(a, b) for a, b in zip(BODY.split("\n"), after.split("\n")) if a != b] +check("three lines differ, same line count, same length", len(changed) == 3 and len(BODY) == len(after)) +for label, needle in { + "rebase row of a qualifying image": "rebase-branch=renovate/ghcr.io-example-has-pr", + "approve row for a non-matching package": "approve-branch=renovate/ghcr.io-other-unrelated", + "approve row matching only by substring": "approve-branch=renovate/ghcr.io-foo-bar-extras", + "approve row for a HIGH-only image": "approve-branch=renovate/ghcr.io-example-high-only", + "approve row for an accepted-CRITICAL image": "approve-branch=renovate/ghcr.io-example-accepted-only", + "approve row for a CRITICAL with no fix": "approve-branch=renovate/ghcr.io-example-nofix", + "approve row with no container package": "approve-branch=renovate/mychart", + "approvePr row of a qualifying image": "approvePr-branch=renovate/ghcr.io-example-app-automerge", + "unschedule row of a qualifying image": "unschedule-branch=renovate/ghcr.io-example-pending-sched", + "decoy approve row below Detected Dependencies": "approve-branch=renovate/decoy", +}.items(): + original = next(line for line in BODY.split("\n") if needle in line) + check(f"untouched: {label}", original in after.split("\n")) +check("untouched: the approve-all row", "- [ ] <!-- approve-all-pending-prs -->" in after) +check("untouched: the already ticked row keeps its mark", + " - [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.x -->" in after) + +# Non-vacuity: each mutation must make the exact assertion above fail. +def mutated_finding(image, **fields): + doc = copy.deepcopy(FIXTURE) + target = next(t for t in doc["targets"] if t["image"] == image) + finding = next(f for f in doc["findings"] if f["targetId"] == target["id"]) + finding.update(fields) + return doc + + +for label, doc in { + "HIGH-only image raised to CRITICAL": mutated_finding("ghcr.io/example/high-only", severity="CRITICAL"), + "accepted CRITICAL un-accepted": mutated_finding("ghcr.io/example/accepted-only", status="fix-available"), + "qualifying finding accepted": mutated_finding("ghcr.io/example/app", status="accepted"), + "CRITICAL gains a fix": mutated_finding("ghcr.io/example/nofix", status="fix-available", fixedVersion="2"), +}.items(): + check(f"non-vacuity: mutated fixture fails ({label})", fails(lambda: assert_ticks(doc, BODY, EXPECTED))) + +mutated_body = BODY.replace("rebase-branch=renovate/ghcr.io-example-has-pr", "approve-branch=renovate/ghcr.io-example-has-pr") +check("non-vacuity: a rebase row rewritten as approve is then ticked", + fails(lambda: assert_ticks(FIXTURE, mutated_body, EXPECTED))) + +real_match = bot.row_matches +bot.row_matches = lambda row, qualifying: any(q in p for p in row.packages for q in qualifying) +check("non-vacuity: a substring matcher fails the assertion", fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED))) +bot.row_matches = real_match + +for raw, want in { + "docker.io/library/nginx": "nginx", + "library/nginx": "nginx", + "nginx:1.27": "nginx", + "Docker.IO/Pihole/Pihole": "pihole/pihole", + "ghcr.io/library/x": "ghcr.io/library/x", + "registry.example:5000/a/b:v1@sha256:" + "0" * 64: "registry.example:5000/a/b", + "bad image": "", + "": "", +}.items(): + check(f"normalise_image({raw[:40]!r})", bot.normalise_image(raw) == want) + +ticks = planned(FIXTURE, BODY) +moved = BODY.replace(" - [ ] <!-- approve-branch=renovate/nginx-2.x -->", " - [ ] <!-- rebase-branch=renovate/nginx-2.x -->") +check("apply_ticks refuses a row that stopped being approve", raises(lambda: bot.apply_ticks(moved, ticks))) +crlf = BODY.replace("\n", "\r\n") +crlf_after = bot.apply_ticks(crlf, planned(FIXTURE, crlf)) +check("CRLF body keeps every line ending", crlf_after.count("\r\n") == crlf.count("\r\n") and crlf_after.count("[x]") == 4) + + +class FakeWorld: + """A fake Gitea and scanner reached through the transport interface. Records every write.""" + + def __init__(self, renovate_body=BODY, report=RAW): + self.report = report + self.labels = [] + self.issues = {91: {"number": 91, "title": "Dependency Dashboard", "state": "open", "labels": [], "body": renovate_body, "pull_request": None}} + self.next_issue = 200 + self.writes = [] + self.triage = None + self.fail = None + self.dns_down = set() + self.seen_hosts = [] + + def __call__(self, method, url, headers, body, max_bytes): + parts = urllib.parse.urlsplit(url) + self.seen_hosts.append(parts.netloc) + if parts.netloc in self.dns_down: + raise bot.DnsFailure() + if self.fail and self.fail[0] == method and self.fail[1] in parts.path: + return 500, b"" + payload = json.loads(body) if body else None + if parts.netloc != "gitea.example.test": + if method == "GET" and parts.path == "/report.json": + return 200, self.report + if method == "POST" and parts.path == "/triage": + self.writes.append(("POST", "triage")) + self.triage = payload + return 204, b"" + return 404, b"" + path = parts.path.removeprefix(f"/api/v1/repos/{REPO}") + query = dict(urllib.parse.parse_qsl(parts.query)) + page = int(query.get("page", "1")) + if method != "GET": + self.writes.append((method, path)) + if method == "GET" and path == "/labels": + return 200, json.dumps(self.labels if page == 1 else []).encode() + if method == "POST" and path == "/labels": + label = {"id": 900 + len(self.labels), "name": payload["name"]} + self.labels.append(label) + return 201, json.dumps(label).encode() + if method == "GET" and path == "/pulls": + return 200, json.dumps(OPEN_PRS if page == 1 else []).encode() + if method == "GET" and path == "/issues": + items = list(self.issues.values()) + if "labels" in query: + items = [i for i in items if query["labels"] in {x["name"] for x in i["labels"]}] + if "q" in query: + items = [i for i in items if query["q"] in i["title"]] + return 200, json.dumps(items if page == 1 else []).encode() + if method == "POST" and path == "/issues": + number = self.next_issue + self.next_issue += 1 + names = [x for x in self.labels if x["id"] in payload["labels"]] + self.issues[number] = {"number": number, "title": payload["title"], "state": "open", "labels": names, "body": payload["body"], "pull_request": None} + return 201, json.dumps(self.issues[number]).encode() + if path.startswith("/issues/") and path.endswith("/labels") and method == "POST": + number = int(path.split("/")[2]) + self.issues[number]["labels"] += [x for x in self.labels if x["id"] in payload["labels"]] + return 200, b"[]" + if path.startswith("/issues/"): + number = int(path.split("/")[2]) + if method == "GET": + return 200, json.dumps(self.issues[number]).encode() + if method == "PATCH": + self.issues[number].update(payload) + return 201, json.dumps(self.issues[number]).encode() + return 404, b"" + + +def execute(world, dry_run=False, fallback=FALLBACK): + cfg = {"dry_run": dry_run, "page_url": "https://cve.example.test", "renovate_issue": 91} + gitea = bot.GiteaClient(GITEA, REPO, "fixture-token", world) + scanner = bot.ScannerClient(VULN, fallback, world) + lines = [] + summary = bot.run(cfg, gitea, scanner, NOW, lines.append) + return summary, "\n".join(lines) + + +def dashboards(world): + return [i for i in world.issues.values() if i["title"] == bot.DASHBOARD_TITLE] + + +# Dry run: reads everything, writes nothing, reports the plan as counts. +world = FakeWorld() +summary, text = execute(world, dry_run=True) +check("dry run performs no writes", world.writes == [] and world.triage is None and not dashboards(world)) +check("dry run plans the three ticks and the issue", summary["ticks"] == 3 and summary["dashboard"]["issue_created"] == 1) +check("dry run leaves the dashboard body untouched", world.issues[91]["body"] == BODY) + +NAMES = {"CVE-2026", "fixture-pkg", "fixture-ns-", "fixture-workload", "nginx", "ghcr.io"} + + +def leaks(text_value): + return [name for name in NAMES if name.lower() in text_value.lower()] + + +check("dry-run summary is counts only", not leaks(text)) + +# Live run. +world = FakeWorld() +summary, text = execute(world) +board = dashboards(world) +check("live run creates one label and one dashboard issue", len(world.labels) == 1 and len(board) == 1) +check("the dashboard issue carries only its own label", [x["name"] for x in board[0]["labels"]] == ["cve-dashboard"]) +check("issue body and summary are counts only", not leaks(board[0]["body"]) and not leaks(text)) +check("exactly one PATCH to issue #91", world.writes.count(("PATCH", "/issues/91")) == 1) +want_body = BODY +for branch in EXPECTED: + line = f" - [ ] <!-- approve-branch={branch} -->" + want_body = want_body.replace(line, line.replace("[ ]", "[x]", 1)) +check("the PATCHed body is the marker-only edit", world.issues[91]["body"] == want_body) +check("the heartbeat is the last write", world.writes[-1] == ("POST", "triage")) +check("heartbeat shape is exact", set(world.triage) == {"heartbeatAt", "remediation"} and world.triage["heartbeatAt"] == "2026-11-09T07:15:00Z") +for entry in world.triage["remediation"]: + if set(entry) != {"image", "state", "url", "updatedAt"} or entry["state"] not in bot.REMEDIATION_STATES: + check("remediation entry shape", False) +states = {e["image"]: (e["state"], e["url"]) for e in world.triage["remediation"]} +for image, want in { + "docker.io/library/nginx": ("approval-requested", None), + "ghcr.io/example/app": ("approval-requested", None), + "ghcr.io/example/grouped": ("approval-requested", None), + "ghcr.io/example/already-ticked": ("approval-requested", None), + "ghcr.io/example/high-only": ("approval-requested", None), + "ghcr.io/example/has-pr": ("pr-open", f"{GITEA}/{REPO}/pulls/173"), + "ghcr.io/example/pr-by-title": ("pr-open", f"{GITEA}/{REPO}/pulls/174"), + "ghcr.io/example/tracked-clean": ("no-update-identified", None), + "ghcr.io/foo/bar": ("no-update-identified", None), + "ghcr.io/example/untracked": ("not-tracked", None), + "ghcr.io/example/pending-sched": ("not-tracked", None), +}.items(): + check(f"remediation for {image}", states.get(image) == want) +check("one remediation entry per image", len(states) == len(FIXTURE["targets"])) + +# A second run updates the same issue. +before = len(world.issues) +second = copy.deepcopy(FIXTURE) +second["targets"][-1]["state"] = "stale" +world.report = json.dumps(second).encode() +summary, _ = execute(world) +check("a second run creates no duplicate", len(dashboards(world)) == 1 and len(world.issues) == before) +check("a second run updates the issue", summary["dashboard"]["issue_updated"] == 1 and "| stale | 1 |" in dashboards(world)[0]["body"]) +check("a second run has nothing left to tick", summary["ticks"] == 0) + +# The dashboard issue is found even without its label, and a closed one is reopened. +world = FakeWorld() +world.labels.append({"id": 900, "name": "cve-dashboard"}) +world.issues[150] = {"number": 150, "title": "CVE Dashboard", "state": "closed", "labels": [], "body": "old", "pull_request": None} +summary, _ = execute(world) +check("an unlabelled closed dashboard is reused, relabelled and reopened", + len(dashboards(world)) == 1 and world.issues[150]["state"] == "open" + and [x["name"] for x in world.issues[150]["labels"]] == ["cve-dashboard"] and summary["dashboard"]["issue_created"] == 0) + +world = FakeWorld() +world.labels.append({"id": 900, "name": "cve-dashboard"}) +for number in (150, 151): + world.issues[number] = {"number": number, "title": "CVE Dashboard", "state": "open", "labels": [world.labels[0]], "body": "x", "pull_request": None} +check("two dashboard issues fail the run", raises(lambda: execute(world))) +check("two dashboard issues write nothing", world.writes == []) + +# Failures end the run before the heartbeat. +world = FakeWorld() +world.fail = ("PATCH", "/issues/91") +check("a failed tick PATCH fails the run", raises(lambda: execute(world))) +check("a failed run posts no heartbeat", world.triage is None) +world = FakeWorld(report=b"{not json") +check("an unreadable report fails the run", raises(lambda: execute(world))) +check("an unreadable report writes nothing", world.writes == []) +world = FakeWorld() +world.issues[91]["title"] = "Something else" +check("issue 91 being something else fails the run", raises(lambda: execute(world))) +world = FakeWorld() +world.issues[91]["state"] = "closed" +check("a closed Renovate dashboard fails the run", raises(lambda: execute(world))) + +# DNS fallback, and nothing but DNS failure selects it. +world = FakeWorld() +world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081") +summary, _ = execute(world) +check("DNS failure uses the fallback for report and heartbeat", summary["fallback_used"] and world.triage is not None) +check("the fallback was actually contacted", "10.43.0.99:8081" in world.seen_hosts) +world = FakeWorld() +world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081") +check("DNS failure with no fallback fails the run", raises(lambda: execute(world, fallback=""))) + + +def refused(method, url, headers, body, max_bytes): + raise bot.BotError("GET /report.json: ConnectionRefusedError") + + +check("a refused connection fails the run and does not fall back", + raises(lambda: bot.ScannerClient(VULN, FALLBACK, refused).report())) + +for label, env, expected_ok in ( + ("missing token", {}, False), + ("present token", {"RENOVATE_TOKEN": "x"}, True), + ("bad DRY_RUN", {"RENOVATE_TOKEN": "x", "DRY_RUN": "maybe"}, False), + ("non-http API URL", {"RENOVATE_TOKEN": "x", "VULN_API_URL": "file:///etc/passwd"}, False), +): + try: + bot.load_config(env) + ok = True + except bot.BotError: + ok = False + check(f"load_config: {label}", ok is expected_ok) + +print(f"{len(failures)} failure(s)") +sys.exit(1 if failures else 0) diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/open-prs.json b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/open-prs.json new file mode 100644 index 0000000..b7771a1 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/open-prs.json @@ -0,0 +1,5 @@ +[ + {"number": 173, "title": "chore(deps): Update ghcr.io/example/has-pr Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-has-pr-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/173"}, + {"number": 174, "title": "chore(deps): Update ghcr.io/example/pr-by-title Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-pr-by-title-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/174"}, + {"number": 160, "title": "WIP: chore(spec): not Renovate work", "head": {"ref": "chore/adopt-spec-sync"}, "html_url": "https://gitea.example.test/o/r/pulls/160"} +] diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/renovate-dashboard.md b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/renovate-dashboard.md new file mode 100644 index 0000000..372cc5d --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/renovate-dashboard.md @@ -0,0 +1,71 @@ +This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. + +## Pending Approval + +The following branches are pending approval. To create them, click on a checkbox below. + + - [ ] <!-- approve-branch=renovate/nginx-2.x -->chore(deps): Update nginx Docker tag to v2 + - [ ] <!-- approve-branch=renovate/ghcr.io-example-app-2.x -->chore(deps): Update ghcr.io/example/app Docker tag to v2 + - [ ] <!-- approve-branch=renovate/example-group -->chore(deps): Update example group (`ghcr.io/example/grouped`, `ghcr.io/example/other`) + - [ ] <!-- approve-branch=renovate/ghcr.io-foo-bar-extras-2.x -->chore(deps): Update ghcr.io/foo/bar-extras Docker tag to v2 + - [ ] <!-- approve-branch=renovate/ghcr.io-other-unrelated-2.x -->chore(deps): Update ghcr.io/other/unrelated Docker tag to v2 + - [ ] <!-- approve-branch=renovate/ghcr.io-example-high-only-2.x -->chore(deps): Update ghcr.io/example/high-only Docker tag to v2 + - [ ] <!-- approve-branch=renovate/ghcr.io-example-accepted-only-2.x -->chore(deps): Update ghcr.io/example/accepted-only Docker tag to v2 + - [ ] <!-- approve-branch=renovate/ghcr.io-example-nofix-2.x -->chore(deps): Update ghcr.io/example/nofix Docker tag to v2 + - [ ] <!-- approve-branch=renovate/mychart-2.x -->chore(deps): Update Helm release mychart to v2 + - [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.x -->chore(deps): Update ghcr.io/example/already-ticked Docker tag to v2 + - [ ] <!-- approve-all-pending-prs -->🔐 **Create all pending approval PRs at once** 🔐 + +## Awaiting Schedule + +The following updates are awaiting their schedule. To get an update now, click on a checkbox below. + + - [ ] <!-- unschedule-branch=renovate/ghcr.io-example-pending-sched-2.x -->chore(deps): Update ghcr.io/example/pending-sched Docker tag to v2 + +## Pending Branch Automerge + +The following branches have automerge enabled. To abort a branch automerge, click on a checkbox below. + + - [ ] <!-- approvePr-branch=renovate/ghcr.io-example-app-automerge -->chore(deps): Update ghcr.io/example/app Docker tag to v2 + +## Open + +The following updates have all been created. To force a retry/rebase of any, click on a checkbox below. + + - [ ] <!-- rebase-branch=renovate/gitea.home.keithjackson.dev-home-network-toolbox-main -->[chore(deps): Update gitea.home.keithjackson.dev/home-network/toolbox:main Docker digest to 83a3501](pulls/161) + - [ ] <!-- rebase-branch=renovate/kube-prometheus-stack-93.x -->[chore(deps): Update Helm release kube-prometheus-stack to v93](pulls/164) + - [ ] <!-- rebase-branch=renovate/ghcr.io-example-has-pr-2.x -->[chore(deps): Update ghcr.io/example/has-pr Docker tag to v2](pulls/173) + - [ ] <!-- rebase-all-open-prs -->**Click on this checkbox to rebase all open PRs at once** + +## Detected Dependencies + +<details><summary>flux (13)</summary> +<blockquote> + +<details><summary>platform/cert-manager/helmrelease.yaml (1)</summary> + + - `cert-manager >=1.21.0 <1.22.0` → [Updates: `>=1.21.3 <1.22.0`] + +</details> + +</blockquote> +</details> + +<details><summary>kubernetes (69)</summary> +<blockquote> + +<details><summary>apps/wave-0/jackett.yaml (4)</summary> + + - `lscr.io/linuxserver/jackett v0.24.3025-ls29` → [Updates: `v0.24.3030-ls30`] + - `quay.io/oauth2-proxy/oauth2-proxy v7.15.6` + - `NetworkPolicy networking.k8s.io/v1` + - `ghcr.io/example/tracked-clean 1.0` + - `ghcr.io/foo/bar 1.0` + - `ghcr.io/example/pending-sched 1.0` → [Updates: `2.0`] + - `ghcr.io/example/app 1.0` → [Updates: `2.0`] + - [ ] <!-- approve-branch=renovate/decoy-below-detected -->chore(deps): Update nginx Docker tag to v9 + +</details> + +</blockquote> +</details> diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/report.json b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/report.json new file mode 100644 index 0000000..860443d --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/fixtures/report.json @@ -0,0 +1,425 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-11-09T06:00:00Z", + "database": { + "updatedAt": "2026-11-09T01:00:00Z", + "version": "fixture-db" + }, + "run": { + "startedAt": "2026-11-09T05:00:00Z", + "finishedAt": "2026-11-09T05:30:00Z", + "status": "complete" + }, + "targets": [ + { + "id": "docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "type": "image", + "image": "docker.io/library/nginx", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-0", + "kind": "Deployment", + "name": "fixture-workload-0", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/app@sha256:0000000000000000000000000000000000000000000000000000000000000001", + "type": "image", + "image": "ghcr.io/example/app", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000001", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-1", + "kind": "Deployment", + "name": "fixture-workload-1", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/grouped@sha256:0000000000000000000000000000000000000000000000000000000000000002", + "type": "image", + "image": "ghcr.io/example/grouped", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000002", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-2", + "kind": "Deployment", + "name": "fixture-workload-2", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/foo/bar@sha256:0000000000000000000000000000000000000000000000000000000000000003", + "type": "image", + "image": "ghcr.io/foo/bar", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000003", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-3", + "kind": "Deployment", + "name": "fixture-workload-3", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/has-pr@sha256:0000000000000000000000000000000000000000000000000000000000000004", + "type": "image", + "image": "ghcr.io/example/has-pr", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000004", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-4", + "kind": "Deployment", + "name": "fixture-workload-4", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/pr-by-title@sha256:0000000000000000000000000000000000000000000000000000000000000005", + "type": "image", + "image": "ghcr.io/example/pr-by-title", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000005", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-5", + "kind": "Deployment", + "name": "fixture-workload-5", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/already-ticked@sha256:0000000000000000000000000000000000000000000000000000000000000006", + "type": "image", + "image": "ghcr.io/example/already-ticked", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000006", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-6", + "kind": "Deployment", + "name": "fixture-workload-6", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/untracked@sha256:0000000000000000000000000000000000000000000000000000000000000007", + "type": "image", + "image": "ghcr.io/example/untracked", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000007", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-7", + "kind": "Deployment", + "name": "fixture-workload-7", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/pending-sched@sha256:0000000000000000000000000000000000000000000000000000000000000008", + "type": "image", + "image": "ghcr.io/example/pending-sched", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000008", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-8", + "kind": "Deployment", + "name": "fixture-workload-8", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/high-only@sha256:0000000000000000000000000000000000000000000000000000000000000009", + "type": "image", + "image": "ghcr.io/example/high-only", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000009", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-9", + "kind": "Deployment", + "name": "fixture-workload-9", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/accepted-only@sha256:000000000000000000000000000000000000000000000000000000000000000a", + "type": "image", + "image": "ghcr.io/example/accepted-only", + "digest": "sha256:000000000000000000000000000000000000000000000000000000000000000a", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-10", + "kind": "Deployment", + "name": "fixture-workload-10", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/nofix@sha256:000000000000000000000000000000000000000000000000000000000000000b", + "type": "image", + "image": "ghcr.io/example/nofix", + "digest": "sha256:000000000000000000000000000000000000000000000000000000000000000b", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-11", + "kind": "Deployment", + "name": "fixture-workload-11", + "containers": [ + "main" + ] + } + ] + }, + { + "id": "ghcr.io/example/tracked-clean@sha256:000000000000000000000000000000000000000000000000000000000000000c", + "type": "image", + "image": "ghcr.io/example/tracked-clean", + "digest": "sha256:000000000000000000000000000000000000000000000000000000000000000c", + "state": "scanned", + "reason": null, + "lastScannedAt": "2026-11-09T05:00:00Z", + "workloads": [ + { + "namespace": "fixture-ns-12", + "kind": "Deployment", + "name": "fixture-workload-12", + "containers": [ + "main" + ] + } + ] + } + ], + "findings": [ + { + "id": "CVE-2026-1000", + "targetId": "docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-0", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1010", + "targetId": "ghcr.io/example/app@sha256:0000000000000000000000000000000000000000000000000000000000000001", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-1", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1020", + "targetId": "ghcr.io/example/grouped@sha256:0000000000000000000000000000000000000000000000000000000000000002", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-2", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1030", + "targetId": "ghcr.io/foo/bar@sha256:0000000000000000000000000000000000000000000000000000000000000003", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-3", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1040", + "targetId": "ghcr.io/example/has-pr@sha256:0000000000000000000000000000000000000000000000000000000000000004", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-4", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1050", + "targetId": "ghcr.io/example/pr-by-title@sha256:0000000000000000000000000000000000000000000000000000000000000005", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-5", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1060", + "targetId": "ghcr.io/example/already-ticked@sha256:0000000000000000000000000000000000000000000000000000000000000006", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-6", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1070", + "targetId": "ghcr.io/example/untracked@sha256:0000000000000000000000000000000000000000000000000000000000000007", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-7", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1080", + "targetId": "ghcr.io/example/pending-sched@sha256:0000000000000000000000000000000000000000000000000000000000000008", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-8", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1090", + "targetId": "ghcr.io/example/high-only@sha256:0000000000000000000000000000000000000000000000000000000000000009", + "severity": "HIGH", + "cvss": 9.0, + "package": "fixture-pkg-9", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "fix-available" + }, + { + "id": "CVE-2026-1100", + "targetId": "ghcr.io/example/accepted-only@sha256:000000000000000000000000000000000000000000000000000000000000000a", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-10", + "installedVersion": "1.0", + "fixedVersion": "9.9.9", + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "accepted", + "acceptance": { + "reason": "fixture", + "expires": "2027-01-01" + } + }, + { + "id": "CVE-2026-1110", + "targetId": "ghcr.io/example/nofix@sha256:000000000000000000000000000000000000000000000000000000000000000b", + "severity": "CRITICAL", + "cvss": 9.0, + "package": "fixture-pkg-11", + "installedVersion": "1.0", + "fixedVersion": null, + "title": "fixture", + "url": "https://advisory.example/x", + "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "open" + } + ], + "remediation": [] +} diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/render-check.sh b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/render-check.sh new file mode 100755 index 0000000..d5b02de --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-approval-ticks/render-check.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# L1 offline check for the CVE triage bot: fixture-driven, no cluster, no network. Assertions and +# their limits are in check.py. rc 0 pass, 1 fail. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHONDONTWRITEBYTECODE=1 exec python3 "${here}/check.py" diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/chainsaw-test.yaml b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/chainsaw-test.yaml new file mode 100644 index 0000000..ff8f327 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/chainsaw-test.yaml @@ -0,0 +1,31 @@ +# ============================================================================= +# L1 RENDERING — CVE Dashboard issue carries aggregates only (R-VULN-05, R-VULN-07) +# Spec: docs/17 "The triage bot". GIVEN a report fixture with hostile and distinctive strings, +# WHEN the bot renders the dashboard issue, THEN no CVE id, package, image, workload, namespace or +# other report string appears, the counts are exact, and a deliberately leaking body is caught. +# +# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-triage-bot-dashboard-aggregates + labels: + conformance.home.k3s/R-VULN-05: "true" + conformance.home.k3s/R-VULN-07: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07" + conformance.home.k3s/layer: "L1" + conformance.home.k3s/safety-critical: "false" + # The script under test lives in .gitea/workflows/, which requirement-scoped selection does + # not attribute to R-VULN-*, so a change to it must still select this test. + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/intent: >- + A dashboard issue rendered from a fixture contains counts and timestamps only, never a CVE id, + package, image or workload name. +spec: + steps: + - name: render-and-assert-aggregates-only + try: + - script: + content: bash ./render-check.sh diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/check.py b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/check.py new file mode 100644 index 0000000..10e1fcc --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/check.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""L1: the CVE Dashboard issue is aggregates only (R-VULN-05, R-VULN-07). + +Exercises the real renderer in .gitea/workflows/scripts/cve_triage.py over fixtures/report.json, +whose every name is distinctive so a leak cannot be confused with template text. + +What this does NOT prove: that Gitea accepts the issue, or that the issue is the one the live bot +wrote. Those need a real run and are not exercised by any L1 test. +""" +import copy +import importlib.util +import json +import re +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +sys.dont_write_bytecode = True +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[3] +spec = importlib.util.spec_from_file_location("cve_triage", ROOT / ".gitea/workflows/scripts/cve_triage.py") +bot = importlib.util.module_from_spec(spec) +sys.modules["cve_triage"] = bot +spec.loader.exec_module(bot) + +RAW = (HERE / "fixtures/report.json").read_bytes() +FIXTURE = json.loads(RAW) +NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc) +PAGE = "https://cve.example.test" +failures = [] + + +def check(name, condition): + print(f"{'ok ' if condition else 'FAIL'} {name}") + if not condition: + failures.append(name) + + +def sensitive_names(doc): + """Every string in the fixture that must never reach the issue.""" + names = set() + for target in doc["targets"]: + for field in ("id", "image", "digest", "reason"): + if target.get(field): + names.add(target[field]) + if target.get("image"): + names.add(bot.normalise_image(target["image"])) + names.add(target["image"].rsplit("/", 1)[-1]) + for workload in target["workloads"]: + names.update((workload["namespace"], workload["name"])) + names.update(workload["containers"]) + for finding in doc["findings"]: + for field in ("id", "package", "installedVersion", "fixedVersion", "title", "url"): + if finding.get(field): + names.add(finding[field]) + if finding.get("acceptance"): + names.add(finding["acceptance"]["reason"]) + names.add(doc["database"]["version"]) + return {name for name in names if name} + + +def assert_no_names(body, names): + lowered = body.lower() + leaked = sorted(name for name in names if name.lower() in lowered) + assert not leaked, f"{len(leaked)} fixture names leaked into the issue body" + + +def rendered(raw=RAW, now=NOW): + return bot.render_dashboard(bot.aggregate(bot.parse_report(raw)), PAGE, now) + + +def fails(function): + try: + function() + except AssertionError: + return True + return False + + +def rejected(doc): + try: + bot.parse_report(json.dumps(doc).encode()) + except bot.BotError: + return True + return False + + +body = rendered() +names = sensitive_names(FIXTURE) +check("fixture carries a non-trivial set of names", len(names) >= 25) +check("rendered body contains no fixture name", not fails(lambda: assert_no_names(body, names))) +check("body has no CVE or GHSA identifier at all", not re.search(r"CVE-\d|GHSA-", body, re.I)) +check("hostile title's markup is absent", "onerror" not in body and "evil.example" not in body and "@keith" not in body) + +# Non-vacuity: the assertion above must detect each class of leak. +for label, leak in { + "CVE id": "CVE-2026-11111", + "package": "fixture-libcrit", + "image": "ghcr.io/fixture-org/fixture-api", + "workload": "fixture-workload-db", + "namespace": "fixture-ns-gamma", +}.items(): + check(f"non-vacuity: a leaked {label} is caught", fails(lambda: assert_no_names(body + leak, names))) + +for row in ( + "| CRITICAL | 1 | 1 | 1 |", + "| HIGH | 0 | 1 | 0 |", + "| MEDIUM | 0 | 0 | 0 |", + "| LOW | 1 | 0 | 0 |", + "| UNKNOWN | 0 | 0 | 0 |", + "| scanned | 1 |", + "| pending | 0 |", + "| stale | 1 |", + "| failed | 0 |", + "| credential-required | 1 |", + "| by-tag | 0 |", + "| not-covered | 1 |", + "Unaccepted CRITICAL with a fix: **1**", + "4 targets.", + "| Report generated | 2026-11-09T06:00:00Z |", + f"[CVE page]({PAGE})", +): + check(f"body states {row!r}", row in body) +check("a fresh report carries no warning", "WARNING" not in body) + +mutated = copy.deepcopy(FIXTURE) +extra = copy.deepcopy(mutated["findings"][0]) +extra["id"] = "CVE-2026-55555" +mutated["findings"].append(extra) +changed = rendered(json.dumps(mutated).encode()) +check("non-vacuity: a mutated fixture changes the counts", "| CRITICAL | 1 | 2 | 1 |" in changed and changed != body) + +check("stale report warns", "older than 26 hours" in rendered(now=NOW + timedelta(hours=30))) +degraded = copy.deepcopy(FIXTURE) +degraded["run"]["status"] = "degraded" +check("degraded run warns", "did not complete cleanly" in rendered(json.dumps(degraded).encode())) +old_db = copy.deepcopy(FIXTURE) +old_db["database"]["updatedAt"] = "2026-11-01T00:00:00Z" +check("stale database warns", "database is older" in rendered(json.dumps(old_db).encode())) + +for label, mutate in { + "wrong schemaVersion": lambda d: d.update(schemaVersion=2), + "unknown severity": lambda d: d["findings"][0].update(severity="SEVERE"), + "unknown status": lambda d: d["findings"][0].update(status="ignored"), + "unknown target state": lambda d: d["targets"][0].update(state="clean"), + "finding on an unknown target": lambda d: d["findings"][0].update(targetId="nope"), + "bad timestamp": lambda d: d.update(generatedAt="yesterday <b>"), + "invalid image": lambda d: d["targets"][0].update(image="bad image <x>"), +}.items(): + doc = copy.deepcopy(FIXTURE) + mutate(doc) + check(f"parse rejects {label}", rejected(doc)) + +check("dashboard label is not one the PM acts on", bot.DASHBOARD_LABEL not in bot.FORBIDDEN_LABELS) +configmap = (ROOT / "platform/pm-agent/configmap.yaml").read_text() +match = re.search(r'^\s*PM_REQUIRE_LABEL:\s*"([^"]*)"', configmap, re.M) +check("PM_REQUIRE_LABEL is readable", match is not None) +check("the PM's label is in FORBIDDEN_LABELS and is not ours", + match is not None and match.group(1) in bot.FORBIDDEN_LABELS and match.group(1) != bot.DASHBOARD_LABEL) + +print(f"{len(failures)} failure(s)") +sys.exit(1 if failures else 0) diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/fixtures/report.json b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/fixtures/report.json new file mode 100644 index 0000000..b74c092 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/fixtures/report.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-11-09T06:00:00Z", + "database": {"updatedAt": "2026-11-09T01:00:00Z", "version": "fixture-db-v2-20261109"}, + "run": {"startedAt": "2026-11-09T05:00:00Z", "finishedAt": "2026-11-09T05:30:00Z", "status": "complete"}, + "targets": [ + {"id": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "type": "image", "image": "ghcr.io/fixture-org/fixture-api", "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "state": "scanned", "reason": null, "lastScannedAt": "2026-11-09T05:10:00Z", "workloads": [{"namespace": "fixture-ns-alpha", "kind": "Deployment", "name": "fixture-workload-api", "containers": ["fixture-container-api"]}]}, + {"id": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "type": "image", "image": "docker.io/library/fixturedb", "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "state": "stale", "reason": "fixture-reason-stale-text", "lastScannedAt": "2026-11-07T05:10:00Z", "workloads": [{"namespace": "fixture-ns-beta", "kind": "StatefulSet", "name": "fixture-workload-db", "containers": ["fixture-container-db"]}]}, + {"id": "fixture.example:5000/priv/fixture-private:v1", "type": "image", "image": "fixture.example:5000/priv/fixture-private", "digest": null, "state": "credential-required", "reason": "fixture.example:5000 refused anonymous access", "lastScannedAt": null, "workloads": [{"namespace": "fixture-ns-gamma", "kind": "CronJob", "name": "fixture-workload-cron", "containers": ["fixture-container-cron"]}]}, + {"id": "vm/fixture-ns-vm/fixture-vm-name", "type": "vm", "image": null, "digest": null, "state": "not-covered", "reason": null, "lastScannedAt": null, "workloads": [{"namespace": "fixture-ns-vm", "kind": "VirtualMachineInstance", "name": "fixture-vm-name", "containers": []}]} + ], + "findings": [ + {"id": "CVE-2026-11111", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "CRITICAL", "cvss": 9.8, "package": "fixture-libcrit", "installedVersion": "1.0.1-installed", "fixedVersion": "1.0.2-fixed", "title": "<img src=x onerror=alert(1)> @keith [click](http://evil.example/hook) | injected |", "url": "https://advisory.example/CVE-2026-11111", "firstSeenAt": "2026-11-01T00:00:00Z", "status": "fix-available"}, + {"id": "GHSA-fixt-ure0-0001", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "CRITICAL", "cvss": 9.1, "package": "fixture-libopen", "installedVersion": "2.0.0-installed", "fixedVersion": null, "title": "fixture title without a fix", "url": "https://advisory.example/ghsa", "firstSeenAt": "2026-11-02T00:00:00Z", "status": "open"}, + {"id": "CVE-2026-22222", "targetId": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "severity": "CRITICAL", "cvss": 9.0, "package": "fixture-libaccepted", "installedVersion": "3.0.0-installed", "fixedVersion": "3.0.1-fixed", "title": "fixture accepted title", "url": "https://advisory.example/accepted", "firstSeenAt": "2026-10-01T00:00:00Z", "status": "accepted", "acceptance": {"reason": "fixture-acceptance-reason", "expires": "2027-01-01"}}, + {"id": "CVE-2026-33333", "targetId": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "severity": "HIGH", "cvss": 7.5, "package": "fixture-libhigh", "installedVersion": "4.0.0-installed", "fixedVersion": "4.0.1-fixed", "title": "fixture high title", "url": "https://advisory.example/high", "firstSeenAt": "2026-11-03T00:00:00Z", "status": "fix-available"}, + {"id": "CVE-2026-44444", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "LOW", "cvss": null, "package": "fixture-liblow", "installedVersion": "5.0.0-installed", "fixedVersion": null, "title": "fixture low title", "url": "", "firstSeenAt": "2026-11-04T00:00:00Z", "status": "open"} + ], + "remediation": [] +} diff --git a/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/render-check.sh b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/render-check.sh new file mode 100755 index 0000000..d5b02de --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-triage-bot-dashboard-aggregates/render-check.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# L1 offline check for the CVE triage bot: fixture-driven, no cluster, no network. Assertions and +# their limits are in check.py. rc 0 pass, 1 fail. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHONDONTWRITEBYTECODE=1 exec python3 "${here}/check.py" -- 2.54.0 From ec6b39bb919d8f67a1993a35efd8b7f8c1c89b97 Mon Sep 17 00:00:00 2001 From: Keith Jackson <keithallenjackson@gmail.com> Date: Mon, 9 Nov 2026 09:39:55 -0600 Subject: [PATCH 02/10] feat(vuln): point the triage bot's fallback at the pinned scanner ClusterIP Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .gitea/workflows/cve-triage.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/cve-triage.yaml b/.gitea/workflows/cve-triage.yaml index ab1b843..5de9f2c 100644 --- a/.gitea/workflows/cve-triage.yaml +++ b/.gitea/workflows/cve-triage.yaml @@ -138,9 +138,8 @@ jobs: RENOVATE_DASHBOARD_ISSUE: "91" VULN_API_URL: http://vuln-scanner.vuln-scanner.svc.cluster.local:8081 # The Service's pinned spec.clusterIP (platform/vuln-scanner/service.yaml), used only - # when the name above fails to resolve. Empty disables the fallback, and a DNS failure - # then fails the job. - VULN_API_FALLBACK_URL: "" + # when the name above fails to resolve. Keep the two in step. + VULN_API_FALLBACK_URL: http://10.43.210.10:8081 VULN_PAGE_URL: https://cve.home.keithjackson.dev # Anything other than an explicit false on a dispatch is a dry run; a schedule is live. DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }} -- 2.54.0 From 50ec2297eee208015022a63d130b1a2e73871592 Mon Sep 17 00:00:00 2001 From: Keith Jackson <keithallenjackson@gmail.com> Date: Mon, 9 Nov 2026 09:41:23 -0600 Subject: [PATCH 03/10] feat(vuln): scanner service and fixture-driven L1 tests Stdlib-only scanner per docs/17: Kubernetes API inventory, Trivy runner, persisted state, schema-validated report.json, strict acceptances parser, server-rendered page, aggregate metrics, internal API, and a scan loop that stays live while scanning. L1 tests under conformance/tests/l1-rendering/vuln-scanner-* run the actual modules against recorded fixtures with a fake Trivy runner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../chainsaw-test.yaml | 25 + .../vuln-scanner-acceptances/check.py | 111 ++++ .../vuln-scanner-acceptances/check.sh | 7 + .../chainsaw-test.yaml | 28 + .../vuln-scanner-api-metrics/check.py | 226 ++++++++ .../vuln-scanner-api-metrics/check.sh | 7 + .../vuln-scanner-inventory/chainsaw-test.yaml | 25 + .../vuln-scanner-inventory/check.py | 155 ++++++ .../vuln-scanner-inventory/check.sh | 7 + .../fixtures/cluster.json | 395 ++++++++++++++ .../vuln-scanner-page/chainsaw-test.yaml | 26 + .../l1-rendering/vuln-scanner-page/check.py | 181 +++++++ .../l1-rendering/vuln-scanner-page/check.sh | 7 + .../vuln-scanner-page/fixtures/report.json | 199 ++++++++ .../vuln-scanner-scan-loop/chainsaw-test.yaml | 27 + .../vuln-scanner-scan-loop/check.py | 266 ++++++++++ .../vuln-scanner-scan-loop/check.sh | 7 + .../fixtures/cluster.json | 198 +++++++ .../fixtures/trivy-empty.json | 4 + .../fixtures/trivy-nginx.json | 33 ++ .../fixtures/trivy-nightly.json | 19 + .../fixtures/trivy-postgres.json | 25 + platform/vuln-scanner/src/acceptances.py | 141 +++++ platform/vuln-scanner/src/inventory.py | 248 +++++++++ platform/vuln-scanner/src/main.py | 276 ++++++++++ platform/vuln-scanner/src/metrics.py | 44 ++ platform/vuln-scanner/src/page.py | 482 ++++++++++++++++++ platform/vuln-scanner/src/report.py | 222 ++++++++ platform/vuln-scanner/src/report.schema.json | 110 ++++ platform/vuln-scanner/src/server.py | 150 ++++++ platform/vuln-scanner/src/state.py | 147 ++++++ platform/vuln-scanner/src/trivy.py | 153 ++++++ 32 files changed, 3951 insertions(+) create mode 100644 conformance/tests/l1-rendering/vuln-scanner-acceptances/chainsaw-test.yaml create mode 100644 conformance/tests/l1-rendering/vuln-scanner-acceptances/check.py create mode 100755 conformance/tests/l1-rendering/vuln-scanner-acceptances/check.sh create mode 100644 conformance/tests/l1-rendering/vuln-scanner-api-metrics/chainsaw-test.yaml create mode 100644 conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.py create mode 100755 conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.sh create mode 100644 conformance/tests/l1-rendering/vuln-scanner-inventory/chainsaw-test.yaml create mode 100644 conformance/tests/l1-rendering/vuln-scanner-inventory/check.py create mode 100755 conformance/tests/l1-rendering/vuln-scanner-inventory/check.sh create mode 100644 conformance/tests/l1-rendering/vuln-scanner-inventory/fixtures/cluster.json create mode 100644 conformance/tests/l1-rendering/vuln-scanner-page/chainsaw-test.yaml create mode 100644 conformance/tests/l1-rendering/vuln-scanner-page/check.py create mode 100755 conformance/tests/l1-rendering/vuln-scanner-page/check.sh create mode 100644 conformance/tests/l1-rendering/vuln-scanner-page/fixtures/report.json create mode 100644 conformance/tests/l1-rendering/vuln-scanner-scan-loop/chainsaw-test.yaml create mode 100644 conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py create mode 100755 conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.sh create mode 100644 conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/cluster.json create mode 100644 conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-empty.json create mode 100644 conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nginx.json create mode 100644 conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nightly.json create mode 100644 conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-postgres.json create mode 100644 platform/vuln-scanner/src/acceptances.py create mode 100644 platform/vuln-scanner/src/inventory.py create mode 100644 platform/vuln-scanner/src/main.py create mode 100644 platform/vuln-scanner/src/metrics.py create mode 100644 platform/vuln-scanner/src/page.py create mode 100644 platform/vuln-scanner/src/report.py create mode 100644 platform/vuln-scanner/src/report.schema.json create mode 100644 platform/vuln-scanner/src/server.py create mode 100644 platform/vuln-scanner/src/state.py create mode 100644 platform/vuln-scanner/src/trivy.py diff --git a/conformance/tests/l1-rendering/vuln-scanner-acceptances/chainsaw-test.yaml b/conformance/tests/l1-rendering/vuln-scanner-acceptances/chainsaw-test.yaml new file mode 100644 index 0000000..4495743 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-acceptances/chainsaw-test.yaml @@ -0,0 +1,25 @@ +# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src, +# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline; +# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions. +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-scanner-acceptances + labels: + conformance.home.k3s/R-VULN-08: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-08" + conformance.home.k3s/layer: "L1" + conformance.home.k3s/safety-critical: "false" + # Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this + # a change to the scanner would select no vuln-scanner test. + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/intent: >- + Given acceptance files, When parsed and applied, Then expiry reverts to open and malformed entries never hide a finding. +spec: + steps: + - name: run-offline-check + description: Run check.sh, the same assertions the offline harness runs. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l1-rendering/vuln-scanner-acceptances/check.py b/conformance/tests/l1-rendering/vuln-scanner-acceptances/check.py new file mode 100644 index 0000000..6fb3560 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-acceptances/check.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""R-VULN-08: acceptance parsing, expiry and fail-safe behaviour.""" +import sys +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src")) +import acceptances # noqa: E402 +import report # noqa: E402 + +FAILED = [] +TODAY = date(2026, 11, 9) +NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc) +A1 = "sha256:" + "a1" * 32 + + +def check(name, condition): + print(("ok " if condition else "FAIL ") + name) + if not condition: + FAILED.append(name) + + +def parse(text): + return acceptances.parse_text(text, TODAY) + + +entries, errors = parse("""# header comment +- id: CVE-2026-1 + image: docker.io/library/nginx # exact match + reason: not reachable - feature disabled + expires: 2027-02-01 + +- id: CVE-2026-2 + reason: "platform wide" + expires: 2026-12-01 +""") +check("valid flat entries parse with no errors", len(entries) == 2 and errors == []) +check("inline comment is stripped", entries[0]["image"] == "docker.io/library/nginx") +check("omitted image means platform-wide", entries[1]["image"] is None) +check("empty list form is accepted", parse("# none\n[]\n") == ([], [])) +check("empty file is accepted", parse("") == ([], [])) + +for name, text in ( + ("missing reason", "- id: CVE-1\n expires: 2026-12-01\n"), + ("missing expires", "- id: CVE-1\n reason: x\n"), + ("expires beyond 180 days", "- id: CVE-1\n reason: x\n expires: 2027-06-01\n"), + ("unknown key", "- id: CVE-1\n reason: x\n expires: 2026-12-01\n scope: all\n"), + ("not a date", "- id: CVE-1\n reason: x\n expires: soon\n"), + ("impossible date", "- id: CVE-1\n reason: x\n expires: 2026-13-45\n"), + ("duplicate key", "- id: CVE-1\n reason: x\n reason: y\n expires: 2026-12-01\n"), + ("flow value", "- id: CVE-1\n reason: [x]\n expires: 2026-12-01\n"), +): + found, problems = parse(text) + check("rejected: " + name, found == [] and len(problems) == 1) + +found, problems = parse("- id: CVE-1\n reason: x\n expires: 2026-12-01\n- id: bad entry\n expires: 2026-12-01\n") +check("one bad entry is ignored, the valid one kept", len(found) == 1 and len(problems) == 1) +found, problems = parse("- id: CVE-1\n reason: x\n") +check("bad indentation invalidates the whole file", found == [] and problems) +found, problems = parse("garbage\n- id: CVE-1\n reason: x\n expires: 2026-12-01\n") +check("a stray line invalidates the whole file", found == [] and problems) +check("missing file is an error, not an empty allowlist", acceptances.load("/nonexistent/acceptances.yaml")[1]) + +entry = {"id": "CVE-1", "image": None, "reason": "r", "expires": date(2026, 11, 9)} +check("acceptance holds on its expiry day", acceptances.match([entry], "CVE-1", "any/image", TODAY) is entry) +check("acceptance gone the day after", acceptances.match([entry], "CVE-1", "any/image", TODAY + timedelta(days=1)) is None) +scoped = {"id": "CVE-1", "image": "docker.io/library/nginx", "reason": "r", "expires": date(2026, 12, 1)} +check("image match is exact", acceptances.match([scoped], "CVE-1", "docker.io/library/nginx-extra", TODAY) is None) +check("image match accepts the exact image", acceptances.match([scoped], "CVE-1", "docker.io/library/nginx", TODAY) is scoped) + + +def state_with(findings): + target = {"id": "docker.io/library/nginx@" + A1, "type": "image", "image": "docker.io/library/nginx", "digest": A1, + "ref": "", "reason": None, "workloads": []} + return { + "inventory": [target], "database": {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"}, + "run": {"startedAt": "2026-11-09T09:00:00Z", "finishedAt": "2026-11-09T09:30:00Z", "status": "complete"}, + "remediation": [], "inventoryError": None, "loadError": None, + "scans": {A1: {"firstDiscoveredAt": "2026-11-01T00:00:00Z", "lastInventoriedAt": "2026-11-09T09:00:00Z", + "lastAttemptAt": "2026-11-09T09:00:00Z", "lastSuccessAt": "2026-11-09T09:00:00Z", "outcome": "ok", + "reason": None, "failedSince": None, "findings": findings, + "firstSeen": {f["id"]: "2026-11-01T00:00:00Z" for f in findings}}}, + } + + +FINDING = {"id": "CVE-1", "severity": "CRITICAL", "cvss": 9.0, "package": "p", "installedVersion": "1", "fixedVersion": "2", + "title": "t", "url": ""} +ST = state_with([FINDING]) + + +def status(when, accepted, errs=()): + built = report.build(ST, accepted, list(errs), when) + return built, built["findings"][0] + + +entry = {"id": "CVE-1", "image": None, "reason": "not reachable", "expires": date(2026, 11, 9)} +built, finding = status(NOW, [entry]) +check("unexpired acceptance marks the finding accepted", finding["status"] == "accepted" + and finding["acceptance"] == {"reason": "not reachable", "expires": "2026-11-09"}) +check("accepted report is schema-valid", report.validate(built) == []) +_, finding = status(NOW + timedelta(days=1), [entry]) +check("expired acceptance reverts to the finding's own status", finding["status"] == "fix-available" and "acceptance" not in finding) +_, finding = status(NOW, []) +check("mutation: no acceptance leaves it open/fix-available", finding["status"] == "fix-available") +built, finding = status(NOW, [], ["bad entry"]) +check("an invalid entry degrades the run", built["run"]["status"] == "degraded") +check("an invalid entry never hides the finding", finding["status"] == "fix-available") + +print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed") +sys.exit(1 if FAILED else 0) diff --git a/conformance/tests/l1-rendering/vuln-scanner-acceptances/check.sh b/conformance/tests/l1-rendering/vuln-scanner-acceptances/check.sh new file mode 100755 index 0000000..77679ca --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-acceptances/check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags. +# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONDONTWRITEBYTECODE=1 +exec python3 "${HERE}/check.py" diff --git a/conformance/tests/l1-rendering/vuln-scanner-api-metrics/chainsaw-test.yaml b/conformance/tests/l1-rendering/vuln-scanner-api-metrics/chainsaw-test.yaml new file mode 100644 index 0000000..0d96fa4 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-api-metrics/chainsaw-test.yaml @@ -0,0 +1,28 @@ +# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src, +# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline; +# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions. +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-scanner-api-metrics + labels: + conformance.home.k3s/R-VULN-02: "true" + conformance.home.k3s/R-VULN-03: "true" + conformance.home.k3s/R-VULN-05: "true" + conformance.home.k3s/R-VULN-06: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-02, R-VULN-03, R-VULN-05, R-VULN-06" + conformance.home.k3s/layer: "L1" + conformance.home.k3s/safety-critical: "false" + # Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this + # a change to the scanner would select no vuln-scanner test. + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/intent: >- + Given a running engine, When the HTTP surfaces are exercised, Then the API validates, metrics are aggregates only, and listeners answer during a scan. +spec: + steps: + - name: run-offline-check + description: Run check.sh, the same assertions the offline harness runs. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.py b/conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.py new file mode 100644 index 0000000..4b81880 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""R-VULN-02/03/05/06: internal API, metrics, publish refusal, and HTTP during a scan.""" +import copy +import http.client +import json +import logging +import os +import re +import sys +import tempfile +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src")) +import main # noqa: E402 +import metrics # noqa: E402 +import report # noqa: E402 +import server # noqa: E402 +import state as st # noqa: E402 + +logging.disable(logging.CRITICAL) +FAILED = [] +NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc) +A1 = "sha256:" + "a1" * 32 + + +def check(name, condition): + print(("ok " if condition else "FAIL ") + name) + if not condition: + FAILED.append(name) + + +class Clock: + def __init__(self): + self.now = NOW + + def __call__(self): + return self.now + + +class Client: + def list_all(self, path): + if path.endswith("pods"): + return [{"metadata": {"namespace": "apps", "name": "p"}, "spec": {"containers": [{"name": "c", "image": "nginx:1"}]}, + "status": {"containerStatuses": [{"name": "c", "imageID": "docker.io/library/nginx@" + A1}]}}] + return [] + + +class Runner: + """Blocks on release while a scan is 'running', so a test can prove the HTTP path stays live.""" + + def __init__(self): + self.entered = threading.Event() + self.release = threading.Event() + self.release.set() + self.result = {"SchemaVersion": 2, "Results": [{"Vulnerabilities": [ + {"VulnerabilityID": "CVE-1", "Severity": "CRITICAL", "PkgName": "openssl", "InstalledVersion": "1", + "FixedVersion": "2", "Title": "t", "PrimaryURL": "https://example.test/1"}]}]} + + def download_db(self): + pass + + def db_info(self): + return {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"} + + def scan(self, _reference): + self.entered.set() + self.release.wait(10) + return self.result + + def clean_cache(self): + pass + + +def request(port, method, path, body=None, headers=None): + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + conn.request(method, path, body, headers or {}) + resp = conn.getresponse() + data = resp.read() + conn.close() + return resp.status, data, resp + + +def post(port, document, content_type="application/json"): + return request(port, "POST", "/triage", json.dumps(document), {"Content-Type": content_type}) + + +with tempfile.TemporaryDirectory() as tmp: + accept = os.path.join(tmp, "a.yaml") + Path(accept).write_text("[]\n") + clock = Clock() + runner = Runner() + eng = main.Engine(tmp, Client(), runner, accept, clock=clock, publish_seconds=0) + servers = server.start_all(eng, ("127.0.0.1", 0), ("127.0.0.1", 0), ("127.0.0.1", 0)) + page_port, internal, metrics_port = (s.server_address[1] for s in servers) + + status, body, _ = request(internal, "GET", "/report.json") + check("report.json is 503 before any report", status == 503) + status, body, resp = request(page_port, "GET", "/") + check("page is 503 before any report, with the CSP", status == 503 and resp.getheader("Content-Security-Policy")) + text = request(metrics_port, "GET", "/metrics")[1].decode() + check("last-complete-run metric is absent before the first run", "last_complete_run" not in text) + check("triage heartbeat metric is absent before the first POST", "triage_heartbeat" not in text) + + # HTTP must stay live while a scan is blocked mid-flight. + runner.release.clear() + worker = threading.Thread(target=eng.tick, daemon=True) + worker.start() + check("the scan is genuinely in flight", runner.entered.wait(10)) + status_h, _, _ = request(internal, "GET", "/healthz") + status_p, _, _ = request(page_port, "GET", "/healthz") + status_m, _, _ = request(metrics_port, "GET", "/metrics") + check("listeners answer during a scan", (status_h, status_p, status_m) == (200, 200, 200)) + runner.release.set() + worker.join(10) + check("the tick then completes", not worker.is_alive()) + + status, body, _ = request(internal, "GET", "/report.json") + check("report.json serves a schema-valid report after a tick", status == 200 and report.validate(json.loads(body)) == []) + + # Page and CSP over HTTP + status, body, resp = request(page_port, "GET", "/?view=inventory") + check("page served over HTTP carries the CSP and nosniff", + status == 200 and resp.getheader("Content-Security-Policy").startswith("default-src 'none'") and resp.getheader("X-Content-Type-Options") == "nosniff") + status, body, resp = request(page_port, "GET", "/missing") + check("a 404 from the page listener also carries the CSP", status == 404 and resp.getheader("Content-Security-Policy")) + + status, body, resp = request(page_port, "POST", "/") + check("an unsupported method on the page listener also carries the CSP", status == 501 and resp.getheader("Content-Security-Policy")) + + # Triage API + valid = {"heartbeatAt": "2026-11-09T11:00:00Z", "remediation": [ + {"image": "docker.io/library/nginx", "state": "pr-open", "url": "https://gitea.test/pulls/9", "updatedAt": "2026-11-09T11:00:00Z"}]} + status, body, _ = post(internal, valid) + check("valid triage is 204", status == 204 and body == b"") + check("remediation surfaces in the report", eng.report["remediation"][0]["state"] == "pr-open") + text = request(metrics_port, "GET", "/metrics")[1].decode() + check("heartbeat metric appears and uses server receipt time, not the bot's", f"triage_heartbeat_timestamp_seconds {int(NOW.timestamp())}" in text) + for name, mutate in ( + ("unknown top-level key", lambda d: d.update(extra=1)), + ("missing heartbeat", lambda d: d.pop("heartbeatAt")), + ("unknown remediation key", lambda d: d["remediation"][0].update(x=1)), + ("bad remediation state", lambda d: d["remediation"][0].update(state="merged")), + ("javascript url", lambda d: d["remediation"][0].update(url="javascript:x")), + ("bad timestamp", lambda d: d.update(heartbeatAt="yesterday")), + ): + document = copy.deepcopy(valid) + mutate(document) + status, _, _ = post(internal, document) + check("triage rejects " + name, status == 400) + check("triage rejects a wrong content type", post(internal, valid, "text/plain")[0] == 415) + status, _, _ = request(internal, "POST", "/triage", b"x" * 300000, {"Content-Type": "application/json"}) + check("triage rejects an oversized body", status == 413) + many = {"heartbeatAt": "2026-11-09T11:00:00Z", "remediation": [valid["remediation"][0]] * 5001} + check("triage rejects more than 5000 entries", post(internal, many)[0] in (400, 413)) + check("rejected triage leaves state untouched", eng.state["remediation"][0]["url"] == "https://gitea.test/pulls/9") + + # Metrics labels and values + text = request(metrics_port, "GET", "/metrics")[1].decode() + allowed = {"state", "severity", "fixable"} + labels = {k for line in text.splitlines() for k in re.findall(r"([A-Za-z_]+)=\"", line)} + check("metric labels are within the allowlist", labels and labels <= allowed) + check("no metric carries a CVE, package, image or workload", not re.search(r"CVE-|openssl|nginx|apps", text)) + for state in report.STATES: + check("targets gauge present for " + state, 'vuln_scanner_targets{state="' + state + '"}' in text) + check("critical fix findings counted with fixable true", 'vuln_scanner_findings{severity="CRITICAL",fixable="true"} 1' in text) + check("every severity and fixability appears", text.count("vuln_scanner_findings{") == 10) + check("failed-over-24h gauge exists", "vuln_scanner_failed_targets_over_24h 0" in text) + check("critical fix overdue is 0 inside the 72h grace", "vuln_scanner_critical_fix_overdue 0" in text) + clock.now += timedelta(hours=73) + text = request(metrics_port, "GET", "/metrics")[1].decode() + check("critical fix overdue is 1 after 72h", "vuln_scanner_critical_fix_overdue 1" in text) + check("last-complete-run metric present after a complete run", "vuln_scanner_last_complete_run_timestamp_seconds" in text) + for srv in servers: + srv.shutdown() + +# Invalid report: refuse and keep the old one. +with tempfile.TemporaryDirectory() as tmp: + good = {"schemaVersion": 1, "generatedAt": "2026-11-09T10:00:00Z", "database": {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"}, + "run": {"startedAt": "2026-11-09T09:00:00Z", "finishedAt": "2026-11-09T09:30:00Z", "status": "complete"}, + "targets": [], "findings": [], "remediation": []} + check("a valid report publishes", report.publish(good, tmp) is True) + before = Path(tmp, "report.json").read_bytes() + for name, mutate in ( + ("wrong schemaVersion", lambda r: r.update(schemaVersion=2)), + ("extra top-level key", lambda r: r.update(extra=1)), + ("bad timestamp", lambda r: r.update(generatedAt="soon")), + ("unknown state", lambda r: r.update(targets=[{"id": "x", "type": "image", "image": "x", "digest": None, "state": "fine", + "reason": None, "lastScannedAt": None, "workloads": []}])), + ("dangling targetId", lambda r: r.update(findings=[{"id": "CVE-1", "targetId": "nope", "severity": "LOW", "cvss": None, + "package": "p", "installedVersion": "1", "fixedVersion": None, "title": "t", + "url": "", "firstSeenAt": "2026-11-01T00:00:00Z", "status": "open"}])), + ): + broken = copy.deepcopy(good) + mutate(broken) + check("invalid report refused: " + name, report.publish(broken, tmp) is False) + check("an invalid report writes nothing", Path(tmp, "report.json").read_bytes() == before) + check("accepted without acceptance is invalid", report.validate({**good, "targets": [{"id": "x", "type": "image", "image": "x", "digest": None, + "state": "scanned", "reason": None, "lastScannedAt": None, "workloads": []}], "findings": [{"id": "CVE-1", "targetId": "x", "severity": "LOW", + "cvss": None, "package": "p", "installedVersion": "1", "fixedVersion": None, "title": "t", "url": "", "firstSeenAt": "2026-11-01T00:00:00Z", + "status": "accepted"}]}) != []) + Path(tmp, "report.json").write_text("{not json") + check("a corrupt published report is not served", report.load_published(tmp) is None) + +# Crash-consistency of state: the temp+rename leaves no temp files behind and the file parses. +with tempfile.TemporaryDirectory() as tmp: + store = st.Store(tmp) + state = st.new_state() + state["triageHeartbeatAt"] = "2026-11-09T11:00:00Z" + store.save(state) + check("state saves with no temp file left behind", sorted(os.listdir(tmp)) == ["state.json"]) + check("state survives a reload", st.Store(tmp).load()["triageHeartbeatAt"] == "2026-11-09T11:00:00Z") + Path(tmp, "state.json").write_text("not json") + loaded = st.Store(tmp).load() + check("corrupt state starts empty and marks degraded", loaded["loadError"] and loaded["scans"] == {}) + check("corrupt state is preserved for inspection", Path(tmp, "state.json.corrupt").exists()) + +# Metrics module directly, without the report. +empty_state = st.new_state() +text = metrics.render(None, empty_state, NOW) +check("with no report only the state-derived gauge exists", text == "vuln_scanner_failed_targets_over_24h 0\n") + +print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed") +sys.exit(1 if FAILED else 0) diff --git a/conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.sh b/conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.sh new file mode 100755 index 0000000..77679ca --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags. +# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONDONTWRITEBYTECODE=1 +exec python3 "${HERE}/check.py" diff --git a/conformance/tests/l1-rendering/vuln-scanner-inventory/chainsaw-test.yaml b/conformance/tests/l1-rendering/vuln-scanner-inventory/chainsaw-test.yaml new file mode 100644 index 0000000..49e46a7 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-inventory/chainsaw-test.yaml @@ -0,0 +1,25 @@ +# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src, +# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline; +# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions. +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-scanner-inventory + labels: + conformance.home.k3s/R-VULN-01: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-01" + conformance.home.k3s/layer: "L1" + conformance.home.k3s/safety-critical: "false" + # Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this + # a change to the scanner would select no vuln-scanner test. + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/intent: >- + Given recorded API lists, When the inventory is built, Then owner chains, digests, by-tag CronJobs and not-covered targets resolve per R-VULN-01. +spec: + steps: + - name: run-offline-check + description: Run check.sh, the same assertions the offline harness runs. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l1-rendering/vuln-scanner-inventory/check.py b/conformance/tests/l1-rendering/vuln-scanner-inventory/check.py new file mode 100644 index 0000000..14feece --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-inventory/check.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""R-VULN-01: inventory from recorded API lists.""" +import copy +import http.server +import json +import os +import sys +import tempfile +import threading +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src")) +import inventory # noqa: E402 + +FAILED = [] + + +def check(name, condition): + print(("ok " if condition else "FAIL ") + name) + if not condition: + FAILED.append(name) + + +CLUSTER = json.loads((HERE / "fixtures/cluster.json").read_text()) +A1 = "sha256:" + "a1" * 32 +B2 = "sha256:" + "b2" * 32 +C3 = "sha256:" + "c3" * 32 +D4 = "sha256:" + "d4" * 32 +E5 = "sha256:" + "e5" * 32 +F6 = "sha256:" + "f6" * 32 + + +def by_id(targets): + return {t["id"]: t for t in targets} + + +def workloads(target): + return {(w["namespace"], w["kind"], w["name"]): w["containers"] for w in target["workloads"]} + + +targets = inventory.build(CLUSTER) +ids = by_id(targets) + +nginx = ids["docker.io/library/nginx@" + A1] +check("ReplicaSet resolves to Deployment, not the ReplicaSet", workloads(nginx) == { + ("apps", "Deployment", "web"): ["app"], ("apps", "Deployment", "api"): ["server"]}) +check("docker-pullable:// prefix stripped and digest taken from imageID", nginx["digest"] == A1) +check("init container inventoried", ("apps", "Deployment", "web") in workloads(ids["ghcr.io/org/tool@" + B2])) +check("ephemeral container inventoried", "debug" in workloads(ids["docker.io/library/busybox@" + C3])[("apps", "Deployment", "web")]) + +backup = ids["ghcr.io/org/backup@" + E5] +check("Job resolves to CronJob", workloads(backup) == {("ops", "CronJob", "backup"): ["backup"]}) +check("a CronJob with a running pod is not also inventoried from its template", + not any(t["id"].startswith("ghcr.io/org/backup:") for t in targets)) + +nightly = ids["ghcr.io/org/nightly:2.1"] +check("idle CronJob inventoried from template with no digest", nightly["digest"] is None + and workloads(nightly) == {("ops", "CronJob", "nightly"): ["job"]}) + +solo = ids["docker.io/library/alpine:3.20"] +check("empty imageID gives a by-tag target from the spec image", solo["digest"] is None) +check("bare Pod is its own workload", ("ops", "Pod", "solo") in workloads(solo)) +check("Succeeded pod's image is not inventoried", not any(F6 in t["id"] for t in targets)) +check("virt-launcher stays owned by its VirtualMachineInstance", + ("kubevirt-vms", "VirtualMachineInstance", "vm1") in workloads(ids["quay.io/kubevirt/virt-launcher@sha256:" + "ab" * 32])) +vm, node = ids["vm/kubevirt-vms/vm1"], ids["node/prod-1"] +check("VM inventoried as non-container target", vm["type"] == "vm" and vm["digest"] is None and vm["reason"]) +check("node inventoried as non-container target", node["type"] == "node" and node["reason"]) + +# Non-vacuous: mutating the fixture changes the answer the assertions above rely on. +broken = copy.deepcopy(CLUSTER) +broken["replicasets"] = [] +broken["jobs"] = [] +broken_ids = by_id(inventory.build(broken)) +check("mutation: losing the ReplicaSet record leaves the ReplicaSet as the workload", + ("apps", "ReplicaSet", "web-5f") in workloads(broken_ids["docker.io/library/nginx@" + A1])) +check("mutation: losing the Job record leaves the Job as the workload", + ("ops", "Job", "backup-123") in workloads(broken_ids["ghcr.io/org/backup@" + E5])) +check("mutation: a Running phase on the Succeeded pod does inventory its image", any( + F6 in t["id"] for t in inventory.build({**CLUSTER, "pods": [ + {**p, "status": {**p["status"], "phase": "Running"}} if p["metadata"]["name"] == "done-1" else p + for p in CLUSTER["pods"]]}))) + +for reference, expected in ( + ("nginx:1.27", ("docker.io/library/nginx", "1.27", None)), + ("nginx", ("docker.io/library/nginx", None, None)), + ("bitnami/redis:7", ("docker.io/bitnami/redis", "7", None)), + ("ghcr.io/org/app:v1", ("ghcr.io/org/app", "v1", None)), + ("registry.local:5000/x/y:2", ("registry.local:5000/x/y", "2", None)), + ("index.docker.io/library/nginx:1", ("docker.io/library/nginx", "1", None)), + ("ghcr.io/org/app:v1@" + A1, ("ghcr.io/org/app", "v1", A1)), +): + check("normalise " + reference, inventory.normalise_reference(reference) == expected) +check("bare config id is not a digest", inventory.digest_from_image_id("sha256:" + "a1" * 32) is None) +check("empty imageID has no digest", inventory.digest_from_image_id("") is None) + + +class FakeListClient: + """list_all replaced; proves collect() tolerates exactly a 404 on the VMI API.""" + + def __init__(self, vmi_error): + self.vmi_error = vmi_error + + def list_all(self, path): + if "kubevirt" in path and self.vmi_error: + raise self.vmi_error + return [] + + +check("VMI API 404 tolerated as no VMs", inventory.collect(FakeListClient(inventory.NotFound("x")))["vmis"] == []) +try: + inventory.collect(FakeListClient(inventory.InventoryError("403"))) + check("a non-404 VMI error is not swallowed", False) +except inventory.InventoryError: + check("a non-404 VMI error is not swallowed", True) + + +class PagedAPI(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + return + + def do_GET(self): + if self.headers.get("Authorization") != "Bearer fixture-token": + self.send_response(403) + self.end_headers() + return + if self.path.startswith("/apis/kubevirt"): + self.send_response(404) + self.end_headers() + return + if "continue=" in self.path: + body = {"items": [{"metadata": {"name": "b"}}], "metadata": {}} + else: + body = {"items": [{"metadata": {"name": "a"}}], "metadata": {"continue": "page2"}} + payload = json.dumps(body).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +with tempfile.TemporaryDirectory() as tmp: + token = os.path.join(tmp, "token") + Path(token).write_text("fixture-token\n") + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), PagedAPI) + threading.Thread(target=server.serve_forever, daemon=True).start() + client = inventory.KubeClient("http://127.0.0.1:" + str(server.server_address[1]), token, os.devnull) + items = client.list_all("/api/v1/pods") + check("pagination follows the continue token with the bearer token", [i["metadata"]["name"] for i in items] == ["a", "b"]) + check("collect over real HTTP tolerates VMI 404", inventory.collect(client)["vmis"] == []) + server.shutdown() + +print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed") +sys.exit(1 if FAILED else 0) diff --git a/conformance/tests/l1-rendering/vuln-scanner-inventory/check.sh b/conformance/tests/l1-rendering/vuln-scanner-inventory/check.sh new file mode 100755 index 0000000..77679ca --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-inventory/check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags. +# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONDONTWRITEBYTECODE=1 +exec python3 "${HERE}/check.py" diff --git a/conformance/tests/l1-rendering/vuln-scanner-inventory/fixtures/cluster.json b/conformance/tests/l1-rendering/vuln-scanner-inventory/fixtures/cluster.json new file mode 100644 index 0000000..c3656cc --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-inventory/fixtures/cluster.json @@ -0,0 +1,395 @@ +{ + "cronjobs": [ + { + "metadata": { + "name": "backup", + "namespace": "ops" + }, + "spec": { + "jobTemplate": { + "spec": { + "template": { + "spec": { + "containers": [ + { + "image": "ghcr.io/org/backup:1.0", + "name": "job" + } + ] + } + } + } + } + } + }, + { + "metadata": { + "name": "nightly", + "namespace": "ops" + }, + "spec": { + "jobTemplate": { + "spec": { + "template": { + "spec": { + "containers": [ + { + "image": "ghcr.io/org/nightly:2.1", + "name": "job" + } + ] + } + } + } + } + } + } + ], + "jobs": [ + { + "metadata": { + "name": "backup-123", + "namespace": "ops", + "ownerReferences": [ + { + "controller": true, + "kind": "CronJob", + "name": "backup" + } + ] + } + }, + { + "metadata": { + "name": "backup-99", + "namespace": "ops", + "ownerReferences": [ + { + "controller": true, + "kind": "CronJob", + "name": "backup" + } + ] + } + } + ], + "nodes": [ + { + "metadata": { + "name": "prod-1" + } + }, + { + "metadata": { + "name": "prod-2" + } + } + ], + "pods": [ + { + "metadata": { + "name": "web-1", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "ReplicaSet", + "name": "web-5f" + } + ] + }, + "spec": { + "containers": [ + { + "image": "nginx:1.27", + "name": "app" + } + ], + "ephemeralContainers": [ + { + "image": "busybox:1.37", + "name": "debug" + } + ], + "initContainers": [ + { + "image": "ghcr.io/org/tool:v2", + "name": "init-db" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "docker-pullable://docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "name": "app" + } + ], + "ephemeralContainerStatuses": [ + { + "imageID": "docker.io/library/busybox@sha256:c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "name": "debug" + } + ], + "initContainerStatuses": [ + { + "imageID": "ghcr.io/org/tool@sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", + "name": "init-db" + } + ], + "phase": "Running" + } + }, + { + "metadata": { + "name": "web-2", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "ReplicaSet", + "name": "web-5f" + } + ] + }, + "spec": { + "containers": [ + { + "image": "nginx:1.27", + "name": "app" + } + ], + "initContainers": [ + { + "image": "ghcr.io/org/tool:v2", + "name": "init-db" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "name": "app" + } + ], + "initContainerStatuses": [ + { + "imageID": "ghcr.io/org/tool@sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", + "name": "init-db" + } + ], + "phase": "Running" + } + }, + { + "metadata": { + "name": "api-1", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "ReplicaSet", + "name": "api-77" + } + ] + }, + "spec": { + "containers": [ + { + "image": "nginx:1.27", + "name": "server" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "name": "server" + } + ], + "phase": "Running" + } + }, + { + "metadata": { + "name": "db-0", + "namespace": "data", + "ownerReferences": [ + { + "controller": true, + "kind": "StatefulSet", + "name": "db" + } + ] + }, + "spec": { + "containers": [ + { + "image": "postgres:16", + "name": "postgres" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "name": "postgres" + } + ], + "phase": "Running" + } + }, + { + "metadata": { + "name": "backup-123-x", + "namespace": "ops", + "ownerReferences": [ + { + "controller": true, + "kind": "Job", + "name": "backup-123" + } + ] + }, + "spec": { + "containers": [ + { + "image": "ghcr.io/org/backup:1.0", + "name": "backup" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "ghcr.io/org/backup@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "name": "backup" + } + ], + "phase": "Running" + } + }, + { + "metadata": { + "name": "solo", + "namespace": "ops" + }, + "spec": { + "containers": [ + { + "image": "alpine:3.20", + "name": "toolbox" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "", + "name": "toolbox" + } + ], + "phase": "Pending" + } + }, + { + "metadata": { + "name": "done-1", + "namespace": "ops", + "ownerReferences": [ + { + "controller": true, + "kind": "Job", + "name": "backup-99" + } + ] + }, + "spec": { + "containers": [ + { + "image": "ghcr.io/org/old-backup:0.1", + "name": "backup" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "ghcr.io/org/old-backup@sha256:f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6", + "name": "backup" + } + ], + "phase": "Succeeded" + } + }, + { + "metadata": { + "name": "virt-launcher-vm1-abc", + "namespace": "kubevirt-vms", + "ownerReferences": [ + { + "controller": true, + "kind": "VirtualMachineInstance", + "name": "vm1" + } + ] + }, + "spec": { + "containers": [ + { + "image": "quay.io/kubevirt/virt-launcher:v1.8", + "name": "compute" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "quay.io/kubevirt/virt-launcher@sha256:abababababababababababababababababababababababababababababababab", + "name": "compute" + } + ], + "phase": "Running" + } + } + ], + "replicasets": [ + { + "metadata": { + "name": "web-5f", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "Deployment", + "name": "web" + } + ] + } + }, + { + "metadata": { + "name": "api-77", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "Deployment", + "name": "api" + } + ] + } + } + ], + "vmis": [ + { + "metadata": { + "name": "vm1", + "namespace": "kubevirt-vms" + } + } + ] +} diff --git a/conformance/tests/l1-rendering/vuln-scanner-page/chainsaw-test.yaml b/conformance/tests/l1-rendering/vuln-scanner-page/chainsaw-test.yaml new file mode 100644 index 0000000..50bbe42 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-page/chainsaw-test.yaml @@ -0,0 +1,26 @@ +# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src, +# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline; +# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions. +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-scanner-page + labels: + conformance.home.k3s/R-VULN-03: "true" + conformance.home.k3s/R-VULN-04: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-03, R-VULN-04" + conformance.home.k3s/layer: "L1" + conformance.home.k3s/safety-critical: "false" + # Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this + # a change to the scanner would select no vuln-scanner test. + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/intent: >- + Given a report fixture, When the page renders, Then all CVEs, containers and workloads appear in all views and hostile input is inert. +spec: + steps: + - name: run-offline-check + description: Run check.sh, the same assertions the offline harness runs. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l1-rendering/vuln-scanner-page/check.py b/conformance/tests/l1-rendering/vuln-scanner-page/check.py new file mode 100644 index 0000000..3a41da1 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-page/check.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""R-VULN-03/04: the rendered page. Assertions run against the actual HTML output.""" +import base64 +import copy +import hashlib +import json +import re +import sys +from datetime import datetime, timedelta, timezone +from html.parser import HTMLParser +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src")) +import page # noqa: E402 + +FAILED = [] +NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc) +REPORT = json.loads((HERE / "fixtures/report.json").read_text()) +CLEAN_RE = re.compile(r"(?i)\bclean\b|no vulnerabilit") + + +def check(name, condition): + print(("ok " if condition else "FAIL ") + name) + if not condition: + FAILED.append(name) + + +def render(report=REPORT, query="", now=NOW): + status, headers, body = page.render(report, query, now) + return status, headers, body.decode("utf-8") + + +class Tags(HTMLParser): + def __init__(self): + super().__init__() + self.tags = [] + self.attrs = [] + + def handle_starttag(self, tag, attrs): + self.tags.append(tag) + self.attrs.append((tag, dict(attrs))) + + +def tags(body): + parser = Tags() + parser.feed(body) + return parser + + +def covers_everything(body, report): + """Every fixture CVE id, workload name and container name appears in the document.""" + missing = [f["id"] for f in report["findings"] if f["id"] not in body] + for target in report["targets"]: + for workload in target["workloads"]: + if workload["name"] not in body: + missing.append(workload["name"]) + missing.extend(c for c in workload["containers"] if c not in body) + return missing + + +def workload_view_covers(body, report): + """Every workload row with its containers, and every CVE in the expansions.""" + missing = covers_everything(body, report) + return missing + + +for view in ("cves", "workloads", "inventory"): + status, headers, body = render(query="view=" + view) + check(view + " view returns 200", status == 200) + check(view + " view has the Content-Security-Policy with no script source", "script-src" not in headers["Content-Security-Policy"] + and headers["Content-Security-Policy"].startswith("default-src 'none'")) + check(view + " view sets nosniff", headers["X-Content-Type-Options"] == "nosniff") + check(view + " view has no script element", "script" not in tags(body).tags) + style_body = re.search(r"<style>(.*?)</style>", body, re.S).group(1) + expected = base64.b64encode(hashlib.sha256(style_body.encode()).digest()).decode() + check(view + " view's CSP hash matches its inline style", "'sha256-" + expected + "'" in headers["Content-Security-Policy"]) + check(view + " view header shows every state count", all(state + ": <strong>" in body for state in page.report_mod.STATES)) + check(view + " view shows a coverage warning", "INCOMPLETE COVERAGE" in body) + check(view + " view makes no clean claim with unscanned targets", not CLEAN_RE.search(body)) + +status, headers, cves = render(query="view=cves") +check("CVE view contains every CVE", all(f["id"] in cves for f in REPORT["findings"])) +affected = {f["targetId"] for f in REPORT["findings"]} +expected_in_cves = {(t["id"], w["name"]) for t in REPORT["targets"] if t["id"] in affected for w in t["workloads"]} +check("CVE view expansions name every workload of an affected target", all(name in cves for _, name in expected_in_cves)) +check("CVE view expansions name every container of an affected target", + all(c in cves for t in REPORT["targets"] if t["id"] in affected for w in t["workloads"] for c in w["containers"])) +for name in ("web", "api", "app", "server"): + check("CVE view expansion names " + name, name in cves) +status, headers, workloads = render(query="view=workloads") +check("workloads view contains every workload and container, including zero-finding ones", covers_everything(workloads, REPORT) == []) +status, headers, inventory = render(query="view=inventory") +check("inventory view contains every target", all(t["id"] in inventory for t in REPORT["targets"])) +check("inventory view contains every workload and container", covers_everything(inventory, REPORT) == []) +mutated = copy.deepcopy(REPORT) +mutated["findings"] = [f for f in mutated["findings"] if f["id"] != "CVE-2026-1003"] +check("mutation: the coverage evaluator catches a dropped CVE", "CVE-2026-1003" in covers_everything(render(mutated, "view=workloads")[2], REPORT)) + +_, _, body = render(query="view=cves") +check("hostile title is escaped", "<script>alert(1)</script>" in body) +check("hostile title is never live", "<script>" not in body) +hostile = copy.deepcopy(REPORT) +hostile["findings"][0]["url"] = "javascript:alert(1)" +hostile["findings"][0]["title"] = '"><img src=x onerror=alert(1)>' +hostile["targets"][0]["reason"] = "<b onmouseover=alert(1)>x</b>" +hostile["remediation"][0]["url"] = "javascript:evil()" +_, _, body = render(hostile, "view=cves") +parsed = tags(body) +check("a javascript: advisory URL never becomes a link", not any("javascript:" in (a.get("href") or "") for t, a in parsed.attrs if t == "a")) +check("no element carries a hostile inline handler", not any(k.startswith("on") for _, a in parsed.attrs for k in a)) +check("no img element was injected", "img" not in parsed.tags) +_, _, body = render(hostile, "view=inventory") +check("hostile reason is escaped in inventory", "<b onmouseover" not in body and "<b onmouseover" in body) +_, _, body = render(query="view=cves&q=" + "%3Cscript%3E") +check("a hostile search term is escaped", "<script>" not in body) +check("a linked CVE points at its advisory", 'href="https://example.test/CVE-2026-1001"' in render(query="view=cves")[2]) + +stale = copy.deepcopy(REPORT) +stale["generatedAt"] = (NOW - timedelta(hours=27)).strftime("%Y-%m-%dT%H:%M:%SZ") +_, _, body = render(stale) +check("stale report shows a warning", "STALE REPORT" in body) +check("stale report shows no normal state", "Status: ATTENTION NEEDED" in body and "Status: CURRENT" not in body) +check("mutation: a fresh report has no stale warning", "STALE REPORT" not in render(REPORT)[2]) +degraded = copy.deepcopy(REPORT) +degraded["run"]["status"] = "degraded" +_, _, body = render(degraded) +check("degraded run shows a warning and no normal state", "DEGRADED RUN" in body and "Status: ATTENTION NEEDED" in body and "Status: CURRENT" not in body) + +allgood = copy.deepcopy(REPORT) +allgood["targets"] = [t for t in allgood["targets"] if t["state"] == "scanned"] +allgood["findings"] = [] +_, _, body = render(allgood) +check("the clean claim appears when every target is scanned with zero findings", "Clean: all 1 targets" in body) +check("mutation: an all-scanned report shows the current state", "Status: CURRENT" in body) +one_pending = copy.deepcopy(allgood) +one_pending["targets"].append({"id": "x@sha256:" + "0" * 64, "type": "image", "image": "x", "digest": "sha256:" + "0" * 64, + "state": "pending", "reason": None, "lastScannedAt": None, "workloads": []}) +_, _, body = render(one_pending) +check("one pending target removes the clean claim", "Clean:" not in body and not CLEAN_RE.search(body)) +for state in ("failed", "stale", "credential-required", "by-tag", "not-covered"): + variant = copy.deepcopy(allgood) + variant["targets"].append({**one_pending["targets"][-1], "state": state}) + check("a " + state + " target removes the clean claim", not CLEAN_RE.search(render(variant)[2])) +empty = copy.deepcopy(allgood) +empty["targets"] = [] +check("zero targets is never a clean claim", not CLEAN_RE.search(render(empty)[2])) +degraded_clean = copy.deepcopy(allgood) +degraded_clean["run"]["status"] = "degraded" +check("a degraded run never makes a clean claim", not CLEAN_RE.search(render(degraded_clean)[2])) + +status, headers, body = render(None) +check("no report yet is a 503 with the CSP", status == 503 and "Content-Security-Policy" in headers) +check("no report yet makes no clean claim", not CLEAN_RE.search(body)) + +_, _, body = render(query="view=cves&severity=CRITICAL") +check("the severity filter narrows the list", "CVE-2026-1001" in body and "CVE-2026-1004" not in body) +check("a filter is carried in pagination and sort links", "severity=CRITICAL" in body) +_, _, body = render(query="view=cves&status=accepted") +check("accepted findings stay listed with their reason", "CVE-2026-1003" in body and "not reachable" in body) +_, _, body = render(query="view=cves&fix=no") +check("the fix filter excludes fixed CVEs", "CVE-2026-1001" not in body and "CVE-2026-1002" in body) +_, _, body = render(query="view=inventory&status=credential-required") +check("inventory filters on coverage state", "ghcr.io/org/private" in body and "postgres" not in body.split("<main>")[1]) +_, _, body = render(query="view=workloads&namespace=ops") +check("the namespace filter narrows workloads", "nightly" in body and "<td>web</td>" not in body) +_, _, body = render(query="view=cves&q=openssl") +check("free text search matches packages", "CVE-2026-1001" in body and "CVE-2026-1004" not in body) +_, _, body = render() +check("default view excludes nothing", all(f["id"] in body for f in REPORT["findings"])) +check("remediation column shows pr-open with a link", 'href="https://gitea.test/pulls/1"' in body and "pr-open" in body) + +big = copy.deepcopy(REPORT) +big["findings"] = [dict(REPORT["findings"][0], id="CVE-2026-" + format(i, "05d")) for i in range(250)] +_, _, body = render(big, "view=cves&size=50&page=2") +check("pagination states the total", "of 250 CVEs (page 2 of 5)" in body) +check("pagination shows only its page", "CVE-2026-00049" not in body and "CVE-2026-00050" in body and "CVE-2026-00100" not in body) +check("every page's links include next and previous", ">next<" in body and ">previous<" in body) + +print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed") +sys.exit(1 if FAILED else 0) diff --git a/conformance/tests/l1-rendering/vuln-scanner-page/check.sh b/conformance/tests/l1-rendering/vuln-scanner-page/check.sh new file mode 100755 index 0000000..77679ca --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-page/check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags. +# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONDONTWRITEBYTECODE=1 +exec python3 "${HERE}/check.py" diff --git a/conformance/tests/l1-rendering/vuln-scanner-page/fixtures/report.json b/conformance/tests/l1-rendering/vuln-scanner-page/fixtures/report.json new file mode 100644 index 0000000..6124145 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-page/fixtures/report.json @@ -0,0 +1,199 @@ +{ + "database": { + "updatedAt": "2026-11-09T06:00:00Z", + "version": "2" + }, + "findings": [ + { + "cvss": 9.1, + "firstSeenAt": "2026-11-01T00:00:00Z", + "fixedVersion": "3.0.2", + "id": "CVE-2026-1001", + "installedVersion": "1.0", + "package": "openssl", + "severity": "CRITICAL", + "status": "fix-available", + "targetId": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "title": "OpenSSL flaw", + "url": "https://example.test/CVE-2026-1001" + }, + { + "cvss": null, + "firstSeenAt": "2026-11-01T00:00:00Z", + "fixedVersion": null, + "id": "CVE-2026-1002", + "installedVersion": "1.0", + "package": "zlib", + "severity": "LOW", + "status": "open", + "targetId": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "title": "<script>alert(1)</script>", + "url": "https://example.test/CVE-2026-1002" + }, + { + "acceptance": { + "expires": "2027-01-01", + "reason": "not reachable" + }, + "cvss": null, + "firstSeenAt": "2026-11-01T00:00:00Z", + "fixedVersion": "16.2", + "id": "CVE-2026-1003", + "installedVersion": "1.0", + "package": "libpq", + "severity": "HIGH", + "status": "accepted", + "targetId": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "title": "libpq flaw (retained from a prior scan)", + "url": "https://example.test/CVE-2026-1003" + }, + { + "cvss": null, + "firstSeenAt": "2026-11-01T00:00:00Z", + "fixedVersion": null, + "id": "CVE-2026-1004", + "installedVersion": "1.0", + "package": "curl", + "severity": "MEDIUM", + "status": "open", + "targetId": "ghcr.io/org/nightly:2.1", + "title": "curl flaw", + "url": "https://example.test/CVE-2026-1004" + } + ], + "generatedAt": "2026-11-09T10:00:00Z", + "remediation": [ + { + "image": "docker.io/library/nginx", + "state": "pr-open", + "updatedAt": "2026-11-09T10:00:00Z", + "url": "https://gitea.test/pulls/1" + } + ], + "run": { + "finishedAt": "2026-11-09T09:30:00Z", + "startedAt": "2026-11-09T09:00:00Z", + "status": "complete" + }, + "schemaVersion": 1, + "targets": [ + { + "digest": "sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "id": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "image": "docker.io/library/nginx", + "lastScannedAt": "2026-11-09T10:00:00Z", + "reason": null, + "state": "scanned", + "type": "image", + "workloads": [ + { + "containers": [ + "app" + ], + "kind": "Deployment", + "name": "web", + "namespace": "apps" + }, + { + "containers": [ + "server" + ], + "kind": "Deployment", + "name": "api", + "namespace": "apps" + } + ] + }, + { + "digest": "sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "id": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "image": "docker.io/library/postgres", + "lastScannedAt": "2026-11-07T09:00:00Z", + "reason": "trivy timed out", + "state": "failed", + "type": "image", + "workloads": [ + { + "containers": [ + "postgres" + ], + "kind": "StatefulSet", + "name": "db", + "namespace": "data" + } + ] + }, + { + "digest": "sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "id": "ghcr.io/org/private@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "image": "ghcr.io/org/private", + "lastScannedAt": null, + "reason": "registry ghcr.io refused anonymous access", + "state": "credential-required", + "type": "image", + "workloads": [ + { + "containers": [ + "priv" + ], + "kind": "Deployment", + "name": "priv", + "namespace": "apps" + } + ] + }, + { + "digest": null, + "id": "ghcr.io/org/nightly:2.1", + "image": "ghcr.io/org/nightly", + "lastScannedAt": "2026-11-09T10:00:00Z", + "reason": null, + "state": "by-tag", + "type": "image", + "workloads": [ + { + "containers": [ + "job" + ], + "kind": "CronJob", + "name": "nightly", + "namespace": "ops" + } + ] + }, + { + "digest": null, + "id": "vm/kubevirt-vms/vm1", + "image": "", + "lastScannedAt": null, + "reason": "guest OS packages are not assessed by this scanner", + "state": "not-covered", + "type": "vm", + "workloads": [ + { + "containers": [], + "kind": "VirtualMachineInstance", + "name": "vm1", + "namespace": "kubevirt-vms" + } + ] + }, + { + "digest": null, + "id": "node/prod-1", + "image": "", + "lastScannedAt": null, + "reason": "node OS packages and the k3s binary are not assessed by this scanner", + "state": "not-covered", + "type": "node", + "workloads": [ + { + "containers": [], + "kind": "Node", + "name": "prod-1", + "namespace": "" + } + ] + } + ] +} diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/chainsaw-test.yaml b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/chainsaw-test.yaml new file mode 100644 index 0000000..68a3c56 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/chainsaw-test.yaml @@ -0,0 +1,27 @@ +# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src, +# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline; +# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions. +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-scanner-scan-loop + labels: + conformance.home.k3s/R-VULN-02: "true" + conformance.home.k3s/R-VULN-03: "true" + conformance.home.k3s/R-VULN-05: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-02, R-VULN-03, R-VULN-05" + conformance.home.k3s/layer: "L1" + conformance.home.k3s/safety-critical: "false" + # Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this + # a change to the scanner would select no vuln-scanner test. + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/intent: >- + Given a fake Trivy runner and clock, When ticks run, Then shared digests scan once, failures isolate, and coverage states and first-seen persist. +spec: + steps: + - name: run-offline-check + description: Run check.sh, the same assertions the offline harness runs. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py new file mode 100644 index 0000000..f86ea2d --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""R-VULN-02/03/05: scan loop semantics with a fake Trivy runner and a controllable clock.""" +import json +import logging +import os +import sys +import tempfile +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src")) +import main # noqa: E402 +import report # noqa: E402 +import trivy # noqa: E402 + +logging.disable(logging.CRITICAL) +FAILED = [] +A1 = "sha256:" + "a1" * 32 +D4 = "sha256:" + "d4" * 32 +E5 = "sha256:" + "e5" * 32 +NGINX = "docker.io/library/nginx@" + A1 +POSTGRES = "docker.io/library/postgres@" + D4 +PRIVATE = "ghcr.io/org/private@" + E5 +NIGHTLY = "ghcr.io/org/nightly:2.1" +CLUSTER = json.loads((HERE / "fixtures/cluster.json").read_text()) + + +def fixture(name): + return json.loads((HERE / "fixtures" / name).read_text()) + + +def check(name, condition): + print(("ok " if condition else "FAIL ") + name) + if not condition: + FAILED.append(name) + + +class Clock: + def __init__(self): + self.now = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc) + + def __call__(self): + return self.now + + def advance(self, **delta): + self.now += timedelta(**delta) + + +class FakeClient: + def __init__(self, cluster): + self.cluster = cluster + self.fail = False + + def list_all(self, path): + if self.fail: + raise RuntimeError("api down") + for name, item in (("pods", "/api/v1/pods"), ("replicasets", "replicasets"), ("jobs", "/jobs"), + ("cronjobs", "cronjobs"), ("nodes", "/nodes"), ("vmis", "virtualmachineinstances")): + if path.endswith(item): + return self.cluster[name] + return [] + + +class FakeRunner: + """Recorded results, no Trivy binary.""" + + def __init__(self): + self.scans = [] + self.outcomes = { + NGINX: fixture("trivy-nginx.json"), POSTGRES: fixture("trivy-postgres.json"), + NIGHTLY: fixture("trivy-nightly.json"), + PRIVATE: trivy.classify_failure("ghcr.io", "FATAL: GET https://ghcr.io/: UNAUTHORIZED: authentication required"), + } + self.db_age_hours = 1 + self.db_fails = False + self.clock = None + + def download_db(self): + if self.db_fails: + raise trivy.ScanFailure("failed", "database update failed") + + def db_info(self): + return {"updatedAt": (self.clock() - timedelta(hours=self.db_age_hours)).strftime("%Y-%m-%dT%H:%M:%SZ"), "version": "2"} + + def scan(self, reference): + self.scans.append(reference) + outcome = self.outcomes[reference] + if isinstance(outcome, Exception): + raise outcome + return outcome + + def clean_cache(self): + pass + + +def engine(tmp, runner=None, clock=None, acc="[]\n", cluster=CLUSTER): + path = os.path.join(tmp, "acceptances.yaml") + if not os.path.exists(path): + Path(path).write_text(acc) + clock = clock or Clock() + runner = runner or FakeRunner() + runner.clock = clock + return main.Engine(tmp, FakeClient(cluster), runner, path, clock=clock, publish_seconds=0), runner, clock + + +def targets(eng): + return {t["id"]: t for t in eng.report["targets"]} + + +def findings(eng, target_id): + return [f for f in eng.report["findings"] if f["targetId"] == target_id] + + +def one_scan_per_digest(scans): + return len(scans) == len(set(scans)) + + +with tempfile.TemporaryDirectory() as tmp: + eng, runner, clock = engine(tmp) + eng.tick() + t = targets(eng) + check("two workloads sharing a digest produce exactly one scan", runner.scans.count(NGINX) == 1 and one_scan_per_digest(runner.scans)) + check("mutation: the digest-uniqueness evaluator rejects a double scan", not one_scan_per_digest(["a", "a"])) + check("the shared digest maps back to both workloads", + {(w["kind"], w["name"]) for w in t[NGINX]["workloads"]} == {("Deployment", "web"), ("Deployment", "api")}) + check("shared digest findings appear once per target", len(findings(eng, NGINX)) == 2) + check("digest target is scanned", t[NGINX]["state"] == "scanned") + check("idle CronJob template is by-tag after scanning, not scanned", t[NIGHTLY]["state"] == "by-tag") + check("node is not-covered", t["node/prod-1"]["state"] == "not-covered") + check("report is schema-valid", report.validate(eng.report) == []) + check("credential-required is its own state, not failed", t[PRIVATE]["state"] == "credential-required") + check("credential-required reason names the registry", "ghcr.io" in t[PRIVATE]["reason"]) + check("credential-required does not count toward failed_over_24h", report.failed_over_24h(eng.state, clock() + timedelta(hours=48)) == 0) + check("report is written to disk", json.loads(Path(tmp, "report.json").read_text())["generatedAt"]) + first_seen = {f["id"]: f["firstSeenAt"] for f in findings(eng, NGINX)} + + clock.advance(hours=21) + runner.outcomes[POSTGRES] = trivy.ScanFailure("failed", "pull error: manifest unknown") + eng.tick() + t = targets(eng) + check("a failed image leaves the run continuing", runner.scans.count(NGINX) == 2 and runner.scans.count(NIGHTLY) == 2) + check("the failed target is failed", t[POSTGRES]["state"] == "failed") + check("the failed target's reason is shown", "manifest unknown" in t[POSTGRES]["reason"]) + check("the failed target keeps its prior findings", [f["id"] for f in findings(eng, POSTGRES)] == ["CVE-2026-0010"]) + check("other targets stay scanned", t[NGINX]["state"] == "scanned") + check("first-seen survives a re-scan", {f["id"]: f["firstSeenAt"] for f in findings(eng, NGINX)} == first_seen) + check("run with one failed image is still complete (failure is per-target)", eng.report["run"]["status"] == "complete") + + clock.advance(hours=25) + check("failed target is counted over 24h after 25h", report.failed_over_24h(eng.state, clock()) == 1) + eng.publish() + check("a target whose last success aged past 24h is stale", targets(eng)[NGINX]["state"] == "stale") + check("stale target keeps findings", len(findings(eng, NGINX)) == 2) + + # Restart: a new Engine loads everything from disk. + eng2, runner2, clock2 = engine(tmp, runner=FakeRunner(), clock=clock) + check("report survives restart", eng2.report is not None and eng2.report_bytes() is not None) + check("last complete run survives restart", eng2.state["lastCompleteRunAt"] is not None) + eng2.publish() + check("first-seen survives restart", {f["id"]: f["firstSeenAt"] for f in findings(eng2, NGINX)} == first_seen) + +with tempfile.TemporaryDirectory() as tmp: + # Not-scanned is never zero findings: the first tick has not scanned anything yet. + eng, runner, clock = engine(tmp) + eng.refresh_inventory() + eng.publish() + t = targets(eng) + check("before scanning, digest targets are pending", t[NGINX]["state"] == "pending" and t[POSTGRES]["state"] == "pending") + check("run object exists and is degraded before the first run", eng.report["run"]["status"] == "degraded") + check("no fabricated findings before scanning", eng.report["findings"] == []) + check("zero findings report still counts every target", len(eng.report["targets"]) == 5) + clock.advance(hours=3) + eng.publish() + t = targets(eng) + check("pending beyond 2h becomes failed, never silently absent", t[NGINX]["state"] == "failed") + check("failed reason is the contract's text", t[NGINX]["reason"] == report.NOT_SCANNED_REASON) + check("a never-scanned failed target has lastScannedAt null", t[NGINX]["lastScannedAt"] is None) + clock.advance(hours=24) + check("never-scanned target counts as failed over 24h", report.failed_over_24h(eng.state, clock()) >= 1) + +with tempfile.TemporaryDirectory() as tmp: + # Database problems degrade the run but it still reports. + eng, runner, clock = engine(tmp) + runner.db_fails = True + eng.tick() + check("DB update failure degrades the run", eng.report["run"]["status"] == "degraded") + check("DB update failure still scans and reports", runner.scans and eng.report["findings"]) + check("a degraded run is not recorded as complete", eng.state["lastCompleteRunAt"] is None) +with tempfile.TemporaryDirectory() as tmp: + eng, runner, clock = engine(tmp) + runner.db_age_hours = 49 + eng.tick() + check("a DB older than 48h degrades the run", eng.report["run"]["status"] == "degraded") + runner.db_age_hours = 1 + clock.advance(hours=21) + eng.tick() + check("mutation: a fresh DB gives a complete run", eng.report["run"]["status"] == "complete" and eng.state["lastCompleteRunAt"]) + +with tempfile.TemporaryDirectory() as tmp: + # A target leaving the inventory leaves the report, with its findings. + eng, runner, clock = engine(tmp) + eng.tick() + check("postgres is present first", POSTGRES in targets(eng)) + gone = dict(CLUSTER) + gone["pods"] = [p for p in CLUSTER["pods"] if p["metadata"]["name"] != "db-0"] + eng.client.cluster = gone + clock.advance(minutes=15) + eng.tick() + check("a target leaving the inventory leaves the report", POSTGRES not in targets(eng)) + check("its findings go with it", not findings(eng, POSTGRES)) + +with tempfile.TemporaryDirectory() as tmp: + # Unexpected exceptions are contained at each level. + eng, runner, clock = engine(tmp) + runner.outcomes[POSTGRES] = RuntimeError("boom") + eng.tick() + check("an unexpected scan exception fails only that target", targets(eng)[POSTGRES]["state"] == "failed") + check("the run continued past it", runner.scans.count(NIGHTLY) == 1) + eng.client.fail = True + clock.advance(minutes=15) + eng.tick() + check("an inventory failure keeps the previous inventory", NGINX in targets(eng)) + check("an inventory failure degrades the report", eng.report["run"]["status"] == "degraded") + + calls = [] + + def crashing_tick(): + calls.append(1) + if len(calls) == 1: + raise RuntimeError("cycle crash") + eng.stop.set() + + eng.tick = crashing_tick + eng.tick_seconds = 0 + thread = threading.Thread(target=eng.run_forever, daemon=True) + thread.start() + thread.join(10) + check("a crash in one cycle is logged and the loop continues", len(calls) == 2 and not thread.is_alive()) + +# Recorded Trivy output through the normaliser: untrusted text. +parsed = trivy.parse_results({"SchemaVersion": 2, "Results": [{"Vulnerabilities": [ + {"VulnerabilityID": "CVE-1", "Severity": "weird", "PkgName": "p", "InstalledVersion": "1", "Title": "x\x07y", + "PrimaryURL": "javascript:alert(1)", "CVSS": {"nvd": {"V3Score": 11}}}, + {"VulnerabilityID": "CVE-2", "Severity": "HIGH", "PkgName": "q", "InstalledVersion": "1", "FixedVersion": "2", + "PrimaryURL": "https://example.test/x", "CVSS": {"nvd": {"V3Score": 7.5}}}, +]}]}) +check("unknown severity becomes UNKNOWN", parsed[0]["severity"] == "UNKNOWN") +check("control characters are stripped from upstream text", "\x07" not in parsed[0]["title"]) +check("non-http(s) advisory URL becomes empty", parsed[0]["url"] == "") +check("out-of-range CVSS is dropped", parsed[0]["cvss"] is None) +check("http(s) URL is kept", parsed[1]["url"] == "https://example.test/x") +check("a recorded report with no Results is zero findings", trivy.parse_results(fixture("trivy-empty.json")) == []) +try: + trivy.parse_results({"not": "a report"}) + check("an unrecognised document is a failure, never zero findings", False) +except trivy.ScanFailure as exc: + check("an unrecognised document is a failure, never zero findings", exc.kind == "failed") +failure = trivy.classify_failure("ghcr.io", "GET failed: 401 Unauthorized") +check("401 is credential-required", failure.kind == "credential-required" and "ghcr.io" in failure.reason) +check("permission denied on local disk is a plain failure", trivy.classify_failure("r", "open /cache/x: permission denied").kind == "failed") +check("a pull timeout is failed, not credential-required", trivy.classify_failure("r", "context deadline exceeded").kind == "failed") + +print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed") +sys.exit(1 if FAILED else 0) diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.sh b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.sh new file mode 100755 index 0000000..77679ca --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags. +# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONDONTWRITEBYTECODE=1 +exec python3 "${HERE}/check.py" diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/cluster.json b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/cluster.json new file mode 100644 index 0000000..5d4e491 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/cluster.json @@ -0,0 +1,198 @@ +{ + "cronjobs": [ + { + "metadata": { + "name": "nightly", + "namespace": "ops" + }, + "spec": { + "jobTemplate": { + "spec": { + "template": { + "spec": { + "containers": [ + { + "image": "ghcr.io/org/nightly:2.1", + "name": "job" + } + ] + } + } + } + } + } + } + ], + "jobs": [], + "nodes": [ + { + "metadata": { + "name": "prod-1" + } + } + ], + "pods": [ + { + "metadata": { + "name": "web-1", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "ReplicaSet", + "name": "web-5f" + } + ] + }, + "spec": { + "containers": [ + { + "image": "nginx:1.27", + "name": "app" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "name": "app" + } + ], + "phase": "Running" + } + }, + { + "metadata": { + "name": "api-1", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "ReplicaSet", + "name": "api-77" + } + ] + }, + "spec": { + "containers": [ + { + "image": "nginx:1.27", + "name": "server" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "name": "server" + } + ], + "phase": "Running" + } + }, + { + "metadata": { + "name": "db-0", + "namespace": "data", + "ownerReferences": [ + { + "controller": true, + "kind": "StatefulSet", + "name": "db" + } + ] + }, + "spec": { + "containers": [ + { + "image": "postgres:16", + "name": "postgres" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "name": "postgres" + } + ], + "phase": "Running" + } + }, + { + "metadata": { + "name": "priv-1", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "ReplicaSet", + "name": "priv-1a" + } + ] + }, + "spec": { + "containers": [ + { + "image": "ghcr.io/org/private:1", + "name": "priv" + } + ] + }, + "status": { + "containerStatuses": [ + { + "imageID": "ghcr.io/org/private@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "name": "priv" + } + ], + "phase": "Running" + } + } + ], + "replicasets": [ + { + "metadata": { + "name": "web-5f", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "Deployment", + "name": "web" + } + ] + } + }, + { + "metadata": { + "name": "api-77", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "Deployment", + "name": "api" + } + ] + } + }, + { + "metadata": { + "name": "priv-1a", + "namespace": "apps", + "ownerReferences": [ + { + "controller": true, + "kind": "Deployment", + "name": "priv" + } + ] + } + } + ], + "vmis": [] +} diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-empty.json b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-empty.json new file mode 100644 index 0000000..b81b42d --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-empty.json @@ -0,0 +1,4 @@ +{ + "ArtifactName": "x", + "SchemaVersion": 2 +} diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nginx.json b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nginx.json new file mode 100644 index 0000000..bbe13b4 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nginx.json @@ -0,0 +1,33 @@ +{ + "ArtifactName": "x", + "Results": [ + { + "Target": "t", + "Vulnerabilities": [ + { + "CVSS": { + "nvd": { + "V3Score": 9.8 + } + }, + "FixedVersion": "3.0.2", + "InstalledVersion": "1.0", + "PkgName": "openssl", + "PrimaryURL": "https://example.test/CVE-2026-0001", + "Severity": "CRITICAL", + "Title": "OpenSSL flaw", + "VulnerabilityID": "CVE-2026-0001" + }, + { + "InstalledVersion": "1.0", + "PkgName": "zlib", + "PrimaryURL": "https://example.test/CVE-2026-0002", + "Severity": "LOW", + "Title": "t", + "VulnerabilityID": "CVE-2026-0002" + } + ] + } + ], + "SchemaVersion": 2 +} diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nightly.json b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nightly.json new file mode 100644 index 0000000..8d37a56 --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-nightly.json @@ -0,0 +1,19 @@ +{ + "ArtifactName": "x", + "Results": [ + { + "Target": "t", + "Vulnerabilities": [ + { + "InstalledVersion": "1.0", + "PkgName": "curl", + "PrimaryURL": "https://example.test/CVE-2026-0020", + "Severity": "MEDIUM", + "Title": "t", + "VulnerabilityID": "CVE-2026-0020" + } + ] + } + ], + "SchemaVersion": 2 +} diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-postgres.json b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-postgres.json new file mode 100644 index 0000000..efc3e3b --- /dev/null +++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/fixtures/trivy-postgres.json @@ -0,0 +1,25 @@ +{ + "ArtifactName": "x", + "Results": [ + { + "Target": "t", + "Vulnerabilities": [ + { + "CVSS": { + "nvd": { + "V3Score": 7.5 + } + }, + "FixedVersion": "16.2", + "InstalledVersion": "1.0", + "PkgName": "libpq", + "PrimaryURL": "https://example.test/CVE-2026-0010", + "Severity": "HIGH", + "Title": "libpq flaw", + "VulnerabilityID": "CVE-2026-0010" + } + ] + } + ], + "SchemaVersion": 2 +} diff --git a/platform/vuln-scanner/src/acceptances.py b/platform/vuln-scanner/src/acceptances.py new file mode 100644 index 0000000..b8f4cd3 --- /dev/null +++ b/platform/vuln-scanner/src/acceptances.py @@ -0,0 +1,141 @@ +"""R-VULN-08 acceptances: a strict parser for exactly the spec's flat list, no YAML library. + + - id: CVE-2026-00000 + image: registry/repository # exact match; omit only for platform-wide + reason: not reachable - feature disabled + expires: 2027-02-01 # mandatory, at most 180 days out + +Fail-safe: a malformed entry is ignored and reported, so it can never hide a finding. Anything the +parser cannot place (bad indentation, a stray line) invalidates the whole file for the same reason. +""" +import re +import sys +from datetime import date, timedelta + +from state import utcnow + +MAX_DAYS = 180 +_KEYS = ("id", "image", "reason", "expires") +_ENTRY = re.compile(r"^- ([a-z]+):(?: (.*))?$") +_FIELD = re.compile(r"^ ([a-z]+):(?: (.*))?$") +_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") +_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_FORBIDDEN_START = tuple("[{&*!|>%@`") + + +def _scalar(raw): + """(value, None) or (None, error) for an unquoted or simply-quoted YAML scalar.""" + value = (raw or "").strip() + if value[:1] in ("'", '"'): + quote = value[0] + end = value.find(quote, 1) + if end == -1 or "\\" in value[:end]: + return None, "bad quoting" + rest = value[end + 1:].strip() + if rest and not rest.startswith("#"): + return None, "text after the closing quote" + return value[1:end], None + if value.startswith(_FORBIDDEN_START): + return None, "only plain scalars are allowed" + comment = re.search(r"\s#", value) + if comment: + value = value[:comment.start()].rstrip() + return value, None + + +def parse_text(source, today=None): + """(valid entries, error strings). Entries carry id, reason, expires (date) and optional image.""" + today = today or utcnow().date() + errors = [] + raw_entries = [] + current = None + for lineno, line in enumerate(source.splitlines(), 1): + if not line.strip() or line.lstrip().startswith("#"): + continue + entry = _ENTRY.match(line) + field = _FIELD.match(line) + if line.strip().split("#", 1)[0].strip() == "[]" and not raw_entries and current is None: + continue + if entry: + current = {"line": lineno, "fields": {}, "errors": []} + raw_entries.append(current) + key, raw = entry.group(1), entry.group(2) + elif field and current is not None: + key, raw = field.group(1), field.group(2) + else: + return [], ["line " + str(lineno) + ": not a flat acceptance entry; the whole file is ignored"] + value, problem = _scalar(raw) + if key not in _KEYS: + current["errors"].append("unknown key " + key) + elif problem: + current["errors"].append(key + ": " + problem) + elif key in current["fields"]: + current["errors"].append("duplicate key " + key) + else: + current["fields"][key] = value + entries = [] + for raw in raw_entries: + problems = list(raw["errors"]) + _validate(raw["fields"], today) + if problems: + errors.append("entry at line " + str(raw["line"]) + ": " + "; ".join(problems)) + continue + fields = raw["fields"] + entries.append({"id": fields["id"], "image": fields.get("image") or None, "reason": fields["reason"], + "expires": date.fromisoformat(fields["expires"])}) + return entries, errors + + +def _validate(fields, today): + problems = [] + if not _ID.match(fields.get("id") or ""): + problems.append("id is missing or malformed") + if not fields.get("reason"): + problems.append("reason is mandatory") + if "image" in fields and not fields["image"]: + problems.append("image is empty") + expires = fields.get("expires") or "" + if not _DATE.match(expires): + problems.append("expires is mandatory and must be YYYY-MM-DD") + return problems + try: + when = date.fromisoformat(expires) + except ValueError: + problems.append("expires is not a real date") + return problems + if when > today + timedelta(days=MAX_DAYS): + problems.append("expires is more than " + str(MAX_DAYS) + " days out") + return problems + + +def load(path, today=None): + """Entries and errors from a file. An unreadable file is an error, never silently empty.""" + try: + with open(path, encoding="utf-8") as handle: + source = handle.read(1024 * 1024 + 1) + except (OSError, UnicodeDecodeError) as exc: + return [], ["acceptances file unreadable: " + type(exc).__name__] + if len(source) > 1024 * 1024: + return [], ["acceptances file exceeds 1 MiB"] + return parse_text(source, today) + + +def match(entries, finding_id, image, today=None): + """The unexpired acceptance covering a finding, if any. Expiry day itself still counts.""" + today = today or utcnow().date() + best = None + for entry in entries: + if entry["id"] != finding_id or entry["expires"] < today: + continue + if entry["image"] is not None and entry["image"] != image: + continue + if best is None or entry["expires"] > best["expires"]: + best = entry + return best + + +if __name__ == "__main__": + found, problems = load(sys.argv[1]) + for problem in problems: + print("INVALID: " + problem, file=sys.stderr) + print(str(len(found)) + " valid entries") + sys.exit(1 if problems else 0) diff --git a/platform/vuln-scanner/src/inventory.py b/platform/vuln-scanner/src/inventory.py new file mode 100644 index 0000000..5751d55 --- /dev/null +++ b/platform/vuln-scanner/src/inventory.py @@ -0,0 +1,248 @@ +"""Inventory from the Kubernetes API: what is actually running, and which workloads run it.""" +import json +import logging +import os +import re +import ssl +import urllib.error +import urllib.parse +import urllib.request + +log = logging.getLogger("vuln-scanner.inventory") + +_ID_PREFIXES = ("docker-pullable://", "docker://", "containerd://", "cri-o://") +_DIGEST = re.compile(r"sha256:[0-9a-f]{64}") +_DOCKER_ALIASES = {"index.docker.io", "registry-1.docker.io", "docker.io"} +_CONTAINER_FIELDS = ( + ("containers", "containerStatuses"), + ("initContainers", "initContainerStatuses"), + ("ephemeralContainers", "ephemeralContainerStatuses"), +) +_VMI_PATH = "/apis/kubevirt.io/v1/virtualmachineinstances" +PATHS = { + "pods": "/api/v1/pods", + "replicasets": "/apis/apps/v1/replicasets", + "jobs": "/apis/batch/v1/jobs", + "cronjobs": "/apis/batch/v1/cronjobs", + "nodes": "/api/v1/nodes", +} + + +class InventoryError(Exception): + """The API could not be listed; the previous inventory stands.""" + + +class NotFound(InventoryError): + pass + + +class KubeClient: + """Paginated GET list against the API using the pod's service account.""" + + def __init__(self, base_url=None, token_file=None, ca_file=None): + sa = "/var/run/secrets/kubernetes.io/serviceaccount" + if base_url is None: + host = os.environ.get("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc") + port = os.environ.get("KUBERNETES_SERVICE_PORT", "443") + host = "[" + host + "]" if ":" in host else host + base_url = "https://" + host + ":" + port + self.base_url = base_url.rstrip("/") + self.token_file = token_file or os.path.join(sa, "token") + self.ca_file = ca_file or os.path.join(sa, "ca.crt") + + def _get_json(self, path, params): + url = self.base_url + path + "?" + urllib.parse.urlencode(params) + with open(self.token_file, encoding="utf-8") as handle: # projected tokens rotate + token = handle.read().strip() + request = urllib.request.Request(url, headers={"Authorization": "Bearer " + token}) + context = ssl.create_default_context(cafile=self.ca_file) if url.startswith("https") else None + try: + with urllib.request.urlopen(request, timeout=30, context=context) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise NotFound(path) from None + raise InventoryError(path + ": HTTP " + str(exc.code)) from None + except (urllib.error.URLError, OSError, ValueError) as exc: + raise InventoryError(path + ": " + type(exc).__name__) from None + + def list_all(self, path): + items = [] + params = {"limit": "500"} + while True: + page = self._get_json(path, params) + items.extend(page.get("items") or []) + token = (page.get("metadata") or {}).get("continue") + if not token: + return items + params = {"limit": "500", "continue": token} + + +def collect(client): + """Every list the inventory needs. A VMI API that 404s means no KubeVirt, hence no VMs.""" + lists = {name: client.list_all(path) for name, path in PATHS.items()} + try: + lists["vmis"] = client.list_all(_VMI_PATH) + except NotFound: + lists["vmis"] = [] + return lists + + +def normalise_reference(reference): + """(registry/repository, tag or None, digest or None) for an image reference.""" + text = reference.strip() + for prefix in _ID_PREFIXES: + if text.startswith(prefix): + text = text[len(prefix):] + digest = None + if "@" in text: + text, _, raw = text.partition("@") + digest = raw if _DIGEST.fullmatch(raw) else None + tag = None + tail = text.rsplit("/", 1)[-1] + if ":" in tail: + text, _, tag = text.rpartition(":") + first, slash, rest = text.partition("/") + if slash and ("." in first or ":" in first or first == "localhost"): + registry, repository = first.lower(), rest + else: + registry, repository = "docker.io", text + if registry in _DOCKER_ALIASES: + registry = "docker.io" + if "/" not in repository: + repository = "library/" + repository + return registry + "/" + repository, tag, digest + + +def digest_from_image_id(image_id): + """The repo digest in a container status imageID, or None. + + A bare `sha256:...` with no repository is a local config id, not a pullable digest, so it is + treated as absent (the target is then by-tag) rather than scanned as something it is not. + """ + text = image_id or "" + for prefix in _ID_PREFIXES: + if text.startswith(prefix): + text = text[len(prefix):] + if "@" not in text: + return None + candidate = text.rsplit("@", 1)[1] + return candidate if _DIGEST.fullmatch(candidate) else None + + +def _controller(obj): + references = (obj.get("metadata") or {}).get("ownerReferences") or [] + for ref in references: + if ref.get("controller"): + return ref + return references[0] if references else None + + +def resolve_owner(pod, replicasets, jobs): + """The top-level workload (kind, name) a pod belongs to.""" + meta = pod.get("metadata") or {} + namespace = meta.get("namespace", "") + owner = _controller(pod) + if owner is None: + return "Pod", meta.get("name", "") + kind, name = owner.get("kind", ""), owner.get("name", "") + index = {"ReplicaSet": replicasets, "Job": jobs}.get(kind) + if index is not None: + parent = index.get((namespace, name)) + upstream = _controller(parent) if parent else None + if upstream is not None: + return upstream.get("kind", kind), upstream.get("name", name) + return kind, name + + +def _index(items): + return {((i.get("metadata") or {}).get("namespace", ""), (i.get("metadata") or {}).get("name", "")): i + for i in items} + + +class _Builder: + def __init__(self): + self.targets = {} + + def add(self, target_id, image, digest, ref, workload, container): + target = self.targets.get(target_id) + if target is None: + target = self.targets[target_id] = { + "id": target_id, "type": "image", "image": image, "digest": digest, "ref": ref, + "reason": None, "workloads": {}, + } + if digest and not target["digest"]: + target["digest"] = digest + target["workloads"].setdefault(workload, set()).add(container) + + def add_image(self, spec_image, image_id, workload, container): + image, tag, spec_digest = normalise_reference(spec_image) + digest = digest_from_image_id(image_id) + if digest: + self.add(image + "@" + digest, image, digest, image + "@" + digest, workload, container) + else: + ref = image + "@" + spec_digest if spec_digest else image + ":" + (tag or "latest") + target_id = image + ":" + tag if tag else (ref if spec_digest else image + ":latest") + self.add(target_id, image, None, ref, workload, container) + + def add_uncovered(self, target_id, kind, reason, namespace, name, node_kind): + self.targets[target_id] = { + "id": target_id, "type": kind, "image": "", "digest": None, "ref": None, "reason": reason, + "workloads": {(namespace, node_kind, name): set()}, + } + + def finish(self): + result = [] + for target in sorted(self.targets.values(), key=lambda t: t["id"]): + target["workloads"] = [ + {"namespace": ns, "kind": kind, "name": name, "containers": sorted(containers)} + for (ns, kind, name), containers in sorted(target["workloads"].items()) + ] + result.append(target) + return result + + +def build(lists): + """Resolve raw API lists into target dicts.""" + replicasets = _index(lists.get("replicasets") or []) + jobs = _index(lists.get("jobs") or []) + builder = _Builder() + running = set() + for pod in lists.get("pods") or []: + status = pod.get("status") or {} + # A Succeeded/Failed pod runs nothing; counting it would let a stale evicted pod pin a digest. + if status.get("phase") in ("Succeeded", "Failed"): + continue + namespace = (pod.get("metadata") or {}).get("namespace", "") + kind, name = resolve_owner(pod, replicasets, jobs) + workload = (namespace, kind, name) + if kind == "CronJob": + running.add((namespace, name)) + spec = pod.get("spec") or {} + for spec_field, status_field in _CONTAINER_FIELDS: + statuses = {s.get("name"): s for s in status.get(status_field) or []} + for container in spec.get(spec_field) or []: + image_id = (statuses.get(container.get("name")) or {}).get("imageID", "") + builder.add_image(container.get("image", ""), image_id, workload, container.get("name", "")) + for cronjob in lists.get("cronjobs") or []: + meta = cronjob.get("metadata") or {} + namespace, name = meta.get("namespace", ""), meta.get("name", "") + if (namespace, name) in running: + continue + job_spec = ((cronjob.get("spec") or {}).get("jobTemplate") or {}).get("spec") or {} + pod_spec = (job_spec.get("template") or {}).get("spec") or {} + for field in ("containers", "initContainers"): + for container in pod_spec.get(field) or []: + builder.add_image(container.get("image", ""), "", (namespace, "CronJob", name), container.get("name", "")) + for vmi in lists.get("vmis") or []: + meta = vmi.get("metadata") or {} + namespace, name = meta.get("namespace", ""), meta.get("name", "") + builder.add_uncovered("vm/" + namespace + "/" + name, "vm", + "guest OS packages are not assessed by this scanner", + namespace, name, "VirtualMachineInstance") + for node in lists.get("nodes") or []: + name = (node.get("metadata") or {}).get("name", "") + builder.add_uncovered("node/" + name, "node", + "node OS packages and the k3s binary are not assessed by this scanner", + "", name, "Node") + return builder.finish() diff --git a/platform/vuln-scanner/src/main.py b/platform/vuln-scanner/src/main.py new file mode 100644 index 0000000..6e0f3e3 --- /dev/null +++ b/platform/vuln-scanner/src/main.py @@ -0,0 +1,276 @@ +"""Scanner entrypoint and the scan loop. Import has no side effects; main() runs the service.""" +import collections +import logging +import os +import signal +import sys +import threading +import time +from datetime import timedelta + +import acceptances +import inventory +import metrics +import page +import report as report_mod +import server +import state as st +import trivy +from state import fmt_ts, parse_ts + +log = logging.getLogger("vuln-scanner") + +FULL_RUN_INTERVAL = timedelta(hours=20) +DB_MAX_AGE = timedelta(hours=48) +DB_REFRESH_AGE = timedelta(hours=24) + + +class Engine: + def __init__(self, data_dir, client, runner, acceptances_path, clock=st.utcnow, tick_seconds=900, publish_seconds=120): + self.store = st.Store(data_dir) + self.data_dir = data_dir + self.client = client + self.runner = runner + self.acceptances_path = acceptances_path + self.clock = clock + self.tick_seconds = tick_seconds + self.publish_seconds = publish_seconds + self.lock = threading.RLock() + self.stop = threading.Event() + self.state = self.store.load() + self.report = report_mod.load_published(data_dir) + self.report_json = report_mod.serialise(self.report) if self.report else None + self.loop_thread = None + self._last_publish = 0.0 + self._last_inventory = None + + # --- views used by the HTTP servers; none of them waits for a scan -------------------------- + def report_bytes(self): + return self.report_json + + def render_page(self, query): + return page.render(self.report, query, self.clock()) + + def metrics_text(self): + with self.lock: + return metrics.render(self.report, self.state, self.clock()) + + def healthy(self): + return self.loop_thread is None or self.loop_thread.is_alive() + + def record_triage(self, document): + with self.lock: + self.state["remediation"] = document["remediation"] + self.state["triageHeartbeatAt"] = fmt_ts(self.clock()) # server receipt time, per contract + self.store.save(self.state) + self.publish() + + # --- persistence ----------------------------------------------------------------------------- + def publish(self): + with self.lock: + now = self.clock() + entries, errors = acceptances.load(self.acceptances_path, now.date()) + for problem in errors: + log.error("invalid acceptance ignored: %s", problem) + built = report_mod.build(self.state, entries, errors, now) + if report_mod.publish(built, self.data_dir): + self.report = built + self.report_json = report_mod.serialise(built) + self._last_publish = time.monotonic() + + # --- inventory ------------------------------------------------------------------------------- + def refresh_inventory(self): + """Install a fresh inventory. A failure keeps the previous one and degrades the report.""" + now = self.clock() + self._last_inventory = time.monotonic() + try: + targets = inventory.build(inventory.collect(self.client)) + except Exception as exc: # pylint: disable=broad-except + log.exception("inventory failed; keeping the previous inventory") + with self.lock: + self.state["inventoryError"] = type(exc).__name__ + self.store.save(self.state) + return False + with self.lock: + st.merge_inventory(self.state, targets, now) + self.store.save(self.state) + return True + + def _units(self): + """{scan key: reference}. Two targets sharing a digest are one unit, scanned once.""" + units = {} + for target in sorted(self.state["inventory"], key=lambda t: t["id"]): + key = st.scan_key(target) + if key is not None and key not in units: + units[key] = target["image"] + "@" + target["digest"] if target["digest"] else target["ref"] + return units + + def _never_attempted(self): + units = self._units() + return [k for k in units if self.state["scans"][k]["lastAttemptAt"] is None] + + def _full_run_due(self): + """Keyed on the last ATTEMPT, not the last success: a permanently failing image would + otherwise look 'older than 20h' forever and restart a full run every tick.""" + if self.state["run"] is None: + return True + now = self.clock() + for key in self._units(): + attempted = parse_ts(self.state["scans"][key]["lastAttemptAt"]) + if attempted is not None and now - attempted >= FULL_RUN_INTERVAL: + return True + return False + + # --- scanning -------------------------------------------------------------------------------- + def _refresh_database(self, always): + """True when a usable database is present. Failures are logged, never raised.""" + info = self._db_info() + if always or info is None or self._db_age(info) >= DB_REFRESH_AGE: + try: + self.runner.download_db() + except Exception: # pylint: disable=broad-except + log.exception("vulnerability database update failed") + return False + info = self._db_info() + if info is None: + return False + with self.lock: + self.state["database"] = info + self.store.save(self.state) + return True + + def _db_info(self): + try: + info = self.runner.db_info() + except Exception: # pylint: disable=broad-except + log.exception("could not read vulnerability database information") + return None + return dict(info) if info else None + + def _db_age(self, info): + moment = parse_ts(info.get("updatedAt")) + return timedelta.max if moment is None else self.clock() - moment + + def _scan_unit(self, key, reference): + try: + found = trivy.parse_results(self.runner.scan(reference)) + except trivy.ScanFailure as exc: + self._record_failure(key, exc.kind, exc.reason) + except Exception as exc: # pylint: disable=broad-except + log.exception("scan of %s failed unexpectedly", reference) + self._record_failure(key, "failed", "scan error: " + type(exc).__name__) + else: + self._record_success(key, found) + try: + self.runner.clean_cache() + except Exception: # pylint: disable=broad-except + log.exception("scan cache cleanup failed") + + def _record_failure(self, key, kind, reason): + now = fmt_ts(self.clock()) + with self.lock: + record = self.state["scans"].get(key) + if record is None: + return + record["lastAttemptAt"] = now + record["outcome"] = kind + record["reason"] = reason + record["failedSince"] = (record["failedSince"] or now) if kind == "failed" else None + self.store.save(self.state) + + def _record_success(self, key, found): + now = fmt_ts(self.clock()) + with self.lock: + record = self.state["scans"].get(key) + if record is None: + return + record["lastAttemptAt"] = now + record["lastSuccessAt"] = now + record["outcome"] = "ok" + record["reason"] = None + record["failedSince"] = None + record["findings"] = found + record["firstSeen"] = {f["id"]: record["firstSeen"].get(f["id"], now) for f in found} + self.store.save(self.state) + + def _scan_queue(self, keys): + """Scan sequentially, keeping the inventory and report fresh while a long run proceeds.""" + queue = collections.deque(keys) + while queue and not self.stop.is_set(): + if self._last_inventory is None or time.monotonic() - self._last_inventory >= self.tick_seconds: + if self.refresh_inventory(): + queue.extendleft(k for k in self._never_attempted() if k not in queue) + key = queue.popleft() + reference = self._units().get(key) + if reference is None: + continue # it left the inventory while queued + try: + self._scan_unit(key, reference) + except Exception: # pylint: disable=broad-except + log.exception("scan bookkeeping failed for %s", key) + if time.monotonic() - self._last_publish >= self.publish_seconds: + self.publish() + + def run_full(self): + started = self.clock() + log.info("full run starting") + database_ok = self._refresh_database(always=True) + self._scan_queue(sorted(self._units())) + info = self.state["database"] + now = self.clock() + degraded = not database_ok or info is None or self._db_age(info) > DB_MAX_AGE + _, errors = acceptances.load(self.acceptances_path, now.date()) + if errors or self.state.get("inventoryError") or self.state.get("loadError"): + degraded = True + status = "degraded" if degraded else "complete" + with self.lock: + self.state["run"] = {"startedAt": fmt_ts(started), "finishedAt": fmt_ts(now), "status": status} + if not degraded: + self.state["lastCompleteRunAt"] = fmt_ts(now) + self.store.save(self.state) + log.info("full run finished: %s", status) + + def tick(self): + self.refresh_inventory() + self.publish() + if self._full_run_due(): + self.run_full() + else: + never = self._never_attempted() + if never: + self._refresh_database(always=False) + self._scan_queue(never) + self.publish() + + def run_forever(self): + while not self.stop.is_set(): + try: + self.tick() + except Exception: # pylint: disable=broad-except + log.exception("scan cycle crashed; continuing with the next cycle") + self.stop.wait(self.tick_seconds) + + def start(self): + self.loop_thread = threading.Thread(target=self.run_forever, name="scan-loop", daemon=True) + self.loop_thread.start() + + +def main(): + logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") + data_dir = os.environ.get("VULN_DATA_DIR", "/data") + cache_dir = os.environ.get("VULN_CACHE_DIR", "/cache") + tools_dir = os.environ.get("VULN_TOOLS_DIR", "/tools") + trivy_bin = os.environ.get("TRIVY_BIN", os.path.join(tools_dir, "trivy")) + runner = trivy.Runner(trivy_bin, cache_dir, int(os.environ.get("VULN_SCAN_TIMEOUT_SECONDS", "600"))) + engine = Engine(data_dir, inventory.KubeClient(), runner, os.environ.get("VULN_ACCEPTANCES", "/config/acceptances.yaml")) + server.start_all(engine, ("127.0.0.1", 8080), ("0.0.0.0", 8081), ("0.0.0.0", 9090)) + finished = threading.Event() + signal.signal(signal.SIGTERM, lambda *_: finished.set()) + signal.signal(signal.SIGINT, lambda *_: finished.set()) + engine.start() + finished.wait() + engine.stop.set() + + +if __name__ == "__main__": + main() diff --git a/platform/vuln-scanner/src/metrics.py b/platform/vuln-scanner/src/metrics.py new file mode 100644 index 0000000..c8fe7c6 --- /dev/null +++ b/platform/vuln-scanner/src/metrics.py @@ -0,0 +1,44 @@ +"""Prometheus text exposition: aggregates only (R-VULN-06). The label allowlist is state, severity, fixable.""" +from datetime import timedelta + +import report as report_mod +from state import parse_ts + +_CRITICAL_GRACE = timedelta(hours=72) +_SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN") + + +def _sample(lines, name, value, labels=""): + lines.append(name + labels + " " + format(value, ".0f" if float(value).is_integer() else "g")) + + +def render(report, state, now): + lines = [] + if report is not None: + _sample(lines, "vuln_scanner_report_generated_timestamp_seconds", parse_ts(report["generatedAt"]).timestamp()) + _sample(lines, "vuln_scanner_database_updated_timestamp_seconds", parse_ts(report["database"]["updatedAt"]).timestamp()) + counts = {name: 0 for name in report_mod.STATES} + for target in report["targets"]: + counts[target["state"]] += 1 + for name in report_mod.STATES: + _sample(lines, "vuln_scanner_targets", counts[name], '{state="' + name + '"}') + findings = {(sev, fix): 0 for sev in _SEVERITIES for fix in ("true", "false")} + overdue = 0 + for finding in report["findings"]: + if finding["status"] == "accepted": + continue + findings[(finding["severity"], "true" if finding["fixedVersion"] else "false")] += 1 + if finding["severity"] == "CRITICAL" and finding["fixedVersion"]: + first_seen = parse_ts(finding["firstSeenAt"]) + if first_seen and now - first_seen > _CRITICAL_GRACE: + overdue += 1 + for (severity, fixable), value in sorted(findings.items()): + _sample(lines, "vuln_scanner_findings", value, '{severity="' + severity + '",fixable="' + fixable + '"}') + _sample(lines, "vuln_scanner_critical_fix_overdue", overdue) + _sample(lines, "vuln_scanner_failed_targets_over_24h", report_mod.failed_over_24h(state, now)) + for name, key in (("vuln_scanner_last_complete_run_timestamp_seconds", "lastCompleteRunAt"), + ("vuln_scanner_triage_heartbeat_timestamp_seconds", "triageHeartbeatAt")): + value = parse_ts(state.get(key)) + if value is not None: + _sample(lines, name, value.timestamp()) + return "\n".join(lines) + "\n" diff --git a/platform/vuln-scanner/src/page.py b/platform/vuln-scanner/src/page.py new file mode 100644 index 0000000..a2cd1a3 --- /dev/null +++ b/platform/vuln-scanner/src/page.py @@ -0,0 +1,482 @@ +"""The CVE page: server-rendered HTML with no script at all. + +Every string that reaches the document goes through e(), the single escape path; links additionally +go through safe_url(). The style block is the only inline resource and is allowed by hash. +""" +import base64 +import hashlib +import html +import math +from datetime import timedelta +from urllib.parse import parse_qs, urlencode + +import report as report_mod +import trivy +from state import parse_ts + +REPORT_STALE = timedelta(hours=26) +DB_STALE = timedelta(hours=48) +SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN") +SEV_RANK = {name: i for i, name in enumerate(SEVERITIES)} +STATUS_RANK = {"fix-available": 0, "open": 1, "accepted": 2} +STATE_RANK = {name: i for i, name in enumerate(report_mod.STATES)} +PAGE_SIZES = (25, 50, 100, 200, 500) +DEFAULT_SIZE = 100 +_MAX_PARAM = 200 +_EXPANSION_LIMIT = 200 # per-target CVE list in the inventory view; the remainder is linked + +STYLE = """ +body{font:14px/1.45 system-ui,sans-serif;margin:0;background:#fff;color:#1a1a1a} +main{padding:0 16px 32px} +header{padding:12px 16px;background:#f4f4f6;border-bottom:1px solid #ccc} +h1{font-size:18px;margin:0 0 8px} +nav a{margin-right:12px} +.warn{background:#fff0d6;border:2px solid #b35900;padding:8px 12px;margin:8px 0;font-weight:600} +.status{font-weight:700} +.tiles span{display:inline-block;margin:2px 10px 2px 0} +table{border-collapse:collapse;width:100%;margin-top:8px} +th,td{border-bottom:1px solid #ddd;padding:4px 6px;text-align:left;vertical-align:top} +th a{text-decoration:none} +.sev-CRITICAL{color:#a00;font-weight:700} +.sev-HIGH{color:#b35900;font-weight:700} +form{margin:8px 0} +form label{margin-right:8px} +code{word-break:break-all} +@media (prefers-color-scheme:dark){body{background:#16181c;color:#e6e6e6} +header{background:#22252b;border-color:#3a3d44} +th,td{border-color:#33363c} +a{color:#7fb4ff} +.warn{background:#3d2a12;border-color:#c98422}} +""" +STYLE_HASH = base64.b64encode(hashlib.sha256(STYLE.encode("utf-8")).digest()).decode("ascii") +CSP = ("default-src 'none'; style-src 'sha256-" + STYLE_HASH + "'; base-uri 'none'; " + "form-action 'self'; frame-ancestors 'none'") +SECURITY_HEADERS = { + "Content-Security-Policy": CSP, + "X-Content-Type-Options": "nosniff", + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", +} + + +def e(value): + """The one escape path for anything placed in the document.""" + return "" if value is None else html.escape(str(value), quote=True) + + +def safe_url(value): + return trivy.safe_url(value) if isinstance(value, str) else "" + + +def _link(url, label): + url = safe_url(url) + return '<a href="' + e(url) + '" rel="noopener noreferrer">' + e(label) + "</a>" if url else e(label) + + +def _parse(query): + raw = parse_qs(query or "", keep_blank_values=False) + params = {k: v[0][:_MAX_PARAM] for k, v in raw.items() if v} + view = params.get("view", "cves") + params["view"] = view if view in ("cves", "workloads", "inventory") else "cves" + try: + params["page"] = max(1, int(params.get("page", "1"))) + except ValueError: + params["page"] = 1 + try: + size = int(params.get("size", str(DEFAULT_SIZE))) + except ValueError: + size = DEFAULT_SIZE + params["size"] = size if size in PAGE_SIZES else DEFAULT_SIZE + return params + + +def _qs(params, **overrides): + merged = dict(params) + merged.update(overrides) + pairs = sorted((k, str(v)) for k, v in merged.items() if v not in (None, "")) + return "?" + urlencode(pairs) + + +def _age(moment, now): + if moment is None: + return "unknown" + seconds = int((now - moment).total_seconds()) + if seconds < 0: + return "in the future" + hours, remainder = divmod(seconds, 3600) + minutes = remainder // 60 + return str(hours) + "h " + str(minutes) + "m ago" + + +def _finding_text(finding, target): + parts = [finding["id"], finding["package"], finding["title"], target["id"], target["image"]] + for workload in target["workloads"]: + parts.extend((workload["namespace"], workload["name"], *workload["containers"])) + return " ".join(parts).lower() + + +def _match_workload(target, params): + namespace, workload = params.get("namespace"), params.get("workload") + if not namespace and not workload: + return True + for item in target["workloads"]: + if namespace and item["namespace"] != namespace: + continue + if workload and workload not in (item["name"], item["kind"] + "/" + item["name"]): + continue + return True + return False + + +def _match_finding(finding, target, params): + if params.get("severity") and finding["severity"] != params["severity"].upper(): + return False + if params.get("status") and finding["status"] != params["status"]: + return False + fix = params.get("fix") + if fix == "yes" and not finding["fixedVersion"]: + return False + if fix == "no" and finding["fixedVersion"]: + return False + if not _match_workload(target, params): + return False + text = params.get("q", "").lower() + return not text or text in _finding_text(finding, target) + + +def _paginate(rows, params): + total = len(rows) + size = params["size"] + pages = max(1, math.ceil(total / size)) + page = min(params["page"], pages) + start = (page - 1) * size + return rows[start:start + size], total, page, pages, start + + +def _pager(params, total, page, pages, start, shown, noun): + first = start + 1 if total else 0 + summary = "Showing " + str(first) + "–" + str(start + shown) + " of " + str(total) + " " + noun \ + + " (page " + str(page) + " of " + str(pages) + ")" + links = [] + if page > 1: + links.append('<a href="' + e(_qs(params, page=page - 1)) + '">previous</a>') + if page < pages: + links.append('<a href="' + e(_qs(params, page=page + 1)) + '">next</a>') + return "<p>" + e(summary) + " " + " ".join(links) + "</p>" + + +def _th(params, label, key): + direction = "desc" if params.get("sort") == key and params.get("dir", "asc") == "asc" else "asc" + return '<th><a href="' + e(_qs(params, sort=key, dir=direction, page=1)) + '">' + e(label) + "</a></th>" + + +def _table(head, rows, empty): + if not rows: + return "<p>" + e(empty) + "</p>" + return "<table><thead><tr>" + "".join(head) + "</tr></thead><tbody>" + "".join(rows) + "</tbody></table>" + + +def _sev(severity): + return '<span class="sev-' + e(severity) + '">' + e(severity) + "</span>" + + +def _workload_lines(target): + items = [] + for workload in target["workloads"]: + items.append("<li>" + e(workload["namespace"]) + " / " + e(workload["kind"]) + " / " + e(workload["name"]) + + " — containers: " + e(", ".join(workload["containers"]) or "none") + "</li>") + return "<ul>" + "".join(items) + "</ul>" + + +def _remediation_cell(images, remediation): + if not images: + return "" + out = [] + for image in sorted(images): + entry = remediation.get(image) + if entry is None: + out.append(e(image) + ": no data") + elif entry["state"] == "pr-open" and safe_url(entry.get("url")): + out.append(e(image) + ": " + _link(entry["url"], "pr-open")) + else: + out.append(e(image) + ": " + e(entry["state"])) + return "<br>".join(out) + + +# --- views ------------------------------------------------------------------------------------------ + +def _cves(report, targets, params, remediation): + groups = {} + for finding in report["findings"]: + target = targets[finding["targetId"]] + if not _match_finding(finding, target, params): + continue + group = groups.setdefault(finding["id"], { + "id": finding["id"], "severity": finding["severity"], "cvss": None, "packages": set(), "fixed": set(), + "firstSeen": finding["firstSeenAt"], "statuses": set(), "url": "", "title": "", "items": [], + }) + if SEV_RANK[finding["severity"]] < SEV_RANK[group["severity"]]: + group["severity"] = finding["severity"] + if finding["cvss"] is not None and (group["cvss"] is None or finding["cvss"] > group["cvss"]): + group["cvss"] = finding["cvss"] + group["packages"].add(finding["package"]) + if finding["fixedVersion"]: + group["fixed"].add(finding["fixedVersion"]) + group["firstSeen"] = min(group["firstSeen"], finding["firstSeenAt"]) + group["statuses"].add(finding["status"]) + group["url"] = group["url"] or finding["url"] + group["title"] = group["title"] or finding["title"] + group["items"].append((finding, target)) + rows = [] + for group in groups.values(): + group["status"] = min(group["statuses"], key=STATUS_RANK.get) + group["imageCount"] = len({t["id"] for _, t in group["items"]}) + group["workloadCount"] = len({(w["namespace"], w["kind"], w["name"]) for _, t in group["items"] for w in t["workloads"]}) + group["package"] = ", ".join(sorted(group["packages"])) + group["fix"] = ", ".join(sorted(group["fixed"])) + rows.append(group) + sorts = { + "id": lambda r: r["id"], "severity": lambda r: SEV_RANK[r["severity"]], "cvss": lambda r: r["cvss"] if r["cvss"] is not None else -1, + "package": lambda r: r["package"], "fixed": lambda r: r["fix"], "firstSeen": lambda r: r["firstSeen"], + "status": lambda r: STATUS_RANK[r["status"]], "images": lambda r: r["imageCount"], "workloads": lambda r: r["workloadCount"], + } + rows.sort(key=lambda r: r["id"]) + rows.sort(key=sorts.get(params.get("sort"), sorts["severity"]), reverse=params.get("dir") == "desc") + page_rows, total, page, pages, start = _paginate(rows, params) + html_rows = [] + for row in page_rows: + lines = [] + fix_images = set() + for finding, target in row["items"]: + if finding["status"] == "fix-available": + fix_images.add(target["image"]) + lines.append("<li>" + e(target["id"]) + " [" + e(target["state"]) + "] — " + e(finding["package"]) + " " + + e(finding["installedVersion"]) + " → " + e(finding["fixedVersion"] or "no fix") + + _workload_lines(target) + "</li>") + acceptance = next((f.get("acceptance") for f, _ in row["items"] if f.get("acceptance")), None) + status_text = e(row["status"]) + if acceptance: + status_text += " (" + e(acceptance["reason"]) + ", until " + e(acceptance["expires"]) + ")" + html_rows.append( + "<tr><td>" + _link(row["url"], row["id"]) + "</td><td>" + _sev(row["severity"]) + "</td><td>" + + e("" if row["cvss"] is None else row["cvss"]) + "</td><td>" + e(row["package"]) + "</td><td>" + e(row["fix"]) + + "</td><td>" + e(row["firstSeen"]) + "</td><td>" + status_text + "</td><td>" + _remediation_cell(fix_images, remediation) + + "</td><td><details><summary>" + e(row["imageCount"]) + " images, " + e(row["workloadCount"]) + + " workloads</summary><p>" + e(row["title"]) + "</p><ul>" + "".join(lines) + "</ul></details></td></tr>") + head = [_th(params, "CVE", "id"), _th(params, "Severity", "severity"), _th(params, "CVSS", "cvss"), _th(params, "Package", "package"), + _th(params, "Fixed in", "fixed"), _th(params, "First seen", "firstSeen"), _th(params, "Status", "status"), + "<th>Remediation</th>", _th(params, "Affected", "images")] + return _pager(params, total, page, pages, start, len(page_rows), "CVEs") \ + + _table(head, html_rows, "No CVE rows match these filters.") + + +def _workload_key(item): + return item["namespace"], item["kind"], item["name"] + + +def _workloads(report, targets, params): + by_target = {} + for finding in report["findings"]: + target = targets[finding["targetId"]] + if _match_finding(finding, target, params): + by_target.setdefault(target["id"], []).append(finding) + rows = {} + finding_filter = params.get("severity") or params.get("status") or params.get("fix") + needle = params.get("q", "").lower() + for target in report["targets"]: + matching = by_target.get(target["id"], []) + for workload in target["workloads"]: + key = _workload_key(workload) + scope = {"namespace": params.get("namespace"), "workload": params.get("workload")} + if not _match_workload({"workloads": [workload]}, scope): + continue + text = " ".join((workload["namespace"], workload["name"], *workload["containers"], target["image"])).lower() + if not matching and (finding_filter or (needle and needle not in text)): + continue + row = rows.setdefault(key, {"key": key, "containers": set(), "images": set(), "findings": {}, "counts": dict.fromkeys(SEVERITIES, 0)}) + row["containers"].update(workload["containers"]) + row["images"].add(target["id"]) + for finding in matching: + row["findings"].setdefault((finding["id"], target["id"], finding["package"]), finding) + for row in rows.values(): + for finding in row["findings"].values(): + row["counts"][finding["severity"]] += 1 + items = list(rows.values()) + sorts = { + "namespace": lambda r: r["key"][0], "kind": lambda r: r["key"][1], "name": lambda r: r["key"][2], + "images": lambda r: len(r["images"]), "total": lambda r: -len(r["findings"]), + } + for severity in SEVERITIES: + sorts[severity.lower()] = (lambda s: lambda r: -r["counts"][s])(severity) + items.sort(key=lambda r: r["key"]) + if params.get("sort") in sorts: + items.sort(key=sorts[params["sort"]], reverse=params.get("dir") == "desc") + else: + items.sort(key=lambda r: tuple(-r["counts"][s] for s in SEVERITIES)) + page_rows, total, page, pages, start = _paginate(items, params) + html_rows = [] + for row in page_rows: + lines = "".join( + "<li>" + _link(f["url"], f["id"]) + " " + _sev(f["severity"]) + " " + e(f["package"]) + " — " + e(f["status"]) + "</li>" + for f in sorted(row["findings"].values(), key=lambda f: (SEV_RANK[f["severity"]], f["id"]))) + counts = "".join("<td>" + e(row["counts"][s]) + "</td>" for s in SEVERITIES) + html_rows.append( + "<tr><td>" + e(row["key"][0]) + "</td><td>" + e(row["key"][1]) + "</td><td>" + e(row["key"][2]) + "</td><td>" + + e(", ".join(sorted(row["containers"])) or "none") + "</td><td>" + "<br>".join(e(i) for i in sorted(row["images"])) + "</td>" + + counts + "<td><details><summary>" + e(len(row["findings"])) + " findings</summary><ul>" + lines + "</ul></details></td></tr>") + head = [_th(params, "Namespace", "namespace"), _th(params, "Kind", "kind"), _th(params, "Workload", "name"), "<th>Containers</th>", + _th(params, "Images", "images")] + [_th(params, s, s.lower()) for s in SEVERITIES] + [_th(params, "Findings", "total")] + return _pager(params, total, page, pages, start, len(page_rows), "workloads") \ + + _table(head, html_rows, "No workload rows match these filters.") + + +def _inventory(report, params): + counts = {} + by_target = {} + for finding in report["findings"]: + counts[finding["targetId"]] = counts.get(finding["targetId"], 0) + 1 + by_target.setdefault(finding["targetId"], []).append(finding) + needle = params.get("q", "").lower() + wanted_state = params.get("status") + rows = [] + for target in report["targets"]: + if wanted_state and target["state"] != wanted_state: + continue + if not _match_workload(target, params): + continue + if needle: + text = " ".join((target["id"], target["image"], target["state"], *[w["namespace"] + " " + w["name"] for w in target["workloads"]])).lower() + if needle not in text: + continue + rows.append(target) + sorts = { + "id": lambda t: t["id"], "type": lambda t: t["type"], "state": lambda t: STATE_RANK[t["state"]], + "lastScanned": lambda t: t["lastScannedAt"] or "", "findings": lambda t: counts.get(t["id"], 0), + } + rows.sort(key=lambda t: t["id"]) + rows.sort(key=sorts.get(params.get("sort"), sorts["state"]), reverse=params.get("dir") == "desc") + page_rows, total, page, pages, start = _paginate(rows, params) + html_rows = [] + for target in page_rows: + found = sorted(by_target.get(target["id"], []), key=lambda f: (SEV_RANK[f["severity"]], f["id"])) + listed = "".join("<li>" + _link(f["url"], f["id"]) + " " + _sev(f["severity"]) + " " + e(f["package"]) + " — " + e(f["status"]) + "</li>" + for f in found[:_EXPANSION_LIMIT]) + if len(found) > _EXPANSION_LIMIT: + listed += '<li><a href="' + e(_qs({"view": "cves", "q": target["image"]})) + '">' + e(len(found) - _EXPANSION_LIMIT) + " more in the CVE view</a></li>" + html_rows.append( + "<tr><td><code>" + e(target["id"]) + "</code></td><td>" + e(target["type"]) + "</td><td>" + e(target["state"]) + "</td><td>" + + e(target["reason"]) + "</td><td>" + e(target["lastScannedAt"] or "never") + "</td><td>" + e(counts.get(target["id"], 0)) + + "</td><td><details><summary>" + e(len(target["workloads"])) + " workloads, " + e(len(found)) + " findings</summary>" + + _workload_lines(target) + ("<ul>" + listed + "</ul>" if listed else "") + "</details></td></tr>") + head = [_th(params, "Target", "id"), _th(params, "Type", "type"), _th(params, "State", "state"), "<th>Reason</th>", + _th(params, "Last scanned", "lastScanned"), _th(params, "Findings", "findings"), "<th>Workloads</th>"] + return _pager(params, total, page, pages, start, len(page_rows), "targets") \ + + _table(head, html_rows, "No targets match these filters.") + + +# --- header ----------------------------------------------------------------------------------------- + +def _header(report, now): + generated = parse_ts(report["generatedAt"]) + database = parse_ts(report["database"]["updatedAt"]) + states = dict.fromkeys(report_mod.STATES, 0) + for target in report["targets"]: + states[target["state"]] += 1 + open_counts = dict.fromkeys(SEVERITIES, 0) + accepted = 0 + for finding in report["findings"]: + if finding["status"] == "accepted": + accepted += 1 + else: + open_counts[finding["severity"]] += 1 + warnings = [] + report_stale = generated is None or now - generated > REPORT_STALE + if report_stale: + warnings.append("STALE REPORT: generated " + _age(generated, now) + "; the expected refresh is within 26 hours.") + if report["run"]["status"] == "degraded": + warnings.append("DEGRADED RUN: the last run did not complete normally (database, acceptances file or inventory problem). Do not treat these results as current.") + db_epoch = report["database"]["version"] == "unavailable" + if db_epoch: + warnings.append("DATABASE UNAVAILABLE: no vulnerability database has been downloaded.") + elif database is None or now - database > DB_STALE: + warnings.append("DATABASE STALE: the vulnerability database is older than 48 hours.") + total_targets = len(report["targets"]) + not_scanned = total_targets - states["scanned"] + if total_targets == 0: + warnings.append("NO TARGETS: the inventory is empty; nothing has been evaluated.") + if not_scanned: + warnings.append("INCOMPLETE COVERAGE: " + str(not_scanned) + " of " + str(total_targets) + + " targets are not in the scanned state; findings for them are missing or may be out of date.") + open_total = sum(open_counts.values()) + if warnings: + status = "Status: ATTENTION NEEDED" + elif open_total or accepted: + status = "Status: CURRENT, findings present" + else: + status = "Status: CURRENT" + tiles = "".join("<span>" + e(name) + ": <strong>" + e(states[name]) + "</strong></span>" for name in report_mod.STATES) + sev_tiles = "".join("<span>" + _sev(s) + ": <strong>" + e(open_counts[s]) + "</strong></span>" for s in SEVERITIES) + claim = "" + if not warnings and not report["findings"] and total_targets > 0 and not_scanned == 0: + claim = "<p><strong>Clean: all " + e(total_targets) + " targets were evaluated within 24 hours with zero findings.</strong></p>" + return ("<header><h1>CVE visibility</h1>" + "<p class=\"status\">" + e(status) + "</p>" + + "".join('<p class="warn">' + e(w) + "</p>" for w in warnings) + claim + + "<p>Report age: " + e(_age(generated, now)) + " · Database age: " + + e("unavailable" if db_epoch else _age(database, now)) + " · Run: " + e(report["run"]["status"]) + "</p>" + + '<p class="tiles">Targets (' + e(total_targets) + "): " + tiles + "</p>" + + '<p class="tiles">Open findings by severity: ' + sev_tiles + "<span>accepted: <strong>" + e(accepted) + "</strong></span></p>" + + "</header>") + + +def _nav(params): + links = [] + for view, label in (("cves", "CVEs"), ("workloads", "Workloads"), ("inventory", "Inventory")): + marker = ' aria-current="page"' if params["view"] == view else "" + target = {k: v for k, v in params.items() if k not in ("page", "sort", "dir")} + target["view"] = view + links.append('<a href="' + e(_qs(target)) + '"' + marker + ">" + e(label) + "</a>") + return "<nav>" + "".join(links) + "</nav>" + + +def _filters(params): + def field(name, label): + return "<label>" + e(label) + ' <input name="' + e(name) + '" value="' + e(params.get(name, "")) + '"></label>' + selected = params.get("fix", "") + fix_options = "".join( + '<option value="' + e(v) + '"' + (" selected" if selected == v else "") + ">" + e(t) + "</option>" + for v, t in (("", "any"), ("yes", "yes"), ("no", "no"))) + return ('<form method="get"><input type="hidden" name="view" value="' + e(params["view"]) + '">' + + field("q", "Search") + field("severity", "Severity") + field("namespace", "Namespace") + + field("workload", "Workload") + field("status", "Status") + + '<label>Fix available <select name="fix">' + fix_options + "</select></label>" + + '<button type="submit">Filter</button></form>') + + +def _document(body): + return ("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>CVE visibility" + '" + "" + body + "").encode("utf-8") + + +def render(report, query, now): + """(status code, header dict, body bytes) for one page request.""" + if report is None: + body = ("

CVE visibility

NO REPORT YET: nothing has been evaluated. " + "Nothing can be said about this cluster until the first report is published.

") + return 503, dict(SECURITY_HEADERS), _document(body) + params = _parse(query) + targets = {t["id"]: t for t in report["targets"]} + remediation = {r["image"]: r for r in report["remediation"]} + view = params["view"] + if view == "workloads": + content = _workloads(report, targets, params) + elif view == "inventory": + content = _inventory(report, params) + else: + content = _cves(report, targets, params, remediation) + return 200, dict(SECURITY_HEADERS), _document(_header(report, now) + "
" + _nav(params) + _filters(params) + content + "
") + + +def not_found(): + return 404, dict(SECURITY_HEADERS), _document("

Not found.

") diff --git a/platform/vuln-scanner/src/report.py b/platform/vuln-scanner/src/report.py new file mode 100644 index 0000000..bb5e213 --- /dev/null +++ b/platform/vuln-scanner/src/report.py @@ -0,0 +1,222 @@ +"""Build, validate and publish report.json, the one interface to the page and the bot.""" +import json +import logging +import os +import re +from datetime import date, timedelta + +import acceptances as acceptances_mod +import state as st +from state import fmt_ts, parse_ts + +log = logging.getLogger("vuln-scanner.report") + +STATES = ("scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered") +SCAN_WINDOW = timedelta(hours=24) +PENDING_WINDOW = timedelta(hours=2) +DB_STALE = timedelta(hours=48) +NOT_SCANNED_REASON = "not scanned within 2h of first appearing" +EPOCH = "1970-01-01T00:00:00Z" # database.updatedAt when no database has ever been downloaded + +_SCHEMA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "report.schema.json") +with open(_SCHEMA_PATH, encoding="utf-8") as _handle: + SCHEMA = json.load(_handle) + + +def coverage(target, record, now): + """(state, reason, failed-since datetime or None) for one target. The single place states come from.""" + if target["type"] != "image": + return "not-covered", target["reason"], None + record = record or st.new_record(now) + outcome = record["outcome"] + if outcome == "credential-required": + return "credential-required", record["reason"], None + if outcome == "failed": + return "failed", record["reason"], parse_ts(record["failedSince"]) or now + if record["lastAttemptAt"] is None: + appeared = parse_ts(record["firstDiscoveredAt"]) or now + if now - appeared > PENDING_WINDOW: + return "failed", NOT_SCANNED_REASON, appeared + PENDING_WINDOW + return "pending", None, None + last_success = parse_ts(record["lastSuccessAt"]) or now + if now - last_success > SCAN_WINDOW: + return "stale", "last successful scan was at " + record["lastSuccessAt"], None + return ("by-tag" if target["digest"] is None else "scanned"), None, None + + +def failed_over_24h(state, now): + count = 0 + for target in state["inventory"]: + status, _, since = coverage(target, state["scans"].get(st.scan_key(target)), now) + if status == "failed" and since is not None and now - since > SCAN_WINDOW: + count += 1 + return count + + +def build(state, accepted, acceptance_errors, now): + """The report for `state` at `now`; acceptance_errors degrades the run.""" + today = now.date() + targets = [] + findings = [] + for target in state["inventory"]: + record = state["scans"].get(st.scan_key(target)) + status, reason, _ = coverage(target, record, now) + last_scanned = record["lastSuccessAt"] if record else None + targets.append({ + "id": target["id"], "type": target["type"], "image": target["image"], "digest": target["digest"], + "state": status, "reason": reason, "lastScannedAt": last_scanned, "workloads": target["workloads"], + }) + for item in (record or {}).get("findings", []): + finding = {key: item[key] for key in + ("id", "severity", "cvss", "package", "installedVersion", "fixedVersion", "title", "url")} + finding["targetId"] = target["id"] + finding["firstSeenAt"] = record["firstSeen"].get(item["id"]) or fmt_ts(now) + acceptance = acceptances_mod.match(accepted, item["id"], target["image"], today) + if acceptance: + finding["status"] = "accepted" + finding["acceptance"] = {"reason": acceptance["reason"], "expires": acceptance["expires"].isoformat()} + else: + finding["status"] = "fix-available" if item["fixedVersion"] else "open" + findings.append(finding) + findings.sort(key=lambda f: (f["id"], f["targetId"], f["package"], f["installedVersion"])) + database = dict(state["database"]) if state["database"] else {"updatedAt": EPOCH, "version": "unavailable"} + run = dict(state["run"]) if state["run"] else {"startedAt": fmt_ts(now), "finishedAt": fmt_ts(now), "status": "degraded"} + db_time = parse_ts(database["updatedAt"]) + if acceptance_errors or state.get("inventoryError") or state.get("loadError") \ + or db_time is None or now - db_time > DB_STALE: + run["status"] = "degraded" + return { + "schemaVersion": 1, "generatedAt": fmt_ts(now), "database": database, "run": run, + "targets": targets, "findings": findings, "remediation": list(state["remediation"]), + } + + +# --- hand validator for the JSON-Schema subset report.schema.json uses ----------------------------- + +def _type_ok(value, name): + if name == "object": + return isinstance(value, dict) + if name == "array": + return isinstance(value, list) + if name == "string": + return isinstance(value, str) + if name == "null": + return value is None + if name == "boolean": + return isinstance(value, bool) + if name == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if name == "integer": + return isinstance(value, int) and not isinstance(value, bool) + return False + + +def _format_ok(value, name): + if name == "date-time": + return parse_ts(value) is not None + if name == "date": + try: + date.fromisoformat(value) + return True + except (ValueError, TypeError): + return False + return True + + +def _check(schema, value, path, errors): + if len(errors) >= 50: + return + if "type" in schema: + names = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if not any(_type_ok(value, n) for n in names): + errors.append(path + ": wrong type") + return + if "const" in schema and value != schema["const"]: + errors.append(path + ": not the required constant") + if "enum" in schema and value not in schema["enum"]: + errors.append(path + ": not an allowed value") + if isinstance(value, str): + if "minLength" in schema and len(value) < schema["minLength"]: + errors.append(path + ": too short") + if "maxLength" in schema and len(value) > schema["maxLength"]: + errors.append(path + ": too long") + if "pattern" in schema and not re.search(schema["pattern"], value): + errors.append(path + ": does not match the pattern") + if "format" in schema and not _format_ok(value, schema["format"]): + errors.append(path + ": not a valid " + schema["format"]) + if isinstance(value, (int, float)) and not isinstance(value, bool): + if "minimum" in schema and value < schema["minimum"]: + errors.append(path + ": below the minimum") + if "maximum" in schema and value > schema["maximum"]: + errors.append(path + ": above the maximum") + if isinstance(value, dict): + for key in schema.get("required", []): + if key not in value: + errors.append(path + ": missing " + key) + properties = schema.get("properties", {}) + for key, item in value.items(): + if key in properties: + _check(properties[key], item, path + "." + key, errors) + elif schema.get("additionalProperties") is False: + errors.append(path + ": unexpected key " + key) + if isinstance(value, list) and "items" in schema: + for index, item in enumerate(value): + _check(schema["items"], item, path + "[" + str(index) + "]", errors) + if "not" in schema: + trial = [] + _check(schema["not"], value, path, trial) + if not trial: + errors.append(path + ": matches a forbidden shape") + if "if" in schema: + trial = [] + _check(schema["if"], value, path, trial) + branch = schema.get("then") if not trial else schema.get("else") + if branch: + _check(branch, value, path, errors) + + +def validate(report): + """Schema and referential errors; an empty list means the report may be served.""" + errors = [] + _check(SCHEMA, report, "$", errors) + if errors: + return errors + ids = [t["id"] for t in report["targets"]] + if len(set(ids)) != len(ids): + errors.append("$.targets: duplicate target id") + known = set(ids) + for finding in report["findings"]: + if finding["targetId"] not in known: + errors.append("$.findings: targetId has no target") + break + return errors + + +def serialise(report): + return json.dumps(report, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def publish(report, data_dir): + """Write report.json. An invalid report writes nothing and is logged loudly.""" + errors = validate(report) + if errors: + log.critical("REFUSING to publish an invalid report; the previous one stands: %s", errors[:10]) + return False + st.atomic_write(os.path.join(data_dir, "report.json"), serialise(report)) + return True + + +def load_published(data_dir): + """The published report if present and still valid, else None (never a corrupt report).""" + try: + with open(os.path.join(data_dir, "report.json"), "rb") as handle: + report = json.loads(handle.read()) + except FileNotFoundError: + return None + except (OSError, ValueError): + log.critical("published report.json is unreadable; serving no report until the next publish") + return None + if validate(report): + log.critical("published report.json is invalid; serving no report until the next publish") + return None + return report diff --git a/platform/vuln-scanner/src/report.schema.json b/platform/vuln-scanner/src/report.schema.json new file mode 100644 index 0000000..cebeba7 --- /dev/null +++ b/platform/vuln-scanner/src/report.schema.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "report.schema.json", + "title": "vuln-scanner report (docs/17, schemaVersion 1)", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "generatedAt", "database", "run", "targets", "findings", "remediation"], + "properties": { + "schemaVersion": {"const": 1}, + "generatedAt": {"type": "string", "format": "date-time"}, + "database": { + "type": "object", + "additionalProperties": false, + "required": ["updatedAt", "version"], + "properties": { + "updatedAt": {"type": "string", "format": "date-time"}, + "version": {"type": "string", "minLength": 1, "maxLength": 128} + } + }, + "run": { + "type": "object", + "additionalProperties": false, + "required": ["startedAt", "finishedAt", "status"], + "properties": { + "startedAt": {"type": "string", "format": "date-time"}, + "finishedAt": {"type": "string", "format": "date-time"}, + "status": {"enum": ["complete", "degraded"]} + } + }, + "targets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "image", "digest", "state", "reason", "lastScannedAt", "workloads"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 2048}, + "type": {"enum": ["image", "vm", "node"]}, + "image": {"type": "string", "maxLength": 2048}, + "digest": {"type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$"}, + "state": {"enum": ["scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered"]}, + "reason": {"type": ["string", "null"], "maxLength": 1024}, + "lastScannedAt": {"type": ["string", "null"], "format": "date-time"}, + "workloads": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["namespace", "kind", "name", "containers"], + "properties": { + "namespace": {"type": "string", "maxLength": 253}, + "kind": {"type": "string", "minLength": 1, "maxLength": 253}, + "name": {"type": "string", "minLength": 1, "maxLength": 253}, + "containers": {"type": "array", "items": {"type": "string", "maxLength": 253}} + } + } + } + } + } + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "targetId", "severity", "cvss", "package", "installedVersion", "fixedVersion", + "title", "url", "firstSeenAt", "status"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 256}, + "targetId": {"type": "string", "minLength": 1, "maxLength": 2048}, + "severity": {"enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]}, + "cvss": {"type": ["number", "null"], "minimum": 0, "maximum": 10}, + "package": {"type": "string", "maxLength": 256}, + "installedVersion": {"type": "string", "maxLength": 256}, + "fixedVersion": {"type": ["string", "null"], "maxLength": 512}, + "title": {"type": "string", "maxLength": 512}, + "url": {"type": "string", "maxLength": 2048, "pattern": "^(https?://.+)?$"}, + "firstSeenAt": {"type": "string", "format": "date-time"}, + "status": {"enum": ["open", "fix-available", "accepted"]}, + "acceptance": { + "type": "object", + "additionalProperties": false, + "required": ["reason", "expires"], + "properties": { + "reason": {"type": "string", "minLength": 1, "maxLength": 2048}, + "expires": {"type": "string", "format": "date"} + } + } + }, + "if": {"properties": {"status": {"const": "accepted"}}}, + "then": {"required": ["acceptance"]}, + "else": {"not": {"required": ["acceptance"]}} + } + }, + "remediation": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["image", "state", "url", "updatedAt"], + "properties": { + "image": {"type": "string", "minLength": 1, "maxLength": 512}, + "state": {"enum": ["pr-open", "approval-requested", "no-update-identified", "not-tracked"]}, + "url": {"type": ["string", "null"], "maxLength": 2048, "pattern": "^https?://.+$"}, + "updatedAt": {"type": "string", "format": "date-time"} + } + } + } + } +} diff --git a/platform/vuln-scanner/src/server.py b/platform/vuln-scanner/src/server.py new file mode 100644 index 0000000..7746bcf --- /dev/null +++ b/platform/vuln-scanner/src/server.py @@ -0,0 +1,150 @@ +"""The three HTTP listeners: page (loopback, behind oauth2-proxy), internal API, metrics.""" +import json +import logging +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +import page +import trivy +from state import parse_ts + +log = logging.getLogger("vuln-scanner.server") + +MAX_TRIAGE_BYTES = 256 * 1024 +MAX_REMEDIATION = 5000 +REMEDIATION_STATES = ("pr-open", "approval-requested", "no-update-identified", "not-tracked") + + +class _Handler(BaseHTTPRequestHandler): + server_version = "vuln-scanner" + sys_version = "" + timeout = 30 + engine = None + + def log_message(self, fmt, *args): # request lines carry filters; keep them out of the log + return + + def _send(self, status, headers, body): + self.send_response(status) + for name, value in headers.items(): + self.send_header(name, value) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _text(self, status, body, content_type="text/plain; charset=utf-8"): + self._send(status, {"Content-Type": content_type}, body.encode("utf-8")) + + +class _PageHandler(_Handler): + def send_error(self, code, message=None, explain=None): # keep the CSP on error responses too + status, headers, body = page.not_found() + self._send(code, headers, body) + + def do_GET(self): + parts = urlsplit(self.path) + if parts.path == "/": + self._send(*self.engine.render_page(parts.query)) + elif parts.path == "/healthz": + self._text(200, "ok\n") + else: + self._send(*page.not_found()) + + +class _MetricsHandler(_Handler): + def do_GET(self): + path = urlsplit(self.path).path + if path == "/metrics": + self._text(200, self.engine.metrics_text(), "text/plain; version=0.0.4; charset=utf-8") + elif path == "/healthz": + healthy = self.engine.healthy() + self._text(200 if healthy else 503, "ok\n" if healthy else "scan loop stopped\n") + else: + self._text(404, "not found\n") + + +class _InternalHandler(_Handler): + def do_GET(self): + path = urlsplit(self.path).path + if path == "/report.json": + body = self.engine.report_bytes() + if body is None: + self._text(503, "no report yet\n") + else: + self._send(200, {"Content-Type": "application/json"}, body) + elif path == "/healthz": + self._text(200, "ok\n") + else: + self._text(404, "not found\n") + + def do_POST(self): + if urlsplit(self.path).path != "/triage": + self._text(404, "not found\n") + return + content_type = (self.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() + if content_type != "application/json": + self._text(415, "content type must be application/json\n") + return + try: + length = int(self.headers.get("Content-Length", "")) + except ValueError: + self._text(411, "a content length is required\n") + return + if length < 0 or length > MAX_TRIAGE_BYTES: + self._text(413, "body too large\n") + return + raw = self.rfile.read(length) + try: + document = json.loads(raw) + except ValueError: + self._text(400, "body is not JSON\n") + return + errors = validate_triage(document) + if errors: + self._text(400, errors[0] + "\n") + return + self.engine.record_triage(document) + self._send(204, {}, b"") + + +def validate_triage(document): + """Error strings for a POST /triage body; empty means valid. Exact shape, nothing extra.""" + if not isinstance(document, dict) or set(document) != {"heartbeatAt", "remediation"}: + return ["body must have exactly heartbeatAt and remediation"] + if parse_ts(document["heartbeatAt"]) is None or len(document["heartbeatAt"]) > 64: + return ["heartbeatAt must be RFC 3339"] + entries = document["remediation"] + if not isinstance(entries, list) or len(entries) > MAX_REMEDIATION: + return ["remediation must be a list of at most " + str(MAX_REMEDIATION) + " entries"] + for index, entry in enumerate(entries): + prefix = "remediation[" + str(index) + "]: " + if not isinstance(entry, dict) or set(entry) != {"image", "state", "url", "updatedAt"}: + return [prefix + "must have exactly image, state, url, updatedAt"] + if not isinstance(entry["image"], str) or not 0 < len(entry["image"]) <= 512: + return [prefix + "image must be a string of at most 512 characters"] + if entry["state"] not in REMEDIATION_STATES: + return [prefix + "unknown state"] + url = entry["url"] + if url is not None and (not isinstance(url, str) or len(url) > 2048 or trivy.safe_url(url) != url): + return [prefix + "url must be null or an http(s) URL"] + if parse_ts(entry["updatedAt"]) is None or len(entry["updatedAt"]) > 64: + return [prefix + "updatedAt must be RFC 3339"] + return [] + + +def serve(handler_type, address, engine): + handler = type(handler_type.__name__, (handler_type,), {"engine": engine}) + server = ThreadingHTTPServer(address, handler) + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, name="http-" + str(address[1]), daemon=True) + thread.start() + return server + + +def start_all(engine, page_address, internal_address, metrics_address): + return [ + serve(_PageHandler, page_address, engine), + serve(_InternalHandler, internal_address, engine), + serve(_MetricsHandler, metrics_address, engine), + ] diff --git a/platform/vuln-scanner/src/state.py b/platform/vuln-scanner/src/state.py new file mode 100644 index 0000000..56912db --- /dev/null +++ b/platform/vuln-scanner/src/state.py @@ -0,0 +1,147 @@ +"""Persisted scanner state and the time helpers every module shares. + +state.json holds what must survive a restart: the last inventory, one record per unit of scan +(a digest, or a by-tag target id), the database info, the last run, remediation and heartbeat. +Every write is temp-file + fsync + rename, so a crash leaves the previous file intact. +""" +import json +import logging +import os +import re +import tempfile +from datetime import datetime, timedelta, timezone + +log = logging.getLogger("vuln-scanner.state") + +STATE_VERSION = 1 +RETENTION = timedelta(days=7) # how long a scan record outlives its target in the inventory + +_RFC3339 = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$") + + +def utcnow(): + return datetime.now(timezone.utc) + + +def fmt_ts(value): + return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def parse_ts(value): + """A timezone-aware datetime for an RFC 3339 string, or None for anything else.""" + if not isinstance(value, str) or not _RFC3339.match(value): + return None + try: + return datetime.fromisoformat(value) + except ValueError: + return None + + +def atomic_write(path, data): + directory = os.path.dirname(path) or "." + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-") + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + dir_fd = os.open(directory, os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + + +def new_state(): + return { + "version": STATE_VERSION, + "inventory": [], + "inventoryAt": None, + "inventoryError": None, + "scans": {}, + "database": None, + "run": None, + "lastCompleteRunAt": None, + "remediation": [], + "triageHeartbeatAt": None, + "loadError": None, + } + + +def new_record(now): + return { + "firstDiscoveredAt": fmt_ts(now), + "lastInventoriedAt": fmt_ts(now), + "lastAttemptAt": None, + "lastSuccessAt": None, + "outcome": None, + "reason": None, + "failedSince": None, + "findings": [], + "firstSeen": {}, + } + + +class Store: + """state.json on the data volume.""" + + def __init__(self, data_dir): + self.data_dir = data_dir + self.path = os.path.join(data_dir, "state.json") + + def load(self): + try: + with open(self.path, "rb") as handle: + loaded = json.loads(handle.read()) + if not isinstance(loaded, dict) or loaded.get("version") != STATE_VERSION: + raise ValueError("unrecognised state version") + except FileNotFoundError: + return new_state() + except (OSError, ValueError) as exc: + log.critical("state.json is unreadable (%s); starting empty and marking the run degraded", exc) + try: + os.replace(self.path, self.path + ".corrupt") + except OSError: + pass + fresh = new_state() + fresh["loadError"] = "state.json was unreadable at startup" + return fresh + merged = new_state() + merged.update(loaded) + return merged + + def save(self, state): + atomic_write(self.path, json.dumps(state, sort_keys=True).encode("utf-8")) + + +def merge_inventory(state, targets, now): + """Install a fresh inventory; create a record per new unit; forget long-gone units.""" + state["inventory"] = targets + state["inventoryAt"] = fmt_ts(now) + state["inventoryError"] = None + stamp = fmt_ts(now) + for target in targets: + key = scan_key(target) + if key is None: + continue + record = state["scans"].get(key) + if record is None: + record = state["scans"][key] = new_record(now) + record["lastInventoriedAt"] = stamp + cutoff = now - RETENTION + for key in [k for k, r in state["scans"].items() if (parse_ts(r["lastInventoriedAt"]) or now) < cutoff]: + del state["scans"][key] + + +def scan_key(target): + """The unit of scan: the digest, else the by-tag target id. VMs and nodes have none.""" + if target["type"] != "image": + return None + return target["digest"] or target["id"] diff --git a/platform/vuln-scanner/src/trivy.py b/platform/vuln-scanner/src/trivy.py new file mode 100644 index 0000000..2481965 --- /dev/null +++ b/platform/vuln-scanner/src/trivy.py @@ -0,0 +1,153 @@ +"""Run Trivy and normalise its output. Everything Trivy emits is untrusted upstream text.""" +import json +import logging +import re +import subprocess +from urllib.parse import urlsplit + +log = logging.getLogger("vuln-scanner.trivy") + +SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN") +_CREDENTIAL = re.compile( + r"unauthorized|authentication required|\bdenied:|access to the resource is denied|forbidden" + r"|status(?: code)?:? ?(?:401|403)\b|\b40[13] (?:unauthorized|forbidden)", + re.IGNORECASE, +) +_CONTROL = re.compile(r"[\x00-\x1f\x7f]") + + +class ScanFailure(Exception): + """A scan did not produce a result. kind is `failed` or `credential-required`.""" + + def __init__(self, kind, reason): + super().__init__(reason) + self.kind = kind + self.reason = reason + + +def text(value, limit=512): + """A bounded, control-character-free string; non-strings become empty.""" + if not isinstance(value, str): + return "" + return _CONTROL.sub(" ", value).strip()[:limit] + + +def safe_url(value): + """value if it is a plain http(s) URL, else the empty string.""" + url = text(value, 2048) + if not url or url != value or re.search(r"\s", url): + return "" + try: + parts = urlsplit(url) + except ValueError: + return "" + return url if parts.scheme.lower() in ("http", "https") and parts.netloc else "" + + +def _cvss(vuln): + best = None + for vendor in (vuln.get("CVSS") or {}).values() if isinstance(vuln.get("CVSS"), dict) else (): + if not isinstance(vendor, dict): + continue + for key in ("V40Score", "V3Score", "V2Score"): + score = vendor.get(key) + if isinstance(score, (int, float)) and not isinstance(score, bool) and 0 <= score <= 10: + best = max(best, float(score)) if best is not None else float(score) + break + return best + + +def parse_results(document): + """Findings (id, severity, cvss, package, installedVersion, fixedVersion, title, url), deduped.""" + if not isinstance(document, dict) or "SchemaVersion" not in document: + raise ScanFailure("failed", "trivy produced an unrecognised report") + results = document.get("Results") + if results is None: + return [] + if not isinstance(results, list): + raise ScanFailure("failed", "trivy report has a malformed Results field") + seen = {} + for result in results: + vulnerabilities = result.get("Vulnerabilities") if isinstance(result, dict) else None + if vulnerabilities is None: + continue + if not isinstance(vulnerabilities, list): + raise ScanFailure("failed", "trivy report has a malformed Vulnerabilities field") + for vuln in vulnerabilities: + if not isinstance(vuln, dict) or not text(vuln.get("VulnerabilityID")): + raise ScanFailure("failed", "trivy report has a finding with no identifier") + severity = text(vuln.get("Severity")).upper() + fixed = text(vuln.get("FixedVersion")) + finding = { + "id": text(vuln.get("VulnerabilityID"), 256), + "severity": severity if severity in SEVERITIES else "UNKNOWN", + "cvss": _cvss(vuln), + "package": text(vuln.get("PkgName"), 256), + "installedVersion": text(vuln.get("InstalledVersion"), 256), + "fixedVersion": fixed or None, + "title": text(vuln.get("Title") or vuln.get("Description"), 512), + "url": safe_url(vuln.get("PrimaryURL")), + } + seen[(finding["id"], finding["package"], finding["installedVersion"])] = finding + return sorted(seen.values(), key=lambda f: (f["id"], f["package"], f["installedVersion"])) + + +def classify_failure(registry, stderr): + """Sort a non-zero Trivy exit into credential-required or failed. Reasons name the registry.""" + if _CREDENTIAL.search(stderr or ""): + return ScanFailure("credential-required", "registry " + registry + " refused anonymous access") + lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()] + return ScanFailure("failed", text(lines[-1], 400) if lines else "trivy exited with an error and no message") + + +class Runner: + """The real Trivy binary. Tests inject a runner with the same four methods.""" + + def __init__(self, trivy_bin, cache_dir, timeout=600): + self.bin = trivy_bin + self.cache_dir = cache_dir + self.timeout = timeout + + def _run(self, args, timeout): + return subprocess.run([self.bin] + args, capture_output=True, text=True, timeout=timeout, check=False) + + def download_db(self): + result = self._run(["image", "--download-db-only", "--no-progress", "--cache-dir", self.cache_dir], 900) + if result.returncode != 0: + raise ScanFailure("failed", "database update failed: " + text(result.stderr, 300)) + + def db_info(self): + """{updatedAt, version} of the DB on disk, or None when no DB is present.""" + result = self._run(["version", "--format", "json", "--cache-dir", self.cache_dir], 60) + if result.returncode != 0: + return None + try: + db = json.loads(result.stdout).get("VulnerabilityDB") + except (ValueError, AttributeError): + return None + if not isinstance(db, dict) or not db.get("UpdatedAt"): + return None + return {"updatedAt": db["UpdatedAt"], "version": str(db.get("Version", "unknown"))} + + def scan(self, reference): + args = [ + "image", "--skip-db-update", "--image-src", "remote", "--scanners", "vuln", "--format", "json", + "--no-progress", "--cache-dir", self.cache_dir, "--timeout", str(self.timeout) + "s", reference, + ] + try: + result = self._run(args, self.timeout + 30) + except subprocess.TimeoutExpired: + raise ScanFailure("failed", "timed out after " + str(self.timeout) + "s") from None + except OSError as exc: + raise ScanFailure("failed", "could not start trivy: " + type(exc).__name__) from None + if result.returncode < 0: + raise ScanFailure("failed", "trivy killed by signal " + str(-result.returncode) + " (out of memory?)") + if result.returncode != 0: + raise classify_failure(reference.split("/", 1)[0], result.stderr) + try: + return json.loads(result.stdout) + except ValueError: + raise ScanFailure("failed", "trivy output was not valid JSON") from None + + def clean_cache(self): + self._run(["clean", "--scan-cache", "--cache-dir", self.cache_dir], 120) -- 2.54.0 From b85d1e3ed7f2a0e0957de67a8362f45294f6150b Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 9 Nov 2026 09:42:52 -0600 Subject: [PATCH 04/10] feat(vuln): vuln-scanner platform manifests, Flux tier, alerts, L0/L2/L3 tests Hand-authored platform/vuln-scanner (non-gating Flux tier, digest-pinned Trivy 0.76.1 and python 3.14.9-alpine, read-only RBAC, per-pod oauth2-proxy, internal-only cve.), five alerts with promtool tests, R-VULN-01..09 registry entries, and vuln-* L0 checks with mutation self-tests. src/ and the bot workflow are integrated separately. Co-Authored-By: Claude Opus 5.5 --- apps/wave-0/pihole.yaml | 2 +- catalog/pihole.yaml | 2 +- clusters/base/kustomization.yaml | 4 + clusters/base/vuln-scanner.yaml | 41 +++ clusters/dev/kustomization.yaml | 15 + clusters/prod/kustomization.yaml | 15 + conformance/requirements/registry.yaml | 38 +++ .../check.sh | 22 ++ .../cloudflared-ingress-rules/check.sh | 4 +- .../check.sh | 4 + .../chainsaw-test.yaml | 28 ++ .../vuln-acceptances-schema/check.sh | 105 ++++++ .../chainsaw-test.yaml | 29 ++ .../vuln-alerts-unit-tested/check.sh | 153 +++++++++ .../chainsaw-test.yaml | 28 ++ .../vuln-metrics-no-per-cve-label/check.sh | 105 ++++++ .../vuln-scanner-isolation/chainsaw-test.yaml | 30 ++ .../l0-static/vuln-scanner-isolation/check.sh | 210 ++++++++++++ .../chainsaw-test.yaml | 34 ++ .../vuln-scanner-supply-chain/check.sh | 298 ++++++++++++++++++ .../chainsaw-test.yaml | 31 ++ .../vuln-triage-bot-workflow-guards/check.sh | 102 ++++++ .../chainsaw-test.yaml | 35 ++ .../chainsaw-test.yaml | 36 +++ .../chainsaw-test.yaml | 39 +++ .../vuln-registry-failure/chainsaw-test.yaml | 40 +++ platform/cloudflared/config.yaml | 4 + platform/cloudflared/networkpolicy.yaml | 1 + platform/homepage/configmap.yaml | 7 + platform/vuln-scanner/README.md | 80 +++++ platform/vuln-scanner/acceptances.yaml | 15 + .../vuln-scanner/alerts.promtool-test.yaml | 167 ++++++++++ platform/vuln-scanner/alerts.yaml | 96 ++++++ platform/vuln-scanner/deployment.yaml | 279 ++++++++++++++++ platform/vuln-scanner/httproute.yaml | 34 ++ platform/vuln-scanner/kustomization.yaml | 50 +++ platform/vuln-scanner/namespace.yaml | 23 ++ platform/vuln-scanner/networkpolicy.yaml | 119 +++++++ platform/vuln-scanner/pvc.yaml | 19 ++ platform/vuln-scanner/rbac.yaml | 50 +++ platform/vuln-scanner/service.yaml | 33 ++ platform/vuln-scanner/servicemonitor.yaml | 20 ++ ...oauth2-proxy-credentials.sops.yaml.example | 28 ++ renovate.json5 | 25 +- runbooks/README.md | 2 + runbooks/kustomization.yaml | 1 + runbooks/vuln-scanner.md | 75 +++++ 47 files changed, 2572 insertions(+), 6 deletions(-) create mode 100644 clusters/base/vuln-scanner.yaml create mode 100644 conformance/tests/l0-static/vuln-acceptances-schema/chainsaw-test.yaml create mode 100755 conformance/tests/l0-static/vuln-acceptances-schema/check.sh create mode 100644 conformance/tests/l0-static/vuln-alerts-unit-tested/chainsaw-test.yaml create mode 100755 conformance/tests/l0-static/vuln-alerts-unit-tested/check.sh create mode 100644 conformance/tests/l0-static/vuln-metrics-no-per-cve-label/chainsaw-test.yaml create mode 100755 conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh create mode 100644 conformance/tests/l0-static/vuln-scanner-isolation/chainsaw-test.yaml create mode 100755 conformance/tests/l0-static/vuln-scanner-isolation/check.sh create mode 100644 conformance/tests/l0-static/vuln-scanner-supply-chain/chainsaw-test.yaml create mode 100755 conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh create mode 100644 conformance/tests/l0-static/vuln-triage-bot-workflow-guards/chainsaw-test.yaml create mode 100755 conformance/tests/l0-static/vuln-triage-bot-workflow-guards/check.sh create mode 100644 conformance/tests/l2-behavioral/vuln-internal-api-isolated/chainsaw-test.yaml create mode 100644 conformance/tests/l2-behavioral/vuln-new-digest-reported/chainsaw-test.yaml create mode 100644 conformance/tests/l2-behavioral/vuln-page-requires-admin/chainsaw-test.yaml create mode 100644 conformance/tests/l3-fault-injection/vuln-registry-failure/chainsaw-test.yaml create mode 100644 platform/vuln-scanner/README.md create mode 100644 platform/vuln-scanner/acceptances.yaml create mode 100644 platform/vuln-scanner/alerts.promtool-test.yaml create mode 100644 platform/vuln-scanner/alerts.yaml create mode 100644 platform/vuln-scanner/deployment.yaml create mode 100644 platform/vuln-scanner/httproute.yaml create mode 100644 platform/vuln-scanner/kustomization.yaml create mode 100644 platform/vuln-scanner/namespace.yaml create mode 100644 platform/vuln-scanner/networkpolicy.yaml create mode 100644 platform/vuln-scanner/pvc.yaml create mode 100644 platform/vuln-scanner/rbac.yaml create mode 100644 platform/vuln-scanner/service.yaml create mode 100644 platform/vuln-scanner/servicemonitor.yaml create mode 100644 platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example create mode 100644 runbooks/vuln-scanner.md diff --git a/apps/wave-0/pihole.yaml b/apps/wave-0/pihole.yaml index 60e27ec..be74066 100644 --- a/apps/wave-0/pihole.yaml +++ b/apps/wave-0/pihole.yaml @@ -73,7 +73,7 @@ spec: - name: FTLCONF_dns_rateLimit_count value: '0' - name: FTLCONF_misc_dnsmasq_lines - value: local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP} + value: local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/cve.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP} - name: FTLCONF_webserver_api_password valueFrom: secretKeyRef: diff --git a/catalog/pihole.yaml b/catalog/pihole.yaml index eba6a11..e63f4c0 100644 --- a/catalog/pihole.yaml +++ b/catalog/pihole.yaml @@ -202,7 +202,7 @@ env: # Applies to every `address=` reply below (dnsmasq: local-ttl governs replies made from # configuration, /etc/hosts and DHCP leases), so one directive covers every listed name. - name: FTLCONF_misc_dnsmasq_lines - value: "local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}" + value: "local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/cve.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}" # THE ADMIN PASSWORD, previously the ONE operator-set value declared nowhere. Sourced from # apps/wave-0/pihole-bootstrap-secret.sops.yaml (SOPS/age, same wave => same reconcile), which # carries the full rationale and the measurement that found it. diff --git a/clusters/base/kustomization.yaml b/clusters/base/kustomization.yaml index 810eebe..c76d09a 100644 --- a/clusters/base/kustomization.yaml +++ b/clusters/base/kustomization.yaml @@ -78,6 +78,10 @@ resources: # `wg-server-key` Secret, so in the gating `platform` tier an unfinished manual step would have # stalled every apps wave. Carries no SOPS secret, so neither overlay adds a decryption patch. - vpn-ingress.yaml # Flux Kustomization -> ./platform/vpn-ingress (dependsOn platform-config) + # CVE visibility scanner (docs/17, ADR-0027) — POST-platform tier (dependsOn platform-config, + # wait:false): a failing scanner must never gate the platform, and nothing dependsOn it. Carries the + # oauth2-proxy credential SOPS file, so both overlays add a decryption patch by name. + - vuln-scanner.yaml # Flux Kustomization -> ./platform/vuln-scanner (dependsOn platform-config) # Cloudflare Tunnel connector (north-south INGRESS from the Cloudflare edge) — POST-platform tier # (dependsOn platform-config, wait:false), and NOTHING dependsOn it. Non-gating is load-bearing, # as for vpn-ingress: the Deployment cannot become Ready until the operator enrols the tunnel diff --git a/clusters/base/vuln-scanner.yaml b/clusters/base/vuln-scanner.yaml new file mode 100644 index 0000000..50766e7 --- /dev/null +++ b/clusters/base/vuln-scanner.yaml @@ -0,0 +1,41 @@ +--- +# Flux Kustomization: the CVE visibility scanner (docs/17, ADR-0027) — reconciles ./platform/vuln-scanner. +# +# ISOLATION IS THE POINT, as for clusters/base/cloudflared.yaml. A failing scanner, an unbound PVC or a +# pending Certificate must never become a stalled platform, so: +# * ITS OWN Kustomization, not a line in platform/kustomization.yaml; +# * wait: false — its health is reported by the VulnScanStale/VulnDatabaseStale/VulnCoverageGap/ +# VulnCriticalFixAvailable/VulnTriageBotSilent alerts (platform/vuln-scanner/alerts.yaml), not by +# this object's Ready condition, which reaches Ready on apply success; +# * NOTHING dependsOn THIS. conformance/tests/l0-static/vuln-scanner-isolation fails the gate if any +# Kustomization gains `dependsOn: vuln-scanner` or this one stops being wait:false. +# +# dependsOn: [platform-config] — a real edge: PrometheusRule/ServiceMonitor/Gateway API CRDs, the +# Longhorn StorageClass, the Gateway, Keycloak's realm, and Longhorn itself come up through +# `platform`/`platform-config` first. +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: vuln-scanner + namespace: flux-system +spec: + interval: 10m + retryInterval: 2m + timeout: 5m + prune: true + wait: false + dependsOn: + - name: platform-config + sourceRef: + kind: GitRepository + name: flux-system + path: ./platform/vuln-scanner + # REQUIRED: the HTTPRoute hostname, the oauth2-proxy issuer/redirect/cookie-domain args and every + # alert's runbook_url are written `${BASE_DOMAIN}`. Substitution fails OPEN, so the file + # conformance/tests/l0-static/flux-postbuild-substitution audits this tier. + postBuild: + substituteFrom: + - kind: ConfigMap + name: cluster-vars + # SOPS decryption is layered per-environment: clusters/dev and clusters/prod each patch THIS + # Kustomization by name with a `decryption:` stanza for the oauth2-proxy credentials. diff --git a/clusters/dev/kustomization.yaml b/clusters/dev/kustomization.yaml index f2fa504..4fcdaaf 100644 --- a/clusters/dev/kustomization.yaml +++ b/clusters/dev/kustomization.yaml @@ -140,6 +140,21 @@ patches: provider: sops secretRef: name: sops-age + # vuln-scanner (the CVE visibility scanner, clusters/base/vuln-scanner.yaml) carries + # platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml — the per-pod oauth2-proxy + # client and cookie secrets. Without this stanza the Secret applies full of ENC[...] and the + # sidecar cannot authenticate; the rest of the scanner runs regardless. `sops-age` is out-of-band. + - patch: |- + apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + metadata: + name: vuln-scanner + namespace: flux-system + spec: + decryption: + provider: sops + secretRef: + name: sops-age # cloudflared (the Cloudflare Tunnel connector, clusters/base/cloudflared.yaml) carries # platform/cloudflared/cloudflared-credentials.sops.yaml — the tunnel credentials JSON, the one # secret in that component. Without this stanza the Secret applies full of ENC[...]: the APPLY diff --git a/clusters/prod/kustomization.yaml b/clusters/prod/kustomization.yaml index 5904bce..5eb3e65 100644 --- a/clusters/prod/kustomization.yaml +++ b/clusters/prod/kustomization.yaml @@ -131,6 +131,21 @@ patches: provider: sops secretRef: name: sops-age + # vuln-scanner (the CVE visibility scanner, clusters/base/vuln-scanner.yaml) carries + # platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml — the per-pod oauth2-proxy + # client and cookie secrets. Without this stanza the Secret applies full of ENC[...] and the + # sidecar cannot authenticate; the rest of the scanner runs regardless. `sops-age` is out-of-band. + - patch: |- + apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + metadata: + name: vuln-scanner + namespace: flux-system + spec: + decryption: + provider: sops + secretRef: + name: sops-age # cloudflared (the Cloudflare Tunnel connector, clusters/base/cloudflared.yaml) carries # platform/cloudflared/cloudflared-credentials.sops.yaml — the tunnel credentials JSON, the one # secret in that component. Without this stanza the Secret applies full of ENC[...]: the APPLY diff --git a/conformance/requirements/registry.yaml b/conformance/requirements/registry.yaml index a6ce4c3..6e501e6 100644 --- a/conformance/requirements/registry.yaml +++ b/conformance/requirements/registry.yaml @@ -135,6 +135,44 @@ requirements: safety_critical: false summary: "Update policy per-service configurable (cadence, channel, pinning)." + # --- Vulnerability visibility --- + - id: R-VULN-01 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Inventory every running container image by digest with its workloads, from the cluster." + - id: R-VULN-02 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Every running image evaluated against a current database at least every 24h; new images within 2h." + - id: R-VULN-03 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Any target not evaluated is visibly reported with its reason; absence is never shown as clean." + - id: R-VULN-04 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Authenticated page lists every CVE with affected images, containers and workloads; searchable and filterable." + - id: R-VULN-05 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Inventory, scanning, reporting and triage run on a schedule with no human or AI action; loud failure." + - id: R-VULN-06 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Scan failure, staleness and unaccepted CRITICAL fixes reported by email as grouped alerts." + - id: R-VULN-07 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Scheduled bot keeps one dashboard issue and requests remediation via Renovate; no CVE detail exposed." + - id: R-VULN-08 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Accepted risk declared in git with reason and mandatory expiry; reverts to open on expiry." + - id: R-VULN-09 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Scanner digest-pinned, no auto-merged updates, no Secret read or write access, no git credential." + # --- Observability & alerting --- - id: R-OBS-01 doc: docs/10-observability-and-alerting.md diff --git a/conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh b/conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh index 741eb40..32c2b58 100755 --- a/conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh +++ b/conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh @@ -411,6 +411,28 @@ SCRAPE_OWNERS: list[dict] = [ "Keeping the two in one file makes that pair inseparable", }, }, + # ── ADDED 2026-11-09 (CVE visibility, ADR-0027). A NEW OWNER, NOT A RELAXED ASSERTION. ────── + # Registers the vuln_scanner_* family and the Service+ServiceMonitor that scrapes it. + { + "owner": "vuln-scanner: Service + ServiceMonitor on port `metrics` (:9090)", + "prefixes": ("vuln_scanner_",), + "file": "platform/vuln-scanner/servicemonitor.yaml", + "require": [ + r"^kind:\s*ServiceMonitor\s*$", + r"^\s*-\s*port:\s*metrics\s*$", + ], + "forbid": [], + "why": "vuln_scanner_* powers the five VulnScan alerts, the only email path for scan " + "failure, staleness and overdue critical fixes (R-VULN-06). Two of those rules fire " + "on an absent metric, so a lost scrape is loud rather than inert", + "companion": { + "file": "platform/vuln-scanner/kustomization.yaml", + "require": [r"^\s*-\s*networkpolicy\.yaml\s*$"], + "why": "platform/vuln-scanner/networkpolicy.yaml ships the namespace's default-deny " + "and the ingress allow for Prometheus on :9090 in one file, so a baseline " + "without its allow cannot empty the TSDB", + }, + }, # ── ADDED 2026-11-08 (feat/cloudflare-tunnel). A NEW OWNER, NOT A RELAXED ASSERTION. ─────── # Purely additive, the same shape as the kured entry: it registers the cloudflared_* family # and the Service+ServiceMonitor that scrapes it, and makes the check STRICTER for that component. diff --git a/conformance/tests/l0-static/cloudflared-ingress-rules/check.sh b/conformance/tests/l0-static/cloudflared-ingress-rules/check.sh index ceba41d..3c215fb 100755 --- a/conformance/tests/l0-static/cloudflared-ingress-rules/check.sh +++ b/conformance/tests/l0-static/cloudflared-ingress-rules/check.sh @@ -86,10 +86,10 @@ if not CONFIG.is_file(): # headlamp: operator decision 2026-11-08 (LAN/VPN-only). qbittorrent/radarr/sonarr: catalog # `exposure: internal`, like jackett/pihole/semaphore. EXCLUDED_HOSTS = ("plex", "plex-vip", "jackett", "pihole", "pihole-dns", "semaphore", - "qbittorrent", "radarr", "sonarr", "headlamp", "unifi", "crafty-mc") + "qbittorrent", "radarr", "sonarr", "headlamp", "cve", "unifi", "crafty-mc") # Their Gateway-backend namespaces (unifi / crafty-mc are L4 VIPs, not Gateway backends). EXCLUDED_NAMESPACES = ("plex", "jackett", "pihole", "semaphore", "qbittorrent", "radarr", - "sonarr", "headlamp") + "sonarr", "headlamp", "vuln-scanner") FORBIDDEN_ENTITIES = {"kube-apiserver", "host", "remote-node", "cluster", "all"} # Any of these in a `service` means a route toward the Kubernetes API (or a raw-TCP/SSH bastion). API_MARKERS = ("kubernetes", "kube-apiserver", "apiserver", ":6443", "10.43.0.1", "tcp://", diff --git a/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh b/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh index 5dd33f2..303e3c4 100755 --- a/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh +++ b/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh @@ -187,6 +187,10 @@ KNOWN_NON_CATALOG_PUBLISHERS: dict[str, str] = { "decision: Headlamp is LAN/VPN-only) and the tunnel 404s the name. A Kubernetes web UI " "with native Keycloak OIDC; listed because it is a hand-authored route, and pinned " "disowned by (9d).", + "platform/vuln-scanner/httproute.yaml:vuln-scanner/vuln-scanner": + "publishes NOTHING: the route carries `controller: none` (docs/17: the CVE page is internal " + "only) and the tunnel 404s the name. Hand-authored because the scanner is not a catalog " + "service (its cluster-wide read role cannot be expressed in a ServiceDefinition, ADR-0027).", "platform/homepage/httproute.yaml:homepage/homepage": "publishes the APEX ${BASE_DOMAIN} itself -> the tunnel (proxied CNAME), via the https-apex " "listener. The apex is not a per-service name and cannot be withdrawn by an exposure " diff --git a/conformance/tests/l0-static/vuln-acceptances-schema/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-acceptances-schema/chainsaw-test.yaml new file mode 100644 index 0000000..c1400bf --- /dev/null +++ b/conformance/tests/l0-static/vuln-acceptances-schema/chainsaw-test.yaml @@ -0,0 +1,28 @@ +# ============================================================================= +# L0 STATIC — accepted CVE risk has a reason and a bounded expiry (R-VULN-08, docs/17 "Risk acceptance"). +# Offline, no cluster. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-acceptances-schema + labels: + conformance.home.k3s/R-VULN-08: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-08" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + platform/vuln-scanner/acceptances.yaml rejects any entry without a reason or expiry, with a + malformed expiry, or expiring more than 180 days out. Proven with mutated negative fixtures. +spec: + steps: + - name: assert-acceptance-schema + description: >- + Validate acceptances.yaml against the docs/17 shape, then run the validator on mutated + negative fixtures to prove it can fail. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-acceptances-schema/check.sh b/conformance/tests/l0-static/vuln-acceptances-schema/check.sh new file mode 100755 index 0000000..95348fa --- /dev/null +++ b/conformance/tests/l0-static/vuln-acceptances-schema/check.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-acceptances-schema +# Serves: R-VULN-08 (docs/17 "Risk acceptance": "An entry with no reason or expiry is rejected by the gate"). +# +# GIVEN platform/vuln-scanner/acceptances.yaml, +# THEN (1) it is a YAML list; +# (2) every entry has a non-empty id, a non-empty reason, an expires of the form YYYY-MM-DD +# at most 180 days from today, and only the permitted keys (image is optional); +# AND (3) the validator rejects mutated negative fixtures for each rule and accepts a good entry. +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import datetime as dt, re, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-acceptances-schema" +FAILURES, CHECKS = [], 0 +TODAY = dt.datetime.now(dt.timezone.utc).date() +MAX_DAYS = 180 +ALLOWED_KEYS = {"id", "image", "reason", "expires"} +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +def validate(entries): + errs = [] + if not isinstance(entries, list): + return ["acceptances must be a YAML list"] + for i, e in enumerate(entries): + if not isinstance(e, dict): + errs.append(f"entry {i} is not a mapping") + continue + if set(e) - ALLOWED_KEYS: + errs.append(f"entry {i} has unknown keys {sorted(set(e) - ALLOWED_KEYS)}") + for field in ("id", "reason"): + if not isinstance(e.get(field), str) or not e[field].strip(): + errs.append(f"entry {i} has no non-empty {field}") + if "image" in e and (not isinstance(e["image"], str) or not e["image"].strip()): + errs.append(f"entry {i} has an empty image") + exp = e.get("expires") + if isinstance(exp, dt.date) and not isinstance(exp, dt.datetime): + exp_s = exp.isoformat() + elif isinstance(exp, str): + exp_s = exp + else: + errs.append(f"entry {i} has no expires") + continue + if not DATE_RE.match(exp_s): + errs.append(f"entry {i} expires is malformed: {exp_s!r}") + continue + try: + d = dt.date.fromisoformat(exp_s) + except ValueError: + errs.append(f"entry {i} expires is not a real date: {exp_s!r}") + continue + if (d - TODAY).days > MAX_DAYS: + errs.append(f"entry {i} expires {exp_s}, more than {MAX_DAYS} days out") + return errs + + +path = ROOT / "platform" / "vuln-scanner" / "acceptances.yaml" +if not path.is_file(): + print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/acceptances.yaml is absent", file=sys.stderr) + raise SystemExit(1) +entries = yaml.safe_load(path.read_text()) +check(entries == [] or (isinstance(entries, list) and entries), "(1) acceptances.yaml parsed to a list") +errs = validate(entries) +check(not errs, f"(2) acceptances.yaml is valid: {errs}") + +soon = (TODAY + dt.timedelta(days=30)).isoformat() +far = (TODAY + dt.timedelta(days=200)).isoformat() +good = {"id": "CVE-2026-00000", "image": "docker.io/library/x", "reason": "not reachable", "expires": soon} +check(validate([good]) == [], "(3) a well-formed entry is accepted") +check(validate([{k: v for k, v in good.items() if k != "image"}]) == [], "(3) image is optional (platform-wide)") +for name, entry in { + "no reason": {k: v for k, v in good.items() if k != "reason"}, + "empty reason": dict(good, reason=""), + "no expires": {k: v for k, v in good.items() if k != "expires"}, + "no id": {k: v for k, v in good.items() if k != "id"}, + "malformed expiry": dict(good, expires="next-tuesday"), + "impossible date": dict(good, expires="2027-13-45"), + "past 180 days": dict(good, expires=far), + "unknown key": dict(good, owner="me"), +}.items(): + check(bool(validate([entry])), f"(3) the validator accepted a bad entry: {name}") +check(bool(validate({"not": "a list"})), "(3) the validator accepted a non-list") + +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l0-static/vuln-alerts-unit-tested/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-alerts-unit-tested/chainsaw-test.yaml new file mode 100644 index 0000000..aa2ad4f --- /dev/null +++ b/conformance/tests/l0-static/vuln-alerts-unit-tested/chainsaw-test.yaml @@ -0,0 +1,29 @@ +# ============================================================================= +# L0 STATIC — the five scanner alerts exist and are unit-tested, including the absent-metric cases +# (R-VULN-06, docs/17 "Acceptance gates"). Offline, no cluster. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-alerts-unit-tested + labels: + conformance.home.k3s/R-VULN-06: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-06" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + VulnScanStale, VulnDatabaseStale, VulnCoverageGap, VulnCriticalFixAvailable and + VulnTriageBotSilent exist, each promtool-tested firing, and the two absent-metric rules fire + in a test with no such series. Proven by mutation. +spec: + steps: + - name: assert-alert-unit-tests + description: >- + Parse alerts.yaml and alerts.promtool-test.yaml, assert every alert and the two absent cases + are pinned, run the harness when promtool is present, and run the evaluator on mutated copies. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-alerts-unit-tested/check.sh b/conformance/tests/l0-static/vuln-alerts-unit-tested/check.sh new file mode 100755 index 0000000..e301104 --- /dev/null +++ b/conformance/tests/l0-static/vuln-alerts-unit-tested/check.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-alerts-unit-tested +# Serves: R-VULN-06 (docs/17 "Acceptance gates": alerts unit-tested, including the absent-metric cases). +# +# GIVEN platform/vuln-scanner/alerts.yaml and alerts.promtool-test.yaml, +# THEN (1) the rules are exactly the five contract alerts, with the two absent()-bearing rules; +# (2) each alert is asserted FIRING in a promtool test; +# (3) VulnScanStale and VulnTriageBotSilent each fire in a test with NO input series for their +# own metric (the absent case) and in one where it vanishes (re-fire); +# (4) each alert carries severity and a runbook_url; +# (5) when promtool is present (the toolbox), the actual harness passes on this file; +# AND (6) the evaluator is run on mutated copies (the absent arm deleted, a firing assertion +# removed, an alert dropped) and must flag each (non-vacuity). +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import copy, re, shutil, subprocess, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-alerts-unit-tested" +FAILURES, CHECKS = [], 0 +ALERTS = {"VulnScanStale", "VulnDatabaseStale", "VulnCoverageGap", "VulnCriticalFixAvailable", "VulnTriageBotSilent"} +ABSENT_ALERTS = { + "VulnScanStale": "vuln_scanner_last_complete_run_timestamp_seconds", + "VulnTriageBotSilent": "vuln_scanner_triage_heartbeat_timestamp_seconds", +} + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +COMP = ROOT / "platform" / "vuln-scanner" +for f in ("alerts.yaml", "alerts.promtool-test.yaml"): + if not (COMP / f).is_file(): + print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/{f} is absent", file=sys.stderr) + raise SystemExit(1) +rules = yaml.safe_load((COMP / "alerts.yaml").read_text()) +tests = yaml.safe_load((COMP / "alerts.promtool-test.yaml").read_text()) + + +def alert_rules(doc): + return {r["alert"]: r for g in doc["spec"]["groups"] for r in g["rules"]} + + +def eval_alerts(doc): + bad = [] + got = alert_rules(doc) + if set(got) != ALERTS: + bad.append(f"alerts are {sorted(got)}, expected {sorted(ALERTS)}") + for name, metric in ABSENT_ALERTS.items(): + if name in got and f"absent({metric})" not in got[name]["expr"].replace(" ", ""): + bad.append(f"{name} lacks the absent({metric}) arm") + for name, r in got.items(): + if not (r.get("labels") or {}).get("severity"): + bad.append(f"{name} has no severity label") + if "/runbooks/" not in ((r.get("annotations") or {}).get("runbook_url") or ""): + bad.append(f"{name} has no runbook_url") + return bad + + +def firing_assertions(test): + """(alertname -> assertion list) for the firing expectations of one promtool test.""" + out = {} + for pe in test.get("promql_expr_test") or []: + expr, samples = pe.get("expr", ""), pe.get("exp_samples") or [] + m = re.search(r'alertname="(Vuln\w+)"', expr) + if m and samples and 'alertstate="firing"' in (expr + " " + " ".join(s.get("labels", "") for s in samples)): + out.setdefault(m.group(1), []).append(pe) + return out + + +def series_for(test): + return [s.get("series", "") for s in test.get("input_series") or []] + + +def eval_tests(doc): + bad = [] + all_tests = doc.get("tests") or [] + fired = set() + for t in all_tests: + fired |= set(firing_assertions(t)) + missing = ALERTS - fired + if missing: + bad.append(f"no firing promtool assertion for {sorted(missing)}") + for alert, metric in ABSENT_ALERTS.items(): + absent_case = any( + alert in firing_assertions(t) and not any(s.startswith(metric) for s in series_for(t)) + for t in all_tests) + if not absent_case: + bad.append(f"{alert} has no firing test with NO {metric} series (the absent case)") + vanish_case = any( + alert in firing_assertions(t) and any(s.startswith(metric) and "stale" in str(i.get("values", "")) + for i in t.get("input_series", []) for s in [i.get("series", "")]) + for t in all_tests) + if not vanish_case: + bad.append(f"{alert} has no test where its metric goes stale (the re-fire case)") + return bad + + +for v in eval_alerts(rules): + check(False, f"(1,4) {v}") +for v in eval_tests(tests): + check(False, f"(2,3) {v}") +check(len(alert_rules(rules)) == 5, "(1) five alert rules parsed") + +# (5) the real harness, only when the rule engine is present. +if shutil.which("promtool"): + rc = subprocess.run(["bash", str(ROOT / "platform/monitoring/tests/run.sh"), + "platform/vuln-scanner/alerts.promtool-test.yaml"], + cwd=str(ROOT), capture_output=True, text=True) + check(rc.returncode == 0, f"(5) the promtool harness failed: rc={rc.returncode}\n{rc.stdout[-800:]}{rc.stderr[-800:]}") + +# (6) non-vacuity +m = copy.deepcopy(rules) +for g in m["spec"]["groups"]: + for r in g["rules"]: + if r["alert"] == "VulnTriageBotSilent": + r["expr"] = r["expr"].split(" or ")[0] +check(bool(eval_alerts(m)), "(6) evaluator missed: absent arm deleted") +m = copy.deepcopy(rules) +m["spec"]["groups"][0]["rules"].pop(1) +check(bool(eval_alerts(m)), "(6) evaluator missed: alert dropped") +m = copy.deepcopy(rules) +for g in m["spec"]["groups"]: + for r in g["rules"]: + r["annotations"].pop("runbook_url", None) +check(bool(eval_alerts(m)), "(6) evaluator missed: runbook_url removed") + +m = copy.deepcopy(tests) +for t in m["tests"]: + t["promql_expr_test"] = [p for p in t["promql_expr_test"] + if 'VulnCoverageGap' not in p.get("expr", "")] +check(bool(eval_tests(m)), "(6) evaluator missed: firing assertion removed") +m = copy.deepcopy(tests) +m["tests"] = [t for t in m["tests"] if "ABSENT" not in t.get("name", "")] +check(bool(eval_tests(m)), "(6) evaluator missed: absent-case tests removed") + +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/chainsaw-test.yaml new file mode 100644 index 0000000..1b54a80 --- /dev/null +++ b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/chainsaw-test.yaml @@ -0,0 +1,28 @@ +# ============================================================================= +# L0 STATIC — Prometheus receives aggregates only (R-VULN-06, docs/17 "Alerting"). +# Offline, no cluster. Depends on platform/vuln-scanner/src/metrics.py (the SCANNER's file). +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-metrics-no-per-cve-label + labels: + conformance.home.k3s/R-VULN-06: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-06" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + No vuln_scanner_* metric or alert rule carries a label other than state, severity or fixable, + so Prometheus can never hold a per-CVE, per-image or per-workload series. Proven by mutation. +spec: + steps: + - name: assert-metric-labels-are-aggregates + description: >- + Extract every vuln_scanner_* metric label from metrics.py and alerts.yaml, assert the + allowlist, and run the extractor on mutated text to prove it can fail. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh new file mode 100755 index 0000000..399b091 --- /dev/null +++ b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-metrics-no-per-cve-label +# Serves: R-VULN-06 ("Prometheus receives aggregates only ... MUST NOT receive a per-CVE series"). +# +# GIVEN platform/vuln-scanner/src/metrics.py and platform/vuln-scanner/alerts.yaml, +# THEN (1) every label name attached to a vuln_scanner_* metric is in the allowlist +# (state, severity, fixable); +# (2) metrics.py exposes every metric name from the contract; +# (3) alerts.yaml selects vuln_scanner_* metrics with no label matcher outside the allowlist; +# AND (4) the extractor is proven on mutated text: a `cve=` label, a `{image=...}` selector and a +# per-workload label are each flagged, and label-free text is not. +# A missing metrics.py is a FAIL: this is a whole-repo-scoped check and the file is part of the same +# delivery (the check has no skip path). +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import re, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-metrics-no-per-cve-label" +FAILURES, CHECKS = [], 0 +ALLOWED = {"state", "severity", "fixable"} +METRICS = ( + "vuln_scanner_report_generated_timestamp_seconds", + "vuln_scanner_last_complete_run_timestamp_seconds", + "vuln_scanner_database_updated_timestamp_seconds", + "vuln_scanner_targets", + "vuln_scanner_findings", + "vuln_scanner_failed_targets_over_24h", + "vuln_scanner_critical_fix_overdue", + "vuln_scanner_triage_heartbeat_timestamp_seconds", +) +# Label keys that could only carry per-CVE or per-target identity, anywhere a metric could be +# written. Catches label construction the name{...} scan cannot see (e.g. "cve_id": cve_id). +IDENTITY_LABEL = re.compile(r'''["'](cve|cve_id|advisory|package|pkg|image|digest|workload|namespace|pod|container|target|target_id)["']\s*[:=]''') + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +def label_names(text): + """Every label key in `vuln_scanner_x{key="value",...}` occurrences (Python or PromQL text), + tolerating f-strings' doubled braces and multi-line calls.""" + found = set() + for m in re.finditer(r"vuln_scanner_[a-z0-9_]+\{", text): + tail = text[m.end(): m.end() + 200] + close = tail.find("}\n") + chunk = tail[: close if close != -1 else None].split("\n", 1)[0] + for key in re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*[\"'{]", chunk): + found.add(key) + return found + + +METRICS_PY = ROOT / "platform" / "vuln-scanner" / "src" / "metrics.py" +ALERTS = ROOT / "platform" / "vuln-scanner" / "alerts.yaml" +if not METRICS_PY.is_file(): + print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/src/metrics.py is absent (the SCANNER's " + f"deliverable); the aggregate-only property cannot be asserted without it", file=sys.stderr) + raise SystemExit(1) +src = METRICS_PY.read_text() +check(all(m in src for m in METRICS), f"(2) metrics.py names every contract metric " + f"(missing: {[m for m in METRICS if m not in src]})") + +labels = label_names(src) +extra = labels - ALLOWED +check(not extra, f"(1) metrics.py attaches labels outside the allowlist: {sorted(extra)}") +hits = IDENTITY_LABEL.findall(src) +check(not hits, f"(1) metrics.py constructs an identity-bearing metric label: {sorted(set(hits))}") + +rules = yaml.safe_load(ALERTS.read_text()) +exprs = [r["expr"] for g in rules["spec"]["groups"] for r in g["rules"]] +check(len(exprs) >= 5, "(3) alerts.yaml parsed five or more rules") +alert_labels = set() +for e in exprs: + alert_labels |= label_names(e) +check(not (alert_labels - ALLOWED), + f"(3) alerts.yaml selects labels outside the allowlist: {sorted(alert_labels - ALLOWED)}") + +# (4) non-vacuity +negatives = { + "cve label": 'vuln_scanner_findings{severity="HIGH",cve="CVE-1"} 1\n', + "image selector": 'max(vuln_scanner_findings{image="docker.io/x"})\n', + "workload label": 'f"vuln_scanner_targets{{workload=\\"{w}\\",state=\\"x\\"}} 1"\n', +} +for name, text in negatives.items(): + check(bool(label_names(text) - ALLOWED), f"(4) the extractor missed: {name}") +ok_text = 'vuln_scanner_targets{state="scanned"} 3\nvuln_scanner_findings{severity="LOW",fixable="true"} 1\n' +check(not (label_names(ok_text) - ALLOWED), "(4) the extractor flagged the allowed labels") + +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l0-static/vuln-scanner-isolation/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-scanner-isolation/chainsaw-test.yaml new file mode 100644 index 0000000..850dc2c --- /dev/null +++ b/conformance/tests/l0-static/vuln-scanner-isolation/chainsaw-test.yaml @@ -0,0 +1,30 @@ +# ============================================================================= +# L0 STATIC — the scanner's Flux tier cannot stall the platform, and its network reach is exactly what +# docs/17 "Security of the scanner itself" allows (R-VULN-09). Offline, no cluster. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-scanner-isolation + labels: + conformance.home.k3s/R-VULN-09: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-09" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + The vuln-scanner Flux tier is wait:false, in neither gating tier, and nothing dependsOn it; + ingress is exactly the Gateway to 4180, the CI runner to 8081 and Prometheus to 9090; + egress reaches the API, DNS, the public internet on 443 only with cluster and LAN excluded, + and the Keycloak path the sidecar needs. Proven by mutation. +spec: + steps: + - name: assert-isolation-and-network-shape + description: >- + Parse the Flux wiring and networkpolicy.yaml, assert isolation and the exact network shape, + and run the evaluators on mutated copies to prove they can fail. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-scanner-isolation/check.sh b/conformance/tests/l0-static/vuln-scanner-isolation/check.sh new file mode 100755 index 0000000..ab51039 --- /dev/null +++ b/conformance/tests/l0-static/vuln-scanner-isolation/check.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-scanner-isolation +# Serves: R-VULN-09 (docs/17 "Security of the scanner itself"). +# +# GIVEN the repository as authored, +# THEN (1) the Flux tier is wait:false, dependsOn platform-config, in neither gating tier, nothing +# anywhere dependsOn it, and both overlays carry its SOPS decryption patch; +# (2) ingress is EXACTLY: reserved:ingress to 4180, act-runner to 8081, prometheus to 9090; +# (3) egress to the world is tcp/443 only through a CIDR rule excluding every private range, +# and the only other destinations are the API entity, the Gateway entity on 443 and Keycloak +# (the oauth2-proxy sidecar's OIDC path, the one deliberate addition to the spec's list); +# no `world`, `all`, `cluster`, `host` or `remote-node` entity; +# (4) the namespace ships a default-deny baseline and is not app-namespace=true (single owner); +# AND (5) each evaluator is run on a mutated copy and must flag it (non-vacuity). +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import copy, re, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-scanner-isolation" +FAILURES, CHECKS = [], 0 +FORBIDDEN_ENTITIES = {"world", "all", "cluster", "host", "remote-node"} +PRIVATE_RANGES = {"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +def docs(path): + return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)] + + +fk_path = ROOT / "clusters" / "base" / "vuln-scanner.yaml" +np_path = ROOT / "platform" / "vuln-scanner" / "networkpolicy.yaml" +for p in (fk_path, np_path): + if not p.is_file(): + print(f"CHECK FAIL ({NAME}): {p.relative_to(ROOT)} is absent", file=sys.stderr) + raise SystemExit(1) + +# (1) Flux wiring +fk = next(d for d in docs(fk_path) if d.get("kind") == "Kustomization") +check(fk["spec"].get("wait") is False, "(1) clusters/base/vuln-scanner.yaml must be wait: false") +check([d.get("name") for d in fk["spec"].get("dependsOn") or []] == ["platform-config"], + "(1) the tier dependsOn exactly platform-config") +check(fk["spec"].get("path") == "./platform/vuln-scanner", "(1) the tier reconciles ./platform/vuln-scanner") +check((fk["spec"].get("postBuild") or {}).get("substituteFrom"), + "(1) postBuild.substituteFrom is present (the route/alerts use ${BASE_DOMAIN})") +check("vuln-scanner.yaml" in (ROOT / "clusters" / "base" / "kustomization.yaml").read_text(), + "(1) clusters/base/kustomization.yaml lists vuln-scanner.yaml") +check("vuln-scanner" not in (ROOT / "platform" / "kustomization.yaml").read_text(), + "(1) platform/kustomization.yaml must not include vuln-scanner (the gating tier)") +for f in sorted((ROOT / "clusters").rglob("*.yaml")): + for d in docs(f): + for dep in (d.get("spec") or {}).get("dependsOn") or []: + check(not (isinstance(dep, dict) and dep.get("name") == "vuln-scanner"), + f"(1) {f.relative_to(ROOT)} {d.get('metadata', {}).get('name')} dependsOn vuln-scanner — " + "a broken scanner must never stall anything") +for env in ("dev", "prod"): + text = (ROOT / "clusters" / env / "kustomization.yaml").read_text() + check(re.search(r"name:\s*vuln-scanner\s*\n\s*namespace:\s*flux-system\s*\n\s*spec:\s*\n\s*decryption:", + text) is not None, + f"(1) clusters/{env}/kustomization.yaml carries the vuln-scanner SOPS decryption patch") + +# Network shape +np_docs = docs(np_path) +dd = [d for d in np_docs if d.get("kind") == "NetworkPolicy" and d["metadata"]["name"] == "default-deny"] +check(len(dd) == 1 and set(dd[0]["spec"]["policyTypes"]) == {"Ingress", "Egress"} and dd[0]["spec"].get("ingress") == [], + "(4) default-deny baseline denies both directions with no ingress") +ns = next(d for d in docs(ROOT / "platform" / "vuln-scanner" / "namespace.yaml") if d.get("kind") == "Namespace") +check("homelab.keithjackson.dev/app-namespace" not in (ns["metadata"].get("labels") or {}), + "(4) the namespace must not be app-namespace=true (the default-deny would be double-owned)") + + +def cnp(name): + return next((d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy" and d["metadata"]["name"] == name), None) + + +def ports_of(rule): + return {(p.get("port"), p.get("protocol", "TCP")) for tp in rule.get("toPorts") or [] for p in tp.get("ports") or []} + + +def eval_ingress(policy): + bad = [] + rules = policy["spec"].get("ingress") or [] + if len(rules) != 3: + bad.append(f"expected exactly 3 ingress rules, found {len(rules)}") + seen = set() + for r in rules: + ports = ports_of(r) + if r.get("fromEntities") == ["ingress"] and ports == {("4180", "TCP")}: + seen.add("gateway") + elif len(r.get("fromEndpoints") or []) == 1: + sel = r["fromEndpoints"][0].get("matchLabels") or {} + if (sel.get("io.kubernetes.pod.namespace") == "gitea-actions" + and sel.get("app.kubernetes.io/name") == "act-runner" and ports == {("8081", "TCP")}): + seen.add("ci") + elif (sel.get("io.kubernetes.pod.namespace") == "monitoring" + and sel.get("app.kubernetes.io/name") == "prometheus" and ports == {("9090", "TCP")}): + seen.add("prometheus") + else: + bad.append(f"unexpected ingress rule {r}") + else: + bad.append(f"unexpected ingress rule {r}") + missing = {"gateway", "ci", "prometheus"} - seen + if missing: + bad.append(f"ingress paths missing: {sorted(missing)}") + return bad + + +def eval_egress(policy): + bad = [] + rules = policy["spec"].get("egress") or [] + if len(rules) != 4: + bad.append(f"expected exactly 4 egress rules, found {len(rules)}") + world = 0 + for r in rules: + ents = set(r.get("toEntities") or []) + if ents & FORBIDDEN_ENTITIES: + bad.append(f"egress admits forbidden entity {sorted(ents & FORBIDDEN_ENTITIES)}") + if ents == {"kube-apiserver"} and not r.get("toPorts"): + continue + if ents == {"ingress"} and ports_of(r) == {("443", "TCP")}: + continue + if r.get("toEndpoints"): + sel = r["toEndpoints"][0].get("matchLabels") or {} + if not (len(r["toEndpoints"]) == 1 and sel.get("io.kubernetes.pod.namespace") == "keycloak" + and sel.get("app") == "keycloak"): + bad.append(f"egress to unexpected endpoints {r}") + continue + if r.get("toCIDRSet"): + world += 1 + sets = r["toCIDRSet"] + if len(sets) != 1 or sets[0].get("cidr") != "0.0.0.0/0": + bad.append(f"unexpected CIDR rule {r}") + continue + if not PRIVATE_RANGES.issubset(set(sets[0].get("except") or [])): + bad.append(f"the world CIDR rule does not exclude {sorted(PRIVATE_RANGES - set(sets[0].get('except') or []))}") + if ports_of(r) != {("443", "TCP")}: + bad.append(f"the world CIDR rule is not tcp/443 only ({sorted(ports_of(r))})") + continue + if r.get("toCIDR"): + bad.append(f"egress names a bare CIDR rule {r}") + continue + if not ents: + bad.append(f"unexpected egress rule {r}") + if world != 1: + bad.append(f"expected exactly one world CIDR rule, found {world}") + return bad + + +ing, eg = cnp("vuln-scanner-allow-ingress"), cnp("vuln-scanner-allow-egress") +check(ing is not None, "(2) the ingress CiliumNetworkPolicy exists") +check(eg is not None, "(3) the egress CiliumNetworkPolicy exists") +if ing is not None: + for v in eval_ingress(ing): + check(False, f"(2) {v}") +if eg is not None: + for v in eval_egress(eg): + check(False, f"(3) {v}") +check(len([d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy"]) == 2, + "(3) exactly two CiliumNetworkPolicies, so no other path is added silently") + +# (5) non-vacuity +if ing is not None and eg is not None: + def m_ing_open(p): p["spec"]["ingress"].append({"fromEntities": ["world"], "toPorts": [{"ports": [{"port": "8081", "protocol": "TCP"}]}]}) + def m_ing_ns(p): p["spec"]["ingress"][1]["fromEndpoints"][0]["matchLabels"]["io.kubernetes.pod.namespace"] = "default" + def m_ing_port(p): p["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"] = "8080" + for name, fn in (("extra world ingress", m_ing_open), ("CI rule moved to another namespace", m_ing_ns), + ("Gateway reaches the page port", m_ing_port)): + m = copy.deepcopy(ing) + fn(m) + check(bool(eval_ingress(m)), f"(5) ingress evaluator missed mutant: {name}") + def m_eg_world(p): p["spec"]["egress"].append({"toEntities": ["world"]}) + def m_eg_lan(p): + for r in p["spec"]["egress"]: + if r.get("toCIDRSet"): + r["toCIDRSet"][0]["except"] = ["10.0.0.0/8"] + def m_eg_port(p): + for r in p["spec"]["egress"]: + if r.get("toCIDRSet"): + r["toPorts"][0]["ports"].append({"port": "80", "protocol": "TCP"}) + def m_eg_endpoint(p): + for r in p["spec"]["egress"]: + if r.get("toEndpoints"): + r["toEndpoints"][0]["matchLabels"]["io.kubernetes.pod.namespace"] = "default" + for name, fn in (("world entity added", m_eg_world), ("LAN not excluded", m_eg_lan), + ("port 80 added", m_eg_port), ("endpoints moved", m_eg_endpoint)): + m = copy.deepcopy(eg) + fn(m) + check(bool(eval_egress(m)), f"(5) egress evaluator missed mutant: {name}") + +if CHECKS < 18: + FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated") +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l0-static/vuln-scanner-supply-chain/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-scanner-supply-chain/chainsaw-test.yaml new file mode 100644 index 0000000..692876b --- /dev/null +++ b/conformance/tests/l0-static/vuln-scanner-supply-chain/chainsaw-test.yaml @@ -0,0 +1,34 @@ +# ============================================================================= +# L0 STATIC — the CVE scanner's supply chain and privileges are what docs/17 "Security of the scanner +# itself" says (R-VULN-09). Offline, no cluster. +# +# WHY SCOPE, NOT REQUIREMENTS: the property spans platform/vuln-scanner and renovate.json5. A +# requirement-scoped selection would miss a Renovate edit that quietly lets the scanner auto-merge. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-scanner-supply-chain + labels: + conformance.home.k3s/R-VULN-09: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-09" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + The scanner image is pinned by digest and is not a banned Trivy version; Renovate never + auto-merges it and proposes it only after 7 days; its RBAC holds get/list/watch only and no + Secret; the pod is unprivileged with bounded resources and a size-limited cache. Each is + proven by mutation. +spec: + steps: + - name: assert-supply-chain-and-privileges + description: >- + Parse platform/vuln-scanner/deployment.yaml, rbac.yaml and renovate.json5, assert the + digest/version/Renovate/RBAC/security-context contract, and run each evaluator on mutated + copies to prove it can fail. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh b/conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh new file mode 100755 index 0000000..56320e8 --- /dev/null +++ b/conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-scanner-supply-chain +# Serves: R-VULN-09 (docs/17 "Security of the scanner itself"). +# +# GIVEN the repository as authored, +# THEN (1) the Trivy image is `ghcr.io/aquasecurity/trivy:@sha256:<64hex>` — by digest, and a +# tag so Renovate has something to follow; +# (2) that version is not 0.69.4/0.69.5/0.69.6 (the compromised March 2026 releases); +# (3) every pod image is digest-pinned except the oauth2-proxy sidecar, which follows the +# per-app sidecar pattern so one Renovate branch moves them all; +# (4) renovate.json5 keeps Trivy in the blast-radius list, automerge:false, minimumReleaseAge +# of at least 7 days, and no later rule re-enables automerge for it; +# (5) rbac.yaml: only ClusterRole/ClusterRoleBinding to the scanner SA, only get/list/watch, no +# Secret, no wildcard, and only the resources the inventory needs; +# (6) the pod: non-root, read-only rootfs, drop ALL, seccomp RuntimeDefault, no host +# namespaces or hostPath, CPU and memory limits on every container, an ephemeral-storage +# limit on the scanner, and a size-limited /cache emptyDir. +# AND (7) each evaluator is run on a mutated copy and must flag it (non-vacuity). +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import copy, json, re, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-scanner-supply-chain" +FAILURES, CHECKS = [], 0 +COMP = ROOT / "platform" / "vuln-scanner" +BANNED = {"0.69.4", "0.69.5", "0.69.6"} +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +ALLOWED_VERBS = {"get", "list", "watch"} +ALLOWED_RESOURCES = { + "": {"pods", "nodes"}, + "apps": {"replicasets", "deployments", "statefulsets", "daemonsets"}, + "batch": {"jobs", "cronjobs"}, + "kubevirt.io": {"virtualmachineinstances"}, +} + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +def docs(path): + return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)] + + +for f in ("deployment.yaml", "rbac.yaml"): + if not (COMP / f).is_file(): + print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/{f} is absent", file=sys.stderr) + raise SystemExit(1) + + +def split_ref(image): + # A digest suffix, then a tag after the last ':' (only when it holds no '/', so a registry port + # stays in the repository part). + ref, _, digest = image.partition("@") + head, colon, tail = ref.rpartition(":") + if colon and "/" not in tail: + return head, tail, digest or None + return ref, None, digest or None + + +def containers(pod): + return (pod.get("initContainers") or []) + (pod.get("containers") or []) + + +def eval_images(pod): + bad = [] + trivy = [c for c in containers(pod) if split_ref(c["image"])[0] == "ghcr.io/aquasecurity/trivy"] + if len(trivy) != 1: + bad.append(f"expected one Trivy image, found {len(trivy)}") + for c in containers(pod): + repo, tag, digest = split_ref(c["image"]) + if repo == "quay.io/oauth2-proxy/oauth2-proxy": + if not tag or tag == "latest": + bad.append("oauth2-proxy sidecar has no version tag") + continue + if not digest or not DIGEST.match(digest): + bad.append(f"{c['name']} image {c['image']} is not pinned by digest") + if not tag: + bad.append(f"{c['name']} image {c['image']} has no tag for Renovate to follow") + if repo == "ghcr.io/aquasecurity/trivy" and tag in BANNED: + bad.append(f"Trivy {tag} is a banned (compromised) version") + return bad + + +def strip_json5(text): + # Drop // comments outside strings, then trailing commas. + out, i, in_str, esc = [], 0, False, False + while i < len(text): + ch = text[i] + if in_str: + out.append(ch) + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + i += 1 + continue + if ch == '"': + in_str = True + out.append(ch) + i += 1 + continue + if text.startswith("//", i): + while i < len(text) and text[i] != "\n": + i += 1 + continue + out.append(ch) + i += 1 + return re.sub(r",(\s*[}\]])", r"\1", "".join(out)) + + +TRIVY_PKG = "ghcr.io/aquasecurity/trivy" +VERSION_UPDATE_RULE_RE = re.compile(r"^(\d+)\s*(day|days)$") + + +def eval_renovate(rules): + bad = [] + trivy_rules = [r for r in rules if TRIVY_PKG in (r.get("matchPackageNames") or [])] + if not trivy_rules: + bad.append("no packageRule matches ghcr.io/aquasecurity/trivy (blast-radius list)") + return bad + if not any(r.get("automerge") is False and "blast-radius" in (r.get("addLabels") or []) + for r in trivy_rules): + bad.append("Trivy is not in a blast-radius rule with automerge:false") + ages = [r["minimumReleaseAge"] for r in trivy_rules if "minimumReleaseAge" in r] + if not ages or not any(VERSION_UPDATE_RULE_RE.match(a) and int(VERSION_UPDATE_RULE_RE.match(a).group(1)) >= 7 + for a in ages): + bad.append(f"Trivy has no minimumReleaseAge of at least 7 days (found {ages})") + # Renovate applies packageRules in order, later wins; the last rule that names automerge for + # Trivy (or matches it generically by update type) must be false. + last = None + for r in rules: + if "automerge" in r and (TRIVY_PKG in (r.get("matchPackageNames") or []) + or set(r) <= {"description", "matchUpdateTypes", "automerge", "automergeType", "platformAutomerge"}): + last = r + if last is None or last.get("automerge") is not False: + bad.append("the last packageRule deciding automerge for Trivy leaves it true") + return bad + + +def eval_rbac(rbac): + bad = [] + croles = [d for d in rbac if d.get("kind") == "ClusterRole"] + if len(croles) != 1: + bad.append(f"expected one ClusterRole, found {len(croles)}") + return bad + for rule in croles[0].get("rules") or []: + verbs = set(rule.get("verbs") or []) + if verbs - ALLOWED_VERBS: + bad.append(f"rule grants non-read verbs {sorted(verbs - ALLOWED_VERBS)}") + if "*" in verbs: + bad.append("rule grants the verb wildcard") + for group in rule.get("apiGroups") or []: + if group == "*" or group not in ALLOWED_RESOURCES: + bad.append(f"rule names apiGroup {group!r}") + continue + for res in rule.get("resources") or []: + if res == "*" or res not in ALLOWED_RESOURCES[group]: + bad.append(f"rule grants resource {group}/{res}") + if "secrets" in (rule.get("resources") or []): + bad.append("rule grants Secret access") + kinds = {d.get("kind") for d in rbac} + if kinds - {"ServiceAccount", "ClusterRole", "ClusterRoleBinding"}: + bad.append(f"unexpected RBAC kinds {sorted(kinds)}") + for b in (d for d in rbac if d.get("kind") == "ClusterRoleBinding"): + if (b.get("roleRef") or {}).get("name") != croles[0]["metadata"]["name"]: + bad.append("the ClusterRoleBinding does not bind the scanner's ClusterRole") + return bad + + +def eval_pod(pod): + bad = [] + psc = pod.get("securityContext") or {} + if psc.get("runAsNonRoot") is not True: + bad.append("pod runAsNonRoot is not true") + if (psc.get("seccompProfile") or {}).get("type") != "RuntimeDefault": + bad.append("pod seccompProfile is not RuntimeDefault") + for f in ("hostNetwork", "hostPID", "hostIPC"): + if pod.get(f): + bad.append(f"pod sets {f}") + if any("hostPath" in v for v in pod.get("volumes") or []): + bad.append("pod mounts a hostPath volume") + for c in containers(pod): + sc = c.get("securityContext") or {} + if sc.get("readOnlyRootFilesystem") is not True: + bad.append(f"{c['name']} root filesystem is writable") + if sc.get("allowPrivilegeEscalation") is not False: + bad.append(f"{c['name']} allows privilege escalation") + if "ALL" not in ((sc.get("capabilities") or {}).get("drop") or []): + bad.append(f"{c['name']} does not drop ALL capabilities") + limits = (c.get("resources") or {}).get("limits") or {} + for lim in ("cpu", "memory"): + if lim not in limits: + bad.append(f"{c['name']} has no {lim} limit") + if c["name"] == "scanner" and "ephemeral-storage" not in limits: + bad.append("the scanner has no ephemeral-storage limit") + vols = {v["name"]: v for v in pod.get("volumes") or []} + if not (vols.get("cache", {}).get("emptyDir") or {}).get("sizeLimit"): + bad.append("the /cache emptyDir has no sizeLimit (Longhorn or etcd must never hold it)") + return bad + + +dep = next(d for d in docs(COMP / "deployment.yaml") if d.get("kind") == "Deployment") +pod = dep["spec"]["template"]["spec"] +for v in eval_images(pod) + eval_pod(pod): + check(False, f"(1-3,6) {v}") +check(dep["spec"].get("replicas") == 1, "(6) one replica (one RWO report store)") +check((dep["spec"].get("strategy") or {}).get("type") == "Recreate", "(6) Recreate strategy") + +renovate = json.loads(strip_json5((ROOT / "renovate.json5").read_text())) +rules = renovate.get("packageRules") or [] +check(len(rules) > 5, "(4) parsed renovate.json5 packageRules (parser degenerated)") +for v in eval_renovate(rules): + check(False, f"(4) {v}") + +rbac = docs(COMP / "rbac.yaml") +for v in eval_rbac(rbac): + check(False, f"(5) {v}") + +# (7) non-vacuity +def mutated(fn, base): + m = copy.deepcopy(base) + fn(m) + return m + +# Image mutants +def img_digest(p): p["initContainers"][0]["image"] = "ghcr.io/aquasecurity/trivy:0.76.1" +def img_banned(p): p["initContainers"][0]["image"] = re.sub(r":[0-9.]+@", ":0.69.5@", p["initContainers"][0]["image"]) +def img_nopin(p): p["containers"][0]["image"] = "docker.io/library/python:3.14.9-alpine" +for name, fn in (("digest stripped from Trivy", img_digest), ("banned Trivy version", img_banned), + ("python image unpinned", img_nopin)): + check(bool(eval_images(mutated(fn, pod))), f"(7) image evaluator missed mutant: {name}") + +# RBAC mutants +def rbac_secret(r): + role = next(d for d in r if d.get("kind") == "ClusterRole") + role["rules"][0]["resources"].append("secrets") +def rbac_write(r): + role = next(d for d in r if d.get("kind") == "ClusterRole") + role["rules"][0]["verbs"].append("delete") +def rbac_wild(r): + role = next(d for d in r if d.get("kind") == "ClusterRole") + role["rules"][1]["resources"] = ["*"] +for name, fn in (("Secret resource granted", rbac_secret), ("write verb granted", rbac_write), + ("resource wildcard granted", rbac_wild)): + check(bool(eval_rbac(mutated(fn, rbac))), f"(7) RBAC evaluator missed mutant: {name}") + +# Renovate mutants +def ren_auto(r): + for x in r: + if TRIVY_PKG in (x.get("matchPackageNames") or []): + x["automerge"] = True +def ren_age(r): + for x in r: + if TRIVY_PKG in (x.get("matchPackageNames") or []) and "minimumReleaseAge" in x: + x["minimumReleaseAge"] = "2 days" +def ren_drop(r): + r[:] = [x for x in r if TRIVY_PKG not in (x.get("matchPackageNames") or [])] +def ren_later(r): + r.append({"matchUpdateTypes": ["digest"], "automerge": True}) +for name, fn in (("Trivy automerge flipped true", ren_auto), ("minimumReleaseAge shortened", ren_age), + ("Trivy removed from Renovate rules", ren_drop), ("later auto-merge rule appended", ren_later)): + check(bool(eval_renovate(mutated(fn, rules))), f"(7) Renovate evaluator missed mutant: {name}") + +# Pod mutants +def pod_root(p): p["securityContext"]["runAsNonRoot"] = False +def pod_host(p): p["hostNetwork"] = True +def pod_rw(p): p["containers"][0]["securityContext"]["readOnlyRootFilesystem"] = False +def pod_nolim(p): del p["containers"][0]["resources"]["limits"]["memory"] +def pod_nocap(p): + for v in p["volumes"]: + if v["name"] == "cache": + v["emptyDir"].pop("sizeLimit", None) +for name, fn in (("runAsNonRoot off", pod_root), ("hostNetwork on", pod_host), ("rw rootfs", pod_rw), + ("no memory limit", pod_nolim), ("no cache sizeLimit", pod_nocap)): + check(bool(eval_pod(mutated(fn, pod))), f"(7) pod evaluator missed mutant: {name}") + +if CHECKS < 15: + FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated") +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/chainsaw-test.yaml new file mode 100644 index 0000000..a0f98eb --- /dev/null +++ b/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/chainsaw-test.yaml @@ -0,0 +1,31 @@ +# ============================================================================= +# L0 STATIC — the CVE triage bot workflow fails loudly and is shaped like Renovate's (R-VULN-05, +# R-VULN-07, docs/17 "The triage bot"). Offline, no cluster. Depends on .gitea/workflows/cve-triage.yaml +# (the BOT's file); fails until that file is integrated. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-triage-bot-workflow-guards + 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: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + The bot workflow runs on the 07:15 cron, has a workflow_dispatch dry-run input, never runs + concurrently with itself, and a step fails the job hard when RENOVATE_TOKEN is empty. + Proven by mutation. +spec: + steps: + - name: assert-workflow-guards + description: >- + Parse .gitea/workflows/cve-triage.yaml and assert the trigger, concurrency and hard + credential failure, then run the evaluator on mutated copies to prove it can fail. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/check.sh b/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/check.sh new file mode 100755 index 0000000..b126d26 --- /dev/null +++ b/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/check.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-triage-bot-workflow-guards +# Serves: R-VULN-05 (runs on a schedule, fails loudly), R-VULN-07 (bot conventions, docs/17). +# +# GIVEN .gitea/workflows/cve-triage.yaml, +# THEN (1) it runs on `15 7 * * *` and has a workflow_dispatch `dry_run` input; +# (2) concurrency is set (the workflow never runs against itself); +# (3) a step fails the job HARD when RENOVATE_TOKEN is empty (exit non-zero, not skip); +# AND (4) the evaluator is run on mutated copies and must flag each (non-vacuity). +# A missing workflow file is a FAIL: this is whole-repo scoped and has no skip path. +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import copy, re, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-triage-bot-workflow-guards" +FAILURES, CHECKS = [], 0 +WF = ROOT / ".gitea" / "workflows" / "cve-triage.yaml" + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +if not WF.is_file(): + print(f"CHECK FAIL ({NAME}): .gitea/workflows/cve-triage.yaml is absent (the BOT's deliverable); " + f"whether it fails on a missing credential cannot be asserted without it", file=sys.stderr) + raise SystemExit(1) +doc = yaml.safe_load(WF.read_text()) + + +def evaluate(doc): + bad = [] + triggers = doc.get(True) if True in doc else doc.get("on") # YAML reads `on:` as boolean True + if not isinstance(triggers, dict): + return ["no triggers parsed"] + if "15 7 * * *" not in [s.get("cron") for s in triggers.get("schedule") or [] if isinstance(s, dict)]: + bad.append("no schedule with cron 15 7 * * *") + inputs = ((triggers.get("workflow_dispatch") or {}).get("inputs") or {}) + if "dry_run" not in inputs: + bad.append("workflow_dispatch has no dry_run input") + conc = doc.get("concurrency") + jobs = doc.get("jobs") or {} + if not conc and not any(isinstance(j, dict) and j.get("concurrency") for j in jobs.values()): + bad.append("no concurrency set (the bot must not run against itself)") + hard_fail = False + for j in jobs.values(): + for step in (j.get("steps") or []) if isinstance(j, dict) else []: + run = str(step.get("run", "")) + if ("RENOVATE_TOKEN" in run and re.search(r"(-z|!\s*-n)[^\n]*RENOVATE_TOKEN|RENOVATE_TOKEN[^\n]*(-z)", run) + and re.search(r"exit\s+[1-9]", run)): + hard_fail = True + if not hard_fail: + bad.append("no step fails hard (exit non-zero) when RENOVATE_TOKEN is empty") + return bad + + +for v in evaluate(doc): + check(False, f"(1-3) {v}") +check(isinstance(doc.get("jobs"), dict) and doc["jobs"], "(1) the workflow declares jobs") + +# (4) non-vacuity +def mutate(fn): + m = copy.deepcopy(doc) + fn(m) + return m + +def trig(m): + return m.get(True) if True in m else m.get("on") + +def no_cron(m): trig(m).pop("schedule", None) +def no_dry(m): trig(m)["workflow_dispatch"] = {} +def no_conc(m): + m.pop("concurrency", None) + for j in m.get("jobs", {}).values(): + if isinstance(j, dict): + j.pop("concurrency", None) +def no_guard(m): + for j in m.get("jobs", {}).values(): + for step in j.get("steps") or []: + if "RENOVATE_TOKEN" in str(step.get("run", "")): + step["run"] = 'echo "::warning::RENOVATE_TOKEN unset, skipping"' +for name, fn in (("cron removed", no_cron), ("dry_run input removed", no_dry), + ("concurrency removed", no_conc), ("credential check softened", no_guard)): + check(bool(evaluate(mutate(fn))), f"(4) evaluator missed mutant: {name}") + +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l2-behavioral/vuln-internal-api-isolated/chainsaw-test.yaml b/conformance/tests/l2-behavioral/vuln-internal-api-isolated/chainsaw-test.yaml new file mode 100644 index 0000000..54aa269 --- /dev/null +++ b/conformance/tests/l2-behavioral/vuln-internal-api-isolated/chainsaw-test.yaml @@ -0,0 +1,35 @@ +# ============================================================================= +# L2 BEHAVIORAL SCAFFOLD — the internal API is unreachable from an unrelated namespace (R-VULN-09) +# Catalog: docs/17 "Acceptance gates" (L2). +# +# A pod in an unrelated namespace cannot reach vuln-scanner :8081 or :9090, while the CI runner identity can reach :8081. +# +# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset, +# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-internal-api-isolated + labels: + conformance.home.k3s/R-VULN-09: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-09" + conformance.home.k3s/layer: "L2" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/intent: >- + A pod in an unrelated namespace cannot reach vuln-scanner :8081 or :9090, while the CI runner identity can reach :8081. +spec: + steps: + - name: given-an-unrelated-namespace + try: + - script: + content: 'echo "TODO: create a throwaway namespace and probe pod"' + - name: when-connecting + try: + - script: + content: 'echo "TODO: attempt tcp to vuln-scanner:8081 and :9090"' + - name: then-denied + try: + - script: + content: 'echo "TODO: assert a timeout on both, and success from the act-runner identity on :8081"' diff --git a/conformance/tests/l2-behavioral/vuln-new-digest-reported/chainsaw-test.yaml b/conformance/tests/l2-behavioral/vuln-new-digest-reported/chainsaw-test.yaml new file mode 100644 index 0000000..1d0098b --- /dev/null +++ b/conformance/tests/l2-behavioral/vuln-new-digest-reported/chainsaw-test.yaml @@ -0,0 +1,36 @@ +# ============================================================================= +# L2 BEHAVIORAL SCAFFOLD — new workload digest appears in the report (R-VULN-01, R-VULN-02) +# Catalog: docs/17 "Acceptance gates" (L2). +# +# A newly started workload's digest appears in report.json, mapped to that workload, within the 2-hour window. +# +# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset, +# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-new-digest-reported + labels: + conformance.home.k3s/R-VULN-01: "true" + conformance.home.k3s/R-VULN-02: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-01, R-VULN-02" + conformance.home.k3s/layer: "L2" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/intent: >- + A newly started workload's digest appears in report.json, mapped to that workload, within the 2-hour window. +spec: + steps: + - name: given-a-new-workload + try: + - script: + content: 'echo "TODO: start a pod from an image digest not yet in the report"' + - name: when-the-scanner-ticks + try: + - script: + content: 'echo "TODO: wait, bounded by the 2h SLO, polling GET /report.json from the CI runner identity"' + - name: then-digest-is-reported + try: + - script: + content: 'echo "TODO: assert a target with that digest and the workload mapping, state not pending past 2h"' diff --git a/conformance/tests/l2-behavioral/vuln-page-requires-admin/chainsaw-test.yaml b/conformance/tests/l2-behavioral/vuln-page-requires-admin/chainsaw-test.yaml new file mode 100644 index 0000000..be80fc4 --- /dev/null +++ b/conformance/tests/l2-behavioral/vuln-page-requires-admin/chainsaw-test.yaml @@ -0,0 +1,39 @@ +# ============================================================================= +# L2 BEHAVIORAL SCAFFOLD — the CVE page requires a platform-admins login (R-VULN-04) +# Catalog: docs/17 "Acceptance gates" (L2). +# +# An unauthenticated request to cve. is redirected to Keycloak, and a logged-in user outside platform-admins gets 403. +# +# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset, +# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-page-requires-admin + labels: + conformance.home.k3s/R-VULN-04: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-04" + conformance.home.k3s/layer: "L2" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/intent: >- + An unauthenticated request to cve. is redirected to Keycloak, and a logged-in user outside platform-admins gets 403. +spec: + steps: + - name: given-the-page-route + try: + - script: + content: 'echo "TODO: resolve cve. through the Gateway"' + - name: when-unauthenticated + try: + - script: + content: 'echo "TODO: request the page with no session"' + - name: then-redirected-to-keycloak + try: + - script: + content: 'echo "TODO: assert a 302 to the homelab realm"' + - name: then-non-admin-forbidden + try: + - script: + content: 'echo "TODO: assert 403 for a session outside platform-admins"' diff --git a/conformance/tests/l3-fault-injection/vuln-registry-failure/chainsaw-test.yaml b/conformance/tests/l3-fault-injection/vuln-registry-failure/chainsaw-test.yaml new file mode 100644 index 0000000..067f799 --- /dev/null +++ b/conformance/tests/l3-fault-injection/vuln-registry-failure/chainsaw-test.yaml @@ -0,0 +1,40 @@ +# ============================================================================= +# L3 FAULT-INJECTION SCAFFOLD — breaking registry access produces failed targets and the coverage alert (R-VULN-03, R-VULN-06) +# Catalog: docs/17 "Acceptance gates" (L3). +# +# With registry access broken for one image, that target becomes failed with its reason, prior findings are retained, and VulnCoverageGap fires after 24h. +# +# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset, +# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-registry-failure + labels: + conformance.home.k3s/R-VULN-03: "true" + conformance.home.k3s/R-VULN-06: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-03, R-VULN-06" + conformance.home.k3s/layer: "L3" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/intent: >- + With registry access broken for one image, that target becomes failed with its reason, prior findings are retained, and VulnCoverageGap fires after 24h. +spec: + steps: + - name: given-a-scanned-target + try: + - script: + content: 'echo "TODO: record a scanned target and its findings"' + - name: when-registry-access-is-broken + try: + - script: + content: 'echo "TODO: inject a fault blocking that registry"' + - name: then-target-failed + try: + - script: + content: 'echo "TODO: assert state failed, reason present, findings retained, run continues"' + - name: then-coverage-alert-fires + try: + - script: + content: 'echo "TODO: assert VulnCoverageGap fires once the failure passes 24h"' diff --git a/platform/cloudflared/config.yaml b/platform/cloudflared/config.yaml index 0334c96..10ea8bf 100644 --- a/platform/cloudflared/config.yaml +++ b/platform/cloudflared/config.yaml @@ -54,6 +54,10 @@ ingress: # tunnel alongside decision 4. - hostname: headlamp.${BASE_DOMAIN} service: http_status:404 + # The CVE page (docs/17: internal only, a list of what is exploitable where). Its route is disowned + # from public DNS (platform/vuln-scanner/httproute.yaml); this is the second layer. + - hostname: cve.${BASE_DOMAIN} + service: http_status:404 # Non-HTTP L4 VIPs (their own LoadBalancer addresses, not the Gateway). Listed so a DNS flip # that CNAMEs them at the tunnel by mistake gets a 404, not the Gateway's catch-all. - hostname: unifi.${BASE_DOMAIN} diff --git a/platform/cloudflared/networkpolicy.yaml b/platform/cloudflared/networkpolicy.yaml index a4297fd..1c7ebf2 100644 --- a/platform/cloudflared/networkpolicy.yaml +++ b/platform/cloudflared/networkpolicy.yaml @@ -147,3 +147,4 @@ spec: - radarr - sonarr - headlamp + - vuln-scanner diff --git a/platform/homepage/configmap.yaml b/platform/homepage/configmap.yaml index 809e517..4459815 100644 --- a/platform/homepage/configmap.yaml +++ b/platform/homepage/configmap.yaml @@ -43,6 +43,13 @@ data: href: https://grafana.${BASE_DOMAIN} description: Metrics, logs & dashboards (fronts Prometheus/Loki/Alertmanager) icon: mdi-chart-areaspline + # The CVE page (platform/vuln-scanner, docs/17): behind SSO, restricted to platform-admins. + # Homepage has no per-group surfaces, so this tile is shown to everyone who can open this + # dashboard; the page itself enforces platform-admins. + - CVE Report: + href: https://cve.${BASE_DOMAIN} + description: Container image CVE inventory (platform-admins) + icon: mdi-shield-bug - Semaphore: href: https://semaphore.${BASE_DOMAIN} description: Ansible automation & CI diff --git a/platform/vuln-scanner/README.md b/platform/vuln-scanner/README.md new file mode 100644 index 0000000..5547af6 --- /dev/null +++ b/platform/vuln-scanner/README.md @@ -0,0 +1,80 @@ +# vuln-scanner — scheduled CVE visibility for what is running + +Answers three questions with no manual step: what is running, what is it vulnerable to, and can the +answer be trusted. Contract: spec `docs/17-vulnerability-visibility.md`, ADR-0027 (R-VULN-01..09). +Hand-authored platform tooling (not a catalog service), reconciled by its own non-gating Flux tier +`clusters/base/vuln-scanner.yaml`. Operator runbook: `runbooks/vuln-scanner.md`. + +| Piece | Where | +|---|---| +| Scanner source (`python3`, stdlib only), mounted from a ConfigMap at `/app` | `src/` | +| Accepted risk (R-VULN-08) | `acceptances.yaml` | +| Pod, security context, sidecar | `deployment.yaml` | +| Read-only identity (R-VULN-09) | `rbac.yaml` | +| Network: Gateway→4180, CI→8081, Prometheus→9090; API, DNS, public 443 | `networkpolicy.yaml` | +| Aggregate metrics and the five alerts | `servicemonitor.yaml`, `alerts.yaml`, `alerts.promtool-test.yaml` | + +The page is `cve.`, internal only, gated by the per-pod oauth2-proxy sidecar to `platform-admins`. +The route is disowned from public DNS and the tunnel 404s it; PiHole answers it on the LAN. + +## Operator step: add the Keycloak client (NOT done by the change that added this tree) + +This change adds everything around the Keycloak client but not the client. Adding it needs a new key in +`platform/keycloak/keycloak-realm-clients.sops.yaml`, an encrypted file an agent must not decrypt; the +L0 check `keycloak-client-secret-reconciler` then requires the client and that key to appear in the +same change. Until this is done the page cannot complete a login (`invalid_client`, fail-closed); the +scanner, its metrics and its alerts are unaffected. + +In one PR (the realm import does not re-run on its own for a NEW client): + +1. Generate one value and set it in both encrypted files, through pipes so it is never echoed: + + ```bash + ./infra/devcontainer/dev.sh run bash -c ' + cs=$(openssl rand -hex 32) + sops set platform/keycloak/keycloak-realm-clients.sops.yaml \ + "[\"stringData\"][\"vuln-scanner-client-secret\"]" "\"$cs\"" + sops set platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml \ + "[\"stringData\"][\"client-secret\"]" "\"$cs\""' + ``` + +2. Add to `platform/keycloak/keycloak-realm-import.yaml`, beside the `crafty` client: + + ```yaml + # spec.placeholders + VULN_SCANNER_CLIENT_SECRET: + secret: + name: keycloak-realm-clients + key: vuln-scanner-client-secret + # spec.realm.clients + - clientId: vuln-scanner + name: CVE report (oauth2-proxy) + protocol: openid-connect + enabled: true + publicClient: false + standardFlowEnabled: true + directAccessGrantsEnabled: false + secret: "$(env:VULN_SCANNER_CLIENT_SECRET)" + redirectUris: + - https://cve.${BASE_DOMAIN}/oauth2/callback + webOrigins: + - https://cve.${BASE_DOMAIN} + defaultClientScopes: [email, profile, roles] + protocolMappers: + - name: groups + protocol: openid-connect + protocolMapper: oidc-group-membership-mapper + config: + "full.path": "false" + "id.token.claim": "true" + "access.token.claim": "true" + "userinfo.token.claim": "true" + "claim.name": groups + ``` + +3. Regenerate the derived URL file: `python3 ci/render-keycloak-client-urls.py --write`. Commit all + three, gate, merge. + +4. After merge, the client must also exist in the live realm (a completed realm import does not re-run, + per `platform/keycloak/README.md`, so create it once with `kcadm` or re-trigger the import). The + client-secret reconciler then converges its secret and URLs. diff --git a/platform/vuln-scanner/acceptances.yaml b/platform/vuln-scanner/acceptances.yaml new file mode 100644 index 0000000..ec82b58 --- /dev/null +++ b/platform/vuln-scanner/acceptances.yaml @@ -0,0 +1,15 @@ +# Accepted CVE risk (R-VULN-08). A list; this file is the ONLY place risk can be accepted, and it reaches +# the scanner through the merge gate (a ConfigMap generated by kustomization.yaml). +# +# Each entry: +# - id: CVE-2026-00000 # the CVE or advisory id, exactly as the scanner reports it +# image: registry/repository # exact image match; omit only for a platform-wide acceptance +# reason: not reachable, feature disabled # mandatory, a real sentence +# expires: 2027-02-01 # mandatory YYYY-MM-DD, at most 180 days out +# +# An accepted finding stays in the listing with its reason and expiry and stops counting toward the +# critical-fix alert. When the expiry passes it reverts to open automatically. An entry with no reason +# or no expiry, or an expiry more than 180 days out or malformed, is rejected by the merge gate +# (conformance/tests/l0-static/vuln-acceptances-schema) and ignored at runtime with the run marked +# degraded, so a finding is never hidden by a bad entry. +[] diff --git a/platform/vuln-scanner/alerts.promtool-test.yaml b/platform/vuln-scanner/alerts.promtool-test.yaml new file mode 100644 index 0000000..464e25c --- /dev/null +++ b/platform/vuln-scanner/alerts.promtool-test.yaml @@ -0,0 +1,167 @@ +--- +# promtool unit tests for platform/vuln-scanner/alerts.yaml (R-VULN-06). +# +# Found by platform/monitoring/tests/run.sh through the COMPANION convention. Run just this file with +# platform/monitoring/tests/run.sh platform/vuln-scanner/alerts.promtool-test.yaml +# +# Each rule is pinned both ways. VulnScanStale and VulnTriageBotSilent additionally pin the two cases +# the spec names by hand: the metric ABSENT must FIRE, and a metric that vanishes must RE-FIRE. +# conformance/tests/l0-static/vuln-alerts-unit-tested reads this file and fails if any of those cases +# is removed. +# +# Timestamps: promtool evaluates from t=0, so a timestamp series can equal its own evaluation time +# with `0+60x` at a 1m interval (time() - value == 0: fresh). +rule_files: + - alerts.rules.yaml +evaluation_interval: 1m + +tests: + - interval: 1m + name: healthy - fresh run, fresh database, heartbeat, zero backlog, nothing fires + input_series: + - series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}' + values: '0+60x60' + - series: 'vuln_scanner_database_updated_timestamp_seconds{namespace="vuln-scanner"}' + values: '0+60x60' + - series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}' + values: '0+60x60' + - series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}' + values: '0x60' + - series: 'vuln_scanner_critical_fix_overdue{namespace="vuln-scanner"}' + values: '0x60' + promql_expr_test: + - expr: 'count(ALERTS{alertname=~"Vuln.*"})' + eval_time: 50m + exp_samples: [] + + - interval: 1m + name: scan stale - last complete run older than 26h fires after the for window + input_series: + - series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}' + values: '0x1600' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}' + eval_time: 1559m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}' + eval_time: 1590m + exp_samples: + - labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: scan stale ABSENT - metric never existed, VulnScanStale still fires + input_series: + # A healthy, unrelated metric proves this is the absent() arm, not a broken test file. + - series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}' + values: '0x60' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}' + eval_time: 20m + exp_samples: + - labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: scan metric VANISHES - a scanner that dies re-fires instead of latching clear + input_series: + - series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}' + values: '0+60x20 stale' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}' + eval_time: 18m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}' + eval_time: 40m + exp_samples: + - labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: database stale - older than 48h fires after the for window + input_series: + - series: 'vuln_scanner_database_updated_timestamp_seconds{namespace="vuln-scanner"}' + values: '0x2960' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing"}' + eval_time: 2870m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing"}' + eval_time: 2900m + exp_samples: + - labels: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: coverage gap - a failed target over 24h fires, zero is quiet + input_series: + - series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}' + values: '0x20 3x40' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing"}' + eval_time: 25m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing"}' + eval_time: 40m + exp_samples: + - labels: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: critical fix overdue - fires, one grouped alert instance + input_series: + - series: 'vuln_scanner_critical_fix_overdue{namespace="vuln-scanner"}' + values: '0x20 12x40' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"}' + eval_time: 25m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"}' + eval_time: 40m + exp_samples: + - labels: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing", severity="warning"}' + value: 1 + - expr: 'count(ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"})' + eval_time: 40m + exp_samples: + - labels: '{}' + value: 1 + + - interval: 1m + name: triage bot silent - last heartbeat older than 26h fires after the for window + input_series: + - series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}' + values: '0x1600' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}' + eval_time: 1559m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}' + eval_time: 1590m + exp_samples: + - labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: triage bot silent ABSENT - no heartbeat metric ever, VulnTriageBotSilent still fires + input_series: + - series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}' + values: '0x60' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}' + eval_time: 20m + exp_samples: + - labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: triage heartbeat VANISHES - re-fires instead of latching clear + input_series: + - series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}' + values: '0+60x20 stale' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}' + eval_time: 40m + exp_samples: + - labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}' + value: 1 diff --git a/platform/vuln-scanner/alerts.yaml b/platform/vuln-scanner/alerts.yaml new file mode 100644 index 0000000..0beec9f --- /dev/null +++ b/platform/vuln-scanner/alerts.yaml @@ -0,0 +1,96 @@ +--- +# Alert rules for the vulnerability scanner (R-VULN-06, docs/17 "Alerting"). +# +# ONE ALERT, ONE GROUPED EMAIL PER CONDITION, never one per CVE. The scanner's metrics are aggregates +# with no per-CVE, per-image or per-workload label; `max()` strips scrape labels, so each rule yields a +# single alert instance, and Alertmanager's `group_by: [alertname, namespace]` route delivers one email. +# +# THE TWO RULES WHOSE ABSENT-METRIC CASE IS LOAD-BEARING: VulnScanStale and VulnTriageBotSilent. Their +# metrics are absent until the first complete run / first bot heartbeat, and absent when the scanner +# or the scrape is dead; both are `or absent(...)` so a scanner that has never run, or an unscraped +# one, fires instead of reading as healthy. Pinned by alerts.promtool-test.yaml in both directions. +# VulnCriticalFixAvailable is expected to fire on first deployment (the backlog made visible). +# +# Severity follows the platform-health convention: warning (a condition to act on, not a page). +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + groups: + - name: platform-health.vulnerability-scanner + rules: + - alert: VulnScanStale + # No complete run within 26 hours, or the metric is absent. 15m rides out a pod restart. + expr: >- + (time() - max(vuln_scanner_last_complete_run_timestamp_seconds) > 93600) + or absent(vuln_scanner_last_complete_run_timestamp_seconds) + for: 15m + labels: + severity: warning + annotations: + summary: No complete vulnerability scan in over 26 hours + description: >- + The scanner has not completed a run within 26 hours, or no scan metric exists at all + (the scanner or its scrape is down). Findings on the CVE page are ageing. + runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner" + + - alert: VulnDatabaseStale + # Vulnerability database older than 48 hours (docs/17): the run is degraded, not a success. + expr: time() - max(vuln_scanner_database_updated_timestamp_seconds) > 172800 + for: 15m + labels: + severity: warning + annotations: + summary: Vulnerability database is older than 48 hours + description: >- + The Trivy vulnerability database has not been refreshed in over 48 hours, so scans + evaluate images against stale advisories and the run reports as degraded. + runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner" + + - alert: VulnCoverageGap + # A target has been `failed` for more than 24 hours. credential-required targets are not + # `failed` and do not count, so a private registry cannot hold this alert firing. + expr: max(vuln_scanner_failed_targets_over_24h) > 0 + for: 15m + labels: + severity: warning + annotations: + summary: "{{ $value }} image(s) have failed scanning for over 24 hours" + description: >- + One or more running images have failed scanning for more than 24 hours, so what is + reported for them is out of date. The CVE page lists each target and the reason. + runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner" + + - alert: VulnCriticalFixAvailable + # An unaccepted CRITICAL with a fix, first seen over 72 hours ago. The grace period gives + # Renovate's daily cycle time to act, so this marks what automation has not resolved. + expr: max(vuln_scanner_critical_fix_overdue) > 0 + for: 15m + labels: + severity: warning + annotations: + summary: "{{ $value }} CRITICAL finding(s) with a fix available for over 72 hours" + description: >- + One or more unaccepted CRITICAL vulnerabilities have had a fix available for more than + 72 hours. The CVE page has the details; this message deliberately names none of them. + runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner" + + - alert: VulnTriageBotSilent + # No bot heartbeat for 26 hours, or the metric is absent (the bot has never run, or the + # scanner or its scrape is down). + expr: >- + (time() - max(vuln_scanner_triage_heartbeat_timestamp_seconds) > 93600) + or absent(vuln_scanner_triage_heartbeat_timestamp_seconds) + for: 15m + labels: + severity: warning + annotations: + summary: CVE triage bot has not reported in over 26 hours + description: >- + The scheduled CVE triage bot has not posted a heartbeat in over 26 hours (or never + has), so remediation states are stale and no approvals are being requested. + runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner" diff --git a/platform/vuln-scanner/deployment.yaml b/platform/vuln-scanner/deployment.yaml new file mode 100644 index 0000000..a394837 --- /dev/null +++ b/platform/vuln-scanner/deployment.yaml @@ -0,0 +1,279 @@ +--- +# The vulnerability scanner (R-VULN-01..09, docs/17). One pod: +# init trivy-tools copies the Trivy binary and CA bundle from the digest-pinned Trivy image into +# an emptyDir, so the running container has a single python interpreter + one +# static Go binary and nothing else from that image. +# main scanner python3 stdlib only (no pip), source from a ConfigMap at /app: inventory, +# sequential scans, report, the page on 127.0.0.1:8080, the internal API on +# :8081, aggregates on :9090. +# sidecar oauth2-proxy the only path to the page, restricted to platform-admins (R-IDP-06, docs/17 +# "Access"), reached from the shared Gateway on :4180. +# +# ONE REPLICA, Recreate: one scanner on one RWO report store; two would double-scan and contend for +# the volume. +# +# SOFT affinity toward k3s-prod-2: prod-1 is the node whose etcd I/O starved historically, and scans +# are CPU- and I/O-heavy. Soft on purpose: a scanner that cannot be placed must still run elsewhere. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + replicas: 1 + strategy: + type: Recreate + revisionHistoryLimit: 3 + selector: + matchLabels: + app.kubernetes.io/name: vuln-scanner + template: + metadata: + labels: + app.kubernetes.io/name: vuln-scanner + # The direct egress path; never the VPN gateway (docs/17). + homelab.keithjackson.dev/egress-class: internet + # Per-pod oauth2-proxy gate in front of the page. This namespace is not app-namespace=true, + # so the auth-mode/egress-class policies do not select it; the labels are kept so the pod is + # compliant by construction if that ever changes. + homelab.keithjackson.dev/auth-mode: forward-auth + spec: + serviceAccountName: vuln-scanner + # The scanner reads the Kubernetes API with this token (read-only, rbac.yaml). + automountServiceAccountToken: true + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + # Longhorn honours fsGroup on an RWO volume; this makes /data and the emptyDirs writable. + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + preference: + matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: + - k3s-prod-2 + initContainers: + - name: trivy-tools + # TRIVY 0.76.1 — pinned by digest (R-VULN-09). Not 0.69.4/5/6 (compromised release, March + # 2026, CVE-2026-33634). Tag digest and config created timestamp read from the registry on + # 2026-11-09: sha256:790fd66d…6c06c, created 2026-10-21T11:16:20Z (19 days old, i.e. past + # the 7-day minimum release age; 0.76.2 was published 2026-11-05 and is too new). + # SIGNATURE VERIFIED 2026-11-09 with cosign v3.1.3, keyless: + # cosign verify ghcr.io/aquasecurity/trivy@sha256:790fd66d... \ + # --certificate-identity-regexp 'https://github\.com/aquasecurity/trivy/\.github/workflows/.+' \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com + # Renovate: blast-radius component, never auto-merges, 7-day minimum release age + # (renovate.json5); this `tag@digest` form is what the `kubernetes` manager tracks. + # The image's ENTRYPOINT is `trivy`, overridden below. /bin/cp (busybox) and + # /etc/ssl/certs/ca-certificates.crt confirmed present by listing the layers via the + # registry API (no image run). + image: ghcr.io/aquasecurity/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c + imagePullPolicy: IfNotPresent + command: + - /bin/cp + - /usr/local/bin/trivy + - /etc/ssl/certs/ca-certificates.crt + - /tools/ + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 10m + memory: 16Mi + ephemeral-storage: 256Mi + limits: + cpu: 200m + memory: 64Mi + ephemeral-storage: 512Mi + volumeMounts: + - name: tools + mountPath: /tools + containers: + # ─────────────────────────────── scanner ────────────────────────────────────────────── + - name: scanner + # python 3.14 on alpine by digest, matching the interpreter the scanner's tests ran on. + # 3.14.9-alpine, config created 2026-10-21T23:01:37Z, read from the registry on 2026-11-09 + # (19 days old; 3.14.10-alpine, the current 3.14-alpine, was built 2026-11-05 and is under + # the 7-day age applied here by hand). Tracked by the `kubernetes` Renovate manager. + # Runs as 65532 from this pod's securityContext; the image's default is root, overridden. + image: docker.io/library/python:3.14.9-alpine@sha256:dbeb11d16bb95c3a7e3c25c842cb9f20cbc8c18f8be0461a4a0dcc861ad386a0 + imagePullPolicy: IfNotPresent + command: + - python3 + - /app/main.py + env: + - name: VULN_DATA_DIR + value: /data + - name: VULN_CACHE_DIR + value: /cache + # Scan caches and temp extraction live on the size-limited emptyDir, never on the + # replicated volume or etcd (docs/17 "Load"). + - name: TMPDIR + value: /cache + - name: HOME + value: /cache + - name: VULN_TOOLS_DIR + value: /tools + - name: TRIVY_BIN + value: /tools/trivy + - name: SSL_CERT_FILE + value: /tools/ca-certificates.crt + - name: VULN_ACCEPTANCES + value: /config/acceptances.yaml + - name: PYTHONUNBUFFERED + value: "1" + - name: PYTHONDONTWRITEBYTECODE + value: "1" + ports: + - { name: internal, containerPort: 8081, protocol: TCP } + - { name: metrics, containerPort: 9090, protocol: TCP } + # Sized to trivy, the only heavy thing here: scans are sequential, the limit is the peak of + # one large image; the emptyDir sizeLimit bounds disk. + resources: + requests: + cpu: 100m + memory: 256Mi + ephemeral-storage: 256Mi + limits: + cpu: "1" + memory: 2Gi + ephemeral-storage: 12Gi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + # /healthz on the metrics port is answered by the HTTP server itself and must not depend + # on scan state: a long scan or a failed one is reported, never a reason to restart. + startupProbe: + httpGet: + path: /healthz + port: metrics + periodSeconds: 5 + failureThreshold: 24 + livenessProbe: + httpGet: + path: /healthz + port: metrics + periodSeconds: 20 + failureThreshold: 6 + readinessProbe: + httpGet: + path: /healthz + port: metrics + periodSeconds: 15 + volumeMounts: + - name: src + mountPath: /app + readOnly: true + - name: acceptances + mountPath: /config + readOnly: true + - name: tools + mountPath: /tools + readOnly: true + - name: data + mountPath: /data + - name: cache + mountPath: /cache + # ─────────────────────────── oauth2-proxy reverse-proxy sidecar ─────────────────────── + - name: oauth2-proxy + # The per-app sidecar pattern (platform/crafty, catalog/render.py): same tag as the + # renderer's OAUTH2_PROXY_IMAGE so a Renovate bump lands every sidecar in one branch. + image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6 + imagePullPolicy: IfNotPresent + args: + - --http-address=0.0.0.0:4180 + - --reverse-proxy=true + - --provider=oidc + - --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab + # The page, bound to loopback in the scanner container; no Service reaches it. + - --upstream=http://127.0.0.1:8080 + - --email-domain=* + - --scope=openid email profile + - --oidc-groups-claim=groups + # R-IDP-06, docs/17 "Access": a valid session is not enough, the user must be a + # platform-admin, because this page is the list of what is exploitable where. + - --allowed-group=platform-admins + - --skip-provider-button=true + - --redirect-url=https://cve.${BASE_DOMAIN}/oauth2/callback + # Unique name + host-scoped domain: the shared forward-auth proxy and the other sidecars + # hold different cookie-secrets, so a shared cookie name would evict sessions + # (oauth2-proxy-cookie-names-distinct). + - --cookie-name=_oauth2_proxy_vuln_scanner + - --cookie-domain=cve.${BASE_DOMAIN} + - --whitelist-domain=.${BASE_DOMAIN} + - --cookie-secure=true + - --set-xauthrequest=true + env: + # client-secret MUST equal the Keycloak `vuln-scanner` client's secret (key + # vuln-scanner-client-secret in platform/keycloak/keycloak-realm-clients.sops.yaml; see + # README.md, Operator step). Secrets by name only (R-IDP-04). + - name: OAUTH2_PROXY_CLIENT_ID + valueFrom: + secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-id } + - name: OAUTH2_PROXY_CLIENT_SECRET + valueFrom: + secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-secret } + - name: OAUTH2_PROXY_COOKIE_SECRET + valueFrom: + secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: cookie-secret } + ports: + - { name: http, containerPort: 4180, protocol: TCP } + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + livenessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + volumes: + - name: src + configMap: + name: vuln-scanner-src + - name: acceptances + configMap: + name: vuln-scanner-acceptances + - name: tools + emptyDir: + sizeLimit: 512Mi + - name: data + persistentVolumeClaim: + claimName: vuln-scanner-data + # Trivy DB + per-image scan cache + TMPDIR (trivy extracts layers here). Size-limited; never + # on a replicated volume or in etcd (docs/17 "Load"). A full run cleans it between images. + - name: cache + emptyDir: + sizeLimit: 10Gi diff --git a/platform/vuln-scanner/httproute.yaml b/platform/vuln-scanner/httproute.yaml new file mode 100644 index 0000000..5e1425a --- /dev/null +++ b/platform/vuln-scanner/httproute.yaml @@ -0,0 +1,34 @@ +--- +# The CVE page at cve.. Internal exposure only (docs/17 "Access"): a list of what is exploitable +# where is sensitive, so this hostname publishes NO public DNS record (the `controller: none` annotation +# below disowns it from external-dns) and the Cloudflare Tunnel 404s it (platform/cloudflared/config.yaml). +# It resolves on the LAN through PiHole's explicit list (catalog/pihole.yaml). +# +# The backend is the `vuln-scanner` Service's `http` port, which fronts the oauth2-proxy sidecar, so the +# SSO gate is in the data path; nothing routes to the page (:8080) directly. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner + annotations: + homelab.keithjackson.dev/auth-mode: oauth2-proxy + homelab.keithjackson.dev/auth-groups: platform-admins + external-dns.alpha.kubernetes.io/controller: "none" +spec: + parentRefs: + - name: shared + namespace: gateway + sectionName: https + hostnames: + - cve.${BASE_DOMAIN} + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: vuln-scanner + port: 4180 diff --git a/platform/vuln-scanner/kustomization.yaml b/platform/vuln-scanner/kustomization.yaml new file mode 100644 index 0000000..3b115dd --- /dev/null +++ b/platform/vuln-scanner/kustomization.yaml @@ -0,0 +1,50 @@ +--- +# The CVE visibility scanner (docs/17, ADR-0027). Reconciled by its OWN non-gating Flux Kustomization +# (clusters/base/vuln-scanner.yaml: dependsOn platform-config, wait:false). A scanner that is failing, +# or a pod that cannot get its report store, is a thing to alert on, never a thing that stalls the +# platform. NOT a line in platform/kustomization.yaml. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# Required for the configMapGenerator (see platform/cloudflared/kustomization.yaml). +namespace: vuln-scanner +resources: + - namespace.yaml + - rbac.yaml + - pvc.yaml + # The per-pod oauth2-proxy credential. SOPS-encrypted, decrypted per-env by the vuln-scanner + # Kustomization (both overlays patch it). Referenced by name only (R-IDP-04). Its template sibling + # is deliberately not named here (no-plaintext-secrets). + - vuln-scanner-oauth2-proxy-credentials.sops.yaml + - deployment.yaml + - service.yaml + # Namespace default-deny, the three ingress paths and the egress allowances. One file, so a baseline + # without its allows cannot ship alone and empty the TSDB. + - networkpolicy.yaml + - httproute.yaml + - servicemonitor.yaml + - alerts.yaml + +# Scanner source and the risk-acceptance file, both as content-hashed ConfigMaps, so a change rolls +# the pod. No `disableNameSuffixHash`: the hash is the rollout mechanism. +configMapGenerator: + - name: vuln-scanner-src + files: + - src/main.py + - src/inventory.py + - src/trivy.py + - src/state.py + - src/report.py + - src/acceptances.py + - src/page.py + - src/metrics.py + - src/server.py + - src/report.schema.json + options: + labels: + app.kubernetes.io/name: vuln-scanner + - name: vuln-scanner-acceptances + files: + - acceptances.yaml + options: + labels: + app.kubernetes.io/name: vuln-scanner diff --git a/platform/vuln-scanner/namespace.yaml b/platform/vuln-scanner/namespace.yaml new file mode 100644 index 0000000..67c9d52 --- /dev/null +++ b/platform/vuln-scanner/namespace.yaml @@ -0,0 +1,23 @@ +--- +# vuln-scanner — the CVE visibility scanner (R-VULN-01..09, ADR-0027, docs/17). Hand-authored platform +# delivery tooling, not a catalog service: its cluster-wide read identity is something a +# ServiceDefinition cannot express. +# +# gateway-access=shared opts this namespace in to attach the cve. HTTPRoute to the shared +# Gateway; the route targets the per-pod oauth2-proxy sidecar (:4180), never the page directly. +# +# INTENTIONALLY NOT homelab.keithjackson.dev/app-namespace=true (the crafty/cloudflared/pm-agent +# posture): every Kyverno app policy matches on that label, and the generated default-deny would then +# be double-owned with the one this tree ships (networkpolicy.yaml), the 2026-09-30 drift loop. The +# pod still carries the egress-class and auth-mode labels those policies would require. +# +# PodSecurity `restricted` IS enforced: nothing here needs any privilege. +apiVersion: v1 +kind: Namespace +metadata: + name: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner + gateway-access: "shared" + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: latest diff --git a/platform/vuln-scanner/networkpolicy.yaml b/platform/vuln-scanner/networkpolicy.yaml new file mode 100644 index 0000000..b84be85 --- /dev/null +++ b/platform/vuln-scanner/networkpolicy.yaml @@ -0,0 +1,119 @@ +--- +# Default-deny baseline (R-NET-01): deny all ingress and egress, allow only DNS. A platform namespace, +# so Kyverno does not generate this and Flux is its only writer (namespace.yaml). +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: vuln-scanner + labels: + homelab.keithjackson.dev/baseline: default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +# INGRESS — exactly three paths (docs/17 "Security of the scanner itself"), one client each: +# :4180 from the shared Gateway only (the oauth2-proxy sidecar). The page itself (:8080, loopback) +# has no Service port and no rule. +# :8081 from the CI runner only. Job containers run inside the DinD sidecar of the act-runner pod in +# namespace gitea-actions, so what the network sees is THAT pod's identity (verified live +# 2026-11-09: pods carry app.kubernetes.io/name=act-runner). No wider than the runner pods. +# :9090 from Prometheus only (verified live: app.kubernetes.io/name=prometheus in monitoring). +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: vuln-scanner-allow-ingress + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: vuln-scanner + ingress: + - fromEntities: + - ingress + toPorts: + - ports: + - { port: "4180", protocol: TCP } + - fromEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: gitea-actions + app.kubernetes.io/name: act-runner + toPorts: + - ports: + - { port: "8081", protocol: TCP } + - fromEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: monitoring + app.kubernetes.io/name: prometheus + toPorts: + - ports: + - { port: "9090", protocol: TCP } +--- +# EGRESS (DNS is in the baseline above). +# * kube-apiserver: the inventory. +# * tcp/443 to the public internet for the vulnerability database and registries, OUT THE DIRECT +# PATH (egress-class=internet, deployment.yaml; never the VPN gateway). Written as a CIDR rule +# rather than `toEntities: world` because the spec excludes cluster and LAN ranges and `world` +# includes every LAN address: RFC1918 + CGNAT + loopback + link-local + multicast are subtracted. +# Consequence, recorded rather than worked around: the Gitea registry resolves to the LAN VIP, so +# images hosted there are unreachable from here and report `failed` with that reason, until a +# registry credential and a narrower LAN rule are decided (docs/17, "Private registry coverage"). +# * The two Keycloak rules below are the ONE addition beyond the spec's list, and they are required: +# the oauth2-proxy sidecar shares this pod's network identity with the scanner, and it must +# reach Keycloak for OIDC discovery and the token exchange. keycloak. resolves in-cluster +# to the Gateway VIP (split-horizon CoreDNS), which Cilium hands to the Gateway's Envoy +# (reserved:ingress, tcp/443), and Envoy then re-evaluates THIS pod's egress against Keycloak's +# real backend identity (enforce_policy_on_l7lb), the same pair catalog/render.py gives every SSO +# app (buildkit/networkpolicy.yaml has the measurement). Without them login 403s. +# * NOT listed: anything else in-cluster, any node entity, the LAN, the VPN. +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: vuln-scanner-allow-egress + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: vuln-scanner + egress: + - toEntities: + - kube-apiserver + - toCIDRSet: + - cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 100.64.0.0/10 + - 127.0.0.0/8 + - 169.254.0.0/16 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 224.0.0.0/4 + toPorts: + - ports: + - { port: "443", protocol: TCP } + - toEntities: + - ingress + toPorts: + - ports: + - { port: "443", protocol: TCP } + - toEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: keycloak + app: keycloak diff --git a/platform/vuln-scanner/pvc.yaml b/platform/vuln-scanner/pvc.yaml new file mode 100644 index 0000000..7fa9c34 --- /dev/null +++ b/platform/vuln-scanner/pvc.yaml @@ -0,0 +1,19 @@ +--- +# The report store: state.json (firstSeenAt, failed-since, heartbeat, last complete run) and +# report.json. 1Gi on the replicated tier is generous for two JSON files; it is DELIBERATELY NOT where +# the vulnerability database or the per-image scan caches live, because etcd and Longhorn share a +# partition on every node (docs/02, docs/17 "Load"). Those are on the size-limited /cache emptyDir. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vuln-scanner-data + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi diff --git a/platform/vuln-scanner/rbac.yaml b/platform/vuln-scanner/rbac.yaml new file mode 100644 index 0000000..86d72a2 --- /dev/null +++ b/platform/vuln-scanner/rbac.yaml @@ -0,0 +1,50 @@ +--- +# Scanner identity (R-VULN-09). The whole of its Kubernetes reach is get/list/watch on the objects it +# needs to answer "what is running, and which workload owns it". NO Secret, NO write verb, no +# wildcard. conformance/tests/l0-static/vuln-scanner-supply-chain fails the gate if that changes. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: vuln-scanner-read + labels: + app.kubernetes.io/name: vuln-scanner +rules: + # Running images, their digests, and the nodes a pod sits on (nodes are inventoried as not-covered). + - apiGroups: [""] + resources: [pods, nodes] + verbs: [get, list, watch] + # The owner chain Pod -> ReplicaSet -> Deployment, and the other two top-level controllers. + - apiGroups: [apps] + resources: [replicasets, deployments, statefulsets, daemonsets] + verbs: [get, list, watch] + # Pod -> Job -> CronJob, and CronJob templates with no running pod. + - apiGroups: [batch] + resources: [jobs, cronjobs] + verbs: [get, list, watch] + # KubeVirt VMs are inventoried as not-covered. + - apiGroups: [kubevirt.io] + resources: [virtualmachineinstances] + verbs: [get, list, watch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: vuln-scanner-read + labels: + app.kubernetes.io/name: vuln-scanner +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: vuln-scanner-read +subjects: + - kind: ServiceAccount + name: vuln-scanner + namespace: vuln-scanner diff --git a/platform/vuln-scanner/service.yaml b/platform/vuln-scanner/service.yaml new file mode 100644 index 0000000..44f86f4 --- /dev/null +++ b/platform/vuln-scanner/service.yaml @@ -0,0 +1,33 @@ +--- +# One Service, three named ports (the page gate, the internal API and metrics). +# +# clusterIP IS PINNED ON PURPOSE (10.43.210.10, verified unused against the live Service list on +# 2026-11-09, inside k3s' default 10.43.0.0/16 Service CIDR). The triage bot runs as a Gitea Actions +# job inside the runner's DinD daemon, and whether in-cluster DNS resolves from a DinD job container +# is UNVERIFIED. The pinned address is the bot's fallback when the Service name does not resolve: +# `http://10.43.210.10:8081` (VULN_API_FALLBACK_URL in .gitea/workflows/cve-triage.yaml). +apiVersion: v1 +kind: Service +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + type: ClusterIP + clusterIP: 10.43.210.10 + selector: + app.kubernetes.io/name: vuln-scanner + ports: + - name: http + port: 4180 + targetPort: http + protocol: TCP + - name: internal + port: 8081 + targetPort: internal + protocol: TCP + - name: metrics + port: 9090 + targetPort: metrics + protocol: TCP diff --git a/platform/vuln-scanner/servicemonitor.yaml b/platform/vuln-scanner/servicemonitor.yaml new file mode 100644 index 0000000..499033a --- /dev/null +++ b/platform/vuln-scanner/servicemonitor.yaml @@ -0,0 +1,20 @@ +--- +# Prometheus scrape of the aggregate-only /metrics (:9090). Only the `metrics` port of the shared +# Service; networkpolicy.yaml admits only Prometheus on it. kube-prometheus-stack selects +# ServiceMonitors from any namespace; the `release` label is kept for consistency. +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner + release: kube-prometheus-stack +spec: + selector: + matchLabels: + app.kubernetes.io/name: vuln-scanner + endpoints: + - port: metrics + path: /metrics + interval: 60s diff --git a/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example b/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example new file mode 100644 index 0000000..ff03df3 --- /dev/null +++ b/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example @@ -0,0 +1,28 @@ +# ============================================================================= +# TEMPLATE, NOT APPLIED, NOT ENCRYPTED. The OIDC client and cookie secrets for the per-pod oauth2-proxy +# sidecar gating the CVE page (deployment.yaml, container oauth2-proxy). The sidecar reads these three +# keys by name only (R-IDP-04). +# +# The `client-secret` value MUST equal `vuln-scanner-client-secret` in +# platform/keycloak/keycloak-realm-clients.sops.yaml (a cross-namespace mirror, like crafty). See +# platform/vuln-scanner/README.md, "Operator step". Never decrypt this to a terminal or a log. +# +# Regenerate (encrypts to the repo's public recipient via .sops.yaml, values never echoed): +# ./infra/devcontainer/dev.sh run bash -c ' +# cs=$(openssl rand -hex 32); ck=$(openssl rand -base64 32 | tr -- "+/" "-_") +# sops -e --input-type yaml --output-type yaml \ +# --filename-override platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml \ +# /dev/stdin < platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml +# ...this document with client-secret: $cs and cookie-secret: $ck... +# YAML' +# ============================================================================= +apiVersion: v1 +kind: Secret +metadata: + name: vuln-scanner-oauth2-proxy + namespace: vuln-scanner +type: Opaque +stringData: + client-id: vuln-scanner + client-secret: REPLACE-ME + cookie-secret: REPLACE-ME diff --git a/renovate.json5 b/renovate.json5 index b611c2d..98554a3 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -146,7 +146,9 @@ "/(^|/)versions\\.env$/", "/(^|/)platform/pm-agent/.+\\.ya?ml$/", // ADDED 2026-11-08: the merge gate's job `container:` is the toolbox, by digest. - "/^\\.gitea/workflows/gate\\.ya?ml$/" + "/^\\.gitea/workflows/gate\\.ya?ml$/", + // ADDED 2026-11-09: the CVE triage bot's job `container:` is pinned by digest with the marker. + "/^\\.gitea/workflows/cve-triage\\.ya?ml$/" ], "matchStrings": [ "#\\s*renovate-digest:\\s*image=(?\\S+)\\s+tag=(?\\S+)\\s*\\n[^\\n]*?(?sha256:[a-f0-9]{64})" @@ -277,7 +279,12 @@ // never propose gitea/act_runner -> gitea/runner — so that migration is a manual PR. Listed // now so the new name is blast-radius from its first reference, not after an auto-merge. "gitea/runner", - "ghcr.io/cloudnative-pg/postgresql" + "ghcr.io/cloudnative-pg/postgresql", + // ADDED 2026-11-09 (R-VULN-09, ADR-0027). The CVE scanner reads every image in the cluster + // and Trivy's release pipeline was compromised in March 2026 (CVE-2026-33634), so it is + // blast-radius: a human reads the changelog. The 7-day minimum release age is the separate + // rule below; this entry is what keeps it out of auto-merge. + "ghcr.io/aquasecurity/trivy" ], "automerge": false, "addLabels": [ @@ -285,6 +292,20 @@ "blast-radius" ] }, + // ADDED 2026-11-09 — MINIMUM RELEASE AGE FOR THE CVE SCANNER (R-VULN-09, docs/17 "Security of the + // scanner itself"). An upstream release that later turns out to be compromised (as 0.69.4/5/6 were + // in March 2026) gets a week to be detected before Renovate will propose it. Applied to the + // image the scanner's Deployment pins, tracked by the `kubernetes` manager (platform/ is in its + // managerFilePatterns; the `tag@digest` form is what it updates), and matched here by package + // name. automerge:false again here so this rule cannot lose to a later one. + // The Renovate validator was NOT run on this addition: contract forbids running images locally. + { + "description": "CVE scanner image (Trivy): 7-day minimum release age, never auto-merged (R-VULN-09). A minimumReleaseAge the datasource cannot date holds the update back rather than letting it through.", + "matchPackageNames": ["ghcr.io/aquasecurity/trivy"], + "automerge": false, + "minimumReleaseAge": "7 days", + "addLabels": ["needs-review", "blast-radius"] + }, // ADDED 2026-10-30 — THE KEYCLOAK OPERATOR IS THE KEYCLOAK VERSION. The operator, its CRDs // and the server image are released in lockstep from keycloak/keycloak-k8s-resources, and the // Keycloak CR carries no spec.image, so the operator's RELATED_IMAGE_KEYCLOAK picks the server diff --git a/runbooks/README.md b/runbooks/README.md index b29a80b..b6775d7 100644 --- a/runbooks/README.md +++ b/runbooks/README.md @@ -87,6 +87,8 @@ Each affected runbook says so on its own page and gives the by-hand equivalent. ## The runbooks ### Nodes +- **`vuln-scanner`** — the CVE page and the five scan alerts (`VulnScanStale`, `VulnDatabaseStale`, + `VulnCoverageGap`, `VulnCriticalFixAvailable`, `VulnTriageBotSilent`), and what each one means here. - **`node-down`** — a node's node-exporter is unscrapable; the node may be gone, or its kubelet may have stopped posting status while the host stays up (the 2026-09-07 shape). - **`node-io`** — disk I/O pressure/starvation. The etcd WAL shares a partition with Longhorn replicas; diff --git a/runbooks/kustomization.yaml b/runbooks/kustomization.yaml index b955213..ca8b771 100644 --- a/runbooks/kustomization.yaml +++ b/runbooks/kustomization.yaml @@ -49,6 +49,7 @@ configMapGenerator: files: # The index, served at /runbooks/ (nginx `try_files /runbooks/README`). - README=README.md + - vuln-scanner=vuln-scanner.md - backup-failure=backup-failure.md - backup-missed=backup-missed.md - backup-partial=backup-partial.md diff --git a/runbooks/vuln-scanner.md b/runbooks/vuln-scanner.md new file mode 100644 index 0000000..fd2663d --- /dev/null +++ b/runbooks/vuln-scanner.md @@ -0,0 +1,75 @@ +# vuln-scanner — the CVE page and the five scan alerts + +Alerts that link here: **`VulnScanStale`**, **`VulnDatabaseStale`**, **`VulnCoverageGap`**, +**`VulnCriticalFixAvailable`** and **`VulnTriageBotSilent`**. + +Component: `platform/vuln-scanner/` (Flux Kustomization `vuln-scanner`, `wait: false`, nothing depends on +it). Design record: spec `docs/17-vulnerability-visibility.md`, ADR-0027. The page is +`https://cve.`, internal only, behind SSO, restricted to `platform-admins`. + +Every mutating step below is an **operator** step (CLAUDE.md §5). + +## What this means here + +The scanner runs on a schedule and needs nobody to start, resume or complete a cycle. The bot is a +scheduled Gitea Actions workflow (`.gitea/workflows/cve-triage.yaml`). Each alert is one grouped email +for one condition and never names a CVE, image or workload; the page has the detail. + +| Alert | Fires when | What it means | +|---|---|---| +| `VulnScanStale` | No complete run for 26 hours, or the metric is absent | Scanning has stopped, the scanner is down, or its scrape is broken. **It fires from first deployment until the first complete run** (a full run covers about 100 images, sequentially). | +| `VulnDatabaseStale` | Trivy DB older than 48 hours | `trivy --download-db-only` is failing; the run is degraded, not a success. | +| `VulnCoverageGap` | Any target `failed` for more than 24 hours | A registry unreachable or a scan keeps failing. `credential-required` targets do not count. | +| `VulnCriticalFixAvailable` | Unaccepted CRITICAL with a fix, first seen more than 72 hours ago | The backlog automation has not resolved. **Expected on first deployment.** | +| `VulnTriageBotSilent` | No heartbeat for 26 hours, or the metric is absent | The bot has not reported, so remediation states and Renovate approvals are stale. | + +A failing scanner is contained by design: its Kustomization is `wait: false` and nothing depends on it, so +none of these alerts mean anything else on the platform is affected. + +## First commands + +All cluster access is read-only and goes through the pinned toolbox, always naming the context: + +```bash +./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n vuln-scanner get pods -o wide' +./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n vuln-scanner logs deploy/vuln-scanner -c scanner --tail=100' +./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n vuln-scanner exec deploy/vuln-scanner -c scanner -- python3 -c "import urllib.request as u; print(u.urlopen(\"http://127.0.0.1:9090/metrics\").read().decode())"' +``` + +The metrics are the quickest read: `vuln_scanner_targets{state}` gives the per-state counts, and +`vuln_scanner_last_complete_run_timestamp_seconds` the last complete run. Then open +`https://cve.`, Inventory view, and sort by state to see which targets, and why. + +For the bot, read the most recent run of the `CVE triage` workflow in Gitea Actions and its job +summary. + +## Likely causes, most likely first + +**`VulnScanStale`** — (1) first deployment: wait for the first complete run; (2) pod not Running: see the +Pod's events and the `longhorn` PVC attachment; (3) metrics not scraped: `up{namespace="vuln-scanner"}` +and `platform/vuln-scanner/networkpolicy.yaml`'s Prometheus rule; (4) the scanner logged a run failure. + +**`VulnDatabaseStale`** — egress to `ghcr.io:443` is blocked (the egress CIDR rule, or the registry is +down) or GitHub's rate limit; the log has the `trivy` error. + +**`VulnCoverageGap`** — a registry is down or a tag was deleted. The page shows each failed target's reason. +A target whose registry refuses anonymous pulls is `credential-required` and does not fire this. + +**`VulnCriticalFixAvailable`** — expected once on first deployment. Otherwise a Renovate update is not +landing: see the bot's dashboard issue and Renovate's dashboard. To accept a risk instead, add an entry to +`platform/vuln-scanner/acceptances.yaml` (reason plus an expiry at most 180 days out) through a normal PR. + +**`VulnTriageBotSilent`** — (1) `RENOVATE_TOKEN` is missing or the workflow is disabled; (2) the bot +cannot reach the scanner's internal API (`gitea-actions` namespace -> `vuln-scanner:8081`, +`networkpolicy.yaml`; the bot falls back to the pinned ClusterIP when in-cluster DNS is unavailable to a +DinD job container); (3) the scanner is down too: `VulnScanStale` will also be firing. + +## When to escalate or stop + +- If the pod is crash-looping, read the scanner's logs before changing anything; a crash loop here is + contained and is not urgent for the rest of the platform. +- Do **not** weaken `networkpolicy.yaml` or `rbac.yaml` to make a scan pass. The scanner must never read + Secrets, hold a write verb, or reach the LAN. Those are the requirement (R-VULN-09), not an oversight. +- Do not edit `platform/vuln-scanner/alerts.yaml` thresholds to silence an alert; the spec fixes them. +- A persistent `VulnCriticalFixAvailable` for a component that cannot move is what the risk-acceptance file + is for, not silencing. -- 2.54.0 From d4bea8a78c17d06fb51c40cb61edff32b86b5d9d Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 9 Nov 2026 09:43:12 -0600 Subject: [PATCH 05/10] feat(vuln): encrypted oauth2-proxy credential for the CVE page Generated through pipes and encrypted to the repo's public age recipient; values never echoed. Co-Authored-By: Claude Opus 5.5 --- platform/vuln-scanner/README.md | 2 +- ...scanner-oauth2-proxy-credentials.sops.yaml | 30 +++++++++++++++++++ ...oauth2-proxy-credentials.sops.yaml.example | 3 +- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml diff --git a/platform/vuln-scanner/README.md b/platform/vuln-scanner/README.md index 5547af6..b5f8b1e 100644 --- a/platform/vuln-scanner/README.md +++ b/platform/vuln-scanner/README.md @@ -31,7 +31,7 @@ In one PR (the realm import does not re-run on its own for a NEW client): ```bash ./infra/devcontainer/dev.sh run bash -c ' - cs=$(openssl rand -hex 32) + cs=$(python3 -c "import secrets; print(secrets.token_hex(32))") sops set platform/keycloak/keycloak-realm-clients.sops.yaml \ "[\"stringData\"][\"vuln-scanner-client-secret\"]" "\"$cs\"" sops set platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml \ diff --git a/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml b/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml new file mode 100644 index 0000000..731d484 --- /dev/null +++ b/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vuln-scanner-oauth2-proxy + namespace: vuln-scanner +type: Opaque +stringData: + client-id: ENC[AES256_GCM,data:9n9fMQyIq+JmfgXy,iv:8FLkOTc6Rmck33ecd4A3DXP2CiGrAkxMJ8Q7nmI9naE=,tag:4wb8rAXx+KAIhWAviEKTJQ==,type:str] + client-secret: ENC[AES256_GCM,data:JQCSBroPwd1KxZhdOw9zYkA9krYdK79QpnP/WW3ZIYVoyvHGVKJ2OVPpvgtWy20Ex0yWCTtmpghn87r+4Kkrfw==,iv:RFuDweYB7z5fykB/Ued2Z7MdO9VVY2XoO4gwekxMo0U=,tag:UseTecg5dJMNR1SN6ZUZ7g==,type:str] + cookie-secret: ENC[AES256_GCM,data:F7McZBTr10fnpTEz8cYjXsvrwRgqiKrlcaL/mSZKjOjuPfaSvq4sV3YBuQc=,iv:2vDEqmUHFFpUsRkBSqj3X92eT2bP8VTj2BiAr/8+7ak=,tag:rdHVky9JmlcEEhsxV6OtnA==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGdGErRzEvQWNTcUdYanhG + RGVaYi9CdDNvYVdhL2ViOWI4Z3k0M2pxb0V3ClgzOFNPRXdyZ1pVZTFKMVEyTGVV + bkxQWDdFZUtvME1jZm9VUXBHaVpHM3cKLS0tIDhodHlQVUNhNlpiVFAzU1JZL2d3 + cUhWcGNGSThYTkRaV0NSOUZrM2ZmS1UKjFe6iGia0+QOZgWcSFoH0LLEp1f2M2ds + V8bIBIFTtRDTKp6kgt0nee+Sgb7g5D5NIGTrRDGE256I8jdiNDcMpw== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-11-09T15:43:11Z" + mac: ENC[AES256_GCM,data:jXBQdruY8yLD4k3iRHqn78Pq0iXzETDk/S8qyXSVFHsacqUvmtzdOw6ajJDg7hAkVGN9PXqH+lsz45wR3a3YVODbQZJyS0+cKuQ1cVWlV/wcWOlNSdeKSLRe8h658KjXGUPQubaDFB4rloBfUr5ebuGQGr4ZEj7g241xjrsjBic=,iv:asoMnTjsIIb1AgrgE9dERixbpw94TpQWCPRwAT3mXYM=,tag:uwCVmjDr3gDQAqWsmiF0tA==,type:str] + pgp: [] + encrypted_regex: ^(data|stringData)$ + version: 3.9.4 diff --git a/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example b/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example index ff03df3..4edf210 100644 --- a/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example +++ b/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example @@ -9,7 +9,8 @@ # # Regenerate (encrypts to the repo's public recipient via .sops.yaml, values never echoed): # ./infra/devcontainer/dev.sh run bash -c ' -# cs=$(openssl rand -hex 32); ck=$(openssl rand -base64 32 | tr -- "+/" "-_") +# cs=$(python3 -c "import secrets; print(secrets.token_hex(32))") +# ck=$(python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())") # sops -e --input-type yaml --output-type yaml \ # --filename-override platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml \ # /dev/stdin < platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml -- 2.54.0 From d06dff9a1959c059ae26765136e7f5ce560c1761 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 9 Nov 2026 10:29:05 -0600 Subject: [PATCH 06/10] fix(vuln): no-per-cve-label check also matches escaped-quote label forms Co-Authored-By: Claude Opus 5.5 --- .../tests/l0-static/vuln-metrics-no-per-cve-label/check.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh index 399b091..009dcab 100755 --- a/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh +++ b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh @@ -55,7 +55,7 @@ def label_names(text): tail = text[m.end(): m.end() + 200] close = tail.find("}\n") chunk = tail[: close if close != -1 else None].split("\n", 1)[0] - for key in re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*[\"'{]", chunk): + for key in re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*\\?[\"'{]", chunk): found.add(key) return found -- 2.54.0 From 4c82c64b2f70733b147b305ce4a972a015b5d36e Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 9 Nov 2026 10:30:43 -0600 Subject: [PATCH 07/10] docs(vuln): list the CVE triage workflow in the workflows README Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md index ebc2955..e497ce9 100644 --- a/.gitea/workflows/README.md +++ b/.gitea/workflows/README.md @@ -164,11 +164,14 @@ Cost: the job shares the single act_runner slot with the gate, but the build its .gitea/workflows/ merge-gate.yaml the pipeline (lint → l0 → l1 → conformance → decision) images.yaml build/verify/publish this repo's container images (see above) + cve-triage.yaml scheduled CVE triage bot (R-VULN-05/07, ADR-0027; runs before Renovate) scripts/ install-tools.sh installs the pinned tools per stage (from ci/versions.env) — used by the dormant merge-gate.yaml blueprint only; gate.yml runs in the toolbox image decide.sh the R-TEST-03 merge decision over job results images.sh the image pipeline's driver (what to build, base, push, digest) + cve_triage.py the CVE triage bot: reads the scanner report, maintains the counts-only + CVE Dashboard issue, ticks Renovate approval rows (cve-triage.yaml) README.md this file ``` -- 2.54.0 From 1763e27859ad602562bc8b804c532298ff694d2d Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 9 Nov 2026 12:28:28 -0600 Subject: [PATCH 08/10] fix(vuln): metric label extractor handles escaped quotes in f-strings Co-Authored-By: Claude Opus 5.5 --- .../tests/l0-static/vuln-metrics-no-per-cve-label/check.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh index 399b091..009dcab 100755 --- a/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh +++ b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh @@ -55,7 +55,7 @@ def label_names(text): tail = text[m.end(): m.end() + 200] close = tail.find("}\n") chunk = tail[: close if close != -1 else None].split("\n", 1)[0] - for key in re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*[\"'{]", chunk): + for key in re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*\\?[\"'{]", chunk): found.add(key) return found -- 2.54.0 From d8b25a67feeea46f3e1311d4e8fb15aedaaad8e8 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 9 Nov 2026 12:28:29 -0600 Subject: [PATCH 09/10] fix(vuln): survive OOM kills, isolate bad upstream strings, label findings per line Review findings on the integrated scanner: - An OOM kill no longer wedges the loop: inFlight is persisted before each scan, and on restart that image is marked failed and sits out full runs for 20h. - One bad upstream string can no longer freeze the report: safe_url requires an exact lowercase scheme, acceptance reasons are bounded, /triage urls use the same rule, and report.build repairs or drops any entry the schema would refuse. lastCompleteRunAt advances only after a report is actually published. - The private registry reports credential-required, not failed: VULN_PRIVATE_REGISTRIES names it, and Trivy is not invoked. - loadError clears after a full run against a good inventory. - Every image under platform/vuln-scanner stays out of Renovate auto-merge. - The page labels status and target state per line, and mixed acceptance reads as mixed. The Keycloak client is still an operator action: its key must go in an existing encrypted file that an agent must not modify. platform/vuln-scanner/README.md has the exact steps. Co-Authored-By: Claude Opus 5.5 --- .../vuln-scanner-supply-chain/check.sh | 53 +++++ .../vuln-scanner-acceptances/check.py | 9 + .../vuln-scanner-api-metrics/check.py | 4 + .../l1-rendering/vuln-scanner-page/check.py | 33 ++++ .../vuln-scanner-scan-loop/check.py | 182 +++++++++++++++++- platform/vuln-scanner/README.md | 59 ++++-- platform/vuln-scanner/deployment.yaml | 8 +- platform/vuln-scanner/src/acceptances.py | 3 + platform/vuln-scanner/src/main.py | 67 ++++++- platform/vuln-scanner/src/page.py | 47 +++-- platform/vuln-scanner/src/report.py | 90 ++++++++- platform/vuln-scanner/src/server.py | 4 +- platform/vuln-scanner/src/state.py | 4 + platform/vuln-scanner/src/trivy.py | 27 ++- renovate.json5 | 10 + 15 files changed, 557 insertions(+), 43 deletions(-) diff --git a/conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh b/conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh index 56320e8..e398522 100755 --- a/conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh +++ b/conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh @@ -16,6 +16,11 @@ # (6) the pod: non-root, read-only rootfs, drop ALL, seccomp RuntimeDefault, no host # namespaces or hostPath, CPU and memory limits on every container, an ephemeral-storage # limit on the scanner, and a size-limited /cache emptyDir. +# (8) a renovate.json5 rule covers every file under platform/vuln-scanner: automerge:false, +# minimumReleaseAge of at least 7 days, and no later rule turns auto-merge back on (the +# python image holds the cluster-read token); +# (9) the scanner names the private registry it cannot reach, so that registry reports +# credential-required rather than a permanent failed. # AND (7) each evaluator is run on a mutated copy and must flag it (non-vacuity). # ============================================================================= set -euo pipefail @@ -150,6 +155,33 @@ def eval_renovate(rules): return bad +SCANNER_FILES = "platform/vuln-scanner/**" + + +def eval_renovate_files(rules): + positions = [i for i, r in enumerate(rules) if SCANNER_FILES in (r.get("matchFileNames") or [])] + if not positions: + return [f"no packageRule has matchFileNames {SCANNER_FILES}"] + bad = [] + rule = rules[positions[-1]] + if rule.get("automerge") is not False: + bad.append("the platform/vuln-scanner file rule is not automerge:false") + age = VERSION_UPDATE_RULE_RE.match(rule.get("minimumReleaseAge") or "") + if not age or int(age.group(1)) < 7: + bad.append("the platform/vuln-scanner file rule has no minimumReleaseAge of at least 7 days") + if any(r.get("automerge") is True for r in rules[positions[-1] + 1:]): + bad.append("a rule after the platform/vuln-scanner file rule re-enables auto-merge") + return bad + + +def eval_private_registries(pod): + scanner = next(c for c in pod["containers"] if c["name"] == "scanner") + env = {e["name"]: e.get("value") for e in scanner.get("env") or []} + if env.get("VULN_PRIVATE_REGISTRIES") != "gitea.${BASE_DOMAIN}": + return ["the scanner does not name the private registry gitea.${BASE_DOMAIN}"] + return [] + + def eval_rbac(rbac): bad = [] croles = [d for d in rbac if d.get("kind") == "ClusterRole"] @@ -224,6 +256,10 @@ rules = renovate.get("packageRules") or [] check(len(rules) > 5, "(4) parsed renovate.json5 packageRules (parser degenerated)") for v in eval_renovate(rules): check(False, f"(4) {v}") +for v in eval_renovate_files(rules): + check(False, f"(8) {v}") +for v in eval_private_registries(pod): + check(False, f"(9) {v}") rbac = docs(COMP / "rbac.yaml") for v in eval_rbac(rbac): @@ -274,6 +310,23 @@ for name, fn in (("Trivy automerge flipped true", ren_auto), ("minimumReleaseAge ("Trivy removed from Renovate rules", ren_drop), ("later auto-merge rule appended", ren_later)): check(bool(eval_renovate(mutated(fn, rules))), f"(7) Renovate evaluator missed mutant: {name}") +# Renovate file-rule mutants +def files_rule(r): + return next(x for x in r if SCANNER_FILES in (x.get("matchFileNames") or [])) +def files_auto(r): files_rule(r)["automerge"] = True +def files_age(r): files_rule(r)["minimumReleaseAge"] = "1 day" +def files_drop(r): r.remove(files_rule(r)) +def files_later(r): r.append({"matchDatasources": ["docker"], "automerge": True}) +for name, fn in (("file rule automerge true", files_auto), ("file rule age shortened", files_age), + ("file rule removed", files_drop), ("later rule re-enables auto-merge", files_later)): + check(bool(eval_renovate_files(mutated(fn, rules))), f"(7) file-rule evaluator missed mutant: {name}") + +def reg_drop(p): + next(c for c in p["containers"] if c["name"] == "scanner")["env"][:] = [ + e for e in next(c for c in p["containers"] if c["name"] == "scanner")["env"] + if e["name"] != "VULN_PRIVATE_REGISTRIES"] +check(bool(eval_private_registries(mutated(reg_drop, pod))), "(7) private-registry evaluator missed the removal") + # Pod mutants def pod_root(p): p["securityContext"]["runAsNonRoot"] = False def pod_host(p): p["hostNetwork"] = True diff --git a/conformance/tests/l1-rendering/vuln-scanner-acceptances/check.py b/conformance/tests/l1-rendering/vuln-scanner-acceptances/check.py index 6fb3560..8cb4d2f 100644 --- a/conformance/tests/l1-rendering/vuln-scanner-acceptances/check.py +++ b/conformance/tests/l1-rendering/vuln-scanner-acceptances/check.py @@ -94,6 +94,15 @@ def status(when, accepted, errs=()): return built, built["findings"][0] +def reason_entry(length): + return acceptances.parse_text("- id: CVE-1\n reason: " + "r" * length + "\n expires: 2026-12-01\n", NOW.date()) + + +found, problems = reason_entry(3000) +check("a reason over 2048 characters ignores the entry and reports it", found == [] and any("2048" in p for p in problems)) +found, problems = reason_entry(2048) +check("mutation: a reason of exactly 2048 characters is valid", len(found) == 1 and problems == []) + entry = {"id": "CVE-1", "image": None, "reason": "not reachable", "expires": date(2026, 11, 9)} built, finding = status(NOW, [entry]) check("unexpired acceptance marks the finding accepted", finding["status"] == "accepted" diff --git a/conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.py b/conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.py index 4b81880..4a43f05 100644 --- a/conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.py +++ b/conformance/tests/l1-rendering/vuln-scanner-api-metrics/check.py @@ -150,6 +150,10 @@ with tempfile.TemporaryDirectory() as tmp: mutate(document) status, _, _ = post(internal, document) check("triage rejects " + name, status == 400) + for name, url in (("an uppercase scheme", "HTTP://gitea.test/pulls/1"), ("an empty url", ""), ("a javascript url", "javascript:alert(1)")): + bad = json.loads(json.dumps(valid)) + bad["remediation"][0]["url"] = url + check("triage rejects " + name, post(internal, bad)[0] == 400) check("triage rejects a wrong content type", post(internal, valid, "text/plain")[0] == 415) status, _, _ = request(internal, "POST", "/triage", b"x" * 300000, {"Content-Type": "application/json"}) check("triage rejects an oversized body", status == 413) diff --git a/conformance/tests/l1-rendering/vuln-scanner-page/check.py b/conformance/tests/l1-rendering/vuln-scanner-page/check.py index 3a41da1..a8e4a39 100644 --- a/conformance/tests/l1-rendering/vuln-scanner-page/check.py +++ b/conformance/tests/l1-rendering/vuln-scanner-page/check.py @@ -170,6 +170,39 @@ _, _, body = render() check("default view excludes nothing", all(f["id"] in body for f in REPORT["findings"])) check("remediation column shows pr-open with a link", 'href="https://gitea.test/pulls/1"' in body and "pr-open" in body) +# Status and state belong to the line they describe (R-VULN-04). +images = [t for t in REPORT["targets"] if t["type"] == "image" and t["workloads"]] +check("fixture has two image targets with workloads", len(images) >= 2) +mixed = copy.deepcopy(REPORT) +first, second = next(t for t in mixed["targets"] if t["id"] == images[0]["id"]), next(t for t in mixed["targets"] if t["id"] == images[1]["id"]) +first["state"], first["reason"] = "scanned", None +second["state"], second["reason"] = "stale", "last success long ago" +base = dict(REPORT["findings"][0], id="CVE-MIXED") +base.pop("acceptance", None) +mixed["findings"] = [ + dict(base, targetId=first["id"], status="accepted", acceptance={"reason": "only-on-the-first", "expires": "2026-12-01"}), + dict(base, targetId=second["id"], status="fix-available"), +] +_, _, body = render(mixed, "view=cves&q=CVE-MIXED") +check("a mixed CVE reads as mixed, with counts", "mixed: 1 fix-available, 1 accepted" in body) +check("a mixed CVE's row does not carry one image's acceptance", "mixed: 1 fix-available, 1 accepted" in body) +first_line = page.e(first["id"]) + " — target scanned — status accepted (only-on-the-first, until 2026-12-01)" +second_line = page.e(second["id"]) + " — target stale (last success long ago) — status fix-available" +check("the accepted image's line carries its own acceptance", first_line in body) +check("the other image's line carries its own status and state", second_line in body) +check("no line attaches the acceptance to the unaccepted status", "status fix-available (only-on-the-first" not in body) +check("workload lines inside the expansion carry state and status", + "target stale (last success long ago) — status fix-available" in body + and "containers: " + page.e(", ".join(second["workloads"][0]["containers"]) or "none") + " — target stale" in body) +_, _, plain = render(query="view=cves&status=accepted") +check("mutation: a uniformly accepted CVE does not read as mixed", "mixed:" not in plain and "CVE-2026-1003" in plain) +_, _, body = render(mixed, "view=workloads") +check("the workloads images column labels each image's state", page.e(second["id"]) + " — target stale (last success long ago)" in body) +check("the workloads expansion labels each finding's status, image and state", + "status fix-available — " + page.e(second["id"]) + " — target stale (last success long ago)" in body) +check("the workloads expansion keeps an acceptance on its own line", + "status accepted (only-on-the-first, until 2026-12-01) — " + page.e(first["id"]) + " — target scanned" in body) + big = copy.deepcopy(REPORT) big["findings"] = [dict(REPORT["findings"][0], id="CVE-2026-" + format(i, "05d")) for i in range(250)] _, _, body = render(big, "view=cves&size=50&page=2") diff --git a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py index f86ea2d..391304f 100644 --- a/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py +++ b/conformance/tests/l1-rendering/vuln-scanner-scan-loop/check.py @@ -95,14 +95,14 @@ class FakeRunner: pass -def engine(tmp, runner=None, clock=None, acc="[]\n", cluster=CLUSTER): +def engine(tmp, runner=None, clock=None, acc="[]\n", cluster=CLUSTER, private=()): path = os.path.join(tmp, "acceptances.yaml") if not os.path.exists(path): Path(path).write_text(acc) clock = clock or Clock() runner = runner or FakeRunner() runner.clock = clock - return main.Engine(tmp, FakeClient(cluster), runner, path, clock=clock, publish_seconds=0), runner, clock + return main.Engine(tmp, FakeClient(cluster), runner, path, clock=clock, publish_seconds=0, private_registries=private), runner, clock def targets(eng): @@ -239,6 +239,184 @@ with tempfile.TemporaryDirectory() as tmp: thread.join(10) check("a crash in one cycle is logged and the loop continues", len(calls) == 2 and not thread.is_alive()) +class Killed(BaseException): + """What an OOM kill looks like from inside the process: no `except Exception` runs.""" + + +class KillingRunner(FakeRunner): + def __init__(self, victim, data_dir): + super().__init__() + self.victim = victim + self.data_dir = data_dir + self.on_disk = None + + def scan(self, reference): + if reference == self.victim: + self.on_disk = json.loads(Path(self.data_dir, "state.json").read_text()).get("inFlight") + raise Killed() + return super().scan(reference) + + +with tempfile.TemporaryDirectory() as tmp: + # R-VULN-05 BLOCKER: a scan that kills the container must cost that image only. + eng, runner, clock = engine(tmp, runner=KillingRunner(POSTGRES, tmp)) + killed = False + try: + eng.tick() + except Killed: + killed = True + check("kill: the simulated OOM really aborted the process path", killed) + check("kill: inFlight is on disk BEFORE the scan starts", runner.on_disk == D4) + eng2, runner2, clock2 = engine(tmp, runner=FakeRunner(), clock=clock) + check("kill: startup clears inFlight", eng2.state["inFlight"] is None) + check("kill: clearing is persisted", json.loads(Path(tmp, "state.json").read_text())["inFlight"] is None) + eng2.publish() + t = targets(eng2) + check("kill: the target is failed", t[POSTGRES]["state"] == "failed") + check("kill: the reason is the killed-scan text", t[POSTGRES]["reason"] == main.KILLED_REASON) + eng2.tick() + t = targets(eng2) + check("kill: images after the killed one get scanned", PRIVATE in runner2.scans and t[PRIVATE]["state"] == "credential-required") + check("kill: the killed image is not retried on restart (no wedge)", POSTGRES not in runner2.scans) + check("kill: the killed target stays failed with its reason", t[POSTGRES]["reason"] == main.KILLED_REASON) + check("kill: inFlight is clear after a normal cycle", eng2.state["inFlight"] is None) + clock.advance(hours=21) + eng2.tick() + check("kill: the killed image is retried after the sit-out", runner2.scans.count(POSTGRES) == 1) + check("kill: a successful retry clears the failure", targets(eng2)[POSTGRES]["state"] == "scanned") + check("kill: a successful retry clears killedAt", eng2.state["scans"][D4]["killedAt"] is None) + +with tempfile.TemporaryDirectory() as tmp: + eng, runner, clock = engine(tmp) + eng.refresh_inventory() + eng2, runner2, clock2 = engine(tmp, runner=FakeRunner(), clock=clock) + eng2.publish() + check("mutation: a restart with no inFlight fails nothing", targets(eng2)[POSTGRES]["state"] == "pending") + +with tempfile.TemporaryDirectory() as tmp: + # A registry the scanner cannot reach is credential-required, and Trivy is never invoked. + eng, runner, clock = engine(tmp, private=[" GHCR.io ", ""]) + eng.tick() + t = targets(eng) + check("private registry: reported credential-required", t[PRIVATE]["state"] == "credential-required") + check("private registry: the reason names the registry", "ghcr.io" in t[PRIVATE]["reason"]) + check("private registry: Trivy is never invoked for it", PRIVATE not in runner.scans and NIGHTLY not in runner.scans) + check("private registry: other registries still scan", NGINX in runner.scans) + check("private registry: not counted as failed", report.failed_over_24h(eng.state, clock() + timedelta(hours=48)) == 0) +with tempfile.TemporaryDirectory() as tmp: + eng, runner, clock = engine(tmp) + eng.tick() + check("mutation: with no private registry configured Trivy is invoked", PRIVATE in runner.scans) + +with tempfile.TemporaryDirectory() as tmp: + # A corrupt state.json degrades the run that rebuilds from it, not every run thereafter. + Path(tmp, "state.json").write_text("{not json") + eng, runner, clock = engine(tmp) + check("loadError: set after a corrupt state.json", eng.state["loadError"]) + eng.tick() + check("loadError: the rebuilding run is degraded", eng.report["run"]["status"] == "degraded") + check("loadError: cleared after a full run with a good inventory", eng.state["loadError"] is None) + clock.advance(hours=21) + eng.tick() + check("loadError: the next full run is complete", eng.report["run"]["status"] == "complete" and eng.state["lastCompleteRunAt"]) +with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "state.json").write_text("{not json") + eng, runner, clock = engine(tmp) + eng.client.fail = True + eng.tick() + check("mutation: loadError is kept when the inventory failed", eng.state["loadError"]) + +with tempfile.TemporaryDirectory() as tmp: + # lastCompleteRunAt means a report reached the store; a refused report must not advance it. + eng, runner, clock = engine(tmp) + real_publish = report.publish + report.publish = lambda *_: False + try: + eng.tick() + finally: + report.publish = real_publish + check("a refused publish does not advance lastCompleteRunAt", eng.state["lastCompleteRunAt"] is None) + clock.advance(hours=21) + eng.tick() + check("mutation: a successful publish does advance it", eng.state["lastCompleteRunAt"] is not None) + +with tempfile.TemporaryDirectory() as tmp: + # One upstream string costs that string. Each of these used to freeze the whole report. + long_reason = "x" * 3000 + eng, runner, clock = engine(tmp, acc="- id: CVE-2026-0010\n reason: " + long_reason + "\n expires: 2026-12-01\n") + runner.outcomes[NGINX] = {"SchemaVersion": 2, "Results": [{"Vulnerabilities": [ + {"VulnerabilityID": "CVE-UP", "Severity": "HIGH", "PkgName": "p", "InstalledVersion": "1", + "PrimaryURL": "HTTPS://upper.example/x", "Title": "t\ud800"}]}]} + eng.tick() + check("hostile strings: the report still publishes", json.loads(Path(tmp, "report.json").read_text())["findings"] and report.validate(eng.report) == []) + up = [f for f in findings(eng, NGINX) if f["id"] == "CVE-UP"][0] + check("hostile strings: an uppercase scheme drops only the URL", up["url"] == "") + check("hostile strings: a lone surrogate is removed", "\ud800" not in up["title"]) + check("hostile strings: an over-long acceptance is ignored, the finding stays open", [f for f in findings(eng, POSTGRES) if f["id"] == "CVE-2026-0010"][0]["status"] != "accepted") + check("hostile strings: an over-long acceptance degrades the run", eng.report["run"]["status"] == "degraded") + check("hostile strings: the degraded run is not recorded as complete", eng.state["lastCompleteRunAt"] is None) + +with tempfile.TemporaryDirectory() as tmp: + # Isolation: values already in state, whatever put them there, cannot refuse the whole report. + eng, runner, clock = engine(tmp) + eng.tick() + clean = report.build(eng.state, [], [], clock()) + check("mutation: a clean state is not reported as repaired", clean["run"]["status"] == "complete" and clean == report.build(eng.state, [], [], clock())) + record = eng.state["scans"][A1] + record["findings"][0].update(url="HTTP://x", title="t" * 900, severity="weird", cvss=99, package="p" * 900) + eng.state["database"] = {"updatedAt": "not-a-time", "version": "1"} + eng.state["remediation"] = [{"image": "a/b", "state": "pr-open", "url": "HTTP://x", "updatedAt": "2026-11-09T00:00:00Z"}] + target = next(i for i in eng.state["inventory"] if i["id"] == NIGHTLY) + target["id"] = "ghcr.io/" + "o" * 3000 + ":1" + target["image"] = "ghcr.io/" + "o" * 3000 + eng.state["scans"][target["id"]] = eng.state["scans"].pop(NIGHTLY) # a by-tag unit is keyed by its id + built = report.build(eng.state, [], [], clock()) + check("isolation: the repaired report is schema-valid", report.validate(built) == []) + check("isolation: a repair degrades the run", built["run"]["status"] == "degraded") + bad = [f for f in built["findings"] if f["targetId"] == NGINX and f["title"] == "t" * 512][0] + check("isolation: the bad finding survives with sanitised fields", + bad["url"] == "" and bad["severity"] == "UNKNOWN" and bad["cvss"] is None and len(bad["package"]) == 256) + check("isolation: every other finding is untouched", len(built["findings"]) == len(clean["findings"])) + check("isolation: a bad remediation loses only its url", built["remediation"][0]["url"] is None) + check("isolation: an unusable database is reported as unavailable", built["database"]["version"] == "unavailable") + long_ids = [t for t in built["targets"] if len(t["id"]) > 2000] + check("isolation: an over-long target stays listed", len(long_ids) == 1 and len(long_ids[0]["id"]) <= 2048) + check("isolation: findings follow the shortened target id", + any(f["targetId"] == long_ids[0]["id"] for f in built["findings"])) + + +class FakeProcess: + returncode = 0 + + def __init__(self, stdout): + self.stdout = stdout + + +class VersionRunner(trivy.Runner): + def __init__(self, stdout): + super().__init__("/nonexistent", "/nonexistent") + self.stdout = stdout + + def _run(self, args, timeout): + return FakeProcess(self.stdout) + + +def db_info(updated, version): + return VersionRunner(json.dumps({"VulnerabilityDB": {"UpdatedAt": updated, "Version": version}})).db_info() + + +check("db_info rejects an unparseable timestamp", db_info("yesterday", "2") is None) +check("db_info rejects a non-string timestamp", db_info(5, "2") is None) +check("db_info bounds the version", len(db_info("2026-11-09T06:13:52.123456789Z", "v" * 500)["version"]) == 128) +check("mutation: db_info keeps a good database", db_info("2026-11-09T06:13:52Z", "2") == {"updatedAt": "2026-11-09T06:13:52Z", "version": "2"}) +check("a credential failure's reason is bounded", len(trivy.classify_failure("r" * 3000, "401 unauthorized").reason) <= 1024) + +for url, want in (("https://a.test/x", "https://a.test/x"), ("http://a.test/", "http://a.test/"), ("HTTPS://a.test/x", ""), + ("HTTP://a.test/x", ""), ("Https://a.test/x", ""), ("javascript:alert(1)", ""), ("", "")): + check("safe_url " + repr(url), trivy.safe_url(url) == want) +check("every url safe_url keeps satisfies the report schema", + report.re.search(report.SCHEMA["properties"]["findings"]["items"]["properties"]["url"]["pattern"], trivy.safe_url("https://a.test/x"))) + # Recorded Trivy output through the normaliser: untrusted text. parsed = trivy.parse_results({"SchemaVersion": 2, "Results": [{"Vulnerabilities": [ {"VulnerabilityID": "CVE-1", "Severity": "weird", "PkgName": "p", "InstalledVersion": "1", "Title": "x\x07y", diff --git a/platform/vuln-scanner/README.md b/platform/vuln-scanner/README.md index b5f8b1e..f689e28 100644 --- a/platform/vuln-scanner/README.md +++ b/platform/vuln-scanner/README.md @@ -17,17 +17,48 @@ Hand-authored platform tooling (not a catalog service), reconciled by its own no The page is `cve.`, internal only, gated by the per-pod oauth2-proxy sidecar to `platform-admins`. The route is disowned from public DNS and the tunnel 404s it; PiHole answers it on the LAN. -## Operator step: add the Keycloak client (NOT done by the change that added this tree) +## Operator action required: the Keycloak client (R-VULN-04 is NOT delivered until this is done) -This change adds everything around the Keycloak client but not the client. Adding it needs a new key in -`platform/keycloak/keycloak-realm-clients.sops.yaml`, an encrypted file an agent must not decrypt; the -L0 check `keycloak-client-secret-reconciler` then requires the client and that key to appear in the -same change. Until this is done the page cannot complete a login (`invalid_client`, fail-closed); the +Until these steps are done the page cannot complete a login (`invalid_client`, fail-closed). The scanner, its metrics and its alerts are unaffected. -In one PR (the realm import does not re-run on its own for a NEW client): +An agent cannot do this, because every route runs through one encrypted file. The client's secret +must be the key `vuln-scanner-client-secret` in `platform/keycloak/keycloak-realm-clients.sops.yaml`: +the reconciler mounts only that Secret, and the L0 check `keycloak-client-secret-reconciler` rejects a +placeholder that reads any other (a second source would be declared but never healed). That check +also requires the client and the key to land in the same change. Adding a key needs the age private +key. Separately, a completed realm import never re-runs, so a new client has to be created in the +live realm once whatever git says. `*.sops.yaml` is a sensitive path, so the PR needs an approval or +an operator-posted `/approve-override` (`infra/gitea/approval-policy.json`). -1. Generate one value and set it in both encrypted files, through pipes so it is never echoed: +**Order matters.** Create the live client first: the reconciler treats every `*-client-secret` key as +a client that must exist, and fails every 15-minute run with `NOT FOUND` otherwise +(`KeycloakClientSecretReconcilerFailing`). + +1. Create the client in the live realm. No secret is passed: the reconciler sets it from git after + the merge, and it reads the admin credential from the pod's own environment, so nothing is echoed. + + ```bash + kubectl --context k3s-prod -n keycloak exec keycloak-0 -- bash -c ' + K="/opt/keycloak/bin/kcadm.sh"; C="--config /tmp/kcadm-vuln.config" + $K config credentials $C --server http://localhost:8080 --realm master \ + --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" + D=home.keithjackson.dev + ID=$($K create clients $C -r homelab -i -s clientId=vuln-scanner -s "name=CVE report (oauth2-proxy)" \ + -s protocol=openid-connect -s enabled=true -s publicClient=false -s standardFlowEnabled=true \ + -s directAccessGrantsEnabled=false -s "redirectUris=[\"https://cve.$D/oauth2/callback\"]" \ + -s "webOrigins=[\"https://cve.$D\"]") + $K create clients/$ID/protocol-mappers/models $C -r homelab -s name=groups -s protocol=openid-connect \ + -s protocolMapper=oidc-group-membership-mapper -s "config.\"full.path\"=false" \ + -s "config.\"id.token.claim\"=true" -s "config.\"access.token.claim\"=true" \ + -s "config.\"userinfo.token.claim\"=true" -s "config.\"claim.name\"=groups" + rm -f /tmp/kcadm-vuln.config' + ``` + + `D` is the prod `BASE_DOMAIN`; check it against the `cluster-vars` ConfigMap first. + +2. On a branch, generate one value and set it in both encrypted files, through pipes so it is never + echoed. `client-secret` in the scanner's file is replaced so that the two match: ```bash ./infra/devcontainer/dev.sh run bash -c ' @@ -38,7 +69,7 @@ In one PR (the realm import does not re-run on its own for a NEW client): "[\"stringData\"][\"client-secret\"]" "\"$cs\""' ``` -2. Add to `platform/keycloak/keycloak-realm-import.yaml`, beside the `crafty` client: +3. Add to `platform/keycloak/keycloak-realm-import.yaml`, beside the `crafty` client: ```yaml # spec.placeholders @@ -72,9 +103,11 @@ In one PR (the realm import does not re-run on its own for a NEW client): "claim.name": groups ``` -3. Regenerate the derived URL file: `python3 ci/render-keycloak-client-urls.py --write`. Commit all - three, gate, merge. +4. Regenerate the derived URL file with `python3 ci/render-keycloak-client-urls.py --write`, and add + `vuln-scanner-client-secret: REPLACE-ME` to `keycloak-realm-clients.sops.yaml.example`. Commit all of + it in one PR, gate, merge. -4. After merge, the client must also exist in the live realm (a completed realm import does not re-run, - per `platform/keycloak/README.md`, so create it once with `kcadm` or re-trigger the import). The - client-secret reconciler then converges its secret and URLs. +5. After Flux applies it, the next reconciler run sets the client's secret and URLs from git. Prove it + with the L2 test `keycloak-client-secrets-match-git`, then log in at `https://cve.`. The + `platform-admins` restriction is the sidecar's `--allowed-group`; the `groups` mapper above is what + supplies the claim it reads, so a user outside that group gets a 403 from oauth2-proxy. diff --git a/platform/vuln-scanner/deployment.yaml b/platform/vuln-scanner/deployment.yaml index a394837..094908c 100644 --- a/platform/vuln-scanner/deployment.yaml +++ b/platform/vuln-scanner/deployment.yaml @@ -133,6 +133,12 @@ spec: value: /tools/ca-certificates.crt - name: VULN_ACCEPTANCES value: /config/acceptances.yaml + # Registries that need a credential this scanner deliberately does not hold (R-VULN-09). + # Comma-separated hostnames. They report credential-required WITHOUT invoking Trivy: + # the egress policy excludes the LAN, so a real attempt ends in a connection error and + # would read as `failed`, firing VulnCoverageGap permanently. Flux substitutes the domain. + - name: VULN_PRIVATE_REGISTRIES + value: gitea.${BASE_DOMAIN} - name: PYTHONUNBUFFERED value: "1" - name: PYTHONDONTWRITEBYTECODE @@ -222,7 +228,7 @@ spec: env: # client-secret MUST equal the Keycloak `vuln-scanner` client's secret (key # vuln-scanner-client-secret in platform/keycloak/keycloak-realm-clients.sops.yaml; see - # README.md, Operator step). Secrets by name only (R-IDP-04). + # README.md, Operator action required). Secrets by name only (R-IDP-04). - name: OAUTH2_PROXY_CLIENT_ID valueFrom: secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-id } diff --git a/platform/vuln-scanner/src/acceptances.py b/platform/vuln-scanner/src/acceptances.py index b8f4cd3..482e49f 100644 --- a/platform/vuln-scanner/src/acceptances.py +++ b/platform/vuln-scanner/src/acceptances.py @@ -15,6 +15,7 @@ from datetime import date, timedelta from state import utcnow MAX_DAYS = 180 +MAX_REASON = 2048 # report.schema.json's limit; a longer reason would make the whole report invalid _KEYS = ("id", "image", "reason", "expires") _ENTRY = re.compile(r"^- ([a-z]+):(?: (.*))?$") _FIELD = re.compile(r"^ ([a-z]+):(?: (.*))?$") @@ -91,6 +92,8 @@ def _validate(fields, today): problems.append("id is missing or malformed") if not fields.get("reason"): problems.append("reason is mandatory") + elif len(fields["reason"]) > MAX_REASON: + problems.append("reason is longer than " + str(MAX_REASON) + " characters") if "image" in fields and not fields["image"]: problems.append("image is empty") expires = fields.get("expires") or "" diff --git a/platform/vuln-scanner/src/main.py b/platform/vuln-scanner/src/main.py index 6e0f3e3..a428d85 100644 --- a/platform/vuln-scanner/src/main.py +++ b/platform/vuln-scanner/src/main.py @@ -23,15 +23,21 @@ log = logging.getLogger("vuln-scanner") FULL_RUN_INTERVAL = timedelta(hours=20) DB_MAX_AGE = timedelta(hours=48) DB_REFRESH_AGE = timedelta(hours=24) +KILLED_REASON = "scanner killed during scan (OOM/eviction?)" class Engine: - def __init__(self, data_dir, client, runner, acceptances_path, clock=st.utcnow, tick_seconds=900, publish_seconds=120): + def __init__(self, data_dir, client, runner, acceptances_path, clock=st.utcnow, tick_seconds=900, publish_seconds=120, + private_registries=()): self.store = st.Store(data_dir) self.data_dir = data_dir self.client = client self.runner = runner self.acceptances_path = acceptances_path + # Registries known to need credentials the scanner deliberately does not hold (R-VULN-09) and, + # being on the LAN, not reachable from its egress policy: a scan would end in a connection + # error (`failed`) rather than a 401 (`credential-required`), so it is never attempted. + self.private_registries = {name.strip().lower() for name in private_registries if name.strip()} self.clock = clock self.tick_seconds = tick_seconds self.publish_seconds = publish_seconds @@ -43,6 +49,18 @@ class Engine: self.loop_thread = None self._last_publish = 0.0 self._last_inventory = None + self._recover_interrupted_scan() + + def _recover_interrupted_scan(self): + """An inFlight key at startup means the last process died inside that scan.""" + key = self.state.get("inFlight") + if not key: + return + log.error("the previous process was killed while scanning %s; marking it failed", key) + record = self.state["scans"].get(key) + if record is not None: + record["killedAt"] = fmt_ts(self.clock()) + self._record_failure(key, "failed", KILLED_REASON) # --- views used by the HTTP servers; none of them waits for a scan -------------------------- def report_bytes(self): @@ -67,16 +85,19 @@ class Engine: # --- persistence ----------------------------------------------------------------------------- def publish(self): + """True when the report was written; False when it was refused and the previous one stands.""" with self.lock: now = self.clock() entries, errors = acceptances.load(self.acceptances_path, now.date()) for problem in errors: log.error("invalid acceptance ignored: %s", problem) built = report_mod.build(self.state, entries, errors, now) - if report_mod.publish(built, self.data_dir): + published = report_mod.publish(built, self.data_dir) + if published: self.report = built self.report_json = report_mod.serialise(built) self._last_publish = time.monotonic() + return published # --- inventory ------------------------------------------------------------------------------- def refresh_inventory(self): @@ -152,6 +173,14 @@ class Engine: return timedelta.max if moment is None else self.clock() - moment def _scan_unit(self, key, reference): + registry = trivy.registry_of(reference) + if registry in self.private_registries or registry.split(":", 1)[0] in self.private_registries: + self._record_failure(key, "credential-required", + "registry " + registry + " is private and the scanner holds no credential for it") + return + with self.lock: + self.state["inFlight"] = key + self.store.save(self.state) try: found = trivy.parse_results(self.runner.scan(reference)) except trivy.ScanFailure as exc: @@ -166,11 +195,24 @@ class Engine: except Exception: # pylint: disable=broad-except log.exception("scan cache cleanup failed") + def _finish_in_flight(self, key): + """Cleared in the same write as the scan's outcome, so no crash can separate the two.""" + if self.state.get("inFlight") == key: + self.state["inFlight"] = None + + def _killed_recently(self, key): + """A unit that killed the scanner sits out full runs for a day: retrying it first on every + restart would OOM again before any later image, which is the wedge this exists to prevent.""" + killed = parse_ts(self.state["scans"][key].get("killedAt")) + return killed is not None and self.clock() - killed < FULL_RUN_INTERVAL + def _record_failure(self, key, kind, reason): now = fmt_ts(self.clock()) with self.lock: + self._finish_in_flight(key) record = self.state["scans"].get(key) if record is None: + self.store.save(self.state) return record["lastAttemptAt"] = now record["outcome"] = kind @@ -181,10 +223,13 @@ class Engine: def _record_success(self, key, found): now = fmt_ts(self.clock()) with self.lock: + self._finish_in_flight(key) record = self.state["scans"].get(key) if record is None: + self.store.save(self.state) return record["lastAttemptAt"] = now + record["killedAt"] = None record["lastSuccessAt"] = now record["outcome"] = "ok" record["reason"] = None @@ -215,7 +260,10 @@ class Engine: started = self.clock() log.info("full run starting") database_ok = self._refresh_database(always=True) - self._scan_queue(sorted(self._units())) + self._scan_queue([key for key in sorted(self._units()) if not self._killed_recently(key)]) + if self.stop.is_set(): + log.info("full run interrupted by shutdown; not recorded") + return info = self.state["database"] now = self.clock() degraded = not database_ok or info is None or self._db_age(info) > DB_MAX_AGE @@ -225,8 +273,15 @@ class Engine: status = "degraded" if degraded else "complete" with self.lock: self.state["run"] = {"startedAt": fmt_ts(started), "finishedAt": fmt_ts(now), "status": status} - if not degraded: + self.store.save(self.state) + # Only a report that actually reached the store counts as a completed run. Otherwise a + # refused report would freeze the page while VulnScanStale stayed quiet. + if self.publish() and not degraded and self.report["run"]["status"] == "complete": self.state["lastCompleteRunAt"] = fmt_ts(now) + # A corrupt state.json degrades the run that rebuilds from it, and no further: once a full + # run has completed against a good inventory, the state is whole again. + if self.state.get("loadError") and self._last_inventory is not None and not self.state.get("inventoryError"): + self.state["loadError"] = None self.store.save(self.state) log.info("full run finished: %s", status) @@ -262,7 +317,9 @@ def main(): tools_dir = os.environ.get("VULN_TOOLS_DIR", "/tools") trivy_bin = os.environ.get("TRIVY_BIN", os.path.join(tools_dir, "trivy")) runner = trivy.Runner(trivy_bin, cache_dir, int(os.environ.get("VULN_SCAN_TIMEOUT_SECONDS", "600"))) - engine = Engine(data_dir, inventory.KubeClient(), runner, os.environ.get("VULN_ACCEPTANCES", "/config/acceptances.yaml")) + private_registries = os.environ.get("VULN_PRIVATE_REGISTRIES", "").split(",") + engine = Engine(data_dir, inventory.KubeClient(), runner, os.environ.get("VULN_ACCEPTANCES", "/config/acceptances.yaml"), + private_registries=private_registries) server.start_all(engine, ("127.0.0.1", 8080), ("0.0.0.0", 8081), ("0.0.0.0", 9090)) finished = threading.Event() signal.signal(signal.SIGTERM, lambda *_: finished.set()) diff --git a/platform/vuln-scanner/src/page.py b/platform/vuln-scanner/src/page.py index a2cd1a3..a8bd7b7 100644 --- a/platform/vuln-scanner/src/page.py +++ b/platform/vuln-scanner/src/page.py @@ -180,14 +180,39 @@ def _sev(severity): return '' + e(severity) + "" -def _workload_lines(target): +def _state_label(target): + return "target " + e(target["state"]) + (" (" + e(target["reason"]) + ")" if target.get("reason") else "") + + +def _status_label(finding): + """One finding's own status, with its own acceptance: never another image's.""" + label = "status " + e(finding["status"]) + acceptance = finding.get("acceptance") + if acceptance: + label += " (" + e(acceptance["reason"]) + ", until " + e(acceptance["expires"]) + ")" + return label + + +def _workload_lines(target, finding=None): + """One line per workload, each carrying the target state and, in a CVE expansion, the status.""" + suffix = " — " + _state_label(target) + (" — " + _status_label(finding) if finding else "") items = [] for workload in target["workloads"]: items.append("
  • " + e(workload["namespace"]) + " / " + e(workload["kind"]) + " / " + e(workload["name"]) - + " — containers: " + e(", ".join(workload["containers"]) or "none") + "
  • ") + + " — containers: " + e(", ".join(workload["containers"]) or "none") + suffix + "") return "
      " + "".join(items) + "
    " +def _status_summary(statuses): + """The row-level status. A CVE accepted on one image and open on another reads as mixed.""" + counts = {} + for status in statuses: + counts[status] = counts.get(status, 0) + 1 + if len(counts) == 1: + return e(next(iter(counts))) + return "mixed: " + ", ".join(e(counts[s]) + " " + e(s) for s in sorted(counts, key=STATUS_RANK.get)) + + def _remediation_cell(images, remediation): if not images: return "" @@ -250,13 +275,10 @@ def _cves(report, targets, params, remediation): for finding, target in row["items"]: if finding["status"] == "fix-available": fix_images.add(target["image"]) - lines.append("
  • " + e(target["id"]) + " [" + e(target["state"]) + "] — " + e(finding["package"]) + " " - + e(finding["installedVersion"]) + " → " + e(finding["fixedVersion"] or "no fix") - + _workload_lines(target) + "
  • ") - acceptance = next((f.get("acceptance") for f, _ in row["items"] if f.get("acceptance")), None) - status_text = e(row["status"]) - if acceptance: - status_text += " (" + e(acceptance["reason"]) + ", until " + e(acceptance["expires"]) + ")" + lines.append("
  • " + e(target["id"]) + " — " + _state_label(target) + " — " + _status_label(finding) + + " — " + e(finding["package"]) + " " + e(finding["installedVersion"]) + " → " + + e(finding["fixedVersion"] or "no fix") + _workload_lines(target, finding) + "
  • ") + status_text = _status_summary(f["status"] for f, _ in row["items"]) html_rows.append( "" + _link(row["url"], row["id"]) + "" + _sev(row["severity"]) + "" + e("" if row["cvss"] is None else row["cvss"]) + "" + e(row["package"]) + "" + e(row["fix"]) @@ -317,12 +339,13 @@ def _workloads(report, targets, params): html_rows = [] for row in page_rows: lines = "".join( - "
  • " + _link(f["url"], f["id"]) + " " + _sev(f["severity"]) + " " + e(f["package"]) + " — " + e(f["status"]) + "
  • " - for f in sorted(row["findings"].values(), key=lambda f: (SEV_RANK[f["severity"]], f["id"]))) + "
  • " + _link(f["url"], f["id"]) + " " + _sev(f["severity"]) + " " + e(f["package"]) + " — " + _status_label(f) + + " — " + e(f["targetId"]) + " — " + _state_label(targets[f["targetId"]]) + "
  • " + for f in sorted(row["findings"].values(), key=lambda f: (SEV_RANK[f["severity"]], f["id"], f["targetId"]))) counts = "".join("" + e(row["counts"][s]) + "" for s in SEVERITIES) html_rows.append( "" + e(row["key"][0]) + "" + e(row["key"][1]) + "" + e(row["key"][2]) + "" - + e(", ".join(sorted(row["containers"])) or "none") + "" + "
    ".join(e(i) for i in sorted(row["images"])) + "" + + e(", ".join(sorted(row["containers"])) or "none") + "" + "
    ".join(e(i) + " — " + _state_label(targets[i]) for i in sorted(row["images"])) + "" + counts + "
    " + e(len(row["findings"])) + " findings
      " + lines + "
    ") head = [_th(params, "Namespace", "namespace"), _th(params, "Kind", "kind"), _th(params, "Workload", "name"), "Containers", _th(params, "Images", "images")] + [_th(params, s, s.lower()) for s in SEVERITIES] + [_th(params, "Findings", "total")] diff --git a/platform/vuln-scanner/src/report.py b/platform/vuln-scanner/src/report.py index bb5e213..9dad978 100644 --- a/platform/vuln-scanner/src/report.py +++ b/platform/vuln-scanner/src/report.py @@ -1,6 +1,7 @@ """Build, validate and publish report.json, the one interface to the page and the bot.""" import json import logging +import hashlib import os import re from datetime import date, timedelta @@ -85,10 +86,15 @@ def build(state, accepted, acceptance_errors, now): if acceptance_errors or state.get("inventoryError") or state.get("loadError") \ or db_time is None or now - db_time > DB_STALE: run["status"] = "degraded" - return { + built = { "schemaVersion": 1, "generatedAt": fmt_ts(now), "database": database, "run": run, "targets": targets, "findings": findings, "remediation": list(state["remediation"]), } + repaired = _isolate(built) + if repaired: + log.error("%d report entries held a value the schema refuses; repaired or dropped them", repaired) + run["status"] = "degraded" + return built # --- hand validator for the JSON-Schema subset report.schema.json uses ----------------------------- @@ -175,6 +181,88 @@ def _check(schema, value, path, errors): _check(branch, value, path, errors) +# --- isolation: one bad upstream value costs that value, never the whole report --------------------- + +def _repair(schema, value): + """(usable, value): value itself when valid, else the nearest valid form of it.""" + trial = [] + _check(schema, value, "$", trial) + if not trial: + return True, value + if isinstance(value, dict) and "properties" in schema: + fixed = {} + for key, sub in schema["properties"].items(): + if key not in value: + continue + usable, item = _repair(sub, value[key]) + if usable: + fixed[key] = item + elif key in schema.get("required", []): + return False, None + trial = [] + _check(schema, fixed, "$", trial) + return not trial, fixed + if isinstance(value, list) and "items" in schema: + repaired = [_repair(schema["items"], item) for item in value] + return True, [item for usable, item in repaired if usable] + candidates = [None, "", "UNKNOWN"] + if isinstance(value, str) and "maxLength" in schema: + candidates.insert(0, value[:schema["maxLength"]]) + for candidate in candidates: + trial = [] + _check(schema, candidate, "$", trial) + if not trial: + return True, candidate + return False, None + + +def _isolate(report): + """Repair (or, as a last resort, drop) each entry the schema refuses. Returns how many changed.""" + changed = 0 + schema = SCHEMA["properties"] + usable, database = _repair(schema["database"], report["database"]) + if database is not report["database"]: + changed += 1 + report["database"] = database if usable else {"updatedAt": EPOCH, "version": "unavailable"} + renamed, dropped, targets = {}, set(), [] + for target in report["targets"]: + original = target["id"] + if len(original) > 2048: # keep it unique: a truncated id may not collide with another + target = dict(target, id=original[:2031] + "#" + hashlib.sha256(original.encode("utf-8")).hexdigest()[:16]) + renamed[original] = target["id"] + usable, fixed = _repair(schema["targets"]["items"], target) + if not usable: + changed += 1 + dropped.add(original) + continue + if fixed is not target or original in renamed: + changed += 1 + targets.append(fixed) + report["targets"] = targets + findings = [] + for finding in report["findings"]: + if finding["targetId"] in dropped: + changed += 1 + continue + if finding["targetId"] in renamed: + finding = dict(finding, targetId=renamed[finding["targetId"]]) + usable, fixed = _repair(schema["findings"]["items"], finding) + if not usable or fixed is not finding: + changed += 1 + if usable: + findings.append(fixed) + report["findings"] = findings + remediation = [] + for entry in report["remediation"]: + usable, fixed = _repair(schema["remediation"]["items"], entry) + if not usable or fixed is not entry: + changed += 1 + if usable: + remediation.append(fixed) + report["remediation"] = remediation + return changed + + def validate(report): """Schema and referential errors; an empty list means the report may be served.""" errors = [] diff --git a/platform/vuln-scanner/src/server.py b/platform/vuln-scanner/src/server.py index 7746bcf..09aadff 100644 --- a/platform/vuln-scanner/src/server.py +++ b/platform/vuln-scanner/src/server.py @@ -126,7 +126,9 @@ def validate_triage(document): if entry["state"] not in REMEDIATION_STATES: return [prefix + "unknown state"] url = entry["url"] - if url is not None and (not isinstance(url, str) or len(url) > 2048 or trivy.safe_url(url) != url): + # safe_url is the same rule the report schema enforces, so a value accepted here cannot make + # the report invalid. Empty is refused: safe_url returns "" for it, and the schema does not. + if url is not None and (not isinstance(url, str) or not url or trivy.safe_url(url) != url): return [prefix + "url must be null or an http(s) URL"] if parse_ts(entry["updatedAt"]) is None or len(entry["updatedAt"]) > 64: return [prefix + "updatedAt must be RFC 3339"] diff --git a/platform/vuln-scanner/src/state.py b/platform/vuln-scanner/src/state.py index 56912db..35dcad8 100644 --- a/platform/vuln-scanner/src/state.py +++ b/platform/vuln-scanner/src/state.py @@ -72,6 +72,9 @@ def new_state(): "remediation": [], "triageHeartbeatAt": None, "loadError": None, + # The scan key Trivy is running against right now, persisted BEFORE the scan starts: an OOM + # kill takes the whole container, so nothing can record the failure afterwards. + "inFlight": None, } @@ -84,6 +87,7 @@ def new_record(now): "outcome": None, "reason": None, "failedSince": None, + "killedAt": None, "findings": [], "firstSeen": {}, } diff --git a/platform/vuln-scanner/src/trivy.py b/platform/vuln-scanner/src/trivy.py index 2481965..21a79fb 100644 --- a/platform/vuln-scanner/src/trivy.py +++ b/platform/vuln-scanner/src/trivy.py @@ -5,6 +5,8 @@ import re import subprocess from urllib.parse import urlsplit +from state import parse_ts + log = logging.getLogger("vuln-scanner.trivy") SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN") @@ -13,7 +15,11 @@ _CREDENTIAL = re.compile( r"|status(?: code)?:? ?(?:401|403)\b|\b40[13] (?:unauthorized|forbidden)", re.IGNORECASE, ) -_CONTROL = re.compile(r"[\x00-\x1f\x7f]") +# Lone surrogates too: json.loads accepts them, and they cannot be encoded as UTF-8 for the page. +_CONTROL = re.compile(r"[\x00-\x1f\x7f\ud800-\udfff]") +# Exact and lowercase, matching report.schema.json, whose pattern is case-sensitive: an `HTTPS://x` +# accepted here would make the schema refuse the entire report. +_URL_SCHEME = re.compile(r"https?://") class ScanFailure(Exception): @@ -35,13 +41,18 @@ def text(value, limit=512): def safe_url(value): """value if it is a plain http(s) URL, else the empty string.""" url = text(value, 2048) - if not url or url != value or re.search(r"\s", url): + if not url or url != value or re.search(r"\s", url) or not _URL_SCHEME.match(url): return "" try: parts = urlsplit(url) except ValueError: return "" - return url if parts.scheme.lower() in ("http", "https") and parts.netloc else "" + return url if parts.netloc else "" + + +def registry_of(reference): + """The lowercase registry host (and port) of a normalised registry/repository reference.""" + return reference.split("/", 1)[0].lower() def _cvss(vuln): @@ -95,7 +106,7 @@ def parse_results(document): def classify_failure(registry, stderr): """Sort a non-zero Trivy exit into credential-required or failed. Reasons name the registry.""" if _CREDENTIAL.search(stderr or ""): - return ScanFailure("credential-required", "registry " + registry + " refused anonymous access") + return ScanFailure("credential-required", "registry " + text(registry, 253) + " refused anonymous access") lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()] return ScanFailure("failed", text(lines[-1], 400) if lines else "trivy exited with an error and no message") @@ -125,9 +136,9 @@ class Runner: db = json.loads(result.stdout).get("VulnerabilityDB") except (ValueError, AttributeError): return None - if not isinstance(db, dict) or not db.get("UpdatedAt"): - return None - return {"updatedAt": db["UpdatedAt"], "version": str(db.get("Version", "unknown"))} + if not isinstance(db, dict) or parse_ts(db.get("UpdatedAt")) is None: + return None # an unparseable timestamp is no usable database, and must not reach the report + return {"updatedAt": db["UpdatedAt"], "version": text(str(db.get("Version", "unknown")), 128) or "unknown"} def scan(self, reference): args = [ @@ -143,7 +154,7 @@ class Runner: if result.returncode < 0: raise ScanFailure("failed", "trivy killed by signal " + str(-result.returncode) + " (out of memory?)") if result.returncode != 0: - raise classify_failure(reference.split("/", 1)[0], result.stderr) + raise classify_failure(registry_of(reference), result.stderr) try: return json.loads(result.stdout) except ValueError: diff --git a/renovate.json5 b/renovate.json5 index 98554a3..9bd46ba 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -483,6 +483,16 @@ "description": "ADDED 2026-10-30. MAJORS WAIT FOR A TICK ON THE DEPENDENCY DASHBOARD instead of opening a PR. They are never auto-merged anyway, and with prConcurrentLimit 5 the dry run's 10 majors would hold every PR slot open indefinitely, starving the patch/minor/digest PRs that can merge on green.", "matchUpdateTypes": ["major"], "dependencyDashboardApproval": true + }, + // ADDED 2026-11-09 — EVERY IMAGE IN THE SCANNER POD, NOT ONLY TRIVY. The python image runs the + // process that holds the cluster-read token (rbac.yaml, R-VULN-09), and without this rule its + // digest and patch bumps fall through to the auto-merge rule above. LAST ON PURPOSE: Renovate + // applies packageRules in order and a later match overrides, so nothing above can undo it. + { + "description": "Anything under platform/vuln-scanner: the python image holds the cluster-read token. Never auto-merged, 7-day minimum release age (R-VULN-09).", + "matchFileNames": ["platform/vuln-scanner/**"], + "automerge": false, + "minimumReleaseAge": "7 days" } ], // WHAT RENOVATE STILL CANNOT FIX, so nobody mistakes a working bot for full coverage. A -- 2.54.0 From e1adc3963c4720dd03b6d798f9cb98c1893887aa Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 9 Nov 2026 12:40:15 -0600 Subject: [PATCH 10/10] docs(vuln): handoff for automated CVE visibility Co-Authored-By: Claude Opus 5.5 --- decisions/2026-11-09-vuln-visibility.md | 84 +++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 decisions/2026-11-09-vuln-visibility.md diff --git a/decisions/2026-11-09-vuln-visibility.md b/decisions/2026-11-09-vuln-visibility.md new file mode 100644 index 0000000..bef6f11 --- /dev/null +++ b/decisions/2026-11-09-vuln-visibility.md @@ -0,0 +1,84 @@ +--- +date: 2026-11-09 +branch: feat/vuln-visibility +title: Automated CVE visibility for production (R-VULN-01..09, ADR-0027) +--- + +## What changed + +Implements `docs/17-vulnerability-visibility.md` from spec PR Home-Network/home-network-k3s#4 +(commit `0fb44bb`, open, not merged). `ci/SPEC_PIN` is deliberately not bumped: the merge SHA does +not exist yet, and open PR #160 owns the pin backlog. + +- `platform/vuln-scanner/`: stdlib Python scanner in a ConfigMap on a digest-pinned + `python:3.14.9-alpine`, Trivy 0.76.1 by digest copied in by an initContainer, a per-app + oauth2-proxy sidecar for `cve.${BASE_DOMAIN}`, read-only ClusterRole, RWO Longhorn PVC for the + report and state, size-limited emptyDir for the DB and scan cache, pinned Service ClusterIP + `10.43.210.10` as the bot's DNS fallback, five alerts with promtool tests and a runbook. +- `clusters/base/vuln-scanner.yaml`: its own Flux tier, `wait: false`, `dependsOn: platform-config`, + nothing depends on it. +- `.gitea/workflows/cve-triage.yaml` and `scripts/cve_triage.py`: the bot, cron `15 7 * * *`, + counts-only `CVE Dashboard` issue with its own `cve-dashboard` label, approve-row ticks only. +- `renovate.json5`: Trivy on the blast-radius list, 7-day minimum release age, and everything under + `platform/vuln-scanner/**` non-automerging. +- `conformance/`: R-VULN-01..09 in the registry; L0 checks and L1 fixture tests (scanner, page, + bot) wired by scope, L2/L3 authored as scaffolds. +- PiHole LAN names and the Homepage link gained the CVE page. + +### Decisions a future reader will want + +- Page is server-rendered with **no JavaScript**, not inline JS: the CSP has no `script-src`, + escaping is in one place, and the L1 test exercises the real rendered page, which a JS page could + not do without a browser engine. Filters, sort and pagination are URL parameters. +- Three pod ports, not two. Prometheus gets 9090 only, so it cannot read `report.json`. +- A killed image (OOM, eviction) is recorded as failed from an in-flight marker and sits out full + runs for 20h, so it cannot wedge the scan loop. +- Private registries are named in `VULN_PRIVATE_REGISTRIES` and short-circuit to + `credential-required` before Trivy runs (see escalation 1). +- Bot's job container is the repo's toolbox image by digest: `actions/checkout` needs node and the + pull is already proven by the merge gate. + +### Spec escalations + +1. Coverage state `credential-required` is defined as "the registry refused anonymous access", but + the egress contract excludes LAN ranges, so the scanner cannot reach the private registry to be + refused. Implemented by naming private registries in configuration. The spec should define the + state by configuration or admit the registry to egress. +2. The egress contract lists only the Kubernetes API, DNS and public TCP/443, but the SSO sidecar + must reach Keycloak. Egress additionally allows the Gateway on 443 and Keycloak. The spec should + name this path. + +## What I verified + +- Toolbox gate on the integrated tree: **rc 3 (UNDETERMINED, not green)**. Stages that did not run: + `spec-immutability` and `gitea-settings`; `conformance` (L2/L3) logged SKIPPED, no cluster. + `approval-policy`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` reported green. + `approval-policy` is N/A offline and proves nothing locally. +- All `vuln-*` L0 and L1 checks pass in the toolbox, each with a mutated-fixture non-vacuity case. +- `renovate-config-validator` from `ghcr.io/renovatebot/renovate:44.167.0`, the tag `renovate.yaml` + runs: validated successfully. This is a read-only container run of a public image. +- Trivy 0.76.1 digest: registry `created` 2026-10-21, 19 days old at authoring; cosign keyless + verification passed (cosign 3.1.3); `cp` and the CA bundle confirmed from a layer listing. +- ClusterIP `10.43.210.10` confirmed unused against the live Service list, read-only. +- An independent reviewer checked the diff against docs/17 and found no anonymous leak path, no way + for an unscanned image to read as clean, no way for the bot to tick a `rebase-branch` row, and no + platform stall path. One BLOCKER and five other code findings were fixed with tests. + +## What I did NOT verify + +Nothing here has been deployed. No runtime behaviour has been observed. + +- **R-VULN-04 is NOT delivered until the operator adds the Keycloak client.** It needs a new key in + `platform/keycloak/keycloak-realm-clients.sops.yaml`, which needs the age private key, and a + one-time `kcadm` against the live realm. Exact steps: `platform/vuln-scanner/README.md`, "Operator + action required". Until then login fails closed with `invalid_client`. Those steps are UNVERIFIED. +- A real Trivy run: every scanner test uses a recorded fixture and a fake runner. +- The real Kubernetes API, real browser rendering, OIDC login and the `platform-admins` restriction. +- Whether in-cluster DNS resolves from a DinD job container, and whether the pinned ClusterIP + fallback is reachable from one. The workflow has never run; Gitea behaviour is tested against a + fake only. +- The `## Pending Approval` row format is from Renovate's source as recalled, because issue #91 has + no live approve row today. The `approve-branch` marker itself is documented. +- L2 and L3 are scaffolds and do not run in CI. +- Renovate `minimumReleaseAge` behaviour on a real update, as opposed to the config validating. +- Cgroup v2 memory behaviour under the scanner's limits is inferred, not measured. -- 2.54.0