Merge pull request 'feat(vuln): automated CVE visibility for production (R-VULN-*, ADR-0027)' (#165) from feat/vuln-visibility into main
Reviewed-on: #165 Reviewed-by: Keith Jackson <[email protected]>
This commit was merged in pull request #165.
This commit is contained in:
commit
190cd6b81a
99 files changed
+9295
-9
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# CVE triage bot (R-VULN-05, R-VULN-07; docs/17 "The triage bot"; ADR-0027). Same shape as
|
||||
# renovate.yaml: scheduled, loud on a missing credential, never concurrent with itself, and it
|
||||
# records what it did in the job summary. The logic and its reasoning live in
|
||||
# scripts/cve_triage.py; this file only supplies the image, the code, the credential and the
|
||||
# schedule.
|
||||
#
|
||||
# WHY IT CANNOT SUCCEED SILENTLY. A skipped Gitea job reports `success` (CLAUDE.md §2), so the
|
||||
# token preflight exits 1 rather than skipping, and an unreachable scanner fails the script
|
||||
# rather than skipping. The heartbeat POST is the script's last action, so VulnTriageBotSilent
|
||||
# fires for every run that did not get all the way through, and when the schedule stops firing.
|
||||
#
|
||||
# THE ONE SECRET: RENOVATE_TOKEN, deliberately reused (ADR-0027). It acts as renovate-bot, which
|
||||
# is also what authored the dashboard this edits. It needs read:repository and write:issue.
|
||||
name: cve-triage
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 07:15 UTC, one hour before Renovate's 08:15 (renovate.yaml), so a tick is read the same
|
||||
# morning, and clear of its run so the bot's edit cannot race Renovate's rewrite of the body.
|
||||
- cron: "15 7 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Dry run: read everything, write nothing, print counts."
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
concurrency:
|
||||
group: cve-triage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
# THE TOOLBOX, the image gate.yml already runs, pinned by digest. A job here has no Docker
|
||||
# daemon, so a job container is the only way to choose an image. The toolbox provides
|
||||
# python3, git, curl and jq, pulls anonymously like the gate's, and is normally already in
|
||||
# the DinD store. It has no Node, so actions/checkout cannot run; a public python image has
|
||||
# neither Node nor git and would add a second image to keep pinned. The cost is a cold pull
|
||||
# after a runner restart, which the gate pays anyway.
|
||||
container:
|
||||
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main
|
||||
image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81
|
||||
# Without a bound, a wedged run would hold the concurrency group and block every later run.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Preflight - the token must exist, and its absence must be LOUD
|
||||
shell: bash
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${RENOVATE_TOKEN:-}" ]; then
|
||||
echo "::error::RENOVATE_TOKEN is not set on this repository." >&2
|
||||
echo "A skipped job reports success, so this fails on purpose: without the token no dashboard is maintained and no approval is requested. Add the Actions secret RENOVATE_TOKEN (scopes read:repository, write:issue)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "RENOVATE_TOKEN is present (value not printed)."
|
||||
|
||||
- name: Preflight - Gitea is reachable and the token can do this job
|
||||
shell: bash
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
# GETs only. The token reaches curl on stdin, never argv. A 401 is the token's value, a 403
|
||||
# its scope; the script's own errors would only show a bare status later.
|
||||
run: |
|
||||
set -uo pipefail
|
||||
GITEA=https://gitea.home.keithjackson.dev
|
||||
REPO=Home-Network/home-network-k3s-impl
|
||||
fail=0
|
||||
body=$(mktemp)
|
||||
fetch() {
|
||||
printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \
|
||||
| curl -sS --max-time 30 --config - -o "$body" -w '%{http_code}' "$GITEA$1" || true
|
||||
}
|
||||
probe() { # <what it proves> <path>
|
||||
local code
|
||||
code=$(fetch "$2")
|
||||
if [ "$code" = 200 ]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
fail=1
|
||||
echo "::error::$1: GET ${2%%\?*} -> $code"
|
||||
fi
|
||||
}
|
||||
probe "read:repository, Gitea reachable" "/api/v1/repos/$REPO"
|
||||
if [ "$(jq -r '.permissions.push // false' "$body" 2>/dev/null)" != "true" ]; then
|
||||
fail=1
|
||||
echo "::error::renovate-bot has no write access to $REPO, so it cannot create labels or edit issues."
|
||||
fi
|
||||
probe "read:issue" "/api/v1/repos/$REPO/labels?limit=1"
|
||||
probe "Renovate's dashboard readable" "/api/v1/repos/$REPO/issues/91"
|
||||
# A GET cannot prove a write scope; Gitea's scope refusal lists the token's scopes
|
||||
# (names only). Same technique as renovate.yaml, advisory if the message format changes.
|
||||
fetch "/api/v1/admin/orgs?limit=1" > /dev/null
|
||||
scopes=$(jq -r '.message // empty' "$body" 2>/dev/null | sed -n 's/.*token scope=//p')
|
||||
if [ -n "$scopes" ]; then
|
||||
case ",$scopes," in
|
||||
*",write:issue,"*|*",all,"*) echo "ok write:issue" ;;
|
||||
*) echo "::error::scope write:issue is missing (token scope=$scopes)"; fail=1 ;;
|
||||
esac
|
||||
else
|
||||
echo "::warning::could not read the token's scope list; write:issue is UNVERIFIED"
|
||||
fi
|
||||
rm -f "$body"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
# Plain git for the same reason as gate.yml: the toolbox has no Node. One commit is enough,
|
||||
# and the job token goes to the single fetch only, never into .git/config.
|
||||
- name: Checkout the script (git, one commit)
|
||||
shell: bash
|
||||
env:
|
||||
GIT_TOKEN: ${{ github.token }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 0022
|
||||
git config --global --add safe.directory "$PWD"
|
||||
git init -q .
|
||||
git remote add origin "${SERVER_URL}/${REPOSITORY}"
|
||||
auth="$(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)"
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \
|
||||
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \
|
||||
git -c protocol.version=2 fetch --quiet --depth=1 --no-tags origin \
|
||||
"+${SHA}:refs/remotes/checkout/head"
|
||||
unset auth GIT_TOKEN
|
||||
git checkout -q --force --detach "$SHA"
|
||||
[ "$(git rev-parse HEAD)" = "$SHA" ]
|
||||
|
||||
- name: Triage
|
||||
shell: bash
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
GITEA_URL: https://gitea.home.keithjackson.dev
|
||||
GITEA_REPOSITORY: Home-Network/home-network-k3s-impl
|
||||
RENOVATE_DASHBOARD_ISSUE: "91"
|
||||
VULN_API_URL: http://vuln-scanner.vuln-scanner.svc.cluster.local:8081
|
||||
# The Service's pinned spec.clusterIP (platform/vuln-scanner/service.yaml), used only
|
||||
# when the name above fails to resolve. Keep the two in step.
|
||||
VULN_API_FALLBACK_URL: http://10.43.210.10:8081
|
||||
VULN_PAGE_URL: https://cve.home.keithjackson.dev
|
||||
# Anything other than an explicit false on a dispatch is a dry run; a schedule is live.
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}
|
||||
run: python3 .gitea/workflows/scripts/cve_triage.py
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## CVE triage"
|
||||
echo
|
||||
echo "- trigger: \`${{ github.event_name }}\`"
|
||||
echo "- dry run requested: \`${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}\`"
|
||||
echo "- outcome: \`${{ job.status }}\`"
|
||||
echo
|
||||
echo "Counts only: which CVE lands where is on the CVE page, never here."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
Executable
+820
@@ -0,0 +1,820 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CVE triage bot (docs/17 "The triage bot", ADR-0027; R-VULN-05, R-VULN-07).
|
||||
|
||||
Runs in .gitea/workflows/cve-triage.yaml, once a day, before Renovate. Each run:
|
||||
1. reads report.json from the scanner's internal API;
|
||||
2. creates or updates the single `CVE Dashboard` issue (aggregates only);
|
||||
3. reads Renovate's Dependency Dashboard and open PRs, derives each image's remediation state;
|
||||
4. ticks `approve-branch` boxes on that dashboard, for packages matching an image with an
|
||||
unaccepted CRITICAL that has a fix;
|
||||
5. POSTs the heartbeat and the remediation states to the scanner.
|
||||
|
||||
WHAT IT MUST NEVER DO. Renovate is the only author of version changes (R-UPD-02): this script
|
||||
never edits a version, opens a bump PR, merges anything, or posts `/approve-override`. It never
|
||||
ticks a `rebase-branch` row. The dashboard issue carries no CVE id, package, image or workload,
|
||||
because the repository is readable without authentication, and no label the in-cluster PM acts
|
||||
on (platform/pm-agent/configmap.yaml PM_REQUIRE_LABEL = `agent`).
|
||||
|
||||
OUTPUT IS COUNTS ONLY. Job logs and the summary are as visible as the issue, so nothing here
|
||||
prints a report string. Every report value is untrusted: strings are validated, and only counts
|
||||
and timestamps that passed RFC 3339 validation ever reach markdown.
|
||||
|
||||
HOW A JOB CONTAINER REACHES THE SCANNER, AND WHAT IS NOT PROVEN
|
||||
---------------------------------------------------------------
|
||||
Jobs are containers on a per-job docker bridge network inside the DinD sidecar of the act-runner
|
||||
pod (apps/wave-1/gitea-actions.yaml). The runner config sets no `container.network` and dockerd no
|
||||
`--dns`, so name resolution is docker's embedded resolver forwarding to the resolvers of the
|
||||
dockerd container, which are that pod's: cluster DNS. Egress is masqueraded to the pod IP
|
||||
(measured 2026-10-29, platform/buildkit/networkpolicy.yaml header), so a ClusterIP is routed and
|
||||
policy-matched as the act-runner pod in gitea-actions. images.yaml relies on the same path to
|
||||
reach buildkitd.buildkit.svc.cluster.local, which is indirect evidence only.
|
||||
UNVERIFIED: in-cluster DNS from a DinD job container.
|
||||
UNVERIFIED: ClusterIP reachability from a DinD job container to vuln-scanner:8081.
|
||||
Hence VULN_API_FALLBACK_URL (the Service's pinned ClusterIP), used only when the name fails to
|
||||
resolve. Anything else, including refused and timed-out connections, fails the job: an
|
||||
unreachable API is a red run, never a skip.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable, Iterable, NamedTuple
|
||||
|
||||
DASHBOARD_TITLE = "CVE Dashboard"
|
||||
DASHBOARD_LABEL = "cve-dashboard"
|
||||
DASHBOARD_LABEL_COLOR = "b60205"
|
||||
# Labels the in-cluster PM dispatcher selects work by. Never applied to anything here.
|
||||
FORBIDDEN_LABELS = frozenset({"agent"})
|
||||
RENOVATE_DASHBOARD_TITLE = "Dependency Dashboard"
|
||||
|
||||
SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
|
||||
FINDING_STATUSES = ("open", "fix-available", "accepted")
|
||||
TARGET_STATES = ("scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered")
|
||||
TARGET_TYPES = ("image", "vm", "node")
|
||||
RUN_STATUSES = ("complete", "degraded")
|
||||
REMEDIATION_STATES = ("pr-open", "approval-requested", "no-update-identified", "not-tracked")
|
||||
|
||||
MAX_REPORT_BYTES = 128 * 1024 * 1024
|
||||
MAX_API_BYTES = 8 * 1024 * 1024
|
||||
MAX_FIELD_LEN = 4096
|
||||
MAX_IMAGE_LEN = 512
|
||||
MAX_ISSUE_BODY = 2 * 1024 * 1024
|
||||
MAX_DASHBOARD_ROWS = 2000
|
||||
MAX_DETECTED_DEPS = 20000
|
||||
MAX_REMEDIATION = 5000
|
||||
MAX_PAGES = 200
|
||||
PAGE_SIZE = 50
|
||||
REPORT_STALE_HOURS = 26
|
||||
DATABASE_STALE_HOURS = 48
|
||||
|
||||
HTTP_TIMEOUT = 60
|
||||
|
||||
RFC3339_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})")
|
||||
IMAGE_RE = re.compile(r"[a-z0-9]([a-z0-9._:/-]*[a-z0-9])?")
|
||||
HTTP_URL_RE = re.compile(r"https?://[A-Za-z0-9.-]+(:\d{1,5})?(/[A-Za-z0-9._~%/-]*)?")
|
||||
REPO_RE = re.compile(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+")
|
||||
|
||||
# Renovate's own row shape (lib/workers/repository/dependency-dashboard.ts, getListItem). The kind
|
||||
# is captured whole so `approvePr-branch` can never read as `approve-branch`.
|
||||
ROW_RE = re.compile(r" - \[(?P<mark>[ xX])\] <!-- (?P<kind>[A-Za-z]+)-branch=(?P<branch>\S+) -->(?P<rest>.*)")
|
||||
LINKED_TITLE_RE = re.compile(r"\[(?P<title>.*)\]\((?P<target>[^()\s]*)\)(?P<tail>.*)")
|
||||
PR_LINK_RE = re.compile(r"(?:^|/)pulls?/(\d{1,9})$")
|
||||
PACKAGE_LIST_RE = re.compile(r"\s\(((?:`[^`\s]+`(?:, )?)+)\)\s*$")
|
||||
TITLE_PACKAGE_RE = re.compile(r"(?:^|\s)(?:Update|Pin)\s+(\S+)\s+Docker\s+(?:tag|digest)\b")
|
||||
SUMMARY_RE = re.compile(r"<details><summary>([^<]*?)(?: \(\d+\))?</summary>")
|
||||
DETECTED_RE = re.compile(r" - `(?P<name>[^\s`]+)(?: [^`]*)?`(?P<updates>\s*→ \[Updates:.*\])?\s*")
|
||||
|
||||
# Detected-dependency managers whose names are charts or actions, never container images.
|
||||
NON_IMAGE_MANAGERS = frozenset({"flux", "helmv3", "github-actions"})
|
||||
|
||||
|
||||
class BotError(Exception):
|
||||
"""A failure that ends the run. The message must be safe to print: no report text, no secret."""
|
||||
|
||||
|
||||
class DnsFailure(Exception):
|
||||
"""The hostname did not resolve. The only condition that selects the fallback address."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pure: normalisation + report
|
||||
|
||||
|
||||
def normalise_image(value: object) -> str:
|
||||
"""Canonical form for exact comparison, or "" when the value is not an image reference.
|
||||
|
||||
Lowercase; drop digest and tag; drop the Docker Hub registry and the `library/` namespace.
|
||||
`library/` is only Hub's implicit namespace, so ghcr.io/library/x keeps it. Comparison of the
|
||||
result is always string equality, never a substring test.
|
||||
"""
|
||||
if not isinstance(value, str) or not value or len(value) > MAX_IMAGE_LEN:
|
||||
return ""
|
||||
name = value.strip().lower().split("@", 1)[0]
|
||||
head, sep, last = name.rpartition("/")
|
||||
name = f"{head}{sep}{last.split(':', 1)[0]}"
|
||||
for hub in ("docker.io/", "index.docker.io/", "registry-1.docker.io/"):
|
||||
if name.startswith(hub):
|
||||
name = name[len(hub):]
|
||||
break
|
||||
if name.startswith("library/"):
|
||||
name = name[len("library/"):]
|
||||
return name if IMAGE_RE.fullmatch(name) else ""
|
||||
|
||||
|
||||
def parse_timestamp(value: object, field: str, nullable: bool = False) -> datetime | None:
|
||||
if value is None and nullable:
|
||||
return None
|
||||
if not isinstance(value, str) or not RFC3339_RE.fullmatch(value):
|
||||
raise BotError(f"report: {field} is not an RFC 3339 timestamp")
|
||||
text = re.sub(r"\.\d+", "", value).replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise BotError(f"report: {field} is not a valid timestamp") from exc
|
||||
|
||||
|
||||
def _object(value: object, field: str) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise BotError(f"report: {field} is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _array(value: object, field: str) -> list:
|
||||
if not isinstance(value, list):
|
||||
raise BotError(f"report: {field} is not an array")
|
||||
return value
|
||||
|
||||
|
||||
def _string(value: object, field: str, nullable: bool = False) -> str | None:
|
||||
if value is None and nullable:
|
||||
return None
|
||||
if not isinstance(value, str) or len(value) > MAX_FIELD_LEN:
|
||||
raise BotError(f"report: {field} is not a bounded string")
|
||||
return value
|
||||
|
||||
|
||||
def _member(value: object, allowed: Iterable[str], field: str) -> str:
|
||||
if not isinstance(value, str) or value not in allowed:
|
||||
raise BotError(f"report: {field} has a value outside its enumeration")
|
||||
return value
|
||||
|
||||
|
||||
def parse_report(raw: bytes) -> dict:
|
||||
"""Decode and validate report.json into the only shape the rest of this file reads."""
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except (ValueError, RecursionError) as exc:
|
||||
raise BotError("report: body is not valid JSON") from exc
|
||||
doc = _object(doc, "document")
|
||||
if doc.get("schemaVersion") != 1 or isinstance(doc.get("schemaVersion"), bool):
|
||||
raise BotError("report: unsupported schemaVersion")
|
||||
parse_timestamp(doc.get("generatedAt"), "generatedAt")
|
||||
database = _object(doc.get("database"), "database")
|
||||
run = _object(doc.get("run"), "run")
|
||||
parse_timestamp(database.get("updatedAt"), "database.updatedAt", nullable=True)
|
||||
parse_timestamp(run.get("startedAt"), "run.startedAt", nullable=True)
|
||||
parse_timestamp(run.get("finishedAt"), "run.finishedAt", nullable=True)
|
||||
if run.get("status") is not None:
|
||||
_member(run.get("status"), RUN_STATUSES, "run.status")
|
||||
|
||||
targets = []
|
||||
known_ids = {}
|
||||
for entry in _array(doc.get("targets"), "targets"):
|
||||
entry = _object(entry, "target")
|
||||
target_id = _string(entry.get("id"), "target.id")
|
||||
kind = _member(entry.get("type"), TARGET_TYPES, "target.type")
|
||||
state = _member(entry.get("state"), TARGET_STATES, "target.state")
|
||||
image = _string(entry.get("image"), "target.image", nullable=True)
|
||||
if kind == "image" and not normalise_image(image):
|
||||
raise BotError("report: an image target has an invalid image reference")
|
||||
known_ids[target_id] = image if kind == "image" else None
|
||||
targets.append({"id": target_id, "type": kind, "state": state, "image": image})
|
||||
|
||||
findings = []
|
||||
for entry in _array(doc.get("findings"), "findings"):
|
||||
entry = _object(entry, "finding")
|
||||
target_id = _string(entry.get("targetId"), "finding.targetId")
|
||||
if target_id not in known_ids:
|
||||
raise BotError("report: a finding references an unknown target")
|
||||
findings.append({
|
||||
"targetId": target_id,
|
||||
"image": known_ids[target_id],
|
||||
"severity": _member(entry.get("severity"), SEVERITIES, "finding.severity"),
|
||||
"status": _member(entry.get("status"), FINDING_STATUSES, "finding.status"),
|
||||
"fixedVersion": _string(entry.get("fixedVersion"), "finding.fixedVersion", nullable=True),
|
||||
})
|
||||
return {
|
||||
"generatedAt": doc["generatedAt"],
|
||||
"database": {"updatedAt": database.get("updatedAt")},
|
||||
"run": {"finishedAt": run.get("finishedAt"), "status": run.get("status")},
|
||||
"targets": targets,
|
||||
"findings": findings,
|
||||
}
|
||||
|
||||
|
||||
def qualifies(finding: dict) -> bool:
|
||||
"""An unaccepted CRITICAL with a fix: the only thing that may cause a tick."""
|
||||
return (
|
||||
finding["severity"] == "CRITICAL"
|
||||
and finding["status"] == "fix-available"
|
||||
and bool(finding["fixedVersion"])
|
||||
)
|
||||
|
||||
|
||||
def qualifying_images(report: dict) -> set[str]:
|
||||
return {
|
||||
norm
|
||||
for finding in report["findings"]
|
||||
if qualifies(finding) and (norm := normalise_image(finding["image"]))
|
||||
}
|
||||
|
||||
|
||||
def aggregate(report: dict) -> dict:
|
||||
severity = {sev: dict.fromkeys(FINDING_STATUSES, 0) for sev in SEVERITIES}
|
||||
for finding in report["findings"]:
|
||||
severity[finding["severity"]][finding["status"]] += 1
|
||||
coverage = dict.fromkeys(TARGET_STATES, 0)
|
||||
for target in report["targets"]:
|
||||
coverage[target["state"]] += 1
|
||||
return {
|
||||
"severity": severity,
|
||||
"coverage": coverage,
|
||||
"targets_total": len(report["targets"]),
|
||||
"critical_with_fix": sum(1 for f in report["findings"] if qualifies(f)),
|
||||
"generatedAt": report["generatedAt"],
|
||||
"databaseUpdatedAt": report["database"]["updatedAt"],
|
||||
"runFinishedAt": report["run"]["finishedAt"],
|
||||
"runStatus": report["run"]["status"],
|
||||
}
|
||||
|
||||
|
||||
def render_dashboard(agg: dict, page_url: str, now: datetime) -> str:
|
||||
"""The CVE Dashboard issue body: a fixed template over counts and validated timestamps."""
|
||||
if not HTTP_URL_RE.fullmatch(page_url):
|
||||
raise BotError("config: VULN_PAGE_URL is not a plain http(s) URL")
|
||||
warnings = []
|
||||
generated = parse_timestamp(agg["generatedAt"], "generatedAt")
|
||||
if now - generated > timedelta(hours=REPORT_STALE_HOURS):
|
||||
warnings.append(f"The report is older than {REPORT_STALE_HOURS} hours.")
|
||||
updated = parse_timestamp(agg["databaseUpdatedAt"], "database.updatedAt", nullable=True)
|
||||
if updated is None or now - updated > timedelta(hours=DATABASE_STALE_HOURS):
|
||||
warnings.append(f"The vulnerability database is older than {DATABASE_STALE_HOURS} hours or unknown.")
|
||||
if agg["runStatus"] != "complete":
|
||||
warnings.append("The latest scan run did not complete cleanly.")
|
||||
|
||||
lines = [
|
||||
"Maintained by the CVE triage bot, once a day. **Counts only**: this repository is readable",
|
||||
"without authentication, so which CVE lands where is only on the",
|
||||
f"[CVE page]({page_url}) (SSO, platform-admins).",
|
||||
"",
|
||||
]
|
||||
lines += [f"> **WARNING:** {text}" for text in warnings]
|
||||
if warnings:
|
||||
lines.append("")
|
||||
lines += [
|
||||
"## Freshness",
|
||||
"",
|
||||
"| | |",
|
||||
"|---|---|",
|
||||
f"| Report generated | {agg['generatedAt']} |",
|
||||
f"| Database updated | {agg['databaseUpdatedAt'] or 'unknown'} |",
|
||||
f"| Last run finished | {agg['runFinishedAt'] or 'none yet'} |",
|
||||
f"| Last run status | {agg['runStatus'] or 'none yet'} |",
|
||||
f"| Dashboard rendered | {now.strftime('%Y-%m-%dT%H:%M:%SZ')} |",
|
||||
"",
|
||||
"## Findings by severity",
|
||||
"",
|
||||
"| Severity | Open | Fix available | Accepted |",
|
||||
"|---|---:|---:|---:|",
|
||||
]
|
||||
for sev in SEVERITIES:
|
||||
row = agg["severity"][sev]
|
||||
lines.append(f"| {sev} | {row['open']} | {row['fix-available']} | {row['accepted']} |")
|
||||
lines += [
|
||||
"",
|
||||
f"Unaccepted CRITICAL with a fix: **{agg['critical_with_fix']}**",
|
||||
"",
|
||||
"## Coverage",
|
||||
"",
|
||||
f"{agg['targets_total']} targets. A count in any state other than `scanned` is something"
|
||||
" that was not freshly examined, and is not a clean result.",
|
||||
"",
|
||||
"| State | Targets |",
|
||||
"|---|---:|",
|
||||
]
|
||||
lines += [f"| {state} | {agg['coverage'][state]} |" for state in TARGET_STATES]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pure: Renovate's dashboard
|
||||
|
||||
|
||||
class Row(NamedTuple):
|
||||
kind: str
|
||||
mark: str
|
||||
branch: str
|
||||
packages: tuple[str, ...]
|
||||
pr_number: int | None
|
||||
offset: int # offset in the body of the first character of the line
|
||||
|
||||
|
||||
def _title_package(title: str) -> str:
|
||||
match = TITLE_PACKAGE_RE.search(title)
|
||||
return normalise_image(match.group(1)) if match else ""
|
||||
|
||||
|
||||
def _row_details(rest: str) -> tuple[tuple[str, ...], int | None]:
|
||||
"""Packages and PR number of a row. No recognisable package means no packages, so no tick."""
|
||||
rest = rest.rstrip("\r")
|
||||
number = None
|
||||
title = rest
|
||||
tail = rest
|
||||
linked = LINKED_TITLE_RE.fullmatch(rest)
|
||||
if linked:
|
||||
title, tail = linked.group("title"), linked.group("tail")
|
||||
link = PR_LINK_RE.search(linked.group("target"))
|
||||
number = int(link.group(1)) if link else None
|
||||
listed = PACKAGE_LIST_RE.search(tail)
|
||||
if listed:
|
||||
names = re.findall(r"`([^`]+)`", listed.group(1))
|
||||
packages = tuple(n for n in map(normalise_image, names) if n)
|
||||
else:
|
||||
package = _title_package(title)
|
||||
packages = (package,) if package else ()
|
||||
return packages, number
|
||||
|
||||
|
||||
def _is_detected_heading(line: str) -> bool:
|
||||
return line.startswith("## ") and line[3:].strip().lower().startswith("detected dependencies")
|
||||
|
||||
|
||||
def parse_rows(body: str) -> list[Row]:
|
||||
"""Checkbox rows above `## Detected Dependencies`. Only a whole-line match counts."""
|
||||
if len(body) > MAX_ISSUE_BODY:
|
||||
raise BotError("renovate dashboard: body exceeds its bound")
|
||||
rows: list[Row] = []
|
||||
offset = 0
|
||||
for line in body.split("\n"):
|
||||
if _is_detected_heading(line):
|
||||
break
|
||||
match = ROW_RE.fullmatch(line.rstrip("\r"))
|
||||
if match:
|
||||
packages, number = _row_details(match.group("rest"))
|
||||
rows.append(Row(match.group("kind"), match.group("mark"), match.group("branch"),
|
||||
packages, number, offset))
|
||||
if len(rows) > MAX_DASHBOARD_ROWS:
|
||||
raise BotError("renovate dashboard: too many rows")
|
||||
offset += len(line) + 1
|
||||
return rows
|
||||
|
||||
|
||||
def parse_detected(body: str) -> dict[str, bool]:
|
||||
"""{normalised depName: has a pending update} from `## Detected Dependencies`."""
|
||||
tracked: dict[str, bool] = {}
|
||||
lines = body.split("\n")
|
||||
in_section = False
|
||||
manager = ""
|
||||
count = 0
|
||||
for index, raw in enumerate(lines):
|
||||
line = raw.rstrip("\r")
|
||||
if line.startswith("## "):
|
||||
in_section = _is_detected_heading(line)
|
||||
continue
|
||||
if not in_section:
|
||||
continue
|
||||
summary = SUMMARY_RE.fullmatch(line)
|
||||
if summary:
|
||||
# A manager heading is the summary directly followed by <blockquote>; a file heading
|
||||
# is not.
|
||||
if index + 1 < len(lines) and lines[index + 1].strip() == "<blockquote>":
|
||||
manager = summary.group(1)
|
||||
continue
|
||||
match = DETECTED_RE.fullmatch(line)
|
||||
if not match or manager in NON_IMAGE_MANAGERS:
|
||||
continue
|
||||
count += 1
|
||||
if count > MAX_DETECTED_DEPS:
|
||||
raise BotError("renovate dashboard: too many detected dependencies")
|
||||
norm = normalise_image(match.group("name"))
|
||||
if norm:
|
||||
tracked[norm] = tracked.get(norm, False) or bool(match.group("updates"))
|
||||
return tracked
|
||||
|
||||
|
||||
def row_matches(row: Row, qualifying: set[str]) -> bool:
|
||||
return any(package in qualifying for package in row.packages)
|
||||
|
||||
|
||||
def select_ticks(rows: list[Row], qualifying: set[str]) -> list[Row]:
|
||||
return [
|
||||
row for row in rows
|
||||
if row.kind == "approve" and row.mark == " " and row_matches(row, qualifying)
|
||||
]
|
||||
|
||||
|
||||
def apply_ticks(body: str, ticks: list[Row]) -> str:
|
||||
"""Replace only the `[ ]` of each row, re-verifying the row where it stands.
|
||||
|
||||
Every byte other than the three of each marker is carried over unchanged.
|
||||
"""
|
||||
pieces = []
|
||||
cursor = 0
|
||||
for row in sorted(ticks, key=lambda r: r.offset):
|
||||
expected = f" - [ ] <!-- approve-branch={row.branch} -->"
|
||||
if row.kind != "approve" or not body.startswith(expected, row.offset):
|
||||
raise BotError("renovate dashboard: a row changed between parse and write")
|
||||
marker = row.offset + len(" - ")
|
||||
pieces += [body[cursor:marker], "[x]"]
|
||||
cursor = marker + len("[ ]")
|
||||
pieces.append(body[cursor:])
|
||||
return "".join(pieces)
|
||||
|
||||
|
||||
def derive_remediation(
|
||||
images: Iterable[str],
|
||||
rows: list[Row],
|
||||
open_prs: list[dict],
|
||||
tracked: dict[str, bool],
|
||||
pr_url: Callable[[int], str],
|
||||
now_text: str,
|
||||
) -> list[dict]:
|
||||
"""One entry per report image.
|
||||
|
||||
pr-open: an open-PR row or an open Renovate PR for that package. approval-requested: an
|
||||
approve row. no-update-identified: Renovate tracks it and lists no update. not-tracked:
|
||||
everything else, which deliberately includes "Renovate knows an update but no PR or approval
|
||||
row exists" (awaiting schedule, rate limited): saying no update was identified would be
|
||||
false, and the enumeration has no closer value, so it lands on the state that draws attention.
|
||||
"""
|
||||
by_branch = {pr["branch"]: pr["number"] for pr in open_prs}
|
||||
result = []
|
||||
for image in sorted(set(images)):
|
||||
norm = normalise_image(image)
|
||||
state = "not-tracked"
|
||||
url = None
|
||||
rebase = next((r for r in rows if r.kind == "rebase" and norm in r.packages), None)
|
||||
pr = next((p for p in open_prs if p["package"] == norm), None)
|
||||
if rebase is not None or pr is not None:
|
||||
state = "pr-open"
|
||||
number = None
|
||||
if rebase is not None:
|
||||
number = rebase.pr_number or by_branch.get(rebase.branch)
|
||||
if number is None and pr is not None:
|
||||
number = pr["number"]
|
||||
url = pr_url(number) if number is not None else None
|
||||
elif any(r.kind == "approve" and norm in r.packages for r in rows):
|
||||
state = "approval-requested"
|
||||
elif norm in tracked and not tracked[norm]:
|
||||
state = "no-update-identified"
|
||||
result.append({"image": image, "state": state, "url": url, "updatedAt": now_text})
|
||||
return result
|
||||
|
||||
|
||||
def count_states(remediation: list[dict]) -> dict[str, int]:
|
||||
counts = dict.fromkeys(REMEDIATION_STATES, 0)
|
||||
for entry in remediation:
|
||||
counts[entry["state"]] += 1
|
||||
return counts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- I/O
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""A redirect would carry the Authorization header to wherever it points."""
|
||||
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class UrllibTransport:
|
||||
"""(method, url, headers, body, max_bytes) -> (status, bytes); never uses a proxy."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
|
||||
|
||||
def __call__(self, method: str, url: str, headers: dict, body: bytes | None, max_bytes: int):
|
||||
request = urllib.request.Request(url, data=body, method=method, headers=headers)
|
||||
try:
|
||||
with self._opener.open(request, timeout=HTTP_TIMEOUT) as response:
|
||||
data = response.read(max_bytes + 1)
|
||||
return response.status, data
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, b""
|
||||
except urllib.error.URLError as exc:
|
||||
if isinstance(exc.reason, socket.gaierror):
|
||||
raise DnsFailure() from exc
|
||||
raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc.reason).__name__}") from exc
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc).__name__}") from exc
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, base_url: str, repo: str, token: str, transport) -> None:
|
||||
self._api = f"{base_url.rstrip('/')}/api/v1/repos/{repo}"
|
||||
self._web = f"{base_url.rstrip('/')}/{repo}"
|
||||
self._token = token
|
||||
self._transport = transport
|
||||
|
||||
def _call(self, method: str, path: str, query: dict | None = None, payload=None, expect=(200,)):
|
||||
url = self._api + path + (f"?{urllib.parse.urlencode(query)}" if query else "")
|
||||
headers = {"Authorization": f"token {self._token}", "Accept": "application/json"}
|
||||
data = None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
try:
|
||||
status, raw = self._transport(method, url, headers, data, MAX_API_BYTES)
|
||||
except DnsFailure as exc:
|
||||
raise BotError(f"gitea: {method} {path}: name resolution failed") from exc
|
||||
if status not in expect:
|
||||
raise BotError(f"gitea: {method} {path} -> HTTP {status}")
|
||||
if len(raw) > MAX_API_BYTES:
|
||||
raise BotError(f"gitea: {method} {path}: response exceeds its bound")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError as exc:
|
||||
raise BotError(f"gitea: {method} {path}: response is not JSON") from exc
|
||||
|
||||
def _list(self, path: str, query: dict | None = None) -> list:
|
||||
items: list = []
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
batch = self._call("GET", path, {**(query or {}), "limit": PAGE_SIZE, "page": page})
|
||||
if not isinstance(batch, list):
|
||||
raise BotError(f"gitea: GET {path}: expected a list")
|
||||
items += batch
|
||||
if len(batch) < PAGE_SIZE:
|
||||
return items
|
||||
raise BotError(f"gitea: GET {path}: more than {MAX_PAGES} pages")
|
||||
|
||||
def pr_url(self, number: int) -> str:
|
||||
return f"{self._web}/pulls/{int(number)}"
|
||||
|
||||
def labels(self) -> list:
|
||||
return self._list("/labels")
|
||||
|
||||
def create_label(self, name: str, color: str) -> dict:
|
||||
return self._call("POST", "/labels", payload={"name": name, "color": color}, expect=(201,))
|
||||
|
||||
def issues_with_label(self, label: str) -> list:
|
||||
return self._list("/issues", {"state": "all", "type": "issues", "labels": label})
|
||||
|
||||
def issues_matching(self, text: str) -> list:
|
||||
return self._list("/issues", {"state": "all", "type": "issues", "q": text})
|
||||
|
||||
def create_issue(self, title: str, body: str, label_id: int) -> dict:
|
||||
return self._call("POST", "/issues", payload={"title": title, "body": body, "labels": [label_id]}, expect=(201,))
|
||||
|
||||
def update_issue(self, number: int, fields: dict) -> None:
|
||||
self._call("PATCH", f"/issues/{int(number)}", payload=fields, expect=(200, 201))
|
||||
|
||||
def add_issue_label(self, number: int, label_id: int) -> None:
|
||||
self._call("POST", f"/issues/{int(number)}/labels", payload={"labels": [label_id]}, expect=(200,))
|
||||
|
||||
def get_issue(self, number: int) -> dict:
|
||||
issue = self._call("GET", f"/issues/{int(number)}")
|
||||
if not isinstance(issue, dict):
|
||||
raise BotError("gitea: issue response is not an object")
|
||||
return issue
|
||||
|
||||
def open_renovate_prs(self) -> list[dict]:
|
||||
result = []
|
||||
for pr in self._list("/pulls", {"state": "open"}):
|
||||
head = pr.get("head") if isinstance(pr, dict) else None
|
||||
ref = head.get("ref") if isinstance(head, dict) else None
|
||||
number = pr.get("number") if isinstance(pr, dict) else None
|
||||
title = pr.get("title") if isinstance(pr, dict) else None
|
||||
if not isinstance(ref, str) or not isinstance(number, int) or isinstance(number, bool):
|
||||
raise BotError("gitea: a pull request has an unexpected shape")
|
||||
if ref.startswith("renovate/"):
|
||||
package = _title_package(title) if isinstance(title, str) else ""
|
||||
result.append({"number": number, "branch": ref, "package": package})
|
||||
return result
|
||||
|
||||
|
||||
class ScannerClient:
|
||||
"""The scanner's internal API. Falls back to the pinned ClusterIP on a DNS failure only."""
|
||||
|
||||
def __init__(self, primary: str, fallback: str, transport) -> None:
|
||||
self._bases = [base.rstrip("/") for base in (primary, fallback) if base]
|
||||
self._transport = transport
|
||||
self.used_fallback = False
|
||||
|
||||
def _call(self, method: str, path: str, body: bytes | None, headers: dict, max_bytes: int):
|
||||
last = len(self._bases) - 1
|
||||
for index, base in enumerate(self._bases):
|
||||
try:
|
||||
result = self._transport(method, base + path, headers, body, max_bytes)
|
||||
except DnsFailure as exc:
|
||||
if index == last:
|
||||
raise BotError(f"scanner: {method} {path}: name resolution failed") from exc
|
||||
continue
|
||||
if index:
|
||||
self.used_fallback = True
|
||||
self._bases = self._bases[index:]
|
||||
return result
|
||||
raise BotError("scanner: no address configured")
|
||||
|
||||
def report(self) -> dict:
|
||||
status, raw = self._call("GET", "/report.json", None, {"Accept": "application/json"}, MAX_REPORT_BYTES)
|
||||
if status != 200:
|
||||
raise BotError(f"scanner: GET /report.json -> HTTP {status}")
|
||||
if len(raw) > MAX_REPORT_BYTES:
|
||||
raise BotError("scanner: report exceeds its bound")
|
||||
return parse_report(raw)
|
||||
|
||||
def triage(self, heartbeat: str, remediation: list[dict]) -> None:
|
||||
if len(remediation) > MAX_REMEDIATION:
|
||||
raise BotError("scanner: more remediation entries than POST /triage accepts")
|
||||
body = json.dumps({"heartbeatAt": heartbeat, "remediation": remediation}).encode()
|
||||
status, _ = self._call("POST", "/triage", body, {"Content-Type": "application/json"}, 4096)
|
||||
if status != 204:
|
||||
raise BotError(f"scanner: POST /triage -> HTTP {status}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- orchestration
|
||||
|
||||
|
||||
def _label_names(issue: dict) -> set:
|
||||
return {label.get("name") for label in (issue.get("labels") or []) if isinstance(label, dict)}
|
||||
|
||||
|
||||
def find_dashboard_issues(gitea: GiteaClient) -> list[dict]:
|
||||
"""Every issue that is the dashboard: by label, and by exact title in case a label was removed."""
|
||||
found: dict[int, dict] = {}
|
||||
for issue in gitea.issues_with_label(DASHBOARD_LABEL) + gitea.issues_matching(DASHBOARD_TITLE):
|
||||
if (
|
||||
isinstance(issue, dict)
|
||||
and issue.get("title") == DASHBOARD_TITLE
|
||||
and not issue.get("pull_request")
|
||||
and isinstance(issue.get("number"), int)
|
||||
):
|
||||
found[issue["number"]] = issue
|
||||
return list(found.values())
|
||||
|
||||
|
||||
def sync_dashboard_issue(gitea: GiteaClient, body: str, dry_run: bool) -> dict:
|
||||
"""Create or update the one dashboard issue. Returns counts of what it did or would do."""
|
||||
if DASHBOARD_LABEL in FORBIDDEN_LABELS:
|
||||
raise BotError("config: the dashboard label is one the PM agent acts on")
|
||||
plan = {"label_created": 0, "issue_created": 0, "issue_updated": 0, "issue_reopened": 0}
|
||||
existing = find_dashboard_issues(gitea)
|
||||
if len(existing) > 1:
|
||||
# Closing an issue on a guess is worse than a red run naming the numbers to look at.
|
||||
numbers = sorted(issue["number"] for issue in existing)
|
||||
raise BotError(f"dashboard: {len(numbers)} issues titled {DASHBOARD_TITLE!r}: {numbers}")
|
||||
|
||||
label = next((x for x in gitea.labels() if isinstance(x, dict) and x.get("name") == DASHBOARD_LABEL), None)
|
||||
if label is None:
|
||||
plan["label_created"] = 1
|
||||
if not dry_run:
|
||||
label = gitea.create_label(DASHBOARD_LABEL, DASHBOARD_LABEL_COLOR)
|
||||
|
||||
if not existing:
|
||||
plan["issue_created"] = 1
|
||||
if not dry_run:
|
||||
created = gitea.create_issue(DASHBOARD_TITLE, body, label["id"])
|
||||
if not isinstance(created, dict) or not isinstance(created.get("number"), int):
|
||||
raise BotError("gitea: issue creation returned an unexpected shape")
|
||||
return plan
|
||||
|
||||
issue = existing[0]
|
||||
fields: dict = {}
|
||||
if issue.get("body") != body:
|
||||
fields["body"] = body
|
||||
if issue.get("state") != "open":
|
||||
fields["state"] = "open"
|
||||
plan["issue_reopened"] = 1
|
||||
if fields:
|
||||
plan["issue_updated"] = 1
|
||||
if not dry_run:
|
||||
if DASHBOARD_LABEL not in _label_names(issue):
|
||||
gitea.add_issue_label(issue["number"], label["id"])
|
||||
if fields:
|
||||
gitea.update_issue(issue["number"], fields)
|
||||
return plan
|
||||
|
||||
|
||||
def _renovate_issue(gitea: GiteaClient, number: int) -> dict:
|
||||
issue = gitea.get_issue(number)
|
||||
body = issue.get("body")
|
||||
if issue.get("title") != RENOVATE_DASHBOARD_TITLE or issue.get("state") != "open" or not isinstance(body, str):
|
||||
raise BotError(f"renovate dashboard: issue {number} is not the open Dependency Dashboard")
|
||||
return issue
|
||||
|
||||
|
||||
def report_images(report: dict) -> list[str]:
|
||||
return [t["image"] for t in report["targets"] if t["type"] == "image"]
|
||||
|
||||
|
||||
def run(cfg: dict, gitea: GiteaClient, scanner: ScannerClient, now: datetime, emit: Callable[[str], None]) -> dict:
|
||||
"""One triage pass. Any failure raises, and the heartbeat is the last thing it writes."""
|
||||
dry_run = cfg["dry_run"]
|
||||
report = scanner.report()
|
||||
agg = aggregate(report)
|
||||
now_text = now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
summary: dict = {"dry_run": dry_run, "targets": agg["targets_total"], "critical_with_fix": agg["critical_with_fix"]}
|
||||
summary["dashboard"] = sync_dashboard_issue(gitea, render_dashboard(agg, cfg["page_url"], now), dry_run)
|
||||
|
||||
number = cfg["renovate_issue"]
|
||||
body = _renovate_issue(gitea, number)["body"]
|
||||
remediation = derive_remediation(
|
||||
report_images(report), parse_rows(body), gitea.open_renovate_prs(), parse_detected(body),
|
||||
gitea.pr_url, now_text,
|
||||
)
|
||||
summary["remediation"] = count_states(remediation)
|
||||
|
||||
# Re-read immediately before writing: Renovate rewrites this body, and a tick must apply to
|
||||
# the body as it is now.
|
||||
fresh = _renovate_issue(gitea, number)["body"]
|
||||
ticks = select_ticks(parse_rows(fresh), qualifying_images(report))
|
||||
summary["ticks"] = len(ticks)
|
||||
if ticks and not dry_run:
|
||||
gitea.update_issue(number, {"body": apply_ticks(fresh, ticks)})
|
||||
|
||||
if not dry_run:
|
||||
scanner.triage(now_text, remediation)
|
||||
summary["heartbeat"] = 0 if dry_run else 1
|
||||
summary["fallback_used"] = scanner.used_fallback
|
||||
emit(render_summary(summary))
|
||||
return summary
|
||||
|
||||
|
||||
def render_summary(summary: dict) -> str:
|
||||
verb = "would be" if summary["dry_run"] else "were"
|
||||
dash = summary["dashboard"]
|
||||
states = ", ".join(f"{name} {count}" for name, count in summary["remediation"].items())
|
||||
return "\n".join([
|
||||
f"### CVE triage ({'DRY RUN, no writes' if summary['dry_run'] else 'live'})",
|
||||
"",
|
||||
f"- targets in report: {summary['targets']}",
|
||||
f"- unaccepted CRITICAL with a fix: {summary['critical_with_fix']}",
|
||||
f"- dashboard label created: {dash['label_created']}; issue created: {dash['issue_created']};"
|
||||
f" updated: {dash['issue_updated']}; reopened: {dash['issue_reopened']} ({verb} applied)",
|
||||
f"- remediation states: {states}",
|
||||
f"- approval boxes that {verb} ticked: {summary['ticks']}",
|
||||
f"- heartbeat posted: {summary['heartbeat']}",
|
||||
f"- scanner reached through the ClusterIP fallback: {summary['fallback_used']}",
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
def load_config(env: dict) -> dict:
|
||||
token = env.get("RENOVATE_TOKEN", "")
|
||||
if not token:
|
||||
raise BotError("RENOVATE_TOKEN is not set")
|
||||
repo = env.get("GITEA_REPOSITORY", "Home-Network/home-network-k3s-impl")
|
||||
if not REPO_RE.fullmatch(repo):
|
||||
raise BotError("config: GITEA_REPOSITORY is malformed")
|
||||
urls = {
|
||||
"gitea_url": env.get("GITEA_URL", "https://gitea.home.keithjackson.dev"),
|
||||
"vuln_url": env.get("VULN_API_URL", "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081"),
|
||||
"page_url": env.get("VULN_PAGE_URL", "https://cve.home.keithjackson.dev"),
|
||||
}
|
||||
fallback = env.get("VULN_API_FALLBACK_URL", "")
|
||||
for name, value in {**urls, **({"fallback": fallback} if fallback else {})}.items():
|
||||
if not HTTP_URL_RE.fullmatch(value):
|
||||
raise BotError(f"config: {name} is not a plain http(s) URL")
|
||||
dry_run = env.get("DRY_RUN", "false")
|
||||
if dry_run not in ("true", "false"):
|
||||
raise BotError("config: DRY_RUN must be true or false")
|
||||
issue = env.get("RENOVATE_DASHBOARD_ISSUE", "91")
|
||||
if not issue.isdigit():
|
||||
raise BotError("config: RENOVATE_DASHBOARD_ISSUE is not a number")
|
||||
return {**urls, "token": token, "repo": repo, "fallback": fallback,
|
||||
"dry_run": dry_run == "true", "renovate_issue": int(issue)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def emit(text: str) -> None:
|
||||
print(text)
|
||||
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if path:
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write(text + "\n")
|
||||
|
||||
try:
|
||||
cfg = load_config(dict(os.environ))
|
||||
transport = UrllibTransport()
|
||||
gitea = GiteaClient(cfg["gitea_url"], cfg["repo"], cfg["token"], transport)
|
||||
scanner = ScannerClient(cfg["vuln_url"], cfg["fallback"], transport)
|
||||
run(cfg, gitea, scanner, datetime.now(timezone.utc), emit)
|
||||
except BotError as exc:
|
||||
print(f"::error::CVE triage failed: {exc}", file=sys.stderr)
|
||||
emit(f"### CVE triage FAILED\n\n{exc}\n")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
# System Architect; each legitimate spec advance is adopted here by bumping this pin
|
||||
# in a reviewed change. CI (ci/spec-immutability.sh) fails if the spec repo is dirty
|
||||
# or its HEAD != this pin — catching any unauthorized/unadopted spec modification.
|
||||
b633b7fb089ecd9c51a0056a3082f8dd638cd8cf
|
||||
2760d69bab038f5dde66bcad2632bc7cc5806a2a
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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://",
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+210
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/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:<ver>@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.
|
||||
# (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
|
||||
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
|
||||
|
||||
|
||||
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"]
|
||||
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}")
|
||||
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):
|
||||
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}")
|
||||
|
||||
# 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
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/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]
|
||||
|
||||
|
||||
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"
|
||||
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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/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)
|
||||
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)
|
||||
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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/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)
|
||||
|
||||
# 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", "<td>mixed: 1 fix-available, 1 accepted</td>" 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</li>" 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)</li>" 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</li>" 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)
|
||||
@@ -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"
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/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, 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, private_registries=private), 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())
|
||||
|
||||
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",
|
||||
"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)
|
||||
@@ -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"
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ArtifactName": "x",
|
||||
"SchemaVersion": 2
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# =============================================================================
|
||||
# L1 RENDERING — the bot ticks only qualifying approve rows (R-VULN-05, R-VULN-07)
|
||||
# Spec: docs/17 "The triage bot" and its L1 acceptance gates. GIVEN a Renovate dashboard in the
|
||||
# real issue #91 format plus a constructed Pending Approval section, WHEN the bot plans ticks,
|
||||
# THEN only `approve` rows exact-matching an image with an unaccepted CRITICAL with a fix change,
|
||||
# by exactly their marker, and every negative row stays byte-identical.
|
||||
#
|
||||
# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-triage-bot-approval-ticks
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-05: "true"
|
||||
conformance.home.k3s/R-VULN-07: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07"
|
||||
conformance.home.k3s/layer: "L1"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
# The script under test lives in .gitea/workflows/, which requirement-scoped selection does
|
||||
# not attribute to R-VULN-*, so a change to it must still select this test.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/intent: >-
|
||||
A tick is applied only to an approve row for an image with a qualifying finding, and dry
|
||||
runs, duplicate prevention and heartbeat ordering hold through the whole run.
|
||||
spec:
|
||||
steps:
|
||||
- name: plan-and-assert-ticks
|
||||
try:
|
||||
- script:
|
||||
content: bash ./render-check.sh
|
||||
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""L1: the bot ticks only qualifying approve rows, and a whole run behaves (R-VULN-05, R-VULN-07).
|
||||
|
||||
fixtures/renovate-dashboard.md follows issue #91's real format (`## Open` rebase rows, Detected
|
||||
Dependencies) plus a constructed Pending Approval section, because the live dashboard has none.
|
||||
Runs the real functions and the real run() over a fake Gitea and a fake scanner.
|
||||
|
||||
What this does NOT prove: that Renovate honours a tick; that the live dashboard keeps this format
|
||||
across Renovate upgrades (a format change yields no tick rather than a wrong one); that Gitea's
|
||||
API matches the fake; or anything about the runner's network path to the scanner.
|
||||
"""
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[3]
|
||||
spec = importlib.util.spec_from_file_location("cve_triage", ROOT / ".gitea/workflows/scripts/cve_triage.py")
|
||||
bot = importlib.util.module_from_spec(spec)
|
||||
sys.modules["cve_triage"] = bot
|
||||
spec.loader.exec_module(bot)
|
||||
|
||||
RAW = (HERE / "fixtures/report.json").read_bytes()
|
||||
FIXTURE = json.loads(RAW)
|
||||
BODY = (HERE / "fixtures/renovate-dashboard.md").read_text()
|
||||
OPEN_PRS = json.loads((HERE / "fixtures/open-prs.json").read_text())
|
||||
NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc)
|
||||
GITEA = "https://gitea.example.test"
|
||||
REPO = "o/r"
|
||||
VULN = "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081"
|
||||
FALLBACK = "http://10.43.0.99:8081"
|
||||
EXPECTED = {
|
||||
"renovate/nginx-2.x",
|
||||
"renovate/ghcr.io-example-app-2.x",
|
||||
"renovate/example-group",
|
||||
}
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
print(f"{'ok ' if condition else 'FAIL'} {name}")
|
||||
if not condition:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def fails(function):
|
||||
try:
|
||||
function()
|
||||
except AssertionError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def raises(function):
|
||||
try:
|
||||
function()
|
||||
except bot.BotError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def planned(report_doc, body):
|
||||
report = bot.parse_report(json.dumps(report_doc).encode())
|
||||
rows = bot.parse_rows(body)
|
||||
return bot.select_ticks(rows, bot.qualifying_images(report))
|
||||
|
||||
|
||||
def assert_ticks(report_doc, body, expected):
|
||||
"""The tick plan is exactly `expected`, and writing it changes exactly those markers."""
|
||||
ticks = planned(report_doc, body)
|
||||
assert {row.branch for row in ticks} == expected, "tick plan differs"
|
||||
after = bot.apply_ticks(body, ticks)
|
||||
want = body
|
||||
for branch in expected:
|
||||
line = f" - [ ] <!-- approve-branch={branch} -->"
|
||||
assert want.count(line) == 1, "expected row not found exactly once"
|
||||
want = want.replace(line, line.replace("[ ]", "[x]", 1))
|
||||
assert after == want, "output differs from a marker-only edit"
|
||||
|
||||
|
||||
check("exactly the three qualifying approve rows are ticked, and nothing else changes",
|
||||
not fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED)))
|
||||
|
||||
after = bot.apply_ticks(BODY, planned(FIXTURE, BODY))
|
||||
changed = [(a, b) for a, b in zip(BODY.split("\n"), after.split("\n")) if a != b]
|
||||
check("three lines differ, same line count, same length", len(changed) == 3 and len(BODY) == len(after))
|
||||
for label, needle in {
|
||||
"rebase row of a qualifying image": "rebase-branch=renovate/ghcr.io-example-has-pr",
|
||||
"approve row for a non-matching package": "approve-branch=renovate/ghcr.io-other-unrelated",
|
||||
"approve row matching only by substring": "approve-branch=renovate/ghcr.io-foo-bar-extras",
|
||||
"approve row for a HIGH-only image": "approve-branch=renovate/ghcr.io-example-high-only",
|
||||
"approve row for an accepted-CRITICAL image": "approve-branch=renovate/ghcr.io-example-accepted-only",
|
||||
"approve row for a CRITICAL with no fix": "approve-branch=renovate/ghcr.io-example-nofix",
|
||||
"approve row with no container package": "approve-branch=renovate/mychart",
|
||||
"approvePr row of a qualifying image": "approvePr-branch=renovate/ghcr.io-example-app-automerge",
|
||||
"unschedule row of a qualifying image": "unschedule-branch=renovate/ghcr.io-example-pending-sched",
|
||||
"decoy approve row below Detected Dependencies": "approve-branch=renovate/decoy",
|
||||
}.items():
|
||||
original = next(line for line in BODY.split("\n") if needle in line)
|
||||
check(f"untouched: {label}", original in after.split("\n"))
|
||||
check("untouched: the approve-all row", "- [ ] <!-- approve-all-pending-prs -->" in after)
|
||||
check("untouched: the already ticked row keeps its mark",
|
||||
" - [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.x -->" in after)
|
||||
|
||||
# Non-vacuity: each mutation must make the exact assertion above fail.
|
||||
def mutated_finding(image, **fields):
|
||||
doc = copy.deepcopy(FIXTURE)
|
||||
target = next(t for t in doc["targets"] if t["image"] == image)
|
||||
finding = next(f for f in doc["findings"] if f["targetId"] == target["id"])
|
||||
finding.update(fields)
|
||||
return doc
|
||||
|
||||
|
||||
for label, doc in {
|
||||
"HIGH-only image raised to CRITICAL": mutated_finding("ghcr.io/example/high-only", severity="CRITICAL"),
|
||||
"accepted CRITICAL un-accepted": mutated_finding("ghcr.io/example/accepted-only", status="fix-available"),
|
||||
"qualifying finding accepted": mutated_finding("ghcr.io/example/app", status="accepted"),
|
||||
"CRITICAL gains a fix": mutated_finding("ghcr.io/example/nofix", status="fix-available", fixedVersion="2"),
|
||||
}.items():
|
||||
check(f"non-vacuity: mutated fixture fails ({label})", fails(lambda: assert_ticks(doc, BODY, EXPECTED)))
|
||||
|
||||
mutated_body = BODY.replace("rebase-branch=renovate/ghcr.io-example-has-pr", "approve-branch=renovate/ghcr.io-example-has-pr")
|
||||
check("non-vacuity: a rebase row rewritten as approve is then ticked",
|
||||
fails(lambda: assert_ticks(FIXTURE, mutated_body, EXPECTED)))
|
||||
|
||||
real_match = bot.row_matches
|
||||
bot.row_matches = lambda row, qualifying: any(q in p for p in row.packages for q in qualifying)
|
||||
check("non-vacuity: a substring matcher fails the assertion", fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED)))
|
||||
bot.row_matches = real_match
|
||||
|
||||
for raw, want in {
|
||||
"docker.io/library/nginx": "nginx",
|
||||
"library/nginx": "nginx",
|
||||
"nginx:1.27": "nginx",
|
||||
"Docker.IO/Pihole/Pihole": "pihole/pihole",
|
||||
"ghcr.io/library/x": "ghcr.io/library/x",
|
||||
"registry.example:5000/a/b:v1@sha256:" + "0" * 64: "registry.example:5000/a/b",
|
||||
"bad image": "",
|
||||
"": "",
|
||||
}.items():
|
||||
check(f"normalise_image({raw[:40]!r})", bot.normalise_image(raw) == want)
|
||||
|
||||
ticks = planned(FIXTURE, BODY)
|
||||
moved = BODY.replace(" - [ ] <!-- approve-branch=renovate/nginx-2.x -->", " - [ ] <!-- rebase-branch=renovate/nginx-2.x -->")
|
||||
check("apply_ticks refuses a row that stopped being approve", raises(lambda: bot.apply_ticks(moved, ticks)))
|
||||
crlf = BODY.replace("\n", "\r\n")
|
||||
crlf_after = bot.apply_ticks(crlf, planned(FIXTURE, crlf))
|
||||
check("CRLF body keeps every line ending", crlf_after.count("\r\n") == crlf.count("\r\n") and crlf_after.count("[x]") == 4)
|
||||
|
||||
|
||||
class FakeWorld:
|
||||
"""A fake Gitea and scanner reached through the transport interface. Records every write."""
|
||||
|
||||
def __init__(self, renovate_body=BODY, report=RAW):
|
||||
self.report = report
|
||||
self.labels = []
|
||||
self.issues = {91: {"number": 91, "title": "Dependency Dashboard", "state": "open", "labels": [], "body": renovate_body, "pull_request": None}}
|
||||
self.next_issue = 200
|
||||
self.writes = []
|
||||
self.triage = None
|
||||
self.fail = None
|
||||
self.dns_down = set()
|
||||
self.seen_hosts = []
|
||||
|
||||
def __call__(self, method, url, headers, body, max_bytes):
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
self.seen_hosts.append(parts.netloc)
|
||||
if parts.netloc in self.dns_down:
|
||||
raise bot.DnsFailure()
|
||||
if self.fail and self.fail[0] == method and self.fail[1] in parts.path:
|
||||
return 500, b""
|
||||
payload = json.loads(body) if body else None
|
||||
if parts.netloc != "gitea.example.test":
|
||||
if method == "GET" and parts.path == "/report.json":
|
||||
return 200, self.report
|
||||
if method == "POST" and parts.path == "/triage":
|
||||
self.writes.append(("POST", "triage"))
|
||||
self.triage = payload
|
||||
return 204, b""
|
||||
return 404, b""
|
||||
path = parts.path.removeprefix(f"/api/v1/repos/{REPO}")
|
||||
query = dict(urllib.parse.parse_qsl(parts.query))
|
||||
page = int(query.get("page", "1"))
|
||||
if method != "GET":
|
||||
self.writes.append((method, path))
|
||||
if method == "GET" and path == "/labels":
|
||||
return 200, json.dumps(self.labels if page == 1 else []).encode()
|
||||
if method == "POST" and path == "/labels":
|
||||
label = {"id": 900 + len(self.labels), "name": payload["name"]}
|
||||
self.labels.append(label)
|
||||
return 201, json.dumps(label).encode()
|
||||
if method == "GET" and path == "/pulls":
|
||||
return 200, json.dumps(OPEN_PRS if page == 1 else []).encode()
|
||||
if method == "GET" and path == "/issues":
|
||||
items = list(self.issues.values())
|
||||
if "labels" in query:
|
||||
items = [i for i in items if query["labels"] in {x["name"] for x in i["labels"]}]
|
||||
if "q" in query:
|
||||
items = [i for i in items if query["q"] in i["title"]]
|
||||
return 200, json.dumps(items if page == 1 else []).encode()
|
||||
if method == "POST" and path == "/issues":
|
||||
number = self.next_issue
|
||||
self.next_issue += 1
|
||||
names = [x for x in self.labels if x["id"] in payload["labels"]]
|
||||
self.issues[number] = {"number": number, "title": payload["title"], "state": "open", "labels": names, "body": payload["body"], "pull_request": None}
|
||||
return 201, json.dumps(self.issues[number]).encode()
|
||||
if path.startswith("/issues/") and path.endswith("/labels") and method == "POST":
|
||||
number = int(path.split("/")[2])
|
||||
self.issues[number]["labels"] += [x for x in self.labels if x["id"] in payload["labels"]]
|
||||
return 200, b"[]"
|
||||
if path.startswith("/issues/"):
|
||||
number = int(path.split("/")[2])
|
||||
if method == "GET":
|
||||
return 200, json.dumps(self.issues[number]).encode()
|
||||
if method == "PATCH":
|
||||
self.issues[number].update(payload)
|
||||
return 201, json.dumps(self.issues[number]).encode()
|
||||
return 404, b""
|
||||
|
||||
|
||||
def execute(world, dry_run=False, fallback=FALLBACK):
|
||||
cfg = {"dry_run": dry_run, "page_url": "https://cve.example.test", "renovate_issue": 91}
|
||||
gitea = bot.GiteaClient(GITEA, REPO, "fixture-token", world)
|
||||
scanner = bot.ScannerClient(VULN, fallback, world)
|
||||
lines = []
|
||||
summary = bot.run(cfg, gitea, scanner, NOW, lines.append)
|
||||
return summary, "\n".join(lines)
|
||||
|
||||
|
||||
def dashboards(world):
|
||||
return [i for i in world.issues.values() if i["title"] == bot.DASHBOARD_TITLE]
|
||||
|
||||
|
||||
# Dry run: reads everything, writes nothing, reports the plan as counts.
|
||||
world = FakeWorld()
|
||||
summary, text = execute(world, dry_run=True)
|
||||
check("dry run performs no writes", world.writes == [] and world.triage is None and not dashboards(world))
|
||||
check("dry run plans the three ticks and the issue", summary["ticks"] == 3 and summary["dashboard"]["issue_created"] == 1)
|
||||
check("dry run leaves the dashboard body untouched", world.issues[91]["body"] == BODY)
|
||||
|
||||
NAMES = {"CVE-2026", "fixture-pkg", "fixture-ns-", "fixture-workload", "nginx", "ghcr.io"}
|
||||
|
||||
|
||||
def leaks(text_value):
|
||||
return [name for name in NAMES if name.lower() in text_value.lower()]
|
||||
|
||||
|
||||
check("dry-run summary is counts only", not leaks(text))
|
||||
|
||||
# Live run.
|
||||
world = FakeWorld()
|
||||
summary, text = execute(world)
|
||||
board = dashboards(world)
|
||||
check("live run creates one label and one dashboard issue", len(world.labels) == 1 and len(board) == 1)
|
||||
check("the dashboard issue carries only its own label", [x["name"] for x in board[0]["labels"]] == ["cve-dashboard"])
|
||||
check("issue body and summary are counts only", not leaks(board[0]["body"]) and not leaks(text))
|
||||
check("exactly one PATCH to issue #91", world.writes.count(("PATCH", "/issues/91")) == 1)
|
||||
want_body = BODY
|
||||
for branch in EXPECTED:
|
||||
line = f" - [ ] <!-- approve-branch={branch} -->"
|
||||
want_body = want_body.replace(line, line.replace("[ ]", "[x]", 1))
|
||||
check("the PATCHed body is the marker-only edit", world.issues[91]["body"] == want_body)
|
||||
check("the heartbeat is the last write", world.writes[-1] == ("POST", "triage"))
|
||||
check("heartbeat shape is exact", set(world.triage) == {"heartbeatAt", "remediation"} and world.triage["heartbeatAt"] == "2026-11-09T07:15:00Z")
|
||||
for entry in world.triage["remediation"]:
|
||||
if set(entry) != {"image", "state", "url", "updatedAt"} or entry["state"] not in bot.REMEDIATION_STATES:
|
||||
check("remediation entry shape", False)
|
||||
states = {e["image"]: (e["state"], e["url"]) for e in world.triage["remediation"]}
|
||||
for image, want in {
|
||||
"docker.io/library/nginx": ("approval-requested", None),
|
||||
"ghcr.io/example/app": ("approval-requested", None),
|
||||
"ghcr.io/example/grouped": ("approval-requested", None),
|
||||
"ghcr.io/example/already-ticked": ("approval-requested", None),
|
||||
"ghcr.io/example/high-only": ("approval-requested", None),
|
||||
"ghcr.io/example/has-pr": ("pr-open", f"{GITEA}/{REPO}/pulls/173"),
|
||||
"ghcr.io/example/pr-by-title": ("pr-open", f"{GITEA}/{REPO}/pulls/174"),
|
||||
"ghcr.io/example/tracked-clean": ("no-update-identified", None),
|
||||
"ghcr.io/foo/bar": ("no-update-identified", None),
|
||||
"ghcr.io/example/untracked": ("not-tracked", None),
|
||||
"ghcr.io/example/pending-sched": ("not-tracked", None),
|
||||
}.items():
|
||||
check(f"remediation for {image}", states.get(image) == want)
|
||||
check("one remediation entry per image", len(states) == len(FIXTURE["targets"]))
|
||||
|
||||
# A second run updates the same issue.
|
||||
before = len(world.issues)
|
||||
second = copy.deepcopy(FIXTURE)
|
||||
second["targets"][-1]["state"] = "stale"
|
||||
world.report = json.dumps(second).encode()
|
||||
summary, _ = execute(world)
|
||||
check("a second run creates no duplicate", len(dashboards(world)) == 1 and len(world.issues) == before)
|
||||
check("a second run updates the issue", summary["dashboard"]["issue_updated"] == 1 and "| stale | 1 |" in dashboards(world)[0]["body"])
|
||||
check("a second run has nothing left to tick", summary["ticks"] == 0)
|
||||
|
||||
# The dashboard issue is found even without its label, and a closed one is reopened.
|
||||
world = FakeWorld()
|
||||
world.labels.append({"id": 900, "name": "cve-dashboard"})
|
||||
world.issues[150] = {"number": 150, "title": "CVE Dashboard", "state": "closed", "labels": [], "body": "old", "pull_request": None}
|
||||
summary, _ = execute(world)
|
||||
check("an unlabelled closed dashboard is reused, relabelled and reopened",
|
||||
len(dashboards(world)) == 1 and world.issues[150]["state"] == "open"
|
||||
and [x["name"] for x in world.issues[150]["labels"]] == ["cve-dashboard"] and summary["dashboard"]["issue_created"] == 0)
|
||||
|
||||
world = FakeWorld()
|
||||
world.labels.append({"id": 900, "name": "cve-dashboard"})
|
||||
for number in (150, 151):
|
||||
world.issues[number] = {"number": number, "title": "CVE Dashboard", "state": "open", "labels": [world.labels[0]], "body": "x", "pull_request": None}
|
||||
check("two dashboard issues fail the run", raises(lambda: execute(world)))
|
||||
check("two dashboard issues write nothing", world.writes == [])
|
||||
|
||||
# Failures end the run before the heartbeat.
|
||||
world = FakeWorld()
|
||||
world.fail = ("PATCH", "/issues/91")
|
||||
check("a failed tick PATCH fails the run", raises(lambda: execute(world)))
|
||||
check("a failed run posts no heartbeat", world.triage is None)
|
||||
world = FakeWorld(report=b"{not json")
|
||||
check("an unreadable report fails the run", raises(lambda: execute(world)))
|
||||
check("an unreadable report writes nothing", world.writes == [])
|
||||
world = FakeWorld()
|
||||
world.issues[91]["title"] = "Something else"
|
||||
check("issue 91 being something else fails the run", raises(lambda: execute(world)))
|
||||
world = FakeWorld()
|
||||
world.issues[91]["state"] = "closed"
|
||||
check("a closed Renovate dashboard fails the run", raises(lambda: execute(world)))
|
||||
|
||||
# DNS fallback, and nothing but DNS failure selects it.
|
||||
world = FakeWorld()
|
||||
world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081")
|
||||
summary, _ = execute(world)
|
||||
check("DNS failure uses the fallback for report and heartbeat", summary["fallback_used"] and world.triage is not None)
|
||||
check("the fallback was actually contacted", "10.43.0.99:8081" in world.seen_hosts)
|
||||
world = FakeWorld()
|
||||
world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081")
|
||||
check("DNS failure with no fallback fails the run", raises(lambda: execute(world, fallback="")))
|
||||
|
||||
|
||||
def refused(method, url, headers, body, max_bytes):
|
||||
raise bot.BotError("GET /report.json: ConnectionRefusedError")
|
||||
|
||||
|
||||
check("a refused connection fails the run and does not fall back",
|
||||
raises(lambda: bot.ScannerClient(VULN, FALLBACK, refused).report()))
|
||||
|
||||
for label, env, expected_ok in (
|
||||
("missing token", {}, False),
|
||||
("present token", {"RENOVATE_TOKEN": "x"}, True),
|
||||
("bad DRY_RUN", {"RENOVATE_TOKEN": "x", "DRY_RUN": "maybe"}, False),
|
||||
("non-http API URL", {"RENOVATE_TOKEN": "x", "VULN_API_URL": "file:///etc/passwd"}, False),
|
||||
):
|
||||
try:
|
||||
bot.load_config(env)
|
||||
ok = True
|
||||
except bot.BotError:
|
||||
ok = False
|
||||
check(f"load_config: {label}", ok is expected_ok)
|
||||
|
||||
print(f"{len(failures)} failure(s)")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"number": 173, "title": "chore(deps): Update ghcr.io/example/has-pr Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-has-pr-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/173"},
|
||||
{"number": 174, "title": "chore(deps): Update ghcr.io/example/pr-by-title Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-pr-by-title-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/174"},
|
||||
{"number": 160, "title": "WIP: chore(spec): not Renovate work", "head": {"ref": "chore/adopt-spec-sync"}, "html_url": "https://gitea.example.test/o/r/pulls/160"}
|
||||
]
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more.
|
||||
|
||||
## Pending Approval
|
||||
|
||||
The following branches are pending approval. To create them, click on a checkbox below.
|
||||
|
||||
- [ ] <!-- approve-branch=renovate/nginx-2.x -->chore(deps): Update nginx Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-example-app-2.x -->chore(deps): Update ghcr.io/example/app Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/example-group -->chore(deps): Update example group (`ghcr.io/example/grouped`, `ghcr.io/example/other`)
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-foo-bar-extras-2.x -->chore(deps): Update ghcr.io/foo/bar-extras Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-other-unrelated-2.x -->chore(deps): Update ghcr.io/other/unrelated Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-example-high-only-2.x -->chore(deps): Update ghcr.io/example/high-only Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-example-accepted-only-2.x -->chore(deps): Update ghcr.io/example/accepted-only Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-example-nofix-2.x -->chore(deps): Update ghcr.io/example/nofix Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/mychart-2.x -->chore(deps): Update Helm release mychart to v2
|
||||
- [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.x -->chore(deps): Update ghcr.io/example/already-ticked Docker tag to v2
|
||||
- [ ] <!-- approve-all-pending-prs -->🔐 **Create all pending approval PRs at once** 🔐
|
||||
|
||||
## Awaiting Schedule
|
||||
|
||||
The following updates are awaiting their schedule. To get an update now, click on a checkbox below.
|
||||
|
||||
- [ ] <!-- unschedule-branch=renovate/ghcr.io-example-pending-sched-2.x -->chore(deps): Update ghcr.io/example/pending-sched Docker tag to v2
|
||||
|
||||
## Pending Branch Automerge
|
||||
|
||||
The following branches have automerge enabled. To abort a branch automerge, click on a checkbox below.
|
||||
|
||||
- [ ] <!-- approvePr-branch=renovate/ghcr.io-example-app-automerge -->chore(deps): Update ghcr.io/example/app Docker tag to v2
|
||||
|
||||
## Open
|
||||
|
||||
The following updates have all been created. To force a retry/rebase of any, click on a checkbox below.
|
||||
|
||||
- [ ] <!-- rebase-branch=renovate/gitea.home.keithjackson.dev-home-network-toolbox-main -->[chore(deps): Update gitea.home.keithjackson.dev/home-network/toolbox:main Docker digest to 83a3501](pulls/161)
|
||||
- [ ] <!-- rebase-branch=renovate/kube-prometheus-stack-93.x -->[chore(deps): Update Helm release kube-prometheus-stack to v93](pulls/164)
|
||||
- [ ] <!-- rebase-branch=renovate/ghcr.io-example-has-pr-2.x -->[chore(deps): Update ghcr.io/example/has-pr Docker tag to v2](pulls/173)
|
||||
- [ ] <!-- rebase-all-open-prs -->**Click on this checkbox to rebase all open PRs at once**
|
||||
|
||||
## Detected Dependencies
|
||||
|
||||
<details><summary>flux (13)</summary>
|
||||
<blockquote>
|
||||
|
||||
<details><summary>platform/cert-manager/helmrelease.yaml (1)</summary>
|
||||
|
||||
- `cert-manager >=1.21.0 <1.22.0` → [Updates: `>=1.21.3 <1.22.0`]
|
||||
|
||||
</details>
|
||||
|
||||
</blockquote>
|
||||
</details>
|
||||
|
||||
<details><summary>kubernetes (69)</summary>
|
||||
<blockquote>
|
||||
|
||||
<details><summary>apps/wave-0/jackett.yaml (4)</summary>
|
||||
|
||||
- `lscr.io/linuxserver/jackett v0.24.3025-ls29` → [Updates: `v0.24.3030-ls30`]
|
||||
- `quay.io/oauth2-proxy/oauth2-proxy v7.15.6`
|
||||
- `NetworkPolicy networking.k8s.io/v1`
|
||||
- `ghcr.io/example/tracked-clean 1.0`
|
||||
- `ghcr.io/foo/bar 1.0`
|
||||
- `ghcr.io/example/pending-sched 1.0` → [Updates: `2.0`]
|
||||
- `ghcr.io/example/app 1.0` → [Updates: `2.0`]
|
||||
- [ ] <!-- approve-branch=renovate/decoy-below-detected -->chore(deps): Update nginx Docker tag to v9
|
||||
|
||||
</details>
|
||||
|
||||
</blockquote>
|
||||
</details>
|
||||
@@ -0,0 +1,425 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-11-09T06:00:00Z",
|
||||
"database": {
|
||||
"updatedAt": "2026-11-09T01:00:00Z",
|
||||
"version": "fixture-db"
|
||||
},
|
||||
"run": {
|
||||
"startedAt": "2026-11-09T05:00:00Z",
|
||||
"finishedAt": "2026-11-09T05:30:00Z",
|
||||
"status": "complete"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"id": "docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"type": "image",
|
||||
"image": "docker.io/library/nginx",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-0",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-0",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/app@sha256:0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/app",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-1",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-1",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/grouped@sha256:0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/grouped",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-2",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-2",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/foo/bar@sha256:0000000000000000000000000000000000000000000000000000000000000003",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/foo/bar",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000003",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-3",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-3",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/has-pr@sha256:0000000000000000000000000000000000000000000000000000000000000004",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/has-pr",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000004",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-4",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-4",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/pr-by-title@sha256:0000000000000000000000000000000000000000000000000000000000000005",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/pr-by-title",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000005",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-5",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-5",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/already-ticked@sha256:0000000000000000000000000000000000000000000000000000000000000006",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/already-ticked",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000006",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-6",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-6",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/untracked@sha256:0000000000000000000000000000000000000000000000000000000000000007",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/untracked",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000007",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-7",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-7",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/pending-sched@sha256:0000000000000000000000000000000000000000000000000000000000000008",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/pending-sched",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000008",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-8",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-8",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/high-only@sha256:0000000000000000000000000000000000000000000000000000000000000009",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/high-only",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000009",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-9",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-9",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/accepted-only@sha256:000000000000000000000000000000000000000000000000000000000000000a",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/accepted-only",
|
||||
"digest": "sha256:000000000000000000000000000000000000000000000000000000000000000a",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-10",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-10",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/nofix@sha256:000000000000000000000000000000000000000000000000000000000000000b",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/nofix",
|
||||
"digest": "sha256:000000000000000000000000000000000000000000000000000000000000000b",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-11",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-11",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/tracked-clean@sha256:000000000000000000000000000000000000000000000000000000000000000c",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/tracked-clean",
|
||||
"digest": "sha256:000000000000000000000000000000000000000000000000000000000000000c",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-12",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-12",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"findings": [
|
||||
{
|
||||
"id": "CVE-2026-1000",
|
||||
"targetId": "docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-0",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1010",
|
||||
"targetId": "ghcr.io/example/app@sha256:0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-1",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1020",
|
||||
"targetId": "ghcr.io/example/grouped@sha256:0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-2",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1030",
|
||||
"targetId": "ghcr.io/foo/bar@sha256:0000000000000000000000000000000000000000000000000000000000000003",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-3",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1040",
|
||||
"targetId": "ghcr.io/example/has-pr@sha256:0000000000000000000000000000000000000000000000000000000000000004",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-4",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1050",
|
||||
"targetId": "ghcr.io/example/pr-by-title@sha256:0000000000000000000000000000000000000000000000000000000000000005",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-5",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1060",
|
||||
"targetId": "ghcr.io/example/already-ticked@sha256:0000000000000000000000000000000000000000000000000000000000000006",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-6",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1070",
|
||||
"targetId": "ghcr.io/example/untracked@sha256:0000000000000000000000000000000000000000000000000000000000000007",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-7",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1080",
|
||||
"targetId": "ghcr.io/example/pending-sched@sha256:0000000000000000000000000000000000000000000000000000000000000008",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-8",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1090",
|
||||
"targetId": "ghcr.io/example/high-only@sha256:0000000000000000000000000000000000000000000000000000000000000009",
|
||||
"severity": "HIGH",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-9",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1100",
|
||||
"targetId": "ghcr.io/example/accepted-only@sha256:000000000000000000000000000000000000000000000000000000000000000a",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-10",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "accepted",
|
||||
"acceptance": {
|
||||
"reason": "fixture",
|
||||
"expires": "2027-01-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1110",
|
||||
"targetId": "ghcr.io/example/nofix@sha256:000000000000000000000000000000000000000000000000000000000000000b",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-11",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": null,
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "open"
|
||||
}
|
||||
],
|
||||
"remediation": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# L1 offline check for the CVE triage bot: fixture-driven, no cluster, no network. Assertions and
|
||||
# their limits are in check.py. rc 0 pass, 1 fail.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHONDONTWRITEBYTECODE=1 exec python3 "${here}/check.py"
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# =============================================================================
|
||||
# L1 RENDERING — CVE Dashboard issue carries aggregates only (R-VULN-05, R-VULN-07)
|
||||
# Spec: docs/17 "The triage bot". GIVEN a report fixture with hostile and distinctive strings,
|
||||
# WHEN the bot renders the dashboard issue, THEN no CVE id, package, image, workload, namespace or
|
||||
# other report string appears, the counts are exact, and a deliberately leaking body is caught.
|
||||
#
|
||||
# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-triage-bot-dashboard-aggregates
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-05: "true"
|
||||
conformance.home.k3s/R-VULN-07: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07"
|
||||
conformance.home.k3s/layer: "L1"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
# The script under test lives in .gitea/workflows/, which requirement-scoped selection does
|
||||
# not attribute to R-VULN-*, so a change to it must still select this test.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/intent: >-
|
||||
A dashboard issue rendered from a fixture contains counts and timestamps only, never a CVE id,
|
||||
package, image or workload name.
|
||||
spec:
|
||||
steps:
|
||||
- name: render-and-assert-aggregates-only
|
||||
try:
|
||||
- script:
|
||||
content: bash ./render-check.sh
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""L1: the CVE Dashboard issue is aggregates only (R-VULN-05, R-VULN-07).
|
||||
|
||||
Exercises the real renderer in .gitea/workflows/scripts/cve_triage.py over fixtures/report.json,
|
||||
whose every name is distinctive so a leak cannot be confused with template text.
|
||||
|
||||
What this does NOT prove: that Gitea accepts the issue, or that the issue is the one the live bot
|
||||
wrote. Those need a real run and are not exercised by any L1 test.
|
||||
"""
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[3]
|
||||
spec = importlib.util.spec_from_file_location("cve_triage", ROOT / ".gitea/workflows/scripts/cve_triage.py")
|
||||
bot = importlib.util.module_from_spec(spec)
|
||||
sys.modules["cve_triage"] = bot
|
||||
spec.loader.exec_module(bot)
|
||||
|
||||
RAW = (HERE / "fixtures/report.json").read_bytes()
|
||||
FIXTURE = json.loads(RAW)
|
||||
NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc)
|
||||
PAGE = "https://cve.example.test"
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
print(f"{'ok ' if condition else 'FAIL'} {name}")
|
||||
if not condition:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def sensitive_names(doc):
|
||||
"""Every string in the fixture that must never reach the issue."""
|
||||
names = set()
|
||||
for target in doc["targets"]:
|
||||
for field in ("id", "image", "digest", "reason"):
|
||||
if target.get(field):
|
||||
names.add(target[field])
|
||||
if target.get("image"):
|
||||
names.add(bot.normalise_image(target["image"]))
|
||||
names.add(target["image"].rsplit("/", 1)[-1])
|
||||
for workload in target["workloads"]:
|
||||
names.update((workload["namespace"], workload["name"]))
|
||||
names.update(workload["containers"])
|
||||
for finding in doc["findings"]:
|
||||
for field in ("id", "package", "installedVersion", "fixedVersion", "title", "url"):
|
||||
if finding.get(field):
|
||||
names.add(finding[field])
|
||||
if finding.get("acceptance"):
|
||||
names.add(finding["acceptance"]["reason"])
|
||||
names.add(doc["database"]["version"])
|
||||
return {name for name in names if name}
|
||||
|
||||
|
||||
def assert_no_names(body, names):
|
||||
lowered = body.lower()
|
||||
leaked = sorted(name for name in names if name.lower() in lowered)
|
||||
assert not leaked, f"{len(leaked)} fixture names leaked into the issue body"
|
||||
|
||||
|
||||
def rendered(raw=RAW, now=NOW):
|
||||
return bot.render_dashboard(bot.aggregate(bot.parse_report(raw)), PAGE, now)
|
||||
|
||||
|
||||
def fails(function):
|
||||
try:
|
||||
function()
|
||||
except AssertionError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def rejected(doc):
|
||||
try:
|
||||
bot.parse_report(json.dumps(doc).encode())
|
||||
except bot.BotError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
body = rendered()
|
||||
names = sensitive_names(FIXTURE)
|
||||
check("fixture carries a non-trivial set of names", len(names) >= 25)
|
||||
check("rendered body contains no fixture name", not fails(lambda: assert_no_names(body, names)))
|
||||
check("body has no CVE or GHSA identifier at all", not re.search(r"CVE-\d|GHSA-", body, re.I))
|
||||
check("hostile title's markup is absent", "onerror" not in body and "evil.example" not in body and "@keith" not in body)
|
||||
|
||||
# Non-vacuity: the assertion above must detect each class of leak.
|
||||
for label, leak in {
|
||||
"CVE id": "CVE-2026-11111",
|
||||
"package": "fixture-libcrit",
|
||||
"image": "ghcr.io/fixture-org/fixture-api",
|
||||
"workload": "fixture-workload-db",
|
||||
"namespace": "fixture-ns-gamma",
|
||||
}.items():
|
||||
check(f"non-vacuity: a leaked {label} is caught", fails(lambda: assert_no_names(body + leak, names)))
|
||||
|
||||
for row in (
|
||||
"| CRITICAL | 1 | 1 | 1 |",
|
||||
"| HIGH | 0 | 1 | 0 |",
|
||||
"| MEDIUM | 0 | 0 | 0 |",
|
||||
"| LOW | 1 | 0 | 0 |",
|
||||
"| UNKNOWN | 0 | 0 | 0 |",
|
||||
"| scanned | 1 |",
|
||||
"| pending | 0 |",
|
||||
"| stale | 1 |",
|
||||
"| failed | 0 |",
|
||||
"| credential-required | 1 |",
|
||||
"| by-tag | 0 |",
|
||||
"| not-covered | 1 |",
|
||||
"Unaccepted CRITICAL with a fix: **1**",
|
||||
"4 targets.",
|
||||
"| Report generated | 2026-11-09T06:00:00Z |",
|
||||
f"[CVE page]({PAGE})",
|
||||
):
|
||||
check(f"body states {row!r}", row in body)
|
||||
check("a fresh report carries no warning", "WARNING" not in body)
|
||||
|
||||
mutated = copy.deepcopy(FIXTURE)
|
||||
extra = copy.deepcopy(mutated["findings"][0])
|
||||
extra["id"] = "CVE-2026-55555"
|
||||
mutated["findings"].append(extra)
|
||||
changed = rendered(json.dumps(mutated).encode())
|
||||
check("non-vacuity: a mutated fixture changes the counts", "| CRITICAL | 1 | 2 | 1 |" in changed and changed != body)
|
||||
|
||||
check("stale report warns", "older than 26 hours" in rendered(now=NOW + timedelta(hours=30)))
|
||||
degraded = copy.deepcopy(FIXTURE)
|
||||
degraded["run"]["status"] = "degraded"
|
||||
check("degraded run warns", "did not complete cleanly" in rendered(json.dumps(degraded).encode()))
|
||||
old_db = copy.deepcopy(FIXTURE)
|
||||
old_db["database"]["updatedAt"] = "2026-11-01T00:00:00Z"
|
||||
check("stale database warns", "database is older" in rendered(json.dumps(old_db).encode()))
|
||||
|
||||
for label, mutate in {
|
||||
"wrong schemaVersion": lambda d: d.update(schemaVersion=2),
|
||||
"unknown severity": lambda d: d["findings"][0].update(severity="SEVERE"),
|
||||
"unknown status": lambda d: d["findings"][0].update(status="ignored"),
|
||||
"unknown target state": lambda d: d["targets"][0].update(state="clean"),
|
||||
"finding on an unknown target": lambda d: d["findings"][0].update(targetId="nope"),
|
||||
"bad timestamp": lambda d: d.update(generatedAt="yesterday <b>"),
|
||||
"invalid image": lambda d: d["targets"][0].update(image="bad image <x>"),
|
||||
}.items():
|
||||
doc = copy.deepcopy(FIXTURE)
|
||||
mutate(doc)
|
||||
check(f"parse rejects {label}", rejected(doc))
|
||||
|
||||
check("dashboard label is not one the PM acts on", bot.DASHBOARD_LABEL not in bot.FORBIDDEN_LABELS)
|
||||
configmap = (ROOT / "platform/pm-agent/configmap.yaml").read_text()
|
||||
match = re.search(r'^\s*PM_REQUIRE_LABEL:\s*"([^"]*)"', configmap, re.M)
|
||||
check("PM_REQUIRE_LABEL is readable", match is not None)
|
||||
check("the PM's label is in FORBIDDEN_LABELS and is not ours",
|
||||
match is not None and match.group(1) in bot.FORBIDDEN_LABELS and match.group(1) != bot.DASHBOARD_LABEL)
|
||||
|
||||
print(f"{len(failures)} failure(s)")
|
||||
sys.exit(1 if failures else 0)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-11-09T06:00:00Z",
|
||||
"database": {"updatedAt": "2026-11-09T01:00:00Z", "version": "fixture-db-v2-20261109"},
|
||||
"run": {"startedAt": "2026-11-09T05:00:00Z", "finishedAt": "2026-11-09T05:30:00Z", "status": "complete"},
|
||||
"targets": [
|
||||
{"id": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "type": "image", "image": "ghcr.io/fixture-org/fixture-api", "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "state": "scanned", "reason": null, "lastScannedAt": "2026-11-09T05:10:00Z", "workloads": [{"namespace": "fixture-ns-alpha", "kind": "Deployment", "name": "fixture-workload-api", "containers": ["fixture-container-api"]}]},
|
||||
{"id": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "type": "image", "image": "docker.io/library/fixturedb", "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "state": "stale", "reason": "fixture-reason-stale-text", "lastScannedAt": "2026-11-07T05:10:00Z", "workloads": [{"namespace": "fixture-ns-beta", "kind": "StatefulSet", "name": "fixture-workload-db", "containers": ["fixture-container-db"]}]},
|
||||
{"id": "fixture.example:5000/priv/fixture-private:v1", "type": "image", "image": "fixture.example:5000/priv/fixture-private", "digest": null, "state": "credential-required", "reason": "fixture.example:5000 refused anonymous access", "lastScannedAt": null, "workloads": [{"namespace": "fixture-ns-gamma", "kind": "CronJob", "name": "fixture-workload-cron", "containers": ["fixture-container-cron"]}]},
|
||||
{"id": "vm/fixture-ns-vm/fixture-vm-name", "type": "vm", "image": null, "digest": null, "state": "not-covered", "reason": null, "lastScannedAt": null, "workloads": [{"namespace": "fixture-ns-vm", "kind": "VirtualMachineInstance", "name": "fixture-vm-name", "containers": []}]}
|
||||
],
|
||||
"findings": [
|
||||
{"id": "CVE-2026-11111", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "CRITICAL", "cvss": 9.8, "package": "fixture-libcrit", "installedVersion": "1.0.1-installed", "fixedVersion": "1.0.2-fixed", "title": "<img src=x onerror=alert(1)> @keith [click](http://evil.example/hook) | injected |", "url": "https://advisory.example/CVE-2026-11111", "firstSeenAt": "2026-11-01T00:00:00Z", "status": "fix-available"},
|
||||
{"id": "GHSA-fixt-ure0-0001", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "CRITICAL", "cvss": 9.1, "package": "fixture-libopen", "installedVersion": "2.0.0-installed", "fixedVersion": null, "title": "fixture title without a fix", "url": "https://advisory.example/ghsa", "firstSeenAt": "2026-11-02T00:00:00Z", "status": "open"},
|
||||
{"id": "CVE-2026-22222", "targetId": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "severity": "CRITICAL", "cvss": 9.0, "package": "fixture-libaccepted", "installedVersion": "3.0.0-installed", "fixedVersion": "3.0.1-fixed", "title": "fixture accepted title", "url": "https://advisory.example/accepted", "firstSeenAt": "2026-10-01T00:00:00Z", "status": "accepted", "acceptance": {"reason": "fixture-acceptance-reason", "expires": "2027-01-01"}},
|
||||
{"id": "CVE-2026-33333", "targetId": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "severity": "HIGH", "cvss": 7.5, "package": "fixture-libhigh", "installedVersion": "4.0.0-installed", "fixedVersion": "4.0.1-fixed", "title": "fixture high title", "url": "https://advisory.example/high", "firstSeenAt": "2026-11-03T00:00:00Z", "status": "fix-available"},
|
||||
{"id": "CVE-2026-44444", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "LOW", "cvss": null, "package": "fixture-liblow", "installedVersion": "5.0.0-installed", "fixedVersion": null, "title": "fixture low title", "url": "", "firstSeenAt": "2026-11-04T00:00:00Z", "status": "open"}
|
||||
],
|
||||
"remediation": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# L1 offline check for the CVE triage bot: fixture-driven, no cluster, no network. Assertions and
|
||||
# their limits are in check.py. rc 0 pass, 1 fail.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHONDONTWRITEBYTECODE=1 exec python3 "${here}/check.py"
|
||||
@@ -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"'
|
||||
@@ -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"'
|
||||
@@ -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.<domain> 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.<domain> 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.<domain> 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"'
|
||||
@@ -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"'
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
@@ -147,3 +147,4 @@ spec:
|
||||
- radarr
|
||||
- sonarr
|
||||
- headlamp
|
||||
- vuln-scanner
|
||||
@@ -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
|
||||
|
||||
@@ -153,5 +153,13 @@ data:
|
||||
"webOrigins": [
|
||||
"https://sonarr.${BASE_DOMAIN}"
|
||||
]
|
||||
},
|
||||
"vuln-scanner": {
|
||||
"redirectUris": [
|
||||
"https://cve.${BASE_DOMAIN}/oauth2/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://cve.${BASE_DOMAIN}"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ stringData:
|
||||
qbittorrent-client-secret: ENC[AES256_GCM,data:jXNg4mF9Li0ilsV53iYeVJCGavVGFjvLk9jKBrRgcH6id1QCzsjs0bbMqJO5nALWfKhD8+CEmYJRrfABYTFKZA==,iv:XbLkX7/tfB/DLeMriHA3Oo2M6hgF1Z6RbHBXmdwUJoA=,tag:gikgdU0ajNKZxxYEUGc1aw==,type:str]
|
||||
radarr-client-secret: ENC[AES256_GCM,data:acsBXdewKB/gJNyoo1CsPLCvZe6fuJWbrKLim1/57OPnDHNoA+OvkQqt12Y4VMAXjzl7BWD2afFTIkIcaNGgaQ==,iv:VSvMaDZeuFyIAWpUoa++khvx07hY6g7X8PxORtjFTVI=,tag:jY5QSw6cMvUhCfX32RNgXA==,type:str]
|
||||
sonarr-client-secret: ENC[AES256_GCM,data:6cCVadKAKh5YbZIPocXG8pNY/AZbo3zCUP/19a0aHK0fIx6QzvBBl7v2vTsY/tq5tiVx8Uzr6oQNo/xt6pIgIA==,iv:EBXBZgpgdt7GiIS0w/Uj/vNE9EBgSgrbaMOKP346GKA=,tag:I2skAB+fdfQTsMgcywOrAg==,type:str]
|
||||
vuln-scanner-client-secret: ENC[AES256_GCM,data:8LHIcFx42UzW3XMqgDe+lPJD6cdj8m/AWQa7tNPweIFZ7dZgvsDp5zjOUN8z+F+BnAVgMlt4fwHh6F/OyRy12w==,iv:BxdvgUWFyGPcFpWx+rYu2GDwc+cUEKqQGBxtBiuu/Lc=,tag:WQ322UyYtxSewyQBo5Ec1w==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
gcp_kms: []
|
||||
@@ -32,8 +33,8 @@ sops:
|
||||
YXJGUUJiTnFndFhDZDU1bmxXTENCbmsKuhb3RQ3LZmuTPAXEck8b6iuZ5vxLb9fV
|
||||
+fQELmU4kOTbZrPSxy+kpttLG5Cy5d6X2ieZXBf6opzpLTUhlBOkHA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-08-31T13:42:49Z"
|
||||
mac: ENC[AES256_GCM,data:/sOLb/XYQ2WB+mPgnTfYXD3y7xwDi926v0e2upBqgcCugtfKHg+Q1C9+XUiG2qE/iIBJyYVtOu9pmiMMNyT9hJ9vm7WFOC6gqukFMo42PjAI4I4PExfjVMKt76kKJ4OZZrKF2IKLjccZFuKDQhfADIR3zl6UNf/x5wz7XpXPEvI=,iv:iV4eOcRxsgTm1uNVMkMwsspj9glKCSZQlBciRY+MDJY=,tag:c72Pks1VJH+gmfP5FOQPSw==,type:str]
|
||||
lastmodified: "2026-11-09T22:22:55Z"
|
||||
mac: ENC[AES256_GCM,data:xZTYvTnXI/W5bKpa5orycFIK00+oZXtck9XASOulL9pvvZL+g3VR79mSN5bjmKHvA7LNcd8z2SJhNVqDxuEs1ciOiFHDgMq/ZNS4l0xmy3LJPFKKvgTYjKWgEfGJM9xxzr3SkrTtIFllydQ/7Q2RhHhb5m4pbr2HCLpANkp5bKo=,iv:tYOuIkS5oS2h623gIRUar5M75xd8E1TKTlkloHft3iU=,tag:/rYmgOs9Pz4D0kO53uaoTw==,type:str]
|
||||
pgp: []
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
version: 3.9.4
|
||||
@@ -40,3 +40,4 @@ stringData:
|
||||
headlamp-client-secret: REPLACE-ME
|
||||
# Mirror into platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml (key: client-secret).
|
||||
crafty-client-secret: REPLACE-ME
|
||||
vuln-scanner-client-secret: REPLACE-ME
|
||||
@@ -43,6 +43,10 @@ spec:
|
||||
secret:
|
||||
name: keycloak-realm-clients
|
||||
key: crafty-client-secret
|
||||
VULN_SCANNER_CLIENT_SECRET:
|
||||
secret:
|
||||
name: keycloak-realm-clients
|
||||
key: vuln-scanner-client-secret
|
||||
PIHOLE_CLIENT_SECRET:
|
||||
secret:
|
||||
name: keycloak-realm-clients
|
||||
@@ -525,6 +529,29 @@ spec:
|
||||
# the SAME value is mirrored in apps/wave-0/pihole-oauth2-proxy-credentials.sops.yaml (cross-
|
||||
# namespace mirror). Provisioned live via kcadm at authoring time (2026-08-31) and captured here
|
||||
# as the durable GitOps source of truth (the operator imports the realm once; kcadm is authoritative).
|
||||
- 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
|
||||
- clientId: pihole
|
||||
name: PiHole (oauth2-proxy)
|
||||
protocol: openid-connect
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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.<domain>`, 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 action required: the Keycloak client (R-VULN-04 is NOT delivered until this is done)
|
||||
|
||||
Until these steps are done the page cannot complete a login (`invalid_client`, fail-closed). The
|
||||
scanner, its metrics and its alerts are unaffected.
|
||||
|
||||
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`).
|
||||
|
||||
**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 '
|
||||
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 \
|
||||
"[\"stringData\"][\"client-secret\"]" "\"$cs\""'
|
||||
```
|
||||
|
||||
3. 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.<domain>`. 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.
|
||||
@@ -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.
|
||||
[]
|
||||
@@ -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<N>` 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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,285 @@
|
||||
---
|
||||
# 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
|
||||
# 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
|
||||
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 action required). 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
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
# The CVE page at cve.<domain>. 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
|
||||
@@ -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
|
||||
@@ -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.<domain> 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
|
||||
@@ -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.<domain> 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,144 @@
|
||||
"""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
|
||||
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]+):(?: (.*))?$")
|
||||
_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")
|
||||
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 ""
|
||||
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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,333 @@
|
||||
"""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)
|
||||
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,
|
||||
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
|
||||
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
|
||||
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):
|
||||
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):
|
||||
"""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)
|
||||
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):
|
||||
"""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):
|
||||
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:
|
||||
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 _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
|
||||
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:
|
||||
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
|
||||
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([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
|
||||
_, 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}
|
||||
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)
|
||||
|
||||
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")))
|
||||
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())
|
||||
signal.signal(signal.SIGINT, lambda *_: finished.set())
|
||||
engine.start()
|
||||
finished.wait()
|
||||
engine.stop.set()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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"
|
||||
@@ -0,0 +1,505 @@
|
||||
"""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 _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("<li>" + e(workload["namespace"]) + " / " + e(workload["kind"]) + " / " + e(workload["name"])
|
||||
+ " — containers: " + e(", ".join(workload["containers"]) or "none") + suffix + "</li>")
|
||||
return "<ul>" + "".join(items) + "</ul>"
|
||||
|
||||
|
||||
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 ""
|
||||
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"]) + " — " + _state_label(target) + " — " + _status_label(finding)
|
||||
+ " — " + e(finding["package"]) + " " + e(finding["installedVersion"]) + " → "
|
||||
+ e(finding["fixedVersion"] or "no fix") + _workload_lines(target, finding) + "</li>")
|
||||
status_text = _status_summary(f["status"] for f, _ in row["items"])
|
||||
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"]) + " — " + _status_label(f)
|
||||
+ " — " + e(f["targetId"]) + " — " + _state_label(targets[f["targetId"]]) + "</li>"
|
||||
for f in sorted(row["findings"].values(), key=lambda f: (SEV_RANK[f["severity"]], f["id"], f["targetId"])))
|
||||
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) + " — " + _state_label(targets[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</title>"
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1"><style>' + STYLE + "</style></head>"
|
||||
"<body>" + body + "</body></html>").encode("utf-8")
|
||||
|
||||
|
||||
def render(report, query, now):
|
||||
"""(status code, header dict, body bytes) for one page request."""
|
||||
if report is None:
|
||||
body = ("<main><h1>CVE visibility</h1><p class=\"warn\">NO REPORT YET: nothing has been evaluated. "
|
||||
"Nothing can be said about this cluster until the first report is published.</p></main>")
|
||||
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) + "<main>" + _nav(params) + _filters(params) + content + "</main>")
|
||||
|
||||
|
||||
def not_found():
|
||||
return 404, dict(SECURITY_HEADERS), _document("<main><p>Not found.</p></main>")
|
||||
@@ -0,0 +1,310 @@
|
||||
"""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
|
||||
|
||||
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"
|
||||
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 -----------------------------
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# --- 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 = []
|
||||
_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
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"""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"]
|
||||
# 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"]
|
||||
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),
|
||||
]
|
||||
@@ -0,0 +1,151 @@
|
||||
"""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,
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
def new_record(now):
|
||||
return {
|
||||
"firstDiscoveredAt": fmt_ts(now),
|
||||
"lastInventoriedAt": fmt_ts(now),
|
||||
"lastAttemptAt": None,
|
||||
"lastSuccessAt": None,
|
||||
"outcome": None,
|
||||
"reason": None,
|
||||
"failedSince": None,
|
||||
"killedAt": 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"]
|
||||
@@ -0,0 +1,164 @@
|
||||
"""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
|
||||
|
||||
from state import parse_ts
|
||||
|
||||
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,
|
||||
)
|
||||
# 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):
|
||||
"""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) or not _URL_SCHEME.match(url):
|
||||
return ""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return ""
|
||||
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):
|
||||
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 " + 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")
|
||||
|
||||
|
||||
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 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 = [
|
||||
"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(registry_of(reference), 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)
|
||||
@@ -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:LSfQvWKkIHzdn95JEsXihM4C6V2UVKYYo7O02Xlsc2F1gZknt7BWcv5MYMxlPBAe/tYbiNDdFwj1j7PhNi3DrA==,iv:k3sQ/AxUOh0Qx1WB5L7+JlVdTv+Hj8Ev+DJK+tFIvGU=,tag:be9SGyGa7tupjfLlpv4Rrw==,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-09T22:22:55Z"
|
||||
mac: ENC[AES256_GCM,data:cC/ciBcVf6ORsjajmvGjiBdUemzP2/UKWRRAJI0I/yM5/XIlF32n/pnah2T2l6bXfCxkzohXgzGB6fc+d65jH27znr4iZ9ZWLsS3K6S6xTcJXaj0TFX+X5xZKhZyn45//4qae4AZa+2PlojaRNBytScj7HvfS62MA/Id2L1A3vA=,iv:IT8DM7Ebp8yjUYQ/FGZtA91rIRTIL62jlqsf9k2Fm6Y=,tag:HEX2gVJ80FM60TynRJME/A==,type:str]
|
||||
pgp: []
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
version: 3.9.4
|
||||
@@ -0,0 +1,29 @@
|
||||
# =============================================================================
|
||||
# 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=$(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 <<YAML > 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
|
||||
+33
-2
@@ -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=(?<depName>\\S+)\\s+tag=(?<currentValue>\\S+)\\s*\\n[^\\n]*?(?<currentDigest>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
|
||||
@@ -462,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<domain>`, 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.<domain>`, 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.
|
||||
Reference in new issue
Block a user