diff --git a/apps/wave-0/pihole.yaml b/apps/wave-0/pihole.yaml index 60e27ec..be74066 100644 --- a/apps/wave-0/pihole.yaml +++ b/apps/wave-0/pihole.yaml @@ -73,7 +73,7 @@ spec: - name: FTLCONF_dns_rateLimit_count value: '0' - name: FTLCONF_misc_dnsmasq_lines - value: local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP} + value: local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/cve.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP} - name: FTLCONF_webserver_api_password valueFrom: secretKeyRef: diff --git a/catalog/pihole.yaml b/catalog/pihole.yaml index eba6a11..e63f4c0 100644 --- a/catalog/pihole.yaml +++ b/catalog/pihole.yaml @@ -202,7 +202,7 @@ env: # Applies to every `address=` reply below (dnsmasq: local-ttl governs replies made from # configuration, /etc/hosts and DHCP leases), so one directive covers every listed name. - name: FTLCONF_misc_dnsmasq_lines - value: "local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}" + value: "local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/cve.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}" # THE ADMIN PASSWORD, previously the ONE operator-set value declared nowhere. Sourced from # apps/wave-0/pihole-bootstrap-secret.sops.yaml (SOPS/age, same wave => same reconcile), which # carries the full rationale and the measurement that found it. diff --git a/clusters/base/kustomization.yaml b/clusters/base/kustomization.yaml index 810eebe..c76d09a 100644 --- a/clusters/base/kustomization.yaml +++ b/clusters/base/kustomization.yaml @@ -78,6 +78,10 @@ resources: # `wg-server-key` Secret, so in the gating `platform` tier an unfinished manual step would have # stalled every apps wave. Carries no SOPS secret, so neither overlay adds a decryption patch. - vpn-ingress.yaml # Flux Kustomization -> ./platform/vpn-ingress (dependsOn platform-config) + # CVE visibility scanner (docs/17, ADR-0027) — POST-platform tier (dependsOn platform-config, + # wait:false): a failing scanner must never gate the platform, and nothing dependsOn it. Carries the + # oauth2-proxy credential SOPS file, so both overlays add a decryption patch by name. + - vuln-scanner.yaml # Flux Kustomization -> ./platform/vuln-scanner (dependsOn platform-config) # Cloudflare Tunnel connector (north-south INGRESS from the Cloudflare edge) — POST-platform tier # (dependsOn platform-config, wait:false), and NOTHING dependsOn it. Non-gating is load-bearing, # as for vpn-ingress: the Deployment cannot become Ready until the operator enrols the tunnel diff --git a/clusters/base/vuln-scanner.yaml b/clusters/base/vuln-scanner.yaml new file mode 100644 index 0000000..50766e7 --- /dev/null +++ b/clusters/base/vuln-scanner.yaml @@ -0,0 +1,41 @@ +--- +# Flux Kustomization: the CVE visibility scanner (docs/17, ADR-0027) — reconciles ./platform/vuln-scanner. +# +# ISOLATION IS THE POINT, as for clusters/base/cloudflared.yaml. A failing scanner, an unbound PVC or a +# pending Certificate must never become a stalled platform, so: +# * ITS OWN Kustomization, not a line in platform/kustomization.yaml; +# * wait: false — its health is reported by the VulnScanStale/VulnDatabaseStale/VulnCoverageGap/ +# VulnCriticalFixAvailable/VulnTriageBotSilent alerts (platform/vuln-scanner/alerts.yaml), not by +# this object's Ready condition, which reaches Ready on apply success; +# * NOTHING dependsOn THIS. conformance/tests/l0-static/vuln-scanner-isolation fails the gate if any +# Kustomization gains `dependsOn: vuln-scanner` or this one stops being wait:false. +# +# dependsOn: [platform-config] — a real edge: PrometheusRule/ServiceMonitor/Gateway API CRDs, the +# Longhorn StorageClass, the Gateway, Keycloak's realm, and Longhorn itself come up through +# `platform`/`platform-config` first. +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: vuln-scanner + namespace: flux-system +spec: + interval: 10m + retryInterval: 2m + timeout: 5m + prune: true + wait: false + dependsOn: + - name: platform-config + sourceRef: + kind: GitRepository + name: flux-system + path: ./platform/vuln-scanner + # REQUIRED: the HTTPRoute hostname, the oauth2-proxy issuer/redirect/cookie-domain args and every + # alert's runbook_url are written `${BASE_DOMAIN}`. Substitution fails OPEN, so the file + # conformance/tests/l0-static/flux-postbuild-substitution audits this tier. + postBuild: + substituteFrom: + - kind: ConfigMap + name: cluster-vars + # SOPS decryption is layered per-environment: clusters/dev and clusters/prod each patch THIS + # Kustomization by name with a `decryption:` stanza for the oauth2-proxy credentials. diff --git a/clusters/dev/kustomization.yaml b/clusters/dev/kustomization.yaml index f2fa504..4fcdaaf 100644 --- a/clusters/dev/kustomization.yaml +++ b/clusters/dev/kustomization.yaml @@ -140,6 +140,21 @@ patches: provider: sops secretRef: name: sops-age + # vuln-scanner (the CVE visibility scanner, clusters/base/vuln-scanner.yaml) carries + # platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml — the per-pod oauth2-proxy + # client and cookie secrets. Without this stanza the Secret applies full of ENC[...] and the + # sidecar cannot authenticate; the rest of the scanner runs regardless. `sops-age` is out-of-band. + - patch: |- + apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + metadata: + name: vuln-scanner + namespace: flux-system + spec: + decryption: + provider: sops + secretRef: + name: sops-age # cloudflared (the Cloudflare Tunnel connector, clusters/base/cloudflared.yaml) carries # platform/cloudflared/cloudflared-credentials.sops.yaml — the tunnel credentials JSON, the one # secret in that component. Without this stanza the Secret applies full of ENC[...]: the APPLY diff --git a/clusters/prod/kustomization.yaml b/clusters/prod/kustomization.yaml index 5904bce..5eb3e65 100644 --- a/clusters/prod/kustomization.yaml +++ b/clusters/prod/kustomization.yaml @@ -131,6 +131,21 @@ patches: provider: sops secretRef: name: sops-age + # vuln-scanner (the CVE visibility scanner, clusters/base/vuln-scanner.yaml) carries + # platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml — the per-pod oauth2-proxy + # client and cookie secrets. Without this stanza the Secret applies full of ENC[...] and the + # sidecar cannot authenticate; the rest of the scanner runs regardless. `sops-age` is out-of-band. + - patch: |- + apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + metadata: + name: vuln-scanner + namespace: flux-system + spec: + decryption: + provider: sops + secretRef: + name: sops-age # cloudflared (the Cloudflare Tunnel connector, clusters/base/cloudflared.yaml) carries # platform/cloudflared/cloudflared-credentials.sops.yaml — the tunnel credentials JSON, the one # secret in that component. Without this stanza the Secret applies full of ENC[...]: the APPLY diff --git a/conformance/requirements/registry.yaml b/conformance/requirements/registry.yaml index a6ce4c3..6e501e6 100644 --- a/conformance/requirements/registry.yaml +++ b/conformance/requirements/registry.yaml @@ -135,6 +135,44 @@ requirements: safety_critical: false summary: "Update policy per-service configurable (cadence, channel, pinning)." + # --- Vulnerability visibility --- + - id: R-VULN-01 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Inventory every running container image by digest with its workloads, from the cluster." + - id: R-VULN-02 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Every running image evaluated against a current database at least every 24h; new images within 2h." + - id: R-VULN-03 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Any target not evaluated is visibly reported with its reason; absence is never shown as clean." + - id: R-VULN-04 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Authenticated page lists every CVE with affected images, containers and workloads; searchable and filterable." + - id: R-VULN-05 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Inventory, scanning, reporting and triage run on a schedule with no human or AI action; loud failure." + - id: R-VULN-06 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Scan failure, staleness and unaccepted CRITICAL fixes reported by email as grouped alerts." + - id: R-VULN-07 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Scheduled bot keeps one dashboard issue and requests remediation via Renovate; no CVE detail exposed." + - id: R-VULN-08 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Accepted risk declared in git with reason and mandatory expiry; reverts to open on expiry." + - id: R-VULN-09 + doc: docs/17-vulnerability-visibility.md + safety_critical: false + summary: "Scanner digest-pinned, no auto-merged updates, no Secret read or write access, no git credential." + # --- Observability & alerting --- - id: R-OBS-01 doc: docs/10-observability-and-alerting.md diff --git a/conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh b/conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh index 741eb40..32c2b58 100755 --- a/conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh +++ b/conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh @@ -411,6 +411,28 @@ SCRAPE_OWNERS: list[dict] = [ "Keeping the two in one file makes that pair inseparable", }, }, + # ── ADDED 2026-11-09 (CVE visibility, ADR-0027). A NEW OWNER, NOT A RELAXED ASSERTION. ────── + # Registers the vuln_scanner_* family and the Service+ServiceMonitor that scrapes it. + { + "owner": "vuln-scanner: Service + ServiceMonitor on port `metrics` (:9090)", + "prefixes": ("vuln_scanner_",), + "file": "platform/vuln-scanner/servicemonitor.yaml", + "require": [ + r"^kind:\s*ServiceMonitor\s*$", + r"^\s*-\s*port:\s*metrics\s*$", + ], + "forbid": [], + "why": "vuln_scanner_* powers the five VulnScan alerts, the only email path for scan " + "failure, staleness and overdue critical fixes (R-VULN-06). Two of those rules fire " + "on an absent metric, so a lost scrape is loud rather than inert", + "companion": { + "file": "platform/vuln-scanner/kustomization.yaml", + "require": [r"^\s*-\s*networkpolicy\.yaml\s*$"], + "why": "platform/vuln-scanner/networkpolicy.yaml ships the namespace's default-deny " + "and the ingress allow for Prometheus on :9090 in one file, so a baseline " + "without its allow cannot empty the TSDB", + }, + }, # ── ADDED 2026-11-08 (feat/cloudflare-tunnel). A NEW OWNER, NOT A RELAXED ASSERTION. ─────── # Purely additive, the same shape as the kured entry: it registers the cloudflared_* family # and the Service+ServiceMonitor that scrapes it, and makes the check STRICTER for that component. diff --git a/conformance/tests/l0-static/cloudflared-ingress-rules/check.sh b/conformance/tests/l0-static/cloudflared-ingress-rules/check.sh index ceba41d..3c215fb 100755 --- a/conformance/tests/l0-static/cloudflared-ingress-rules/check.sh +++ b/conformance/tests/l0-static/cloudflared-ingress-rules/check.sh @@ -86,10 +86,10 @@ if not CONFIG.is_file(): # headlamp: operator decision 2026-11-08 (LAN/VPN-only). qbittorrent/radarr/sonarr: catalog # `exposure: internal`, like jackett/pihole/semaphore. EXCLUDED_HOSTS = ("plex", "plex-vip", "jackett", "pihole", "pihole-dns", "semaphore", - "qbittorrent", "radarr", "sonarr", "headlamp", "unifi", "crafty-mc") + "qbittorrent", "radarr", "sonarr", "headlamp", "cve", "unifi", "crafty-mc") # Their Gateway-backend namespaces (unifi / crafty-mc are L4 VIPs, not Gateway backends). EXCLUDED_NAMESPACES = ("plex", "jackett", "pihole", "semaphore", "qbittorrent", "radarr", - "sonarr", "headlamp") + "sonarr", "headlamp", "vuln-scanner") FORBIDDEN_ENTITIES = {"kube-apiserver", "host", "remote-node", "cluster", "all"} # Any of these in a `service` means a route toward the Kubernetes API (or a raw-TCP/SSH bastion). API_MARKERS = ("kubernetes", "kube-apiserver", "apiserver", ":6443", "10.43.0.1", "tcp://", diff --git a/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh b/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh index 5dd33f2..303e3c4 100755 --- a/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh +++ b/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh @@ -187,6 +187,10 @@ KNOWN_NON_CATALOG_PUBLISHERS: dict[str, str] = { "decision: Headlamp is LAN/VPN-only) and the tunnel 404s the name. A Kubernetes web UI " "with native Keycloak OIDC; listed because it is a hand-authored route, and pinned " "disowned by (9d).", + "platform/vuln-scanner/httproute.yaml:vuln-scanner/vuln-scanner": + "publishes NOTHING: the route carries `controller: none` (docs/17: the CVE page is internal " + "only) and the tunnel 404s the name. Hand-authored because the scanner is not a catalog " + "service (its cluster-wide read role cannot be expressed in a ServiceDefinition, ADR-0027).", "platform/homepage/httproute.yaml:homepage/homepage": "publishes the APEX ${BASE_DOMAIN} itself -> the tunnel (proxied CNAME), via the https-apex " "listener. The apex is not a per-service name and cannot be withdrawn by an exposure " diff --git a/conformance/tests/l0-static/vuln-acceptances-schema/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-acceptances-schema/chainsaw-test.yaml new file mode 100644 index 0000000..c1400bf --- /dev/null +++ b/conformance/tests/l0-static/vuln-acceptances-schema/chainsaw-test.yaml @@ -0,0 +1,28 @@ +# ============================================================================= +# L0 STATIC — accepted CVE risk has a reason and a bounded expiry (R-VULN-08, docs/17 "Risk acceptance"). +# Offline, no cluster. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-acceptances-schema + labels: + conformance.home.k3s/R-VULN-08: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-08" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + platform/vuln-scanner/acceptances.yaml rejects any entry without a reason or expiry, with a + malformed expiry, or expiring more than 180 days out. Proven with mutated negative fixtures. +spec: + steps: + - name: assert-acceptance-schema + description: >- + Validate acceptances.yaml against the docs/17 shape, then run the validator on mutated + negative fixtures to prove it can fail. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-acceptances-schema/check.sh b/conformance/tests/l0-static/vuln-acceptances-schema/check.sh new file mode 100755 index 0000000..95348fa --- /dev/null +++ b/conformance/tests/l0-static/vuln-acceptances-schema/check.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-acceptances-schema +# Serves: R-VULN-08 (docs/17 "Risk acceptance": "An entry with no reason or expiry is rejected by the gate"). +# +# GIVEN platform/vuln-scanner/acceptances.yaml, +# THEN (1) it is a YAML list; +# (2) every entry has a non-empty id, a non-empty reason, an expires of the form YYYY-MM-DD +# at most 180 days from today, and only the permitted keys (image is optional); +# AND (3) the validator rejects mutated negative fixtures for each rule and accepts a good entry. +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import datetime as dt, re, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-acceptances-schema" +FAILURES, CHECKS = [], 0 +TODAY = dt.datetime.now(dt.timezone.utc).date() +MAX_DAYS = 180 +ALLOWED_KEYS = {"id", "image", "reason", "expires"} +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +def validate(entries): + errs = [] + if not isinstance(entries, list): + return ["acceptances must be a YAML list"] + for i, e in enumerate(entries): + if not isinstance(e, dict): + errs.append(f"entry {i} is not a mapping") + continue + if set(e) - ALLOWED_KEYS: + errs.append(f"entry {i} has unknown keys {sorted(set(e) - ALLOWED_KEYS)}") + for field in ("id", "reason"): + if not isinstance(e.get(field), str) or not e[field].strip(): + errs.append(f"entry {i} has no non-empty {field}") + if "image" in e and (not isinstance(e["image"], str) or not e["image"].strip()): + errs.append(f"entry {i} has an empty image") + exp = e.get("expires") + if isinstance(exp, dt.date) and not isinstance(exp, dt.datetime): + exp_s = exp.isoformat() + elif isinstance(exp, str): + exp_s = exp + else: + errs.append(f"entry {i} has no expires") + continue + if not DATE_RE.match(exp_s): + errs.append(f"entry {i} expires is malformed: {exp_s!r}") + continue + try: + d = dt.date.fromisoformat(exp_s) + except ValueError: + errs.append(f"entry {i} expires is not a real date: {exp_s!r}") + continue + if (d - TODAY).days > MAX_DAYS: + errs.append(f"entry {i} expires {exp_s}, more than {MAX_DAYS} days out") + return errs + + +path = ROOT / "platform" / "vuln-scanner" / "acceptances.yaml" +if not path.is_file(): + print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/acceptances.yaml is absent", file=sys.stderr) + raise SystemExit(1) +entries = yaml.safe_load(path.read_text()) +check(entries == [] or (isinstance(entries, list) and entries), "(1) acceptances.yaml parsed to a list") +errs = validate(entries) +check(not errs, f"(2) acceptances.yaml is valid: {errs}") + +soon = (TODAY + dt.timedelta(days=30)).isoformat() +far = (TODAY + dt.timedelta(days=200)).isoformat() +good = {"id": "CVE-2026-00000", "image": "docker.io/library/x", "reason": "not reachable", "expires": soon} +check(validate([good]) == [], "(3) a well-formed entry is accepted") +check(validate([{k: v for k, v in good.items() if k != "image"}]) == [], "(3) image is optional (platform-wide)") +for name, entry in { + "no reason": {k: v for k, v in good.items() if k != "reason"}, + "empty reason": dict(good, reason=""), + "no expires": {k: v for k, v in good.items() if k != "expires"}, + "no id": {k: v for k, v in good.items() if k != "id"}, + "malformed expiry": dict(good, expires="next-tuesday"), + "impossible date": dict(good, expires="2027-13-45"), + "past 180 days": dict(good, expires=far), + "unknown key": dict(good, owner="me"), +}.items(): + check(bool(validate([entry])), f"(3) the validator accepted a bad entry: {name}") +check(bool(validate({"not": "a list"})), "(3) the validator accepted a non-list") + +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l0-static/vuln-alerts-unit-tested/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-alerts-unit-tested/chainsaw-test.yaml new file mode 100644 index 0000000..aa2ad4f --- /dev/null +++ b/conformance/tests/l0-static/vuln-alerts-unit-tested/chainsaw-test.yaml @@ -0,0 +1,29 @@ +# ============================================================================= +# L0 STATIC — the five scanner alerts exist and are unit-tested, including the absent-metric cases +# (R-VULN-06, docs/17 "Acceptance gates"). Offline, no cluster. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-alerts-unit-tested + labels: + conformance.home.k3s/R-VULN-06: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-06" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + VulnScanStale, VulnDatabaseStale, VulnCoverageGap, VulnCriticalFixAvailable and + VulnTriageBotSilent exist, each promtool-tested firing, and the two absent-metric rules fire + in a test with no such series. Proven by mutation. +spec: + steps: + - name: assert-alert-unit-tests + description: >- + Parse alerts.yaml and alerts.promtool-test.yaml, assert every alert and the two absent cases + are pinned, run the harness when promtool is present, and run the evaluator on mutated copies. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-alerts-unit-tested/check.sh b/conformance/tests/l0-static/vuln-alerts-unit-tested/check.sh new file mode 100755 index 0000000..e301104 --- /dev/null +++ b/conformance/tests/l0-static/vuln-alerts-unit-tested/check.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-alerts-unit-tested +# Serves: R-VULN-06 (docs/17 "Acceptance gates": alerts unit-tested, including the absent-metric cases). +# +# GIVEN platform/vuln-scanner/alerts.yaml and alerts.promtool-test.yaml, +# THEN (1) the rules are exactly the five contract alerts, with the two absent()-bearing rules; +# (2) each alert is asserted FIRING in a promtool test; +# (3) VulnScanStale and VulnTriageBotSilent each fire in a test with NO input series for their +# own metric (the absent case) and in one where it vanishes (re-fire); +# (4) each alert carries severity and a runbook_url; +# (5) when promtool is present (the toolbox), the actual harness passes on this file; +# AND (6) the evaluator is run on mutated copies (the absent arm deleted, a firing assertion +# removed, an alert dropped) and must flag each (non-vacuity). +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import copy, re, shutil, subprocess, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-alerts-unit-tested" +FAILURES, CHECKS = [], 0 +ALERTS = {"VulnScanStale", "VulnDatabaseStale", "VulnCoverageGap", "VulnCriticalFixAvailable", "VulnTriageBotSilent"} +ABSENT_ALERTS = { + "VulnScanStale": "vuln_scanner_last_complete_run_timestamp_seconds", + "VulnTriageBotSilent": "vuln_scanner_triage_heartbeat_timestamp_seconds", +} + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +COMP = ROOT / "platform" / "vuln-scanner" +for f in ("alerts.yaml", "alerts.promtool-test.yaml"): + if not (COMP / f).is_file(): + print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/{f} is absent", file=sys.stderr) + raise SystemExit(1) +rules = yaml.safe_load((COMP / "alerts.yaml").read_text()) +tests = yaml.safe_load((COMP / "alerts.promtool-test.yaml").read_text()) + + +def alert_rules(doc): + return {r["alert"]: r for g in doc["spec"]["groups"] for r in g["rules"]} + + +def eval_alerts(doc): + bad = [] + got = alert_rules(doc) + if set(got) != ALERTS: + bad.append(f"alerts are {sorted(got)}, expected {sorted(ALERTS)}") + for name, metric in ABSENT_ALERTS.items(): + if name in got and f"absent({metric})" not in got[name]["expr"].replace(" ", ""): + bad.append(f"{name} lacks the absent({metric}) arm") + for name, r in got.items(): + if not (r.get("labels") or {}).get("severity"): + bad.append(f"{name} has no severity label") + if "/runbooks/" not in ((r.get("annotations") or {}).get("runbook_url") or ""): + bad.append(f"{name} has no runbook_url") + return bad + + +def firing_assertions(test): + """(alertname -> assertion list) for the firing expectations of one promtool test.""" + out = {} + for pe in test.get("promql_expr_test") or []: + expr, samples = pe.get("expr", ""), pe.get("exp_samples") or [] + m = re.search(r'alertname="(Vuln\w+)"', expr) + if m and samples and 'alertstate="firing"' in (expr + " " + " ".join(s.get("labels", "") for s in samples)): + out.setdefault(m.group(1), []).append(pe) + return out + + +def series_for(test): + return [s.get("series", "") for s in test.get("input_series") or []] + + +def eval_tests(doc): + bad = [] + all_tests = doc.get("tests") or [] + fired = set() + for t in all_tests: + fired |= set(firing_assertions(t)) + missing = ALERTS - fired + if missing: + bad.append(f"no firing promtool assertion for {sorted(missing)}") + for alert, metric in ABSENT_ALERTS.items(): + absent_case = any( + alert in firing_assertions(t) and not any(s.startswith(metric) for s in series_for(t)) + for t in all_tests) + if not absent_case: + bad.append(f"{alert} has no firing test with NO {metric} series (the absent case)") + vanish_case = any( + alert in firing_assertions(t) and any(s.startswith(metric) and "stale" in str(i.get("values", "")) + for i in t.get("input_series", []) for s in [i.get("series", "")]) + for t in all_tests) + if not vanish_case: + bad.append(f"{alert} has no test where its metric goes stale (the re-fire case)") + return bad + + +for v in eval_alerts(rules): + check(False, f"(1,4) {v}") +for v in eval_tests(tests): + check(False, f"(2,3) {v}") +check(len(alert_rules(rules)) == 5, "(1) five alert rules parsed") + +# (5) the real harness, only when the rule engine is present. +if shutil.which("promtool"): + rc = subprocess.run(["bash", str(ROOT / "platform/monitoring/tests/run.sh"), + "platform/vuln-scanner/alerts.promtool-test.yaml"], + cwd=str(ROOT), capture_output=True, text=True) + check(rc.returncode == 0, f"(5) the promtool harness failed: rc={rc.returncode}\n{rc.stdout[-800:]}{rc.stderr[-800:]}") + +# (6) non-vacuity +m = copy.deepcopy(rules) +for g in m["spec"]["groups"]: + for r in g["rules"]: + if r["alert"] == "VulnTriageBotSilent": + r["expr"] = r["expr"].split(" or ")[0] +check(bool(eval_alerts(m)), "(6) evaluator missed: absent arm deleted") +m = copy.deepcopy(rules) +m["spec"]["groups"][0]["rules"].pop(1) +check(bool(eval_alerts(m)), "(6) evaluator missed: alert dropped") +m = copy.deepcopy(rules) +for g in m["spec"]["groups"]: + for r in g["rules"]: + r["annotations"].pop("runbook_url", None) +check(bool(eval_alerts(m)), "(6) evaluator missed: runbook_url removed") + +m = copy.deepcopy(tests) +for t in m["tests"]: + t["promql_expr_test"] = [p for p in t["promql_expr_test"] + if 'VulnCoverageGap' not in p.get("expr", "")] +check(bool(eval_tests(m)), "(6) evaluator missed: firing assertion removed") +m = copy.deepcopy(tests) +m["tests"] = [t for t in m["tests"] if "ABSENT" not in t.get("name", "")] +check(bool(eval_tests(m)), "(6) evaluator missed: absent-case tests removed") + +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/chainsaw-test.yaml new file mode 100644 index 0000000..1b54a80 --- /dev/null +++ b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/chainsaw-test.yaml @@ -0,0 +1,28 @@ +# ============================================================================= +# L0 STATIC — Prometheus receives aggregates only (R-VULN-06, docs/17 "Alerting"). +# Offline, no cluster. Depends on platform/vuln-scanner/src/metrics.py (the SCANNER's file). +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-metrics-no-per-cve-label + labels: + conformance.home.k3s/R-VULN-06: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-06" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + No vuln_scanner_* metric or alert rule carries a label other than state, severity or fixable, + so Prometheus can never hold a per-CVE, per-image or per-workload series. Proven by mutation. +spec: + steps: + - name: assert-metric-labels-are-aggregates + description: >- + Extract every vuln_scanner_* metric label from metrics.py and alerts.yaml, assert the + allowlist, and run the extractor on mutated text to prove it can fail. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh new file mode 100755 index 0000000..399b091 --- /dev/null +++ b/conformance/tests/l0-static/vuln-metrics-no-per-cve-label/check.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-metrics-no-per-cve-label +# Serves: R-VULN-06 ("Prometheus receives aggregates only ... MUST NOT receive a per-CVE series"). +# +# GIVEN platform/vuln-scanner/src/metrics.py and platform/vuln-scanner/alerts.yaml, +# THEN (1) every label name attached to a vuln_scanner_* metric is in the allowlist +# (state, severity, fixable); +# (2) metrics.py exposes every metric name from the contract; +# (3) alerts.yaml selects vuln_scanner_* metrics with no label matcher outside the allowlist; +# AND (4) the extractor is proven on mutated text: a `cve=` label, a `{image=...}` selector and a +# per-workload label are each flagged, and label-free text is not. +# A missing metrics.py is a FAIL: this is a whole-repo-scoped check and the file is part of the same +# delivery (the check has no skip path). +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import re, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-metrics-no-per-cve-label" +FAILURES, CHECKS = [], 0 +ALLOWED = {"state", "severity", "fixable"} +METRICS = ( + "vuln_scanner_report_generated_timestamp_seconds", + "vuln_scanner_last_complete_run_timestamp_seconds", + "vuln_scanner_database_updated_timestamp_seconds", + "vuln_scanner_targets", + "vuln_scanner_findings", + "vuln_scanner_failed_targets_over_24h", + "vuln_scanner_critical_fix_overdue", + "vuln_scanner_triage_heartbeat_timestamp_seconds", +) +# Label keys that could only carry per-CVE or per-target identity, anywhere a metric could be +# written. Catches label construction the name{...} scan cannot see (e.g. "cve_id": cve_id). +IDENTITY_LABEL = re.compile(r'''["'](cve|cve_id|advisory|package|pkg|image|digest|workload|namespace|pod|container|target|target_id)["']\s*[:=]''') + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +def label_names(text): + """Every label key in `vuln_scanner_x{key="value",...}` occurrences (Python or PromQL text), + tolerating f-strings' doubled braces and multi-line calls.""" + found = set() + for m in re.finditer(r"vuln_scanner_[a-z0-9_]+\{", text): + tail = text[m.end(): m.end() + 200] + close = tail.find("}\n") + chunk = tail[: close if close != -1 else None].split("\n", 1)[0] + for key in re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*[\"'{]", chunk): + found.add(key) + return found + + +METRICS_PY = ROOT / "platform" / "vuln-scanner" / "src" / "metrics.py" +ALERTS = ROOT / "platform" / "vuln-scanner" / "alerts.yaml" +if not METRICS_PY.is_file(): + print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/src/metrics.py is absent (the SCANNER's " + f"deliverable); the aggregate-only property cannot be asserted without it", file=sys.stderr) + raise SystemExit(1) +src = METRICS_PY.read_text() +check(all(m in src for m in METRICS), f"(2) metrics.py names every contract metric " + f"(missing: {[m for m in METRICS if m not in src]})") + +labels = label_names(src) +extra = labels - ALLOWED +check(not extra, f"(1) metrics.py attaches labels outside the allowlist: {sorted(extra)}") +hits = IDENTITY_LABEL.findall(src) +check(not hits, f"(1) metrics.py constructs an identity-bearing metric label: {sorted(set(hits))}") + +rules = yaml.safe_load(ALERTS.read_text()) +exprs = [r["expr"] for g in rules["spec"]["groups"] for r in g["rules"]] +check(len(exprs) >= 5, "(3) alerts.yaml parsed five or more rules") +alert_labels = set() +for e in exprs: + alert_labels |= label_names(e) +check(not (alert_labels - ALLOWED), + f"(3) alerts.yaml selects labels outside the allowlist: {sorted(alert_labels - ALLOWED)}") + +# (4) non-vacuity +negatives = { + "cve label": 'vuln_scanner_findings{severity="HIGH",cve="CVE-1"} 1\n', + "image selector": 'max(vuln_scanner_findings{image="docker.io/x"})\n', + "workload label": 'f"vuln_scanner_targets{{workload=\\"{w}\\",state=\\"x\\"}} 1"\n', +} +for name, text in negatives.items(): + check(bool(label_names(text) - ALLOWED), f"(4) the extractor missed: {name}") +ok_text = 'vuln_scanner_targets{state="scanned"} 3\nvuln_scanner_findings{severity="LOW",fixable="true"} 1\n' +check(not (label_names(ok_text) - ALLOWED), "(4) the extractor flagged the allowed labels") + +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l0-static/vuln-scanner-isolation/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-scanner-isolation/chainsaw-test.yaml new file mode 100644 index 0000000..850dc2c --- /dev/null +++ b/conformance/tests/l0-static/vuln-scanner-isolation/chainsaw-test.yaml @@ -0,0 +1,30 @@ +# ============================================================================= +# L0 STATIC — the scanner's Flux tier cannot stall the platform, and its network reach is exactly what +# docs/17 "Security of the scanner itself" allows (R-VULN-09). Offline, no cluster. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-scanner-isolation + labels: + conformance.home.k3s/R-VULN-09: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-09" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + The vuln-scanner Flux tier is wait:false, in neither gating tier, and nothing dependsOn it; + ingress is exactly the Gateway to 4180, the CI runner to 8081 and Prometheus to 9090; + egress reaches the API, DNS, the public internet on 443 only with cluster and LAN excluded, + and the Keycloak path the sidecar needs. Proven by mutation. +spec: + steps: + - name: assert-isolation-and-network-shape + description: >- + Parse the Flux wiring and networkpolicy.yaml, assert isolation and the exact network shape, + and run the evaluators on mutated copies to prove they can fail. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-scanner-isolation/check.sh b/conformance/tests/l0-static/vuln-scanner-isolation/check.sh new file mode 100755 index 0000000..ab51039 --- /dev/null +++ b/conformance/tests/l0-static/vuln-scanner-isolation/check.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-scanner-isolation +# Serves: R-VULN-09 (docs/17 "Security of the scanner itself"). +# +# GIVEN the repository as authored, +# THEN (1) the Flux tier is wait:false, dependsOn platform-config, in neither gating tier, nothing +# anywhere dependsOn it, and both overlays carry its SOPS decryption patch; +# (2) ingress is EXACTLY: reserved:ingress to 4180, act-runner to 8081, prometheus to 9090; +# (3) egress to the world is tcp/443 only through a CIDR rule excluding every private range, +# and the only other destinations are the API entity, the Gateway entity on 443 and Keycloak +# (the oauth2-proxy sidecar's OIDC path, the one deliberate addition to the spec's list); +# no `world`, `all`, `cluster`, `host` or `remote-node` entity; +# (4) the namespace ships a default-deny baseline and is not app-namespace=true (single owner); +# AND (5) each evaluator is run on a mutated copy and must flag it (non-vacuity). +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import copy, re, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-scanner-isolation" +FAILURES, CHECKS = [], 0 +FORBIDDEN_ENTITIES = {"world", "all", "cluster", "host", "remote-node"} +PRIVATE_RANGES = {"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +def docs(path): + return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)] + + +fk_path = ROOT / "clusters" / "base" / "vuln-scanner.yaml" +np_path = ROOT / "platform" / "vuln-scanner" / "networkpolicy.yaml" +for p in (fk_path, np_path): + if not p.is_file(): + print(f"CHECK FAIL ({NAME}): {p.relative_to(ROOT)} is absent", file=sys.stderr) + raise SystemExit(1) + +# (1) Flux wiring +fk = next(d for d in docs(fk_path) if d.get("kind") == "Kustomization") +check(fk["spec"].get("wait") is False, "(1) clusters/base/vuln-scanner.yaml must be wait: false") +check([d.get("name") for d in fk["spec"].get("dependsOn") or []] == ["platform-config"], + "(1) the tier dependsOn exactly platform-config") +check(fk["spec"].get("path") == "./platform/vuln-scanner", "(1) the tier reconciles ./platform/vuln-scanner") +check((fk["spec"].get("postBuild") or {}).get("substituteFrom"), + "(1) postBuild.substituteFrom is present (the route/alerts use ${BASE_DOMAIN})") +check("vuln-scanner.yaml" in (ROOT / "clusters" / "base" / "kustomization.yaml").read_text(), + "(1) clusters/base/kustomization.yaml lists vuln-scanner.yaml") +check("vuln-scanner" not in (ROOT / "platform" / "kustomization.yaml").read_text(), + "(1) platform/kustomization.yaml must not include vuln-scanner (the gating tier)") +for f in sorted((ROOT / "clusters").rglob("*.yaml")): + for d in docs(f): + for dep in (d.get("spec") or {}).get("dependsOn") or []: + check(not (isinstance(dep, dict) and dep.get("name") == "vuln-scanner"), + f"(1) {f.relative_to(ROOT)} {d.get('metadata', {}).get('name')} dependsOn vuln-scanner — " + "a broken scanner must never stall anything") +for env in ("dev", "prod"): + text = (ROOT / "clusters" / env / "kustomization.yaml").read_text() + check(re.search(r"name:\s*vuln-scanner\s*\n\s*namespace:\s*flux-system\s*\n\s*spec:\s*\n\s*decryption:", + text) is not None, + f"(1) clusters/{env}/kustomization.yaml carries the vuln-scanner SOPS decryption patch") + +# Network shape +np_docs = docs(np_path) +dd = [d for d in np_docs if d.get("kind") == "NetworkPolicy" and d["metadata"]["name"] == "default-deny"] +check(len(dd) == 1 and set(dd[0]["spec"]["policyTypes"]) == {"Ingress", "Egress"} and dd[0]["spec"].get("ingress") == [], + "(4) default-deny baseline denies both directions with no ingress") +ns = next(d for d in docs(ROOT / "platform" / "vuln-scanner" / "namespace.yaml") if d.get("kind") == "Namespace") +check("homelab.keithjackson.dev/app-namespace" not in (ns["metadata"].get("labels") or {}), + "(4) the namespace must not be app-namespace=true (the default-deny would be double-owned)") + + +def cnp(name): + return next((d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy" and d["metadata"]["name"] == name), None) + + +def ports_of(rule): + return {(p.get("port"), p.get("protocol", "TCP")) for tp in rule.get("toPorts") or [] for p in tp.get("ports") or []} + + +def eval_ingress(policy): + bad = [] + rules = policy["spec"].get("ingress") or [] + if len(rules) != 3: + bad.append(f"expected exactly 3 ingress rules, found {len(rules)}") + seen = set() + for r in rules: + ports = ports_of(r) + if r.get("fromEntities") == ["ingress"] and ports == {("4180", "TCP")}: + seen.add("gateway") + elif len(r.get("fromEndpoints") or []) == 1: + sel = r["fromEndpoints"][0].get("matchLabels") or {} + if (sel.get("io.kubernetes.pod.namespace") == "gitea-actions" + and sel.get("app.kubernetes.io/name") == "act-runner" and ports == {("8081", "TCP")}): + seen.add("ci") + elif (sel.get("io.kubernetes.pod.namespace") == "monitoring" + and sel.get("app.kubernetes.io/name") == "prometheus" and ports == {("9090", "TCP")}): + seen.add("prometheus") + else: + bad.append(f"unexpected ingress rule {r}") + else: + bad.append(f"unexpected ingress rule {r}") + missing = {"gateway", "ci", "prometheus"} - seen + if missing: + bad.append(f"ingress paths missing: {sorted(missing)}") + return bad + + +def eval_egress(policy): + bad = [] + rules = policy["spec"].get("egress") or [] + if len(rules) != 4: + bad.append(f"expected exactly 4 egress rules, found {len(rules)}") + world = 0 + for r in rules: + ents = set(r.get("toEntities") or []) + if ents & FORBIDDEN_ENTITIES: + bad.append(f"egress admits forbidden entity {sorted(ents & FORBIDDEN_ENTITIES)}") + if ents == {"kube-apiserver"} and not r.get("toPorts"): + continue + if ents == {"ingress"} and ports_of(r) == {("443", "TCP")}: + continue + if r.get("toEndpoints"): + sel = r["toEndpoints"][0].get("matchLabels") or {} + if not (len(r["toEndpoints"]) == 1 and sel.get("io.kubernetes.pod.namespace") == "keycloak" + and sel.get("app") == "keycloak"): + bad.append(f"egress to unexpected endpoints {r}") + continue + if r.get("toCIDRSet"): + world += 1 + sets = r["toCIDRSet"] + if len(sets) != 1 or sets[0].get("cidr") != "0.0.0.0/0": + bad.append(f"unexpected CIDR rule {r}") + continue + if not PRIVATE_RANGES.issubset(set(sets[0].get("except") or [])): + bad.append(f"the world CIDR rule does not exclude {sorted(PRIVATE_RANGES - set(sets[0].get('except') or []))}") + if ports_of(r) != {("443", "TCP")}: + bad.append(f"the world CIDR rule is not tcp/443 only ({sorted(ports_of(r))})") + continue + if r.get("toCIDR"): + bad.append(f"egress names a bare CIDR rule {r}") + continue + if not ents: + bad.append(f"unexpected egress rule {r}") + if world != 1: + bad.append(f"expected exactly one world CIDR rule, found {world}") + return bad + + +ing, eg = cnp("vuln-scanner-allow-ingress"), cnp("vuln-scanner-allow-egress") +check(ing is not None, "(2) the ingress CiliumNetworkPolicy exists") +check(eg is not None, "(3) the egress CiliumNetworkPolicy exists") +if ing is not None: + for v in eval_ingress(ing): + check(False, f"(2) {v}") +if eg is not None: + for v in eval_egress(eg): + check(False, f"(3) {v}") +check(len([d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy"]) == 2, + "(3) exactly two CiliumNetworkPolicies, so no other path is added silently") + +# (5) non-vacuity +if ing is not None and eg is not None: + def m_ing_open(p): p["spec"]["ingress"].append({"fromEntities": ["world"], "toPorts": [{"ports": [{"port": "8081", "protocol": "TCP"}]}]}) + def m_ing_ns(p): p["spec"]["ingress"][1]["fromEndpoints"][0]["matchLabels"]["io.kubernetes.pod.namespace"] = "default" + def m_ing_port(p): p["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"] = "8080" + for name, fn in (("extra world ingress", m_ing_open), ("CI rule moved to another namespace", m_ing_ns), + ("Gateway reaches the page port", m_ing_port)): + m = copy.deepcopy(ing) + fn(m) + check(bool(eval_ingress(m)), f"(5) ingress evaluator missed mutant: {name}") + def m_eg_world(p): p["spec"]["egress"].append({"toEntities": ["world"]}) + def m_eg_lan(p): + for r in p["spec"]["egress"]: + if r.get("toCIDRSet"): + r["toCIDRSet"][0]["except"] = ["10.0.0.0/8"] + def m_eg_port(p): + for r in p["spec"]["egress"]: + if r.get("toCIDRSet"): + r["toPorts"][0]["ports"].append({"port": "80", "protocol": "TCP"}) + def m_eg_endpoint(p): + for r in p["spec"]["egress"]: + if r.get("toEndpoints"): + r["toEndpoints"][0]["matchLabels"]["io.kubernetes.pod.namespace"] = "default" + for name, fn in (("world entity added", m_eg_world), ("LAN not excluded", m_eg_lan), + ("port 80 added", m_eg_port), ("endpoints moved", m_eg_endpoint)): + m = copy.deepcopy(eg) + fn(m) + check(bool(eval_egress(m)), f"(5) egress evaluator missed mutant: {name}") + +if CHECKS < 18: + FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated") +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l0-static/vuln-scanner-supply-chain/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-scanner-supply-chain/chainsaw-test.yaml new file mode 100644 index 0000000..692876b --- /dev/null +++ b/conformance/tests/l0-static/vuln-scanner-supply-chain/chainsaw-test.yaml @@ -0,0 +1,34 @@ +# ============================================================================= +# L0 STATIC — the CVE scanner's supply chain and privileges are what docs/17 "Security of the scanner +# itself" says (R-VULN-09). Offline, no cluster. +# +# WHY SCOPE, NOT REQUIREMENTS: the property spans platform/vuln-scanner and renovate.json5. A +# requirement-scoped selection would miss a Renovate edit that quietly lets the scanner auto-merge. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-scanner-supply-chain + labels: + conformance.home.k3s/R-VULN-09: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-09" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + The scanner image is pinned by digest and is not a banned Trivy version; Renovate never + auto-merges it and proposes it only after 7 days; its RBAC holds get/list/watch only and no + Secret; the pod is unprivileged with bounded resources and a size-limited cache. Each is + proven by mutation. +spec: + steps: + - name: assert-supply-chain-and-privileges + description: >- + Parse platform/vuln-scanner/deployment.yaml, rbac.yaml and renovate.json5, assert the + digest/version/Renovate/RBAC/security-context contract, and run each evaluator on mutated + copies to prove it can fail. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh b/conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh new file mode 100755 index 0000000..56320e8 --- /dev/null +++ b/conformance/tests/l0-static/vuln-scanner-supply-chain/check.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-scanner-supply-chain +# Serves: R-VULN-09 (docs/17 "Security of the scanner itself"). +# +# GIVEN the repository as authored, +# THEN (1) the Trivy image is `ghcr.io/aquasecurity/trivy:@sha256:<64hex>` — by digest, and a +# tag so Renovate has something to follow; +# (2) that version is not 0.69.4/0.69.5/0.69.6 (the compromised March 2026 releases); +# (3) every pod image is digest-pinned except the oauth2-proxy sidecar, which follows the +# per-app sidecar pattern so one Renovate branch moves them all; +# (4) renovate.json5 keeps Trivy in the blast-radius list, automerge:false, minimumReleaseAge +# of at least 7 days, and no later rule re-enables automerge for it; +# (5) rbac.yaml: only ClusterRole/ClusterRoleBinding to the scanner SA, only get/list/watch, no +# Secret, no wildcard, and only the resources the inventory needs; +# (6) the pod: non-root, read-only rootfs, drop ALL, seccomp RuntimeDefault, no host +# namespaces or hostPath, CPU and memory limits on every container, an ephemeral-storage +# limit on the scanner, and a size-limited /cache emptyDir. +# AND (7) each evaluator is run on a mutated copy and must flag it (non-vacuity). +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import copy, json, re, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-scanner-supply-chain" +FAILURES, CHECKS = [], 0 +COMP = ROOT / "platform" / "vuln-scanner" +BANNED = {"0.69.4", "0.69.5", "0.69.6"} +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +ALLOWED_VERBS = {"get", "list", "watch"} +ALLOWED_RESOURCES = { + "": {"pods", "nodes"}, + "apps": {"replicasets", "deployments", "statefulsets", "daemonsets"}, + "batch": {"jobs", "cronjobs"}, + "kubevirt.io": {"virtualmachineinstances"}, +} + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +def docs(path): + return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)] + + +for f in ("deployment.yaml", "rbac.yaml"): + if not (COMP / f).is_file(): + print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/{f} is absent", file=sys.stderr) + raise SystemExit(1) + + +def split_ref(image): + # A digest suffix, then a tag after the last ':' (only when it holds no '/', so a registry port + # stays in the repository part). + ref, _, digest = image.partition("@") + head, colon, tail = ref.rpartition(":") + if colon and "/" not in tail: + return head, tail, digest or None + return ref, None, digest or None + + +def containers(pod): + return (pod.get("initContainers") or []) + (pod.get("containers") or []) + + +def eval_images(pod): + bad = [] + trivy = [c for c in containers(pod) if split_ref(c["image"])[0] == "ghcr.io/aquasecurity/trivy"] + if len(trivy) != 1: + bad.append(f"expected one Trivy image, found {len(trivy)}") + for c in containers(pod): + repo, tag, digest = split_ref(c["image"]) + if repo == "quay.io/oauth2-proxy/oauth2-proxy": + if not tag or tag == "latest": + bad.append("oauth2-proxy sidecar has no version tag") + continue + if not digest or not DIGEST.match(digest): + bad.append(f"{c['name']} image {c['image']} is not pinned by digest") + if not tag: + bad.append(f"{c['name']} image {c['image']} has no tag for Renovate to follow") + if repo == "ghcr.io/aquasecurity/trivy" and tag in BANNED: + bad.append(f"Trivy {tag} is a banned (compromised) version") + return bad + + +def strip_json5(text): + # Drop // comments outside strings, then trailing commas. + out, i, in_str, esc = [], 0, False, False + while i < len(text): + ch = text[i] + if in_str: + out.append(ch) + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + i += 1 + continue + if ch == '"': + in_str = True + out.append(ch) + i += 1 + continue + if text.startswith("//", i): + while i < len(text) and text[i] != "\n": + i += 1 + continue + out.append(ch) + i += 1 + return re.sub(r",(\s*[}\]])", r"\1", "".join(out)) + + +TRIVY_PKG = "ghcr.io/aquasecurity/trivy" +VERSION_UPDATE_RULE_RE = re.compile(r"^(\d+)\s*(day|days)$") + + +def eval_renovate(rules): + bad = [] + trivy_rules = [r for r in rules if TRIVY_PKG in (r.get("matchPackageNames") or [])] + if not trivy_rules: + bad.append("no packageRule matches ghcr.io/aquasecurity/trivy (blast-radius list)") + return bad + if not any(r.get("automerge") is False and "blast-radius" in (r.get("addLabels") or []) + for r in trivy_rules): + bad.append("Trivy is not in a blast-radius rule with automerge:false") + ages = [r["minimumReleaseAge"] for r in trivy_rules if "minimumReleaseAge" in r] + if not ages or not any(VERSION_UPDATE_RULE_RE.match(a) and int(VERSION_UPDATE_RULE_RE.match(a).group(1)) >= 7 + for a in ages): + bad.append(f"Trivy has no minimumReleaseAge of at least 7 days (found {ages})") + # Renovate applies packageRules in order, later wins; the last rule that names automerge for + # Trivy (or matches it generically by update type) must be false. + last = None + for r in rules: + if "automerge" in r and (TRIVY_PKG in (r.get("matchPackageNames") or []) + or set(r) <= {"description", "matchUpdateTypes", "automerge", "automergeType", "platformAutomerge"}): + last = r + if last is None or last.get("automerge") is not False: + bad.append("the last packageRule deciding automerge for Trivy leaves it true") + return bad + + +def eval_rbac(rbac): + bad = [] + croles = [d for d in rbac if d.get("kind") == "ClusterRole"] + if len(croles) != 1: + bad.append(f"expected one ClusterRole, found {len(croles)}") + return bad + for rule in croles[0].get("rules") or []: + verbs = set(rule.get("verbs") or []) + if verbs - ALLOWED_VERBS: + bad.append(f"rule grants non-read verbs {sorted(verbs - ALLOWED_VERBS)}") + if "*" in verbs: + bad.append("rule grants the verb wildcard") + for group in rule.get("apiGroups") or []: + if group == "*" or group not in ALLOWED_RESOURCES: + bad.append(f"rule names apiGroup {group!r}") + continue + for res in rule.get("resources") or []: + if res == "*" or res not in ALLOWED_RESOURCES[group]: + bad.append(f"rule grants resource {group}/{res}") + if "secrets" in (rule.get("resources") or []): + bad.append("rule grants Secret access") + kinds = {d.get("kind") for d in rbac} + if kinds - {"ServiceAccount", "ClusterRole", "ClusterRoleBinding"}: + bad.append(f"unexpected RBAC kinds {sorted(kinds)}") + for b in (d for d in rbac if d.get("kind") == "ClusterRoleBinding"): + if (b.get("roleRef") or {}).get("name") != croles[0]["metadata"]["name"]: + bad.append("the ClusterRoleBinding does not bind the scanner's ClusterRole") + return bad + + +def eval_pod(pod): + bad = [] + psc = pod.get("securityContext") or {} + if psc.get("runAsNonRoot") is not True: + bad.append("pod runAsNonRoot is not true") + if (psc.get("seccompProfile") or {}).get("type") != "RuntimeDefault": + bad.append("pod seccompProfile is not RuntimeDefault") + for f in ("hostNetwork", "hostPID", "hostIPC"): + if pod.get(f): + bad.append(f"pod sets {f}") + if any("hostPath" in v for v in pod.get("volumes") or []): + bad.append("pod mounts a hostPath volume") + for c in containers(pod): + sc = c.get("securityContext") or {} + if sc.get("readOnlyRootFilesystem") is not True: + bad.append(f"{c['name']} root filesystem is writable") + if sc.get("allowPrivilegeEscalation") is not False: + bad.append(f"{c['name']} allows privilege escalation") + if "ALL" not in ((sc.get("capabilities") or {}).get("drop") or []): + bad.append(f"{c['name']} does not drop ALL capabilities") + limits = (c.get("resources") or {}).get("limits") or {} + for lim in ("cpu", "memory"): + if lim not in limits: + bad.append(f"{c['name']} has no {lim} limit") + if c["name"] == "scanner" and "ephemeral-storage" not in limits: + bad.append("the scanner has no ephemeral-storage limit") + vols = {v["name"]: v for v in pod.get("volumes") or []} + if not (vols.get("cache", {}).get("emptyDir") or {}).get("sizeLimit"): + bad.append("the /cache emptyDir has no sizeLimit (Longhorn or etcd must never hold it)") + return bad + + +dep = next(d for d in docs(COMP / "deployment.yaml") if d.get("kind") == "Deployment") +pod = dep["spec"]["template"]["spec"] +for v in eval_images(pod) + eval_pod(pod): + check(False, f"(1-3,6) {v}") +check(dep["spec"].get("replicas") == 1, "(6) one replica (one RWO report store)") +check((dep["spec"].get("strategy") or {}).get("type") == "Recreate", "(6) Recreate strategy") + +renovate = json.loads(strip_json5((ROOT / "renovate.json5").read_text())) +rules = renovate.get("packageRules") or [] +check(len(rules) > 5, "(4) parsed renovate.json5 packageRules (parser degenerated)") +for v in eval_renovate(rules): + check(False, f"(4) {v}") + +rbac = docs(COMP / "rbac.yaml") +for v in eval_rbac(rbac): + check(False, f"(5) {v}") + +# (7) non-vacuity +def mutated(fn, base): + m = copy.deepcopy(base) + fn(m) + return m + +# Image mutants +def img_digest(p): p["initContainers"][0]["image"] = "ghcr.io/aquasecurity/trivy:0.76.1" +def img_banned(p): p["initContainers"][0]["image"] = re.sub(r":[0-9.]+@", ":0.69.5@", p["initContainers"][0]["image"]) +def img_nopin(p): p["containers"][0]["image"] = "docker.io/library/python:3.14.9-alpine" +for name, fn in (("digest stripped from Trivy", img_digest), ("banned Trivy version", img_banned), + ("python image unpinned", img_nopin)): + check(bool(eval_images(mutated(fn, pod))), f"(7) image evaluator missed mutant: {name}") + +# RBAC mutants +def rbac_secret(r): + role = next(d for d in r if d.get("kind") == "ClusterRole") + role["rules"][0]["resources"].append("secrets") +def rbac_write(r): + role = next(d for d in r if d.get("kind") == "ClusterRole") + role["rules"][0]["verbs"].append("delete") +def rbac_wild(r): + role = next(d for d in r if d.get("kind") == "ClusterRole") + role["rules"][1]["resources"] = ["*"] +for name, fn in (("Secret resource granted", rbac_secret), ("write verb granted", rbac_write), + ("resource wildcard granted", rbac_wild)): + check(bool(eval_rbac(mutated(fn, rbac))), f"(7) RBAC evaluator missed mutant: {name}") + +# Renovate mutants +def ren_auto(r): + for x in r: + if TRIVY_PKG in (x.get("matchPackageNames") or []): + x["automerge"] = True +def ren_age(r): + for x in r: + if TRIVY_PKG in (x.get("matchPackageNames") or []) and "minimumReleaseAge" in x: + x["minimumReleaseAge"] = "2 days" +def ren_drop(r): + r[:] = [x for x in r if TRIVY_PKG not in (x.get("matchPackageNames") or [])] +def ren_later(r): + r.append({"matchUpdateTypes": ["digest"], "automerge": True}) +for name, fn in (("Trivy automerge flipped true", ren_auto), ("minimumReleaseAge shortened", ren_age), + ("Trivy removed from Renovate rules", ren_drop), ("later auto-merge rule appended", ren_later)): + check(bool(eval_renovate(mutated(fn, rules))), f"(7) Renovate evaluator missed mutant: {name}") + +# Pod mutants +def pod_root(p): p["securityContext"]["runAsNonRoot"] = False +def pod_host(p): p["hostNetwork"] = True +def pod_rw(p): p["containers"][0]["securityContext"]["readOnlyRootFilesystem"] = False +def pod_nolim(p): del p["containers"][0]["resources"]["limits"]["memory"] +def pod_nocap(p): + for v in p["volumes"]: + if v["name"] == "cache": + v["emptyDir"].pop("sizeLimit", None) +for name, fn in (("runAsNonRoot off", pod_root), ("hostNetwork on", pod_host), ("rw rootfs", pod_rw), + ("no memory limit", pod_nolim), ("no cache sizeLimit", pod_nocap)): + check(bool(eval_pod(mutated(fn, pod))), f"(7) pod evaluator missed mutant: {name}") + +if CHECKS < 15: + FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated") +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/chainsaw-test.yaml b/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/chainsaw-test.yaml new file mode 100644 index 0000000..a0f98eb --- /dev/null +++ b/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/chainsaw-test.yaml @@ -0,0 +1,31 @@ +# ============================================================================= +# L0 STATIC — the CVE triage bot workflow fails loudly and is shaped like Renovate's (R-VULN-05, +# R-VULN-07, docs/17 "The triage bot"). Offline, no cluster. Depends on .gitea/workflows/cve-triage.yaml +# (the BOT's file); fails until that file is integrated. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-triage-bot-workflow-guards + labels: + conformance.home.k3s/R-VULN-05: "true" + conformance.home.k3s/R-VULN-07: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-05, R-VULN-07" + conformance.home.k3s/layer: "L0" + conformance.home.k3s/scope: "whole-repo" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/executable: "true" + conformance.home.k3s/intent: >- + The bot workflow runs on the 07:15 cron, has a workflow_dispatch dry-run input, never runs + concurrently with itself, and a step fails the job hard when RENOVATE_TOKEN is empty. + Proven by mutation. +spec: + steps: + - name: assert-workflow-guards + description: >- + Parse .gitea/workflows/cve-triage.yaml and assert the trigger, concurrency and hard + credential failure, then run the evaluator on mutated copies to prove it can fail. + try: + - script: + content: bash ./check.sh diff --git a/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/check.sh b/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/check.sh new file mode 100755 index 0000000..b126d26 --- /dev/null +++ b/conformance/tests/l0-static/vuln-triage-bot-workflow-guards/check.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# ============================================================================= +# L0 static check (offline, no cluster) — vuln-triage-bot-workflow-guards +# Serves: R-VULN-05 (runs on a schedule, fails loudly), R-VULN-07 (bot conventions, docs/17). +# +# GIVEN .gitea/workflows/cve-triage.yaml, +# THEN (1) it runs on `15 7 * * *` and has a workflow_dispatch `dry_run` input; +# (2) concurrency is set (the workflow never runs against itself); +# (3) a step fails the job HARD when RENOVATE_TOKEN is empty (exit non-zero, not skip); +# AND (4) the evaluator is run on mutated copies and must flag each (non-vacuity). +# A missing workflow file is a FAIL: this is whole-repo scoped and has no skip path. +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" +exec python3 - "$ROOT" <<'PY' +import copy, re, sys +from pathlib import Path +import yaml + +ROOT = Path(sys.argv[1]) +NAME = "vuln-triage-bot-workflow-guards" +FAILURES, CHECKS = [], 0 +WF = ROOT / ".gitea" / "workflows" / "cve-triage.yaml" + + +def check(cond, desc): + global CHECKS + CHECKS += 1 + if not cond: + FAILURES.append(desc) + + +if not WF.is_file(): + print(f"CHECK FAIL ({NAME}): .gitea/workflows/cve-triage.yaml is absent (the BOT's deliverable); " + f"whether it fails on a missing credential cannot be asserted without it", file=sys.stderr) + raise SystemExit(1) +doc = yaml.safe_load(WF.read_text()) + + +def evaluate(doc): + bad = [] + triggers = doc.get(True) if True in doc else doc.get("on") # YAML reads `on:` as boolean True + if not isinstance(triggers, dict): + return ["no triggers parsed"] + if "15 7 * * *" not in [s.get("cron") for s in triggers.get("schedule") or [] if isinstance(s, dict)]: + bad.append("no schedule with cron 15 7 * * *") + inputs = ((triggers.get("workflow_dispatch") or {}).get("inputs") or {}) + if "dry_run" not in inputs: + bad.append("workflow_dispatch has no dry_run input") + conc = doc.get("concurrency") + jobs = doc.get("jobs") or {} + if not conc and not any(isinstance(j, dict) and j.get("concurrency") for j in jobs.values()): + bad.append("no concurrency set (the bot must not run against itself)") + hard_fail = False + for j in jobs.values(): + for step in (j.get("steps") or []) if isinstance(j, dict) else []: + run = str(step.get("run", "")) + if ("RENOVATE_TOKEN" in run and re.search(r"(-z|!\s*-n)[^\n]*RENOVATE_TOKEN|RENOVATE_TOKEN[^\n]*(-z)", run) + and re.search(r"exit\s+[1-9]", run)): + hard_fail = True + if not hard_fail: + bad.append("no step fails hard (exit non-zero) when RENOVATE_TOKEN is empty") + return bad + + +for v in evaluate(doc): + check(False, f"(1-3) {v}") +check(isinstance(doc.get("jobs"), dict) and doc["jobs"], "(1) the workflow declares jobs") + +# (4) non-vacuity +def mutate(fn): + m = copy.deepcopy(doc) + fn(m) + return m + +def trig(m): + return m.get(True) if True in m else m.get("on") + +def no_cron(m): trig(m).pop("schedule", None) +def no_dry(m): trig(m)["workflow_dispatch"] = {} +def no_conc(m): + m.pop("concurrency", None) + for j in m.get("jobs", {}).values(): + if isinstance(j, dict): + j.pop("concurrency", None) +def no_guard(m): + for j in m.get("jobs", {}).values(): + for step in j.get("steps") or []: + if "RENOVATE_TOKEN" in str(step.get("run", "")): + step["run"] = 'echo "::warning::RENOVATE_TOKEN unset, skipping"' +for name, fn in (("cron removed", no_cron), ("dry_run input removed", no_dry), + ("concurrency removed", no_conc), ("credential check softened", no_guard)): + check(bool(evaluate(mutate(fn))), f"(4) evaluator missed mutant: {name}") + +if FAILURES: + print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + raise SystemExit(1) +print(f"PASS ({NAME}): {CHECKS} assertions") +PY diff --git a/conformance/tests/l2-behavioral/vuln-internal-api-isolated/chainsaw-test.yaml b/conformance/tests/l2-behavioral/vuln-internal-api-isolated/chainsaw-test.yaml new file mode 100644 index 0000000..54aa269 --- /dev/null +++ b/conformance/tests/l2-behavioral/vuln-internal-api-isolated/chainsaw-test.yaml @@ -0,0 +1,35 @@ +# ============================================================================= +# L2 BEHAVIORAL SCAFFOLD — the internal API is unreachable from an unrelated namespace (R-VULN-09) +# Catalog: docs/17 "Acceptance gates" (L2). +# +# A pod in an unrelated namespace cannot reach vuln-scanner :8081 or :9090, while the CI runner identity can reach :8081. +# +# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset, +# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-internal-api-isolated + labels: + conformance.home.k3s/R-VULN-09: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-09" + conformance.home.k3s/layer: "L2" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/intent: >- + A pod in an unrelated namespace cannot reach vuln-scanner :8081 or :9090, while the CI runner identity can reach :8081. +spec: + steps: + - name: given-an-unrelated-namespace + try: + - script: + content: 'echo "TODO: create a throwaway namespace and probe pod"' + - name: when-connecting + try: + - script: + content: 'echo "TODO: attempt tcp to vuln-scanner:8081 and :9090"' + - name: then-denied + try: + - script: + content: 'echo "TODO: assert a timeout on both, and success from the act-runner identity on :8081"' diff --git a/conformance/tests/l2-behavioral/vuln-new-digest-reported/chainsaw-test.yaml b/conformance/tests/l2-behavioral/vuln-new-digest-reported/chainsaw-test.yaml new file mode 100644 index 0000000..1d0098b --- /dev/null +++ b/conformance/tests/l2-behavioral/vuln-new-digest-reported/chainsaw-test.yaml @@ -0,0 +1,36 @@ +# ============================================================================= +# L2 BEHAVIORAL SCAFFOLD — new workload digest appears in the report (R-VULN-01, R-VULN-02) +# Catalog: docs/17 "Acceptance gates" (L2). +# +# A newly started workload's digest appears in report.json, mapped to that workload, within the 2-hour window. +# +# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset, +# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-new-digest-reported + labels: + conformance.home.k3s/R-VULN-01: "true" + conformance.home.k3s/R-VULN-02: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-01, R-VULN-02" + conformance.home.k3s/layer: "L2" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/intent: >- + A newly started workload's digest appears in report.json, mapped to that workload, within the 2-hour window. +spec: + steps: + - name: given-a-new-workload + try: + - script: + content: 'echo "TODO: start a pod from an image digest not yet in the report"' + - name: when-the-scanner-ticks + try: + - script: + content: 'echo "TODO: wait, bounded by the 2h SLO, polling GET /report.json from the CI runner identity"' + - name: then-digest-is-reported + try: + - script: + content: 'echo "TODO: assert a target with that digest and the workload mapping, state not pending past 2h"' diff --git a/conformance/tests/l2-behavioral/vuln-page-requires-admin/chainsaw-test.yaml b/conformance/tests/l2-behavioral/vuln-page-requires-admin/chainsaw-test.yaml new file mode 100644 index 0000000..be80fc4 --- /dev/null +++ b/conformance/tests/l2-behavioral/vuln-page-requires-admin/chainsaw-test.yaml @@ -0,0 +1,39 @@ +# ============================================================================= +# L2 BEHAVIORAL SCAFFOLD — the CVE page requires a platform-admins login (R-VULN-04) +# Catalog: docs/17 "Acceptance gates" (L2). +# +# An unauthenticated request to cve. is redirected to Keycloak, and a logged-in user outside platform-admins gets 403. +# +# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset, +# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-page-requires-admin + labels: + conformance.home.k3s/R-VULN-04: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-04" + conformance.home.k3s/layer: "L2" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/intent: >- + An unauthenticated request to cve. is redirected to Keycloak, and a logged-in user outside platform-admins gets 403. +spec: + steps: + - name: given-the-page-route + try: + - script: + content: 'echo "TODO: resolve cve. through the Gateway"' + - name: when-unauthenticated + try: + - script: + content: 'echo "TODO: request the page with no session"' + - name: then-redirected-to-keycloak + try: + - script: + content: 'echo "TODO: assert a 302 to the homelab realm"' + - name: then-non-admin-forbidden + try: + - script: + content: 'echo "TODO: assert 403 for a session outside platform-admins"' diff --git a/conformance/tests/l3-fault-injection/vuln-registry-failure/chainsaw-test.yaml b/conformance/tests/l3-fault-injection/vuln-registry-failure/chainsaw-test.yaml new file mode 100644 index 0000000..067f799 --- /dev/null +++ b/conformance/tests/l3-fault-injection/vuln-registry-failure/chainsaw-test.yaml @@ -0,0 +1,40 @@ +# ============================================================================= +# L3 FAULT-INJECTION SCAFFOLD — breaking registry access produces failed targets and the coverage alert (R-VULN-03, R-VULN-06) +# Catalog: docs/17 "Acceptance gates" (L3). +# +# With registry access broken for one image, that target becomes failed with its reason, prior findings are retained, and VulnCoverageGap fires after 24h. +# +# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset, +# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence. +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vuln-registry-failure + labels: + conformance.home.k3s/R-VULN-03: "true" + conformance.home.k3s/R-VULN-06: "true" + annotations: + conformance.home.k3s/requirements: "R-VULN-03, R-VULN-06" + conformance.home.k3s/layer: "L3" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/intent: >- + With registry access broken for one image, that target becomes failed with its reason, prior findings are retained, and VulnCoverageGap fires after 24h. +spec: + steps: + - name: given-a-scanned-target + try: + - script: + content: 'echo "TODO: record a scanned target and its findings"' + - name: when-registry-access-is-broken + try: + - script: + content: 'echo "TODO: inject a fault blocking that registry"' + - name: then-target-failed + try: + - script: + content: 'echo "TODO: assert state failed, reason present, findings retained, run continues"' + - name: then-coverage-alert-fires + try: + - script: + content: 'echo "TODO: assert VulnCoverageGap fires once the failure passes 24h"' diff --git a/platform/cloudflared/config.yaml b/platform/cloudflared/config.yaml index 0334c96..10ea8bf 100644 --- a/platform/cloudflared/config.yaml +++ b/platform/cloudflared/config.yaml @@ -54,6 +54,10 @@ ingress: # tunnel alongside decision 4. - hostname: headlamp.${BASE_DOMAIN} service: http_status:404 + # The CVE page (docs/17: internal only, a list of what is exploitable where). Its route is disowned + # from public DNS (platform/vuln-scanner/httproute.yaml); this is the second layer. + - hostname: cve.${BASE_DOMAIN} + service: http_status:404 # Non-HTTP L4 VIPs (their own LoadBalancer addresses, not the Gateway). Listed so a DNS flip # that CNAMEs them at the tunnel by mistake gets a 404, not the Gateway's catch-all. - hostname: unifi.${BASE_DOMAIN} diff --git a/platform/cloudflared/networkpolicy.yaml b/platform/cloudflared/networkpolicy.yaml index a4297fd..1c7ebf2 100644 --- a/platform/cloudflared/networkpolicy.yaml +++ b/platform/cloudflared/networkpolicy.yaml @@ -147,3 +147,4 @@ spec: - radarr - sonarr - headlamp + - vuln-scanner diff --git a/platform/homepage/configmap.yaml b/platform/homepage/configmap.yaml index 809e517..4459815 100644 --- a/platform/homepage/configmap.yaml +++ b/platform/homepage/configmap.yaml @@ -43,6 +43,13 @@ data: href: https://grafana.${BASE_DOMAIN} description: Metrics, logs & dashboards (fronts Prometheus/Loki/Alertmanager) icon: mdi-chart-areaspline + # The CVE page (platform/vuln-scanner, docs/17): behind SSO, restricted to platform-admins. + # Homepage has no per-group surfaces, so this tile is shown to everyone who can open this + # dashboard; the page itself enforces platform-admins. + - CVE Report: + href: https://cve.${BASE_DOMAIN} + description: Container image CVE inventory (platform-admins) + icon: mdi-shield-bug - Semaphore: href: https://semaphore.${BASE_DOMAIN} description: Ansible automation & CI diff --git a/platform/vuln-scanner/README.md b/platform/vuln-scanner/README.md new file mode 100644 index 0000000..b5f8b1e --- /dev/null +++ b/platform/vuln-scanner/README.md @@ -0,0 +1,80 @@ +# vuln-scanner — scheduled CVE visibility for what is running + +Answers three questions with no manual step: what is running, what is it vulnerable to, and can the +answer be trusted. Contract: spec `docs/17-vulnerability-visibility.md`, ADR-0027 (R-VULN-01..09). +Hand-authored platform tooling (not a catalog service), reconciled by its own non-gating Flux tier +`clusters/base/vuln-scanner.yaml`. Operator runbook: `runbooks/vuln-scanner.md`. + +| Piece | Where | +|---|---| +| Scanner source (`python3`, stdlib only), mounted from a ConfigMap at `/app` | `src/` | +| Accepted risk (R-VULN-08) | `acceptances.yaml` | +| Pod, security context, sidecar | `deployment.yaml` | +| Read-only identity (R-VULN-09) | `rbac.yaml` | +| Network: Gateway→4180, CI→8081, Prometheus→9090; API, DNS, public 443 | `networkpolicy.yaml` | +| Aggregate metrics and the five alerts | `servicemonitor.yaml`, `alerts.yaml`, `alerts.promtool-test.yaml` | + +The page is `cve.`, internal only, gated by the per-pod oauth2-proxy sidecar to `platform-admins`. +The route is disowned from public DNS and the tunnel 404s it; PiHole answers it on the LAN. + +## Operator step: add the Keycloak client (NOT done by the change that added this tree) + +This change adds everything around the Keycloak client but not the client. Adding it needs a new key in +`platform/keycloak/keycloak-realm-clients.sops.yaml`, an encrypted file an agent must not decrypt; the +L0 check `keycloak-client-secret-reconciler` then requires the client and that key to appear in the +same change. Until this is done the page cannot complete a login (`invalid_client`, fail-closed); the +scanner, its metrics and its alerts are unaffected. + +In one PR (the realm import does not re-run on its own for a NEW client): + +1. Generate one value and set it in both encrypted files, through pipes so it is never echoed: + + ```bash + ./infra/devcontainer/dev.sh run bash -c ' + cs=$(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. diff --git a/platform/vuln-scanner/acceptances.yaml b/platform/vuln-scanner/acceptances.yaml new file mode 100644 index 0000000..ec82b58 --- /dev/null +++ b/platform/vuln-scanner/acceptances.yaml @@ -0,0 +1,15 @@ +# Accepted CVE risk (R-VULN-08). A list; this file is the ONLY place risk can be accepted, and it reaches +# the scanner through the merge gate (a ConfigMap generated by kustomization.yaml). +# +# Each entry: +# - id: CVE-2026-00000 # the CVE or advisory id, exactly as the scanner reports it +# image: registry/repository # exact image match; omit only for a platform-wide acceptance +# reason: not reachable, feature disabled # mandatory, a real sentence +# expires: 2027-02-01 # mandatory YYYY-MM-DD, at most 180 days out +# +# An accepted finding stays in the listing with its reason and expiry and stops counting toward the +# critical-fix alert. When the expiry passes it reverts to open automatically. An entry with no reason +# or no expiry, or an expiry more than 180 days out or malformed, is rejected by the merge gate +# (conformance/tests/l0-static/vuln-acceptances-schema) and ignored at runtime with the run marked +# degraded, so a finding is never hidden by a bad entry. +[] diff --git a/platform/vuln-scanner/alerts.promtool-test.yaml b/platform/vuln-scanner/alerts.promtool-test.yaml new file mode 100644 index 0000000..464e25c --- /dev/null +++ b/platform/vuln-scanner/alerts.promtool-test.yaml @@ -0,0 +1,167 @@ +--- +# promtool unit tests for platform/vuln-scanner/alerts.yaml (R-VULN-06). +# +# Found by platform/monitoring/tests/run.sh through the COMPANION convention. Run just this file with +# platform/monitoring/tests/run.sh platform/vuln-scanner/alerts.promtool-test.yaml +# +# Each rule is pinned both ways. VulnScanStale and VulnTriageBotSilent additionally pin the two cases +# the spec names by hand: the metric ABSENT must FIRE, and a metric that vanishes must RE-FIRE. +# conformance/tests/l0-static/vuln-alerts-unit-tested reads this file and fails if any of those cases +# is removed. +# +# Timestamps: promtool evaluates from t=0, so a timestamp series can equal its own evaluation time +# with `0+60x` at a 1m interval (time() - value == 0: fresh). +rule_files: + - alerts.rules.yaml +evaluation_interval: 1m + +tests: + - interval: 1m + name: healthy - fresh run, fresh database, heartbeat, zero backlog, nothing fires + input_series: + - series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}' + values: '0+60x60' + - series: 'vuln_scanner_database_updated_timestamp_seconds{namespace="vuln-scanner"}' + values: '0+60x60' + - series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}' + values: '0+60x60' + - series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}' + values: '0x60' + - series: 'vuln_scanner_critical_fix_overdue{namespace="vuln-scanner"}' + values: '0x60' + promql_expr_test: + - expr: 'count(ALERTS{alertname=~"Vuln.*"})' + eval_time: 50m + exp_samples: [] + + - interval: 1m + name: scan stale - last complete run older than 26h fires after the for window + input_series: + - series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}' + values: '0x1600' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}' + eval_time: 1559m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}' + eval_time: 1590m + exp_samples: + - labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: scan stale ABSENT - metric never existed, VulnScanStale still fires + input_series: + # A healthy, unrelated metric proves this is the absent() arm, not a broken test file. + - series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}' + values: '0x60' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}' + eval_time: 20m + exp_samples: + - labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: scan metric VANISHES - a scanner that dies re-fires instead of latching clear + input_series: + - series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}' + values: '0+60x20 stale' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}' + eval_time: 18m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}' + eval_time: 40m + exp_samples: + - labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: database stale - older than 48h fires after the for window + input_series: + - series: 'vuln_scanner_database_updated_timestamp_seconds{namespace="vuln-scanner"}' + values: '0x2960' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing"}' + eval_time: 2870m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing"}' + eval_time: 2900m + exp_samples: + - labels: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: coverage gap - a failed target over 24h fires, zero is quiet + input_series: + - series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}' + values: '0x20 3x40' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing"}' + eval_time: 25m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing"}' + eval_time: 40m + exp_samples: + - labels: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: critical fix overdue - fires, one grouped alert instance + input_series: + - series: 'vuln_scanner_critical_fix_overdue{namespace="vuln-scanner"}' + values: '0x20 12x40' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"}' + eval_time: 25m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"}' + eval_time: 40m + exp_samples: + - labels: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing", severity="warning"}' + value: 1 + - expr: 'count(ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"})' + eval_time: 40m + exp_samples: + - labels: '{}' + value: 1 + + - interval: 1m + name: triage bot silent - last heartbeat older than 26h fires after the for window + input_series: + - series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}' + values: '0x1600' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}' + eval_time: 1559m + exp_samples: [] + - expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}' + eval_time: 1590m + exp_samples: + - labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: triage bot silent ABSENT - no heartbeat metric ever, VulnTriageBotSilent still fires + input_series: + - series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}' + values: '0x60' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}' + eval_time: 20m + exp_samples: + - labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}' + value: 1 + + - interval: 1m + name: triage heartbeat VANISHES - re-fires instead of latching clear + input_series: + - series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}' + values: '0+60x20 stale' + promql_expr_test: + - expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}' + eval_time: 40m + exp_samples: + - labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}' + value: 1 diff --git a/platform/vuln-scanner/alerts.yaml b/platform/vuln-scanner/alerts.yaml new file mode 100644 index 0000000..0beec9f --- /dev/null +++ b/platform/vuln-scanner/alerts.yaml @@ -0,0 +1,96 @@ +--- +# Alert rules for the vulnerability scanner (R-VULN-06, docs/17 "Alerting"). +# +# ONE ALERT, ONE GROUPED EMAIL PER CONDITION, never one per CVE. The scanner's metrics are aggregates +# with no per-CVE, per-image or per-workload label; `max()` strips scrape labels, so each rule yields a +# single alert instance, and Alertmanager's `group_by: [alertname, namespace]` route delivers one email. +# +# THE TWO RULES WHOSE ABSENT-METRIC CASE IS LOAD-BEARING: VulnScanStale and VulnTriageBotSilent. Their +# metrics are absent until the first complete run / first bot heartbeat, and absent when the scanner +# or the scrape is dead; both are `or absent(...)` so a scanner that has never run, or an unscraped +# one, fires instead of reading as healthy. Pinned by alerts.promtool-test.yaml in both directions. +# VulnCriticalFixAvailable is expected to fire on first deployment (the backlog made visible). +# +# Severity follows the platform-health convention: warning (a condition to act on, not a page). +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + groups: + - name: platform-health.vulnerability-scanner + rules: + - alert: VulnScanStale + # No complete run within 26 hours, or the metric is absent. 15m rides out a pod restart. + expr: >- + (time() - max(vuln_scanner_last_complete_run_timestamp_seconds) > 93600) + or absent(vuln_scanner_last_complete_run_timestamp_seconds) + for: 15m + labels: + severity: warning + annotations: + summary: No complete vulnerability scan in over 26 hours + description: >- + The scanner has not completed a run within 26 hours, or no scan metric exists at all + (the scanner or its scrape is down). Findings on the CVE page are ageing. + runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner" + + - alert: VulnDatabaseStale + # Vulnerability database older than 48 hours (docs/17): the run is degraded, not a success. + expr: time() - max(vuln_scanner_database_updated_timestamp_seconds) > 172800 + for: 15m + labels: + severity: warning + annotations: + summary: Vulnerability database is older than 48 hours + description: >- + The Trivy vulnerability database has not been refreshed in over 48 hours, so scans + evaluate images against stale advisories and the run reports as degraded. + runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner" + + - alert: VulnCoverageGap + # A target has been `failed` for more than 24 hours. credential-required targets are not + # `failed` and do not count, so a private registry cannot hold this alert firing. + expr: max(vuln_scanner_failed_targets_over_24h) > 0 + for: 15m + labels: + severity: warning + annotations: + summary: "{{ $value }} image(s) have failed scanning for over 24 hours" + description: >- + One or more running images have failed scanning for more than 24 hours, so what is + reported for them is out of date. The CVE page lists each target and the reason. + runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner" + + - alert: VulnCriticalFixAvailable + # An unaccepted CRITICAL with a fix, first seen over 72 hours ago. The grace period gives + # Renovate's daily cycle time to act, so this marks what automation has not resolved. + expr: max(vuln_scanner_critical_fix_overdue) > 0 + for: 15m + labels: + severity: warning + annotations: + summary: "{{ $value }} CRITICAL finding(s) with a fix available for over 72 hours" + description: >- + One or more unaccepted CRITICAL vulnerabilities have had a fix available for more than + 72 hours. The CVE page has the details; this message deliberately names none of them. + runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner" + + - alert: VulnTriageBotSilent + # No bot heartbeat for 26 hours, or the metric is absent (the bot has never run, or the + # scanner or its scrape is down). + expr: >- + (time() - max(vuln_scanner_triage_heartbeat_timestamp_seconds) > 93600) + or absent(vuln_scanner_triage_heartbeat_timestamp_seconds) + for: 15m + labels: + severity: warning + annotations: + summary: CVE triage bot has not reported in over 26 hours + description: >- + The scheduled CVE triage bot has not posted a heartbeat in over 26 hours (or never + has), so remediation states are stale and no approvals are being requested. + runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner" diff --git a/platform/vuln-scanner/deployment.yaml b/platform/vuln-scanner/deployment.yaml new file mode 100644 index 0000000..a394837 --- /dev/null +++ b/platform/vuln-scanner/deployment.yaml @@ -0,0 +1,279 @@ +--- +# The vulnerability scanner (R-VULN-01..09, docs/17). One pod: +# init trivy-tools copies the Trivy binary and CA bundle from the digest-pinned Trivy image into +# an emptyDir, so the running container has a single python interpreter + one +# static Go binary and nothing else from that image. +# main scanner python3 stdlib only (no pip), source from a ConfigMap at /app: inventory, +# sequential scans, report, the page on 127.0.0.1:8080, the internal API on +# :8081, aggregates on :9090. +# sidecar oauth2-proxy the only path to the page, restricted to platform-admins (R-IDP-06, docs/17 +# "Access"), reached from the shared Gateway on :4180. +# +# ONE REPLICA, Recreate: one scanner on one RWO report store; two would double-scan and contend for +# the volume. +# +# SOFT affinity toward k3s-prod-2: prod-1 is the node whose etcd I/O starved historically, and scans +# are CPU- and I/O-heavy. Soft on purpose: a scanner that cannot be placed must still run elsewhere. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + replicas: 1 + strategy: + type: Recreate + revisionHistoryLimit: 3 + selector: + matchLabels: + app.kubernetes.io/name: vuln-scanner + template: + metadata: + labels: + app.kubernetes.io/name: vuln-scanner + # The direct egress path; never the VPN gateway (docs/17). + homelab.keithjackson.dev/egress-class: internet + # Per-pod oauth2-proxy gate in front of the page. This namespace is not app-namespace=true, + # so the auth-mode/egress-class policies do not select it; the labels are kept so the pod is + # compliant by construction if that ever changes. + homelab.keithjackson.dev/auth-mode: forward-auth + spec: + serviceAccountName: vuln-scanner + # The scanner reads the Kubernetes API with this token (read-only, rbac.yaml). + automountServiceAccountToken: true + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + # Longhorn honours fsGroup on an RWO volume; this makes /data and the emptyDirs writable. + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + preference: + matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: + - k3s-prod-2 + initContainers: + - name: trivy-tools + # TRIVY 0.76.1 — pinned by digest (R-VULN-09). Not 0.69.4/5/6 (compromised release, March + # 2026, CVE-2026-33634). Tag digest and config created timestamp read from the registry on + # 2026-11-09: sha256:790fd66d…6c06c, created 2026-10-21T11:16:20Z (19 days old, i.e. past + # the 7-day minimum release age; 0.76.2 was published 2026-11-05 and is too new). + # SIGNATURE VERIFIED 2026-11-09 with cosign v3.1.3, keyless: + # cosign verify ghcr.io/aquasecurity/trivy@sha256:790fd66d... \ + # --certificate-identity-regexp 'https://github\.com/aquasecurity/trivy/\.github/workflows/.+' \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com + # Renovate: blast-radius component, never auto-merges, 7-day minimum release age + # (renovate.json5); this `tag@digest` form is what the `kubernetes` manager tracks. + # The image's ENTRYPOINT is `trivy`, overridden below. /bin/cp (busybox) and + # /etc/ssl/certs/ca-certificates.crt confirmed present by listing the layers via the + # registry API (no image run). + image: ghcr.io/aquasecurity/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c + imagePullPolicy: IfNotPresent + command: + - /bin/cp + - /usr/local/bin/trivy + - /etc/ssl/certs/ca-certificates.crt + - /tools/ + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 10m + memory: 16Mi + ephemeral-storage: 256Mi + limits: + cpu: 200m + memory: 64Mi + ephemeral-storage: 512Mi + volumeMounts: + - name: tools + mountPath: /tools + containers: + # ─────────────────────────────── scanner ────────────────────────────────────────────── + - name: scanner + # python 3.14 on alpine by digest, matching the interpreter the scanner's tests ran on. + # 3.14.9-alpine, config created 2026-10-21T23:01:37Z, read from the registry on 2026-11-09 + # (19 days old; 3.14.10-alpine, the current 3.14-alpine, was built 2026-11-05 and is under + # the 7-day age applied here by hand). Tracked by the `kubernetes` Renovate manager. + # Runs as 65532 from this pod's securityContext; the image's default is root, overridden. + image: docker.io/library/python:3.14.9-alpine@sha256:dbeb11d16bb95c3a7e3c25c842cb9f20cbc8c18f8be0461a4a0dcc861ad386a0 + imagePullPolicy: IfNotPresent + command: + - python3 + - /app/main.py + env: + - name: VULN_DATA_DIR + value: /data + - name: VULN_CACHE_DIR + value: /cache + # Scan caches and temp extraction live on the size-limited emptyDir, never on the + # replicated volume or etcd (docs/17 "Load"). + - name: TMPDIR + value: /cache + - name: HOME + value: /cache + - name: VULN_TOOLS_DIR + value: /tools + - name: TRIVY_BIN + value: /tools/trivy + - name: SSL_CERT_FILE + value: /tools/ca-certificates.crt + - name: VULN_ACCEPTANCES + value: /config/acceptances.yaml + - name: PYTHONUNBUFFERED + value: "1" + - name: PYTHONDONTWRITEBYTECODE + value: "1" + ports: + - { name: internal, containerPort: 8081, protocol: TCP } + - { name: metrics, containerPort: 9090, protocol: TCP } + # Sized to trivy, the only heavy thing here: scans are sequential, the limit is the peak of + # one large image; the emptyDir sizeLimit bounds disk. + resources: + requests: + cpu: 100m + memory: 256Mi + ephemeral-storage: 256Mi + limits: + cpu: "1" + memory: 2Gi + ephemeral-storage: 12Gi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + # /healthz on the metrics port is answered by the HTTP server itself and must not depend + # on scan state: a long scan or a failed one is reported, never a reason to restart. + startupProbe: + httpGet: + path: /healthz + port: metrics + periodSeconds: 5 + failureThreshold: 24 + livenessProbe: + httpGet: + path: /healthz + port: metrics + periodSeconds: 20 + failureThreshold: 6 + readinessProbe: + httpGet: + path: /healthz + port: metrics + periodSeconds: 15 + volumeMounts: + - name: src + mountPath: /app + readOnly: true + - name: acceptances + mountPath: /config + readOnly: true + - name: tools + mountPath: /tools + readOnly: true + - name: data + mountPath: /data + - name: cache + mountPath: /cache + # ─────────────────────────── oauth2-proxy reverse-proxy sidecar ─────────────────────── + - name: oauth2-proxy + # The per-app sidecar pattern (platform/crafty, catalog/render.py): same tag as the + # renderer's OAUTH2_PROXY_IMAGE so a Renovate bump lands every sidecar in one branch. + image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6 + imagePullPolicy: IfNotPresent + args: + - --http-address=0.0.0.0:4180 + - --reverse-proxy=true + - --provider=oidc + - --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab + # The page, bound to loopback in the scanner container; no Service reaches it. + - --upstream=http://127.0.0.1:8080 + - --email-domain=* + - --scope=openid email profile + - --oidc-groups-claim=groups + # R-IDP-06, docs/17 "Access": a valid session is not enough, the user must be a + # platform-admin, because this page is the list of what is exploitable where. + - --allowed-group=platform-admins + - --skip-provider-button=true + - --redirect-url=https://cve.${BASE_DOMAIN}/oauth2/callback + # Unique name + host-scoped domain: the shared forward-auth proxy and the other sidecars + # hold different cookie-secrets, so a shared cookie name would evict sessions + # (oauth2-proxy-cookie-names-distinct). + - --cookie-name=_oauth2_proxy_vuln_scanner + - --cookie-domain=cve.${BASE_DOMAIN} + - --whitelist-domain=.${BASE_DOMAIN} + - --cookie-secure=true + - --set-xauthrequest=true + env: + # client-secret MUST equal the Keycloak `vuln-scanner` client's secret (key + # vuln-scanner-client-secret in platform/keycloak/keycloak-realm-clients.sops.yaml; see + # README.md, Operator step). Secrets by name only (R-IDP-04). + - name: OAUTH2_PROXY_CLIENT_ID + valueFrom: + secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-id } + - name: OAUTH2_PROXY_CLIENT_SECRET + valueFrom: + secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-secret } + - name: OAUTH2_PROXY_COOKIE_SECRET + valueFrom: + secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: cookie-secret } + ports: + - { name: http, containerPort: 4180, protocol: TCP } + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + livenessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + volumes: + - name: src + configMap: + name: vuln-scanner-src + - name: acceptances + configMap: + name: vuln-scanner-acceptances + - name: tools + emptyDir: + sizeLimit: 512Mi + - name: data + persistentVolumeClaim: + claimName: vuln-scanner-data + # Trivy DB + per-image scan cache + TMPDIR (trivy extracts layers here). Size-limited; never + # on a replicated volume or in etcd (docs/17 "Load"). A full run cleans it between images. + - name: cache + emptyDir: + sizeLimit: 10Gi diff --git a/platform/vuln-scanner/httproute.yaml b/platform/vuln-scanner/httproute.yaml new file mode 100644 index 0000000..5e1425a --- /dev/null +++ b/platform/vuln-scanner/httproute.yaml @@ -0,0 +1,34 @@ +--- +# The CVE page at cve.. Internal exposure only (docs/17 "Access"): a list of what is exploitable +# where is sensitive, so this hostname publishes NO public DNS record (the `controller: none` annotation +# below disowns it from external-dns) and the Cloudflare Tunnel 404s it (platform/cloudflared/config.yaml). +# It resolves on the LAN through PiHole's explicit list (catalog/pihole.yaml). +# +# The backend is the `vuln-scanner` Service's `http` port, which fronts the oauth2-proxy sidecar, so the +# SSO gate is in the data path; nothing routes to the page (:8080) directly. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner + annotations: + homelab.keithjackson.dev/auth-mode: oauth2-proxy + homelab.keithjackson.dev/auth-groups: platform-admins + external-dns.alpha.kubernetes.io/controller: "none" +spec: + parentRefs: + - name: shared + namespace: gateway + sectionName: https + hostnames: + - cve.${BASE_DOMAIN} + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: vuln-scanner + port: 4180 diff --git a/platform/vuln-scanner/kustomization.yaml b/platform/vuln-scanner/kustomization.yaml new file mode 100644 index 0000000..3b115dd --- /dev/null +++ b/platform/vuln-scanner/kustomization.yaml @@ -0,0 +1,50 @@ +--- +# The CVE visibility scanner (docs/17, ADR-0027). Reconciled by its OWN non-gating Flux Kustomization +# (clusters/base/vuln-scanner.yaml: dependsOn platform-config, wait:false). A scanner that is failing, +# or a pod that cannot get its report store, is a thing to alert on, never a thing that stalls the +# platform. NOT a line in platform/kustomization.yaml. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# Required for the configMapGenerator (see platform/cloudflared/kustomization.yaml). +namespace: vuln-scanner +resources: + - namespace.yaml + - rbac.yaml + - pvc.yaml + # The per-pod oauth2-proxy credential. SOPS-encrypted, decrypted per-env by the vuln-scanner + # Kustomization (both overlays patch it). Referenced by name only (R-IDP-04). Its template sibling + # is deliberately not named here (no-plaintext-secrets). + - vuln-scanner-oauth2-proxy-credentials.sops.yaml + - deployment.yaml + - service.yaml + # Namespace default-deny, the three ingress paths and the egress allowances. One file, so a baseline + # without its allows cannot ship alone and empty the TSDB. + - networkpolicy.yaml + - httproute.yaml + - servicemonitor.yaml + - alerts.yaml + +# Scanner source and the risk-acceptance file, both as content-hashed ConfigMaps, so a change rolls +# the pod. No `disableNameSuffixHash`: the hash is the rollout mechanism. +configMapGenerator: + - name: vuln-scanner-src + files: + - src/main.py + - src/inventory.py + - src/trivy.py + - src/state.py + - src/report.py + - src/acceptances.py + - src/page.py + - src/metrics.py + - src/server.py + - src/report.schema.json + options: + labels: + app.kubernetes.io/name: vuln-scanner + - name: vuln-scanner-acceptances + files: + - acceptances.yaml + options: + labels: + app.kubernetes.io/name: vuln-scanner diff --git a/platform/vuln-scanner/namespace.yaml b/platform/vuln-scanner/namespace.yaml new file mode 100644 index 0000000..67c9d52 --- /dev/null +++ b/platform/vuln-scanner/namespace.yaml @@ -0,0 +1,23 @@ +--- +# vuln-scanner — the CVE visibility scanner (R-VULN-01..09, ADR-0027, docs/17). Hand-authored platform +# delivery tooling, not a catalog service: its cluster-wide read identity is something a +# ServiceDefinition cannot express. +# +# gateway-access=shared opts this namespace in to attach the cve. HTTPRoute to the shared +# Gateway; the route targets the per-pod oauth2-proxy sidecar (:4180), never the page directly. +# +# INTENTIONALLY NOT homelab.keithjackson.dev/app-namespace=true (the crafty/cloudflared/pm-agent +# posture): every Kyverno app policy matches on that label, and the generated default-deny would then +# be double-owned with the one this tree ships (networkpolicy.yaml), the 2026-09-30 drift loop. The +# pod still carries the egress-class and auth-mode labels those policies would require. +# +# PodSecurity `restricted` IS enforced: nothing here needs any privilege. +apiVersion: v1 +kind: Namespace +metadata: + name: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner + gateway-access: "shared" + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: latest diff --git a/platform/vuln-scanner/networkpolicy.yaml b/platform/vuln-scanner/networkpolicy.yaml new file mode 100644 index 0000000..b84be85 --- /dev/null +++ b/platform/vuln-scanner/networkpolicy.yaml @@ -0,0 +1,119 @@ +--- +# Default-deny baseline (R-NET-01): deny all ingress and egress, allow only DNS. A platform namespace, +# so Kyverno does not generate this and Flux is its only writer (namespace.yaml). +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: vuln-scanner + labels: + homelab.keithjackson.dev/baseline: default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +# INGRESS — exactly three paths (docs/17 "Security of the scanner itself"), one client each: +# :4180 from the shared Gateway only (the oauth2-proxy sidecar). The page itself (:8080, loopback) +# has no Service port and no rule. +# :8081 from the CI runner only. Job containers run inside the DinD sidecar of the act-runner pod in +# namespace gitea-actions, so what the network sees is THAT pod's identity (verified live +# 2026-11-09: pods carry app.kubernetes.io/name=act-runner). No wider than the runner pods. +# :9090 from Prometheus only (verified live: app.kubernetes.io/name=prometheus in monitoring). +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: vuln-scanner-allow-ingress + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: vuln-scanner + ingress: + - fromEntities: + - ingress + toPorts: + - ports: + - { port: "4180", protocol: TCP } + - fromEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: gitea-actions + app.kubernetes.io/name: act-runner + toPorts: + - ports: + - { port: "8081", protocol: TCP } + - fromEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: monitoring + app.kubernetes.io/name: prometheus + toPorts: + - ports: + - { port: "9090", protocol: TCP } +--- +# EGRESS (DNS is in the baseline above). +# * kube-apiserver: the inventory. +# * tcp/443 to the public internet for the vulnerability database and registries, OUT THE DIRECT +# PATH (egress-class=internet, deployment.yaml; never the VPN gateway). Written as a CIDR rule +# rather than `toEntities: world` because the spec excludes cluster and LAN ranges and `world` +# includes every LAN address: RFC1918 + CGNAT + loopback + link-local + multicast are subtracted. +# Consequence, recorded rather than worked around: the Gitea registry resolves to the LAN VIP, so +# images hosted there are unreachable from here and report `failed` with that reason, until a +# registry credential and a narrower LAN rule are decided (docs/17, "Private registry coverage"). +# * The two Keycloak rules below are the ONE addition beyond the spec's list, and they are required: +# the oauth2-proxy sidecar shares this pod's network identity with the scanner, and it must +# reach Keycloak for OIDC discovery and the token exchange. keycloak. resolves in-cluster +# to the Gateway VIP (split-horizon CoreDNS), which Cilium hands to the Gateway's Envoy +# (reserved:ingress, tcp/443), and Envoy then re-evaluates THIS pod's egress against Keycloak's +# real backend identity (enforce_policy_on_l7lb), the same pair catalog/render.py gives every SSO +# app (buildkit/networkpolicy.yaml has the measurement). Without them login 403s. +# * NOT listed: anything else in-cluster, any node entity, the LAN, the VPN. +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: vuln-scanner-allow-egress + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: vuln-scanner + egress: + - toEntities: + - kube-apiserver + - toCIDRSet: + - cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 100.64.0.0/10 + - 127.0.0.0/8 + - 169.254.0.0/16 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 224.0.0.0/4 + toPorts: + - ports: + - { port: "443", protocol: TCP } + - toEntities: + - ingress + toPorts: + - ports: + - { port: "443", protocol: TCP } + - toEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: keycloak + app: keycloak diff --git a/platform/vuln-scanner/pvc.yaml b/platform/vuln-scanner/pvc.yaml new file mode 100644 index 0000000..7fa9c34 --- /dev/null +++ b/platform/vuln-scanner/pvc.yaml @@ -0,0 +1,19 @@ +--- +# The report store: state.json (firstSeenAt, failed-since, heartbeat, last complete run) and +# report.json. 1Gi on the replicated tier is generous for two JSON files; it is DELIBERATELY NOT where +# the vulnerability database or the per-image scan caches live, because etcd and Longhorn share a +# partition on every node (docs/02, docs/17 "Load"). Those are on the size-limited /cache emptyDir. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vuln-scanner-data + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi diff --git a/platform/vuln-scanner/rbac.yaml b/platform/vuln-scanner/rbac.yaml new file mode 100644 index 0000000..86d72a2 --- /dev/null +++ b/platform/vuln-scanner/rbac.yaml @@ -0,0 +1,50 @@ +--- +# Scanner identity (R-VULN-09). The whole of its Kubernetes reach is get/list/watch on the objects it +# needs to answer "what is running, and which workload owns it". NO Secret, NO write verb, no +# wildcard. conformance/tests/l0-static/vuln-scanner-supply-chain fails the gate if that changes. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: vuln-scanner-read + labels: + app.kubernetes.io/name: vuln-scanner +rules: + # Running images, their digests, and the nodes a pod sits on (nodes are inventoried as not-covered). + - apiGroups: [""] + resources: [pods, nodes] + verbs: [get, list, watch] + # The owner chain Pod -> ReplicaSet -> Deployment, and the other two top-level controllers. + - apiGroups: [apps] + resources: [replicasets, deployments, statefulsets, daemonsets] + verbs: [get, list, watch] + # Pod -> Job -> CronJob, and CronJob templates with no running pod. + - apiGroups: [batch] + resources: [jobs, cronjobs] + verbs: [get, list, watch] + # KubeVirt VMs are inventoried as not-covered. + - apiGroups: [kubevirt.io] + resources: [virtualmachineinstances] + verbs: [get, list, watch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: vuln-scanner-read + labels: + app.kubernetes.io/name: vuln-scanner +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: vuln-scanner-read +subjects: + - kind: ServiceAccount + name: vuln-scanner + namespace: vuln-scanner diff --git a/platform/vuln-scanner/service.yaml b/platform/vuln-scanner/service.yaml new file mode 100644 index 0000000..44f86f4 --- /dev/null +++ b/platform/vuln-scanner/service.yaml @@ -0,0 +1,33 @@ +--- +# One Service, three named ports (the page gate, the internal API and metrics). +# +# clusterIP IS PINNED ON PURPOSE (10.43.210.10, verified unused against the live Service list on +# 2026-11-09, inside k3s' default 10.43.0.0/16 Service CIDR). The triage bot runs as a Gitea Actions +# job inside the runner's DinD daemon, and whether in-cluster DNS resolves from a DinD job container +# is UNVERIFIED. The pinned address is the bot's fallback when the Service name does not resolve: +# `http://10.43.210.10:8081` (VULN_API_FALLBACK_URL in .gitea/workflows/cve-triage.yaml). +apiVersion: v1 +kind: Service +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner +spec: + type: ClusterIP + clusterIP: 10.43.210.10 + selector: + app.kubernetes.io/name: vuln-scanner + ports: + - name: http + port: 4180 + targetPort: http + protocol: TCP + - name: internal + port: 8081 + targetPort: internal + protocol: TCP + - name: metrics + port: 9090 + targetPort: metrics + protocol: TCP diff --git a/platform/vuln-scanner/servicemonitor.yaml b/platform/vuln-scanner/servicemonitor.yaml new file mode 100644 index 0000000..499033a --- /dev/null +++ b/platform/vuln-scanner/servicemonitor.yaml @@ -0,0 +1,20 @@ +--- +# Prometheus scrape of the aggregate-only /metrics (:9090). Only the `metrics` port of the shared +# Service; networkpolicy.yaml admits only Prometheus on it. kube-prometheus-stack selects +# ServiceMonitors from any namespace; the `release` label is kept for consistency. +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: vuln-scanner + namespace: vuln-scanner + labels: + app.kubernetes.io/name: vuln-scanner + release: kube-prometheus-stack +spec: + selector: + matchLabels: + app.kubernetes.io/name: vuln-scanner + endpoints: + - port: metrics + path: /metrics + interval: 60s diff --git a/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml b/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml new file mode 100644 index 0000000..731d484 --- /dev/null +++ b/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vuln-scanner-oauth2-proxy + namespace: vuln-scanner +type: Opaque +stringData: + client-id: ENC[AES256_GCM,data:9n9fMQyIq+JmfgXy,iv:8FLkOTc6Rmck33ecd4A3DXP2CiGrAkxMJ8Q7nmI9naE=,tag:4wb8rAXx+KAIhWAviEKTJQ==,type:str] + client-secret: ENC[AES256_GCM,data:JQCSBroPwd1KxZhdOw9zYkA9krYdK79QpnP/WW3ZIYVoyvHGVKJ2OVPpvgtWy20Ex0yWCTtmpghn87r+4Kkrfw==,iv:RFuDweYB7z5fykB/Ued2Z7MdO9VVY2XoO4gwekxMo0U=,tag:UseTecg5dJMNR1SN6ZUZ7g==,type:str] + cookie-secret: ENC[AES256_GCM,data:F7McZBTr10fnpTEz8cYjXsvrwRgqiKrlcaL/mSZKjOjuPfaSvq4sV3YBuQc=,iv:2vDEqmUHFFpUsRkBSqj3X92eT2bP8VTj2BiAr/8+7ak=,tag:rdHVky9JmlcEEhsxV6OtnA==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGdGErRzEvQWNTcUdYanhG + RGVaYi9CdDNvYVdhL2ViOWI4Z3k0M2pxb0V3ClgzOFNPRXdyZ1pVZTFKMVEyTGVV + bkxQWDdFZUtvME1jZm9VUXBHaVpHM3cKLS0tIDhodHlQVUNhNlpiVFAzU1JZL2d3 + cUhWcGNGSThYTkRaV0NSOUZrM2ZmS1UKjFe6iGia0+QOZgWcSFoH0LLEp1f2M2ds + V8bIBIFTtRDTKp6kgt0nee+Sgb7g5D5NIGTrRDGE256I8jdiNDcMpw== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-11-09T15:43:11Z" + mac: ENC[AES256_GCM,data:jXBQdruY8yLD4k3iRHqn78Pq0iXzETDk/S8qyXSVFHsacqUvmtzdOw6ajJDg7hAkVGN9PXqH+lsz45wR3a3YVODbQZJyS0+cKuQ1cVWlV/wcWOlNSdeKSLRe8h658KjXGUPQubaDFB4rloBfUr5ebuGQGr4ZEj7g241xjrsjBic=,iv:asoMnTjsIIb1AgrgE9dERixbpw94TpQWCPRwAT3mXYM=,tag:uwCVmjDr3gDQAqWsmiF0tA==,type:str] + pgp: [] + encrypted_regex: ^(data|stringData)$ + version: 3.9.4 diff --git a/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example b/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example new file mode 100644 index 0000000..4edf210 --- /dev/null +++ b/platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml.example @@ -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 < platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml +# ...this document with client-secret: $cs and cookie-secret: $ck... +# YAML' +# ============================================================================= +apiVersion: v1 +kind: Secret +metadata: + name: vuln-scanner-oauth2-proxy + namespace: vuln-scanner +type: Opaque +stringData: + client-id: vuln-scanner + client-secret: REPLACE-ME + cookie-secret: REPLACE-ME diff --git a/renovate.json5 b/renovate.json5 index b611c2d..98554a3 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -146,7 +146,9 @@ "/(^|/)versions\\.env$/", "/(^|/)platform/pm-agent/.+\\.ya?ml$/", // ADDED 2026-11-08: the merge gate's job `container:` is the toolbox, by digest. - "/^\\.gitea/workflows/gate\\.ya?ml$/" + "/^\\.gitea/workflows/gate\\.ya?ml$/", + // ADDED 2026-11-09: the CVE triage bot's job `container:` is pinned by digest with the marker. + "/^\\.gitea/workflows/cve-triage\\.ya?ml$/" ], "matchStrings": [ "#\\s*renovate-digest:\\s*image=(?\\S+)\\s+tag=(?\\S+)\\s*\\n[^\\n]*?(?sha256:[a-f0-9]{64})" @@ -277,7 +279,12 @@ // never propose gitea/act_runner -> gitea/runner — so that migration is a manual PR. Listed // now so the new name is blast-radius from its first reference, not after an auto-merge. "gitea/runner", - "ghcr.io/cloudnative-pg/postgresql" + "ghcr.io/cloudnative-pg/postgresql", + // ADDED 2026-11-09 (R-VULN-09, ADR-0027). The CVE scanner reads every image in the cluster + // and Trivy's release pipeline was compromised in March 2026 (CVE-2026-33634), so it is + // blast-radius: a human reads the changelog. The 7-day minimum release age is the separate + // rule below; this entry is what keeps it out of auto-merge. + "ghcr.io/aquasecurity/trivy" ], "automerge": false, "addLabels": [ @@ -285,6 +292,20 @@ "blast-radius" ] }, + // ADDED 2026-11-09 — MINIMUM RELEASE AGE FOR THE CVE SCANNER (R-VULN-09, docs/17 "Security of the + // scanner itself"). An upstream release that later turns out to be compromised (as 0.69.4/5/6 were + // in March 2026) gets a week to be detected before Renovate will propose it. Applied to the + // image the scanner's Deployment pins, tracked by the `kubernetes` manager (platform/ is in its + // managerFilePatterns; the `tag@digest` form is what it updates), and matched here by package + // name. automerge:false again here so this rule cannot lose to a later one. + // The Renovate validator was NOT run on this addition: contract forbids running images locally. + { + "description": "CVE scanner image (Trivy): 7-day minimum release age, never auto-merged (R-VULN-09). A minimumReleaseAge the datasource cannot date holds the update back rather than letting it through.", + "matchPackageNames": ["ghcr.io/aquasecurity/trivy"], + "automerge": false, + "minimumReleaseAge": "7 days", + "addLabels": ["needs-review", "blast-radius"] + }, // ADDED 2026-10-30 — THE KEYCLOAK OPERATOR IS THE KEYCLOAK VERSION. The operator, its CRDs // and the server image are released in lockstep from keycloak/keycloak-k8s-resources, and the // Keycloak CR carries no spec.image, so the operator's RELATED_IMAGE_KEYCLOAK picks the server diff --git a/runbooks/README.md b/runbooks/README.md index b29a80b..b6775d7 100644 --- a/runbooks/README.md +++ b/runbooks/README.md @@ -87,6 +87,8 @@ Each affected runbook says so on its own page and gives the by-hand equivalent. ## The runbooks ### Nodes +- **`vuln-scanner`** — the CVE page and the five scan alerts (`VulnScanStale`, `VulnDatabaseStale`, + `VulnCoverageGap`, `VulnCriticalFixAvailable`, `VulnTriageBotSilent`), and what each one means here. - **`node-down`** — a node's node-exporter is unscrapable; the node may be gone, or its kubelet may have stopped posting status while the host stays up (the 2026-09-07 shape). - **`node-io`** — disk I/O pressure/starvation. The etcd WAL shares a partition with Longhorn replicas; diff --git a/runbooks/kustomization.yaml b/runbooks/kustomization.yaml index b955213..ca8b771 100644 --- a/runbooks/kustomization.yaml +++ b/runbooks/kustomization.yaml @@ -49,6 +49,7 @@ configMapGenerator: files: # The index, served at /runbooks/ (nginx `try_files /runbooks/README`). - README=README.md + - vuln-scanner=vuln-scanner.md - backup-failure=backup-failure.md - backup-missed=backup-missed.md - backup-partial=backup-partial.md diff --git a/runbooks/vuln-scanner.md b/runbooks/vuln-scanner.md new file mode 100644 index 0000000..fd2663d --- /dev/null +++ b/runbooks/vuln-scanner.md @@ -0,0 +1,75 @@ +# vuln-scanner — the CVE page and the five scan alerts + +Alerts that link here: **`VulnScanStale`**, **`VulnDatabaseStale`**, **`VulnCoverageGap`**, +**`VulnCriticalFixAvailable`** and **`VulnTriageBotSilent`**. + +Component: `platform/vuln-scanner/` (Flux Kustomization `vuln-scanner`, `wait: false`, nothing depends on +it). Design record: spec `docs/17-vulnerability-visibility.md`, ADR-0027. The page is +`https://cve.`, internal only, behind SSO, restricted to `platform-admins`. + +Every mutating step below is an **operator** step (CLAUDE.md §5). + +## What this means here + +The scanner runs on a schedule and needs nobody to start, resume or complete a cycle. The bot is a +scheduled Gitea Actions workflow (`.gitea/workflows/cve-triage.yaml`). Each alert is one grouped email +for one condition and never names a CVE, image or workload; the page has the detail. + +| Alert | Fires when | What it means | +|---|---|---| +| `VulnScanStale` | No complete run for 26 hours, or the metric is absent | Scanning has stopped, the scanner is down, or its scrape is broken. **It fires from first deployment until the first complete run** (a full run covers about 100 images, sequentially). | +| `VulnDatabaseStale` | Trivy DB older than 48 hours | `trivy --download-db-only` is failing; the run is degraded, not a success. | +| `VulnCoverageGap` | Any target `failed` for more than 24 hours | A registry unreachable or a scan keeps failing. `credential-required` targets do not count. | +| `VulnCriticalFixAvailable` | Unaccepted CRITICAL with a fix, first seen more than 72 hours ago | The backlog automation has not resolved. **Expected on first deployment.** | +| `VulnTriageBotSilent` | No heartbeat for 26 hours, or the metric is absent | The bot has not reported, so remediation states and Renovate approvals are stale. | + +A failing scanner is contained by design: its Kustomization is `wait: false` and nothing depends on it, so +none of these alerts mean anything else on the platform is affected. + +## First commands + +All cluster access is read-only and goes through the pinned toolbox, always naming the context: + +```bash +./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n vuln-scanner get pods -o wide' +./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n vuln-scanner logs deploy/vuln-scanner -c scanner --tail=100' +./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n vuln-scanner exec deploy/vuln-scanner -c scanner -- python3 -c "import urllib.request as u; print(u.urlopen(\"http://127.0.0.1:9090/metrics\").read().decode())"' +``` + +The metrics are the quickest read: `vuln_scanner_targets{state}` gives the per-state counts, and +`vuln_scanner_last_complete_run_timestamp_seconds` the last complete run. Then open +`https://cve.`, Inventory view, and sort by state to see which targets, and why. + +For the bot, read the most recent run of the `CVE triage` workflow in Gitea Actions and its job +summary. + +## Likely causes, most likely first + +**`VulnScanStale`** — (1) first deployment: wait for the first complete run; (2) pod not Running: see the +Pod's events and the `longhorn` PVC attachment; (3) metrics not scraped: `up{namespace="vuln-scanner"}` +and `platform/vuln-scanner/networkpolicy.yaml`'s Prometheus rule; (4) the scanner logged a run failure. + +**`VulnDatabaseStale`** — egress to `ghcr.io:443` is blocked (the egress CIDR rule, or the registry is +down) or GitHub's rate limit; the log has the `trivy` error. + +**`VulnCoverageGap`** — a registry is down or a tag was deleted. The page shows each failed target's reason. +A target whose registry refuses anonymous pulls is `credential-required` and does not fire this. + +**`VulnCriticalFixAvailable`** — expected once on first deployment. Otherwise a Renovate update is not +landing: see the bot's dashboard issue and Renovate's dashboard. To accept a risk instead, add an entry to +`platform/vuln-scanner/acceptances.yaml` (reason plus an expiry at most 180 days out) through a normal PR. + +**`VulnTriageBotSilent`** — (1) `RENOVATE_TOKEN` is missing or the workflow is disabled; (2) the bot +cannot reach the scanner's internal API (`gitea-actions` namespace -> `vuln-scanner:8081`, +`networkpolicy.yaml`; the bot falls back to the pinned ClusterIP when in-cluster DNS is unavailable to a +DinD job container); (3) the scanner is down too: `VulnScanStale` will also be firing. + +## When to escalate or stop + +- If the pod is crash-looping, read the scanner's logs before changing anything; a crash loop here is + contained and is not urgent for the rest of the platform. +- Do **not** weaken `networkpolicy.yaml` or `rbac.yaml` to make a scan pass. The scanner must never read + Secrets, hold a write verb, or reach the LAN. Those are the requirement (R-VULN-09), not an oversight. +- Do not edit `platform/vuln-scanner/alerts.yaml` thresholds to silence an alert; the spec fixes them. +- A persistent `VulnCriticalFixAvailable` for a component that cannot move is what the risk-acceptance file + is for, not silencing.