Merge branch 'wip/vuln-platform' into feat/vuln-visibility

This commit is contained in:
jakman85 committed 2026-11-09 10:29:05 -06:00
commit b1a6fe6503
48 files changed
+2603 -6

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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.
+4
View File
@@ -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
+41
View File
@@ -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.
+15
View File
@@ -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
+15
View File
@@ -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
+38
View File
@@ -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
View File
@@ -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,298 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — vuln-scanner-supply-chain
# Serves: R-VULN-09 (docs/17 "Security of the scanner itself").
#
# GIVEN the repository as authored,
# THEN (1) the Trivy image is `ghcr.io/aquasecurity/trivy:<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.
# AND (7) each evaluator is run on a mutated copy and must flag it (non-vacuity).
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)"
exec python3 - "$ROOT" <<'PY'
import copy, json, re, sys
from pathlib import Path
import yaml
ROOT = Path(sys.argv[1])
NAME = "vuln-scanner-supply-chain"
FAILURES, CHECKS = [], 0
COMP = ROOT / "platform" / "vuln-scanner"
BANNED = {"0.69.4", "0.69.5", "0.69.6"}
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
ALLOWED_VERBS = {"get", "list", "watch"}
ALLOWED_RESOURCES = {
"": {"pods", "nodes"},
"apps": {"replicasets", "deployments", "statefulsets", "daemonsets"},
"batch": {"jobs", "cronjobs"},
"kubevirt.io": {"virtualmachineinstances"},
}
def check(cond, desc):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(desc)
def docs(path):
return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)]
for f in ("deployment.yaml", "rbac.yaml"):
if not (COMP / f).is_file():
print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/{f} is absent", file=sys.stderr)
raise SystemExit(1)
def split_ref(image):
# A digest suffix, then a tag after the last ':' (only when it holds no '/', so a registry port
# stays in the repository part).
ref, _, digest = image.partition("@")
head, colon, tail = ref.rpartition(":")
if colon and "/" not in tail:
return head, tail, digest or None
return ref, None, digest or None
def containers(pod):
return (pod.get("initContainers") or []) + (pod.get("containers") or [])
def eval_images(pod):
bad = []
trivy = [c for c in containers(pod) if split_ref(c["image"])[0] == "ghcr.io/aquasecurity/trivy"]
if len(trivy) != 1:
bad.append(f"expected one Trivy image, found {len(trivy)}")
for c in containers(pod):
repo, tag, digest = split_ref(c["image"])
if repo == "quay.io/oauth2-proxy/oauth2-proxy":
if not tag or tag == "latest":
bad.append("oauth2-proxy sidecar has no version tag")
continue
if not digest or not DIGEST.match(digest):
bad.append(f"{c['name']} image {c['image']} is not pinned by digest")
if not tag:
bad.append(f"{c['name']} image {c['image']} has no tag for Renovate to follow")
if repo == "ghcr.io/aquasecurity/trivy" and tag in BANNED:
bad.append(f"Trivy {tag} is a banned (compromised) version")
return bad
def strip_json5(text):
# Drop // comments outside strings, then trailing commas.
out, i, in_str, esc = [], 0, False, False
while i < len(text):
ch = text[i]
if in_str:
out.append(ch)
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
i += 1
continue
if ch == '"':
in_str = True
out.append(ch)
i += 1
continue
if text.startswith("//", i):
while i < len(text) and text[i] != "\n":
i += 1
continue
out.append(ch)
i += 1
return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
TRIVY_PKG = "ghcr.io/aquasecurity/trivy"
VERSION_UPDATE_RULE_RE = re.compile(r"^(\d+)\s*(day|days)$")
def eval_renovate(rules):
bad = []
trivy_rules = [r for r in rules if TRIVY_PKG in (r.get("matchPackageNames") or [])]
if not trivy_rules:
bad.append("no packageRule matches ghcr.io/aquasecurity/trivy (blast-radius list)")
return bad
if not any(r.get("automerge") is False and "blast-radius" in (r.get("addLabels") or [])
for r in trivy_rules):
bad.append("Trivy is not in a blast-radius rule with automerge:false")
ages = [r["minimumReleaseAge"] for r in trivy_rules if "minimumReleaseAge" in r]
if not ages or not any(VERSION_UPDATE_RULE_RE.match(a) and int(VERSION_UPDATE_RULE_RE.match(a).group(1)) >= 7
for a in ages):
bad.append(f"Trivy has no minimumReleaseAge of at least 7 days (found {ages})")
# Renovate applies packageRules in order, later wins; the last rule that names automerge for
# Trivy (or matches it generically by update type) must be false.
last = None
for r in rules:
if "automerge" in r and (TRIVY_PKG in (r.get("matchPackageNames") or [])
or set(r) <= {"description", "matchUpdateTypes", "automerge", "automergeType", "platformAutomerge"}):
last = r
if last is None or last.get("automerge") is not False:
bad.append("the last packageRule deciding automerge for Trivy leaves it true")
return bad
def eval_rbac(rbac):
bad = []
croles = [d for d in rbac if d.get("kind") == "ClusterRole"]
if len(croles) != 1:
bad.append(f"expected one ClusterRole, found {len(croles)}")
return bad
for rule in croles[0].get("rules") or []:
verbs = set(rule.get("verbs") or [])
if verbs - ALLOWED_VERBS:
bad.append(f"rule grants non-read verbs {sorted(verbs - ALLOWED_VERBS)}")
if "*" in verbs:
bad.append("rule grants the verb wildcard")
for group in rule.get("apiGroups") or []:
if group == "*" or group not in ALLOWED_RESOURCES:
bad.append(f"rule names apiGroup {group!r}")
continue
for res in rule.get("resources") or []:
if res == "*" or res not in ALLOWED_RESOURCES[group]:
bad.append(f"rule grants resource {group}/{res}")
if "secrets" in (rule.get("resources") or []):
bad.append("rule grants Secret access")
kinds = {d.get("kind") for d in rbac}
if kinds - {"ServiceAccount", "ClusterRole", "ClusterRoleBinding"}:
bad.append(f"unexpected RBAC kinds {sorted(kinds)}")
for b in (d for d in rbac if d.get("kind") == "ClusterRoleBinding"):
if (b.get("roleRef") or {}).get("name") != croles[0]["metadata"]["name"]:
bad.append("the ClusterRoleBinding does not bind the scanner's ClusterRole")
return bad
def eval_pod(pod):
bad = []
psc = pod.get("securityContext") or {}
if psc.get("runAsNonRoot") is not True:
bad.append("pod runAsNonRoot is not true")
if (psc.get("seccompProfile") or {}).get("type") != "RuntimeDefault":
bad.append("pod seccompProfile is not RuntimeDefault")
for f in ("hostNetwork", "hostPID", "hostIPC"):
if pod.get(f):
bad.append(f"pod sets {f}")
if any("hostPath" in v for v in pod.get("volumes") or []):
bad.append("pod mounts a hostPath volume")
for c in containers(pod):
sc = c.get("securityContext") or {}
if sc.get("readOnlyRootFilesystem") is not True:
bad.append(f"{c['name']} root filesystem is writable")
if sc.get("allowPrivilegeEscalation") is not False:
bad.append(f"{c['name']} allows privilege escalation")
if "ALL" not in ((sc.get("capabilities") or {}).get("drop") or []):
bad.append(f"{c['name']} does not drop ALL capabilities")
limits = (c.get("resources") or {}).get("limits") or {}
for lim in ("cpu", "memory"):
if lim not in limits:
bad.append(f"{c['name']} has no {lim} limit")
if c["name"] == "scanner" and "ephemeral-storage" not in limits:
bad.append("the scanner has no ephemeral-storage limit")
vols = {v["name"]: v for v in pod.get("volumes") or []}
if not (vols.get("cache", {}).get("emptyDir") or {}).get("sizeLimit"):
bad.append("the /cache emptyDir has no sizeLimit (Longhorn or etcd must never hold it)")
return bad
dep = next(d for d in docs(COMP / "deployment.yaml") if d.get("kind") == "Deployment")
pod = dep["spec"]["template"]["spec"]
for v in eval_images(pod) + eval_pod(pod):
check(False, f"(1-3,6) {v}")
check(dep["spec"].get("replicas") == 1, "(6) one replica (one RWO report store)")
check((dep["spec"].get("strategy") or {}).get("type") == "Recreate", "(6) Recreate strategy")
renovate = json.loads(strip_json5((ROOT / "renovate.json5").read_text()))
rules = renovate.get("packageRules") or []
check(len(rules) > 5, "(4) parsed renovate.json5 packageRules (parser degenerated)")
for v in eval_renovate(rules):
check(False, f"(4) {v}")
rbac = docs(COMP / "rbac.yaml")
for v in eval_rbac(rbac):
check(False, f"(5) {v}")
# (7) non-vacuity
def mutated(fn, base):
m = copy.deepcopy(base)
fn(m)
return m
# Image mutants
def img_digest(p): p["initContainers"][0]["image"] = "ghcr.io/aquasecurity/trivy:0.76.1"
def img_banned(p): p["initContainers"][0]["image"] = re.sub(r":[0-9.]+@", ":0.69.5@", p["initContainers"][0]["image"])
def img_nopin(p): p["containers"][0]["image"] = "docker.io/library/python:3.14.9-alpine"
for name, fn in (("digest stripped from Trivy", img_digest), ("banned Trivy version", img_banned),
("python image unpinned", img_nopin)):
check(bool(eval_images(mutated(fn, pod))), f"(7) image evaluator missed mutant: {name}")
# RBAC mutants
def rbac_secret(r):
role = next(d for d in r if d.get("kind") == "ClusterRole")
role["rules"][0]["resources"].append("secrets")
def rbac_write(r):
role = next(d for d in r if d.get("kind") == "ClusterRole")
role["rules"][0]["verbs"].append("delete")
def rbac_wild(r):
role = next(d for d in r if d.get("kind") == "ClusterRole")
role["rules"][1]["resources"] = ["*"]
for name, fn in (("Secret resource granted", rbac_secret), ("write verb granted", rbac_write),
("resource wildcard granted", rbac_wild)):
check(bool(eval_rbac(mutated(fn, rbac))), f"(7) RBAC evaluator missed mutant: {name}")
# Renovate mutants
def ren_auto(r):
for x in r:
if TRIVY_PKG in (x.get("matchPackageNames") or []):
x["automerge"] = True
def ren_age(r):
for x in r:
if TRIVY_PKG in (x.get("matchPackageNames") or []) and "minimumReleaseAge" in x:
x["minimumReleaseAge"] = "2 days"
def ren_drop(r):
r[:] = [x for x in r if TRIVY_PKG not in (x.get("matchPackageNames") or [])]
def ren_later(r):
r.append({"matchUpdateTypes": ["digest"], "automerge": True})
for name, fn in (("Trivy automerge flipped true", ren_auto), ("minimumReleaseAge shortened", ren_age),
("Trivy removed from Renovate rules", ren_drop), ("later auto-merge rule appended", ren_later)):
check(bool(eval_renovate(mutated(fn, rules))), f"(7) Renovate evaluator missed mutant: {name}")
# Pod mutants
def pod_root(p): p["securityContext"]["runAsNonRoot"] = False
def pod_host(p): p["hostNetwork"] = True
def pod_rw(p): p["containers"][0]["securityContext"]["readOnlyRootFilesystem"] = False
def pod_nolim(p): del p["containers"][0]["resources"]["limits"]["memory"]
def pod_nocap(p):
for v in p["volumes"]:
if v["name"] == "cache":
v["emptyDir"].pop("sizeLimit", None)
for name, fn in (("runAsNonRoot off", pod_root), ("hostNetwork on", pod_host), ("rw rootfs", pod_rw),
("no memory limit", pod_nolim), ("no cache sizeLimit", pod_nocap)):
check(bool(eval_pod(mutated(fn, pod))), f"(7) pod evaluator missed mutant: {name}")
if CHECKS < 15:
FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated")
if FAILURES:
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
for f in FAILURES:
print(f" - {f}", file=sys.stderr)
raise SystemExit(1)
print(f"PASS ({NAME}): {CHECKS} assertions")
PY
@@ -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,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"'
+4
View File
@@ -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}
+1
View File
@@ -147,3 +147,4 @@ spec:
- radarr
- sonarr
- headlamp
- vuln-scanner
+7
View File
@@ -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
+80
View File
@@ -0,0 +1,80 @@
# vuln-scanner — scheduled CVE visibility for what is running
Answers three questions with no manual step: what is running, what is it vulnerable to, and can the
answer be trusted. Contract: spec `docs/17-vulnerability-visibility.md`, ADR-0027 (R-VULN-01..09).
Hand-authored platform tooling (not a catalog service), reconciled by its own non-gating Flux tier
`clusters/base/vuln-scanner.yaml`. Operator runbook: `runbooks/vuln-scanner.md`.
| Piece | Where |
|---|---|
| Scanner source (`python3`, stdlib only), mounted from a ConfigMap at `/app` | `src/` |
| Accepted risk (R-VULN-08) | `acceptances.yaml` |
| Pod, security context, sidecar | `deployment.yaml` |
| Read-only identity (R-VULN-09) | `rbac.yaml` |
| Network: Gateway→4180, CI→8081, Prometheus→9090; API, DNS, public 443 | `networkpolicy.yaml` |
| Aggregate metrics and the five alerts | `servicemonitor.yaml`, `alerts.yaml`, `alerts.promtool-test.yaml` |
The page is `cve.<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 step: add the Keycloak client (NOT done by the change that added this tree)
This change adds everything around the Keycloak client but not the client. Adding it needs a new key in
`platform/keycloak/keycloak-realm-clients.sops.yaml`, an encrypted file an agent must not decrypt; the
L0 check `keycloak-client-secret-reconciler` then requires the client and that key to appear in the
same change. Until this is done the page cannot complete a login (`invalid_client`, fail-closed); the
scanner, its metrics and its alerts are unaffected.
In one PR (the realm import does not re-run on its own for a NEW client):
1. Generate one value and set it in both encrypted files, through pipes so it is never echoed:
```bash
./infra/devcontainer/dev.sh run bash -c '
cs=$(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\""'
```
2. Add to `platform/keycloak/keycloak-realm-import.yaml`, beside the `crafty` client:
```yaml
# spec.placeholders
VULN_SCANNER_CLIENT_SECRET:
secret:
name: keycloak-realm-clients
key: vuln-scanner-client-secret
# spec.realm.clients
- clientId: vuln-scanner
name: CVE report (oauth2-proxy)
protocol: openid-connect
enabled: true
publicClient: false
standardFlowEnabled: true
directAccessGrantsEnabled: false
secret: "$(env:VULN_SCANNER_CLIENT_SECRET)"
redirectUris:
- https://cve.${BASE_DOMAIN}/oauth2/callback
webOrigins:
- https://cve.${BASE_DOMAIN}
defaultClientScopes: [email, profile, roles]
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
```
3. Regenerate the derived URL file: `python3 ci/render-keycloak-client-urls.py --write`. Commit all
three, gate, merge.
4. After merge, the client must also exist in the live realm (a completed realm import does not re-run,
per `platform/keycloak/README.md`, so create it once with `kcadm` or re-trigger the import). The
client-secret reconciler then converges its secret and URLs.
+15
View File
@@ -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
+96
View File
@@ -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"
+279
View File
@@ -0,0 +1,279 @@
---
# The vulnerability scanner (R-VULN-01..09, docs/17). One pod:
# init trivy-tools copies the Trivy binary and CA bundle from the digest-pinned Trivy image into
# an emptyDir, so the running container has a single python interpreter + one
# static Go binary and nothing else from that image.
# main scanner python3 stdlib only (no pip), source from a ConfigMap at /app: inventory,
# sequential scans, report, the page on 127.0.0.1:8080, the internal API on
# :8081, aggregates on :9090.
# sidecar oauth2-proxy the only path to the page, restricted to platform-admins (R-IDP-06, docs/17
# "Access"), reached from the shared Gateway on :4180.
#
# ONE REPLICA, Recreate: one scanner on one RWO report store; two would double-scan and contend for
# the volume.
#
# SOFT affinity toward k3s-prod-2: prod-1 is the node whose etcd I/O starved historically, and scans
# are CPU- and I/O-heavy. Soft on purpose: a scanner that cannot be placed must still run elsewhere.
apiVersion: apps/v1
kind: Deployment
metadata:
name: vuln-scanner
namespace: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
spec:
replicas: 1
strategy:
type: Recreate
revisionHistoryLimit: 3
selector:
matchLabels:
app.kubernetes.io/name: vuln-scanner
template:
metadata:
labels:
app.kubernetes.io/name: vuln-scanner
# The direct egress path; never the VPN gateway (docs/17).
homelab.keithjackson.dev/egress-class: internet
# Per-pod oauth2-proxy gate in front of the page. This namespace is not app-namespace=true,
# so the auth-mode/egress-class policies do not select it; the labels are kept so the pod is
# compliant by construction if that ever changes.
homelab.keithjackson.dev/auth-mode: forward-auth
spec:
serviceAccountName: vuln-scanner
# The scanner reads the Kubernetes API with this token (read-only, rbac.yaml).
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
# Longhorn honours fsGroup on an RWO volume; this makes /data and the emptyDirs writable.
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- k3s-prod-2
initContainers:
- name: trivy-tools
# TRIVY 0.76.1 — pinned by digest (R-VULN-09). Not 0.69.4/5/6 (compromised release, March
# 2026, CVE-2026-33634). Tag digest and config created timestamp read from the registry on
# 2026-11-09: sha256:790fd66d…6c06c, created 2026-10-21T11:16:20Z (19 days old, i.e. past
# the 7-day minimum release age; 0.76.2 was published 2026-11-05 and is too new).
# SIGNATURE VERIFIED 2026-11-09 with cosign v3.1.3, keyless:
# cosign verify ghcr.io/aquasecurity/trivy@sha256:790fd66d... \
# --certificate-identity-regexp 'https://github\.com/aquasecurity/trivy/\.github/workflows/.+' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
# Renovate: blast-radius component, never auto-merges, 7-day minimum release age
# (renovate.json5); this `tag@digest` form is what the `kubernetes` manager tracks.
# The image's ENTRYPOINT is `trivy`, overridden below. /bin/cp (busybox) and
# /etc/ssl/certs/ca-certificates.crt confirmed present by listing the layers via the
# registry API (no image run).
image: ghcr.io/aquasecurity/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
imagePullPolicy: IfNotPresent
command:
- /bin/cp
- /usr/local/bin/trivy
- /etc/ssl/certs/ca-certificates.crt
- /tools/
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 10m
memory: 16Mi
ephemeral-storage: 256Mi
limits:
cpu: 200m
memory: 64Mi
ephemeral-storage: 512Mi
volumeMounts:
- name: tools
mountPath: /tools
containers:
# ─────────────────────────────── scanner ──────────────────────────────────────────────
- name: scanner
# python 3.14 on alpine by digest, matching the interpreter the scanner's tests ran on.
# 3.14.9-alpine, config created 2026-10-21T23:01:37Z, read from the registry on 2026-11-09
# (19 days old; 3.14.10-alpine, the current 3.14-alpine, was built 2026-11-05 and is under
# the 7-day age applied here by hand). Tracked by the `kubernetes` Renovate manager.
# Runs as 65532 from this pod's securityContext; the image's default is root, overridden.
image: docker.io/library/python:3.14.9-alpine@sha256:dbeb11d16bb95c3a7e3c25c842cb9f20cbc8c18f8be0461a4a0dcc861ad386a0
imagePullPolicy: IfNotPresent
command:
- python3
- /app/main.py
env:
- name: VULN_DATA_DIR
value: /data
- name: VULN_CACHE_DIR
value: /cache
# Scan caches and temp extraction live on the size-limited emptyDir, never on the
# replicated volume or etcd (docs/17 "Load").
- name: TMPDIR
value: /cache
- name: HOME
value: /cache
- name: VULN_TOOLS_DIR
value: /tools
- name: TRIVY_BIN
value: /tools/trivy
- name: SSL_CERT_FILE
value: /tools/ca-certificates.crt
- name: VULN_ACCEPTANCES
value: /config/acceptances.yaml
- name: PYTHONUNBUFFERED
value: "1"
- name: PYTHONDONTWRITEBYTECODE
value: "1"
ports:
- { name: internal, containerPort: 8081, protocol: TCP }
- { name: metrics, containerPort: 9090, protocol: TCP }
# Sized to trivy, the only heavy thing here: scans are sequential, the limit is the peak of
# one large image; the emptyDir sizeLimit bounds disk.
resources:
requests:
cpu: 100m
memory: 256Mi
ephemeral-storage: 256Mi
limits:
cpu: "1"
memory: 2Gi
ephemeral-storage: 12Gi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
# /healthz on the metrics port is answered by the HTTP server itself and must not depend
# on scan state: a long scan or a failed one is reported, never a reason to restart.
startupProbe:
httpGet:
path: /healthz
port: metrics
periodSeconds: 5
failureThreshold: 24
livenessProbe:
httpGet:
path: /healthz
port: metrics
periodSeconds: 20
failureThreshold: 6
readinessProbe:
httpGet:
path: /healthz
port: metrics
periodSeconds: 15
volumeMounts:
- name: src
mountPath: /app
readOnly: true
- name: acceptances
mountPath: /config
readOnly: true
- name: tools
mountPath: /tools
readOnly: true
- name: data
mountPath: /data
- name: cache
mountPath: /cache
# ─────────────────────────── oauth2-proxy reverse-proxy sidecar ───────────────────────
- name: oauth2-proxy
# The per-app sidecar pattern (platform/crafty, catalog/render.py): same tag as the
# renderer's OAUTH2_PROXY_IMAGE so a Renovate bump lands every sidecar in one branch.
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
imagePullPolicy: IfNotPresent
args:
- --http-address=0.0.0.0:4180
- --reverse-proxy=true
- --provider=oidc
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
# The page, bound to loopback in the scanner container; no Service reaches it.
- --upstream=http://127.0.0.1:8080
- --email-domain=*
- --scope=openid email profile
- --oidc-groups-claim=groups
# R-IDP-06, docs/17 "Access": a valid session is not enough, the user must be a
# platform-admin, because this page is the list of what is exploitable where.
- --allowed-group=platform-admins
- --skip-provider-button=true
- --redirect-url=https://cve.${BASE_DOMAIN}/oauth2/callback
# Unique name + host-scoped domain: the shared forward-auth proxy and the other sidecars
# hold different cookie-secrets, so a shared cookie name would evict sessions
# (oauth2-proxy-cookie-names-distinct).
- --cookie-name=_oauth2_proxy_vuln_scanner
- --cookie-domain=cve.${BASE_DOMAIN}
- --whitelist-domain=.${BASE_DOMAIN}
- --cookie-secure=true
- --set-xauthrequest=true
env:
# client-secret MUST equal the Keycloak `vuln-scanner` client's secret (key
# vuln-scanner-client-secret in platform/keycloak/keycloak-realm-clients.sops.yaml; see
# README.md, Operator step). Secrets by name only (R-IDP-04).
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom:
secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-id }
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-secret }
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: cookie-secret }
ports:
- { name: http, containerPort: 4180, protocol: TCP }
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
livenessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 5
periodSeconds: 15
volumes:
- name: src
configMap:
name: vuln-scanner-src
- name: acceptances
configMap:
name: vuln-scanner-acceptances
- name: tools
emptyDir:
sizeLimit: 512Mi
- name: data
persistentVolumeClaim:
claimName: vuln-scanner-data
# Trivy DB + per-image scan cache + TMPDIR (trivy extracts layers here). Size-limited; never
# on a replicated volume or in etcd (docs/17 "Load"). A full run cleans it between images.
- name: cache
emptyDir:
sizeLimit: 10Gi
+34
View File
@@ -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
+50
View File
@@ -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
+23
View File
@@ -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
+119
View File
@@ -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
+19
View File
@@ -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
+50
View File
@@ -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
+33
View File
@@ -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
+20
View File
@@ -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,30 @@
apiVersion: v1
kind: Secret
metadata:
name: vuln-scanner-oauth2-proxy
namespace: vuln-scanner
type: Opaque
stringData:
client-id: ENC[AES256_GCM,data:9n9fMQyIq+JmfgXy,iv:8FLkOTc6Rmck33ecd4A3DXP2CiGrAkxMJ8Q7nmI9naE=,tag:4wb8rAXx+KAIhWAviEKTJQ==,type:str]
client-secret: ENC[AES256_GCM,data:JQCSBroPwd1KxZhdOw9zYkA9krYdK79QpnP/WW3ZIYVoyvHGVKJ2OVPpvgtWy20Ex0yWCTtmpghn87r+4Kkrfw==,iv:RFuDweYB7z5fykB/Ued2Z7MdO9VVY2XoO4gwekxMo0U=,tag:UseTecg5dJMNR1SN6ZUZ7g==,type:str]
cookie-secret: ENC[AES256_GCM,data:F7McZBTr10fnpTEz8cYjXsvrwRgqiKrlcaL/mSZKjOjuPfaSvq4sV3YBuQc=,iv:2vDEqmUHFFpUsRkBSqj3X92eT2bP8VTj2BiAr/8+7ak=,tag:rdHVky9JmlcEEhsxV6OtnA==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGdGErRzEvQWNTcUdYanhG
RGVaYi9CdDNvYVdhL2ViOWI4Z3k0M2pxb0V3ClgzOFNPRXdyZ1pVZTFKMVEyTGVV
bkxQWDdFZUtvME1jZm9VUXBHaVpHM3cKLS0tIDhodHlQVUNhNlpiVFAzU1JZL2d3
cUhWcGNGSThYTkRaV0NSOUZrM2ZmS1UKjFe6iGia0+QOZgWcSFoH0LLEp1f2M2ds
V8bIBIFTtRDTKp6kgt0nee+Sgb7g5D5NIGTrRDGE256I8jdiNDcMpw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-11-09T15:43:11Z"
mac: ENC[AES256_GCM,data:jXBQdruY8yLD4k3iRHqn78Pq0iXzETDk/S8qyXSVFHsacqUvmtzdOw6ajJDg7hAkVGN9PXqH+lsz45wR3a3YVODbQZJyS0+cKuQ1cVWlV/wcWOlNSdeKSLRe8h658KjXGUPQubaDFB4rloBfUr5ebuGQGr4ZEj7g241xjrsjBic=,iv:asoMnTjsIIb1AgrgE9dERixbpw94TpQWCPRwAT3mXYM=,tag:uwCVmjDr3gDQAqWsmiF0tA==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
@@ -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
+23 -2
View File
@@ -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
+2
View File
@@ -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;
+1
View File
@@ -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
+75
View File
@@ -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.