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
|
- name: FTLCONF_dns_rateLimit_count
|
||||||
value: '0'
|
value: '0'
|
||||||
- name: FTLCONF_misc_dnsmasq_lines
|
- 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
|
- name: FTLCONF_webserver_api_password
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
+1
-1
@@ -202,7 +202,7 @@ env:
|
|||||||
# Applies to every `address=` reply below (dnsmasq: local-ttl governs replies made from
|
# 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.
|
# configuration, /etc/hosts and DHCP leases), so one directive covers every listed name.
|
||||||
- name: FTLCONF_misc_dnsmasq_lines
|
- 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
|
# 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
|
# 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.
|
# 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
|
# `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.
|
# 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)
|
- 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
|
# 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,
|
# (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
|
# 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
|
provider: sops
|
||||||
secretRef:
|
secretRef:
|
||||||
name: sops-age
|
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
|
# cloudflared (the Cloudflare Tunnel connector, clusters/base/cloudflared.yaml) carries
|
||||||
# platform/cloudflared/cloudflared-credentials.sops.yaml — the tunnel credentials JSON, the one
|
# 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
|
# secret in that component. Without this stanza the Secret applies full of ENC[...]: the APPLY
|
||||||
|
|||||||
@@ -131,6 +131,21 @@ patches:
|
|||||||
provider: sops
|
provider: sops
|
||||||
secretRef:
|
secretRef:
|
||||||
name: sops-age
|
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
|
# cloudflared (the Cloudflare Tunnel connector, clusters/base/cloudflared.yaml) carries
|
||||||
# platform/cloudflared/cloudflared-credentials.sops.yaml — the tunnel credentials JSON, the one
|
# 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
|
# secret in that component. Without this stanza the Secret applies full of ENC[...]: the APPLY
|
||||||
|
|||||||
@@ -135,6 +135,44 @@ requirements:
|
|||||||
safety_critical: false
|
safety_critical: false
|
||||||
summary: "Update policy per-service configurable (cadence, channel, pinning)."
|
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 ---
|
# --- Observability & alerting ---
|
||||||
- id: R-OBS-01
|
- id: R-OBS-01
|
||||||
doc: docs/10-observability-and-alerting.md
|
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",
|
"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. ───────
|
# ── 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
|
# 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.
|
# 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
|
# headlamp: operator decision 2026-11-08 (LAN/VPN-only). qbittorrent/radarr/sonarr: catalog
|
||||||
# `exposure: internal`, like jackett/pihole/semaphore.
|
# `exposure: internal`, like jackett/pihole/semaphore.
|
||||||
EXCLUDED_HOSTS = ("plex", "plex-vip", "jackett", "pihole", "pihole-dns", "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).
|
# Their Gateway-backend namespaces (unifi / crafty-mc are L4 VIPs, not Gateway backends).
|
||||||
EXCLUDED_NAMESPACES = ("plex", "jackett", "pihole", "semaphore", "qbittorrent", "radarr",
|
EXCLUDED_NAMESPACES = ("plex", "jackett", "pihole", "semaphore", "qbittorrent", "radarr",
|
||||||
"sonarr", "headlamp")
|
"sonarr", "headlamp", "vuln-scanner")
|
||||||
FORBIDDEN_ENTITIES = {"kube-apiserver", "host", "remote-node", "cluster", "all"}
|
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).
|
# 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://",
|
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 "
|
"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 "
|
"with native Keycloak OIDC; listed because it is a hand-authored route, and pinned "
|
||||||
"disowned by (9d).",
|
"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":
|
"platform/homepage/httproute.yaml:homepage/homepage":
|
||||||
"publishes the APEX ${BASE_DOMAIN} itself -> the tunnel (proxied CNAME), via the https-apex "
|
"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 "
|
"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.
|
# tunnel alongside decision 4.
|
||||||
- hostname: headlamp.${BASE_DOMAIN}
|
- hostname: headlamp.${BASE_DOMAIN}
|
||||||
service: http_status:404
|
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
|
# 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.
|
# that CNAMEs them at the tunnel by mistake gets a 404, not the Gateway's catch-all.
|
||||||
- hostname: unifi.${BASE_DOMAIN}
|
- hostname: unifi.${BASE_DOMAIN}
|
||||||
|
|||||||
@@ -147,3 +147,4 @@ spec:
|
|||||||
- radarr
|
- radarr
|
||||||
- sonarr
|
- sonarr
|
||||||
- headlamp
|
- headlamp
|
||||||
|
- vuln-scanner
|
||||||
@@ -43,6 +43,13 @@ data:
|
|||||||
href: https://grafana.${BASE_DOMAIN}
|
href: https://grafana.${BASE_DOMAIN}
|
||||||
description: Metrics, logs & dashboards (fronts Prometheus/Loki/Alertmanager)
|
description: Metrics, logs & dashboards (fronts Prometheus/Loki/Alertmanager)
|
||||||
icon: mdi-chart-areaspline
|
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:
|
- Semaphore:
|
||||||
href: https://semaphore.${BASE_DOMAIN}
|
href: https://semaphore.${BASE_DOMAIN}
|
||||||
description: Ansible automation & CI
|
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$/",
|
"/(^|/)versions\\.env$/",
|
||||||
"/(^|/)platform/pm-agent/.+\\.ya?ml$/",
|
"/(^|/)platform/pm-agent/.+\\.ya?ml$/",
|
||||||
// ADDED 2026-11-08: the merge gate's job `container:` is the toolbox, by digest.
|
// 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": [
|
"matchStrings": [
|
||||||
"#\\s*renovate-digest:\\s*image=(?<depName>\\S+)\\s+tag=(?<currentValue>\\S+)\\s*\\n[^\\n]*?(?<currentDigest>sha256:[a-f0-9]{64})"
|
"#\\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
|
// 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.
|
// now so the new name is blast-radius from its first reference, not after an auto-merge.
|
||||||
"gitea/runner",
|
"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,
|
"automerge": false,
|
||||||
"addLabels": [
|
"addLabels": [
|
||||||
@@ -285,6 +292,20 @@
|
|||||||
"blast-radius"
|
"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
|
// 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
|
// 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
|
// 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
|
## The runbooks
|
||||||
|
|
||||||
### Nodes
|
### 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
|
- **`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).
|
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;
|
- **`node-io`** — disk I/O pressure/starvation. The etcd WAL shares a partition with Longhorn replicas;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ configMapGenerator:
|
|||||||
files:
|
files:
|
||||||
# The index, served at /runbooks/ (nginx `try_files /runbooks/README`).
|
# The index, served at /runbooks/ (nginx `try_files /runbooks/README`).
|
||||||
- README=README.md
|
- README=README.md
|
||||||
|
- vuln-scanner=vuln-scanner.md
|
||||||
- backup-failure=backup-failure.md
|
- backup-failure=backup-failure.md
|
||||||
- backup-missed=backup-missed.md
|
- backup-missed=backup-missed.md
|
||||||
- backup-partial=backup-partial.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