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