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

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

No files matched your search

+4
View File
@@ -54,6 +54,10 @@ ingress:
# tunnel alongside decision 4.
- hostname: headlamp.${BASE_DOMAIN}
service: http_status:404
# The CVE page (docs/17: internal only, a list of what is exploitable where). Its route is disowned
# from public DNS (platform/vuln-scanner/httproute.yaml); this is the second layer.
- hostname: cve.${BASE_DOMAIN}
service: http_status:404
# Non-HTTP L4 VIPs (their own LoadBalancer addresses, not the Gateway). Listed so a DNS flip
# that CNAMEs them at the tunnel by mistake gets a 404, not the Gateway's catch-all.
- hostname: unifi.${BASE_DOMAIN}
+1
View File
@@ -147,3 +147,4 @@ spec:
- radarr
- sonarr
- headlamp
- vuln-scanner
+7
View File
@@ -43,6 +43,13 @@ data:
href: https://grafana.${BASE_DOMAIN}
description: Metrics, logs & dashboards (fronts Prometheus/Loki/Alertmanager)
icon: mdi-chart-areaspline
# The CVE page (platform/vuln-scanner, docs/17): behind SSO, restricted to platform-admins.
# Homepage has no per-group surfaces, so this tile is shown to everyone who can open this
# dashboard; the page itself enforces platform-admins.
- CVE Report:
href: https://cve.${BASE_DOMAIN}
description: Container image CVE inventory (platform-admins)
icon: mdi-shield-bug
- Semaphore:
href: https://semaphore.${BASE_DOMAIN}
description: Ansible automation & CI
+80
View File
@@ -0,0 +1,80 @@
# vuln-scanner — scheduled CVE visibility for what is running
Answers three questions with no manual step: what is running, what is it vulnerable to, and can the
answer be trusted. Contract: spec `docs/17-vulnerability-visibility.md`, ADR-0027 (R-VULN-01..09).
Hand-authored platform tooling (not a catalog service), reconciled by its own non-gating Flux tier
`clusters/base/vuln-scanner.yaml`. Operator runbook: `runbooks/vuln-scanner.md`.
| Piece | Where |
|---|---|
| Scanner source (`python3`, stdlib only), mounted from a ConfigMap at `/app` | `src/` |
| Accepted risk (R-VULN-08) | `acceptances.yaml` |
| Pod, security context, sidecar | `deployment.yaml` |
| Read-only identity (R-VULN-09) | `rbac.yaml` |
| Network: Gateway→4180, CI→8081, Prometheus→9090; API, DNS, public 443 | `networkpolicy.yaml` |
| Aggregate metrics and the five alerts | `servicemonitor.yaml`, `alerts.yaml`, `alerts.promtool-test.yaml` |
The page is `cve.<domain>`, internal only, gated by the per-pod oauth2-proxy sidecar to `platform-admins`.
The route is disowned from public DNS and the tunnel 404s it; PiHole answers it on the LAN.
## Operator step: add the Keycloak client (NOT done by the change that added this tree)
This change adds everything around the Keycloak client but not the client. Adding it needs a new key in
`platform/keycloak/keycloak-realm-clients.sops.yaml`, an encrypted file an agent must not decrypt; the
L0 check `keycloak-client-secret-reconciler` then requires the client and that key to appear in the
same change. Until this is done the page cannot complete a login (`invalid_client`, fail-closed); the
scanner, its metrics and its alerts are unaffected.
In one PR (the realm import does not re-run on its own for a NEW client):
1. Generate one value and set it in both encrypted files, through pipes so it is never echoed:
```bash
./infra/devcontainer/dev.sh run bash -c '
cs=$(python3 -c "import secrets; print(secrets.token_hex(32))")
sops set platform/keycloak/keycloak-realm-clients.sops.yaml \
"[\"stringData\"][\"vuln-scanner-client-secret\"]" "\"$cs\""
sops set platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml \
"[\"stringData\"][\"client-secret\"]" "\"$cs\""'
```
2. Add to `platform/keycloak/keycloak-realm-import.yaml`, beside the `crafty` client:
```yaml
# spec.placeholders
VULN_SCANNER_CLIENT_SECRET:
secret:
name: keycloak-realm-clients
key: vuln-scanner-client-secret
# spec.realm.clients
- clientId: vuln-scanner
name: CVE report (oauth2-proxy)
protocol: openid-connect
enabled: true
publicClient: false
standardFlowEnabled: true
directAccessGrantsEnabled: false
secret: "$(env:VULN_SCANNER_CLIENT_SECRET)"
redirectUris:
- https://cve.${BASE_DOMAIN}/oauth2/callback
webOrigins:
- https://cve.${BASE_DOMAIN}
defaultClientScopes: [email, profile, roles]
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
```
3. Regenerate the derived URL file: `python3 ci/render-keycloak-client-urls.py --write`. Commit all
three, gate, merge.
4. After merge, the client must also exist in the live realm (a completed realm import does not re-run,
per `platform/keycloak/README.md`, so create it once with `kcadm` or re-trigger the import). The
client-secret reconciler then converges its secret and URLs.
+15
View File
@@ -0,0 +1,15 @@
# Accepted CVE risk (R-VULN-08). A list; this file is the ONLY place risk can be accepted, and it reaches
# the scanner through the merge gate (a ConfigMap generated by kustomization.yaml).
#
# Each entry:
# - id: CVE-2026-00000 # the CVE or advisory id, exactly as the scanner reports it
# image: registry/repository # exact image match; omit only for a platform-wide acceptance
# reason: not reachable, feature disabled # mandatory, a real sentence
# expires: 2027-02-01 # mandatory YYYY-MM-DD, at most 180 days out
#
# An accepted finding stays in the listing with its reason and expiry and stops counting toward the
# critical-fix alert. When the expiry passes it reverts to open automatically. An entry with no reason
# or no expiry, or an expiry more than 180 days out or malformed, is rejected by the merge gate
# (conformance/tests/l0-static/vuln-acceptances-schema) and ignored at runtime with the run marked
# degraded, so a finding is never hidden by a bad entry.
[]
@@ -0,0 +1,167 @@
---
# promtool unit tests for platform/vuln-scanner/alerts.yaml (R-VULN-06).
#
# Found by platform/monitoring/tests/run.sh through the COMPANION convention. Run just this file with
# platform/monitoring/tests/run.sh platform/vuln-scanner/alerts.promtool-test.yaml
#
# Each rule is pinned both ways. VulnScanStale and VulnTriageBotSilent additionally pin the two cases
# the spec names by hand: the metric ABSENT must FIRE, and a metric that vanishes must RE-FIRE.
# conformance/tests/l0-static/vuln-alerts-unit-tested reads this file and fails if any of those cases
# is removed.
#
# Timestamps: promtool evaluates from t=0, so a timestamp series can equal its own evaluation time
# with `0+60x<N>` at a 1m interval (time() - value == 0: fresh).
rule_files:
- alerts.rules.yaml
evaluation_interval: 1m
tests:
- interval: 1m
name: healthy - fresh run, fresh database, heartbeat, zero backlog, nothing fires
input_series:
- series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}'
values: '0+60x60'
- series: 'vuln_scanner_database_updated_timestamp_seconds{namespace="vuln-scanner"}'
values: '0+60x60'
- series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}'
values: '0+60x60'
- series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}'
values: '0x60'
- series: 'vuln_scanner_critical_fix_overdue{namespace="vuln-scanner"}'
values: '0x60'
promql_expr_test:
- expr: 'count(ALERTS{alertname=~"Vuln.*"})'
eval_time: 50m
exp_samples: []
- interval: 1m
name: scan stale - last complete run older than 26h fires after the for window
input_series:
- series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}'
values: '0x1600'
promql_expr_test:
- expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}'
eval_time: 1559m
exp_samples: []
- expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}'
eval_time: 1590m
exp_samples:
- labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}'
value: 1
- interval: 1m
name: scan stale ABSENT - metric never existed, VulnScanStale still fires
input_series:
# A healthy, unrelated metric proves this is the absent() arm, not a broken test file.
- series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}'
values: '0x60'
promql_expr_test:
- expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}'
eval_time: 20m
exp_samples:
- labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}'
value: 1
- interval: 1m
name: scan metric VANISHES - a scanner that dies re-fires instead of latching clear
input_series:
- series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}'
values: '0+60x20 stale'
promql_expr_test:
- expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}'
eval_time: 18m
exp_samples: []
- expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}'
eval_time: 40m
exp_samples:
- labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}'
value: 1
- interval: 1m
name: database stale - older than 48h fires after the for window
input_series:
- series: 'vuln_scanner_database_updated_timestamp_seconds{namespace="vuln-scanner"}'
values: '0x2960'
promql_expr_test:
- expr: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing"}'
eval_time: 2870m
exp_samples: []
- expr: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing"}'
eval_time: 2900m
exp_samples:
- labels: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing", severity="warning"}'
value: 1
- interval: 1m
name: coverage gap - a failed target over 24h fires, zero is quiet
input_series:
- series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}'
values: '0x20 3x40'
promql_expr_test:
- expr: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing"}'
eval_time: 25m
exp_samples: []
- expr: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing"}'
eval_time: 40m
exp_samples:
- labels: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing", severity="warning"}'
value: 1
- interval: 1m
name: critical fix overdue - fires, one grouped alert instance
input_series:
- series: 'vuln_scanner_critical_fix_overdue{namespace="vuln-scanner"}'
values: '0x20 12x40'
promql_expr_test:
- expr: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"}'
eval_time: 25m
exp_samples: []
- expr: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"}'
eval_time: 40m
exp_samples:
- labels: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing", severity="warning"}'
value: 1
- expr: 'count(ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"})'
eval_time: 40m
exp_samples:
- labels: '{}'
value: 1
- interval: 1m
name: triage bot silent - last heartbeat older than 26h fires after the for window
input_series:
- series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}'
values: '0x1600'
promql_expr_test:
- expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}'
eval_time: 1559m
exp_samples: []
- expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}'
eval_time: 1590m
exp_samples:
- labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}'
value: 1
- interval: 1m
name: triage bot silent ABSENT - no heartbeat metric ever, VulnTriageBotSilent still fires
input_series:
- series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}'
values: '0x60'
promql_expr_test:
- expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}'
eval_time: 20m
exp_samples:
- labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}'
value: 1
- interval: 1m
name: triage heartbeat VANISHES - re-fires instead of latching clear
input_series:
- series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}'
values: '0+60x20 stale'
promql_expr_test:
- expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}'
eval_time: 40m
exp_samples:
- labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}'
value: 1
+96
View File
@@ -0,0 +1,96 @@
---
# Alert rules for the vulnerability scanner (R-VULN-06, docs/17 "Alerting").
#
# ONE ALERT, ONE GROUPED EMAIL PER CONDITION, never one per CVE. The scanner's metrics are aggregates
# with no per-CVE, per-image or per-workload label; `max()` strips scrape labels, so each rule yields a
# single alert instance, and Alertmanager's `group_by: [alertname, namespace]` route delivers one email.
#
# THE TWO RULES WHOSE ABSENT-METRIC CASE IS LOAD-BEARING: VulnScanStale and VulnTriageBotSilent. Their
# metrics are absent until the first complete run / first bot heartbeat, and absent when the scanner
# or the scrape is dead; both are `or absent(...)` so a scanner that has never run, or an unscraped
# one, fires instead of reading as healthy. Pinned by alerts.promtool-test.yaml in both directions.
# VulnCriticalFixAvailable is expected to fire on first deployment (the backlog made visible).
#
# Severity follows the platform-health convention: warning (a condition to act on, not a page).
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: vuln-scanner
namespace: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
spec:
groups:
- name: platform-health.vulnerability-scanner
rules:
- alert: VulnScanStale
# No complete run within 26 hours, or the metric is absent. 15m rides out a pod restart.
expr: >-
(time() - max(vuln_scanner_last_complete_run_timestamp_seconds) > 93600)
or absent(vuln_scanner_last_complete_run_timestamp_seconds)
for: 15m
labels:
severity: warning
annotations:
summary: No complete vulnerability scan in over 26 hours
description: >-
The scanner has not completed a run within 26 hours, or no scan metric exists at all
(the scanner or its scrape is down). Findings on the CVE page are ageing.
runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner"
- alert: VulnDatabaseStale
# Vulnerability database older than 48 hours (docs/17): the run is degraded, not a success.
expr: time() - max(vuln_scanner_database_updated_timestamp_seconds) > 172800
for: 15m
labels:
severity: warning
annotations:
summary: Vulnerability database is older than 48 hours
description: >-
The Trivy vulnerability database has not been refreshed in over 48 hours, so scans
evaluate images against stale advisories and the run reports as degraded.
runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner"
- alert: VulnCoverageGap
# A target has been `failed` for more than 24 hours. credential-required targets are not
# `failed` and do not count, so a private registry cannot hold this alert firing.
expr: max(vuln_scanner_failed_targets_over_24h) > 0
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $value }} image(s) have failed scanning for over 24 hours"
description: >-
One or more running images have failed scanning for more than 24 hours, so what is
reported for them is out of date. The CVE page lists each target and the reason.
runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner"
- alert: VulnCriticalFixAvailable
# An unaccepted CRITICAL with a fix, first seen over 72 hours ago. The grace period gives
# Renovate's daily cycle time to act, so this marks what automation has not resolved.
expr: max(vuln_scanner_critical_fix_overdue) > 0
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $value }} CRITICAL finding(s) with a fix available for over 72 hours"
description: >-
One or more unaccepted CRITICAL vulnerabilities have had a fix available for more than
72 hours. The CVE page has the details; this message deliberately names none of them.
runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner"
- alert: VulnTriageBotSilent
# No bot heartbeat for 26 hours, or the metric is absent (the bot has never run, or the
# scanner or its scrape is down).
expr: >-
(time() - max(vuln_scanner_triage_heartbeat_timestamp_seconds) > 93600)
or absent(vuln_scanner_triage_heartbeat_timestamp_seconds)
for: 15m
labels:
severity: warning
annotations:
summary: CVE triage bot has not reported in over 26 hours
description: >-
The scheduled CVE triage bot has not posted a heartbeat in over 26 hours (or never
has), so remediation states are stale and no approvals are being requested.
runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner"
+279
View File
@@ -0,0 +1,279 @@
---
# The vulnerability scanner (R-VULN-01..09, docs/17). One pod:
# init trivy-tools copies the Trivy binary and CA bundle from the digest-pinned Trivy image into
# an emptyDir, so the running container has a single python interpreter + one
# static Go binary and nothing else from that image.
# main scanner python3 stdlib only (no pip), source from a ConfigMap at /app: inventory,
# sequential scans, report, the page on 127.0.0.1:8080, the internal API on
# :8081, aggregates on :9090.
# sidecar oauth2-proxy the only path to the page, restricted to platform-admins (R-IDP-06, docs/17
# "Access"), reached from the shared Gateway on :4180.
#
# ONE REPLICA, Recreate: one scanner on one RWO report store; two would double-scan and contend for
# the volume.
#
# SOFT affinity toward k3s-prod-2: prod-1 is the node whose etcd I/O starved historically, and scans
# are CPU- and I/O-heavy. Soft on purpose: a scanner that cannot be placed must still run elsewhere.
apiVersion: apps/v1
kind: Deployment
metadata:
name: vuln-scanner
namespace: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
spec:
replicas: 1
strategy:
type: Recreate
revisionHistoryLimit: 3
selector:
matchLabels:
app.kubernetes.io/name: vuln-scanner
template:
metadata:
labels:
app.kubernetes.io/name: vuln-scanner
# The direct egress path; never the VPN gateway (docs/17).
homelab.keithjackson.dev/egress-class: internet
# Per-pod oauth2-proxy gate in front of the page. This namespace is not app-namespace=true,
# so the auth-mode/egress-class policies do not select it; the labels are kept so the pod is
# compliant by construction if that ever changes.
homelab.keithjackson.dev/auth-mode: forward-auth
spec:
serviceAccountName: vuln-scanner
# The scanner reads the Kubernetes API with this token (read-only, rbac.yaml).
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
# Longhorn honours fsGroup on an RWO volume; this makes /data and the emptyDirs writable.
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- k3s-prod-2
initContainers:
- name: trivy-tools
# TRIVY 0.76.1 — pinned by digest (R-VULN-09). Not 0.69.4/5/6 (compromised release, March
# 2026, CVE-2026-33634). Tag digest and config created timestamp read from the registry on
# 2026-11-09: sha256:790fd66d…6c06c, created 2026-10-21T11:16:20Z (19 days old, i.e. past
# the 7-day minimum release age; 0.76.2 was published 2026-11-05 and is too new).
# SIGNATURE VERIFIED 2026-11-09 with cosign v3.1.3, keyless:
# cosign verify ghcr.io/aquasecurity/trivy@sha256:790fd66d... \
# --certificate-identity-regexp 'https://github\.com/aquasecurity/trivy/\.github/workflows/.+' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
# Renovate: blast-radius component, never auto-merges, 7-day minimum release age
# (renovate.json5); this `tag@digest` form is what the `kubernetes` manager tracks.
# The image's ENTRYPOINT is `trivy`, overridden below. /bin/cp (busybox) and
# /etc/ssl/certs/ca-certificates.crt confirmed present by listing the layers via the
# registry API (no image run).
image: ghcr.io/aquasecurity/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
imagePullPolicy: IfNotPresent
command:
- /bin/cp
- /usr/local/bin/trivy
- /etc/ssl/certs/ca-certificates.crt
- /tools/
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 10m
memory: 16Mi
ephemeral-storage: 256Mi
limits:
cpu: 200m
memory: 64Mi
ephemeral-storage: 512Mi
volumeMounts:
- name: tools
mountPath: /tools
containers:
# ─────────────────────────────── scanner ──────────────────────────────────────────────
- name: scanner
# python 3.14 on alpine by digest, matching the interpreter the scanner's tests ran on.
# 3.14.9-alpine, config created 2026-10-21T23:01:37Z, read from the registry on 2026-11-09
# (19 days old; 3.14.10-alpine, the current 3.14-alpine, was built 2026-11-05 and is under
# the 7-day age applied here by hand). Tracked by the `kubernetes` Renovate manager.
# Runs as 65532 from this pod's securityContext; the image's default is root, overridden.
image: docker.io/library/python:3.14.9-alpine@sha256:dbeb11d16bb95c3a7e3c25c842cb9f20cbc8c18f8be0461a4a0dcc861ad386a0
imagePullPolicy: IfNotPresent
command:
- python3
- /app/main.py
env:
- name: VULN_DATA_DIR
value: /data
- name: VULN_CACHE_DIR
value: /cache
# Scan caches and temp extraction live on the size-limited emptyDir, never on the
# replicated volume or etcd (docs/17 "Load").
- name: TMPDIR
value: /cache
- name: HOME
value: /cache
- name: VULN_TOOLS_DIR
value: /tools
- name: TRIVY_BIN
value: /tools/trivy
- name: SSL_CERT_FILE
value: /tools/ca-certificates.crt
- name: VULN_ACCEPTANCES
value: /config/acceptances.yaml
- name: PYTHONUNBUFFERED
value: "1"
- name: PYTHONDONTWRITEBYTECODE
value: "1"
ports:
- { name: internal, containerPort: 8081, protocol: TCP }
- { name: metrics, containerPort: 9090, protocol: TCP }
# Sized to trivy, the only heavy thing here: scans are sequential, the limit is the peak of
# one large image; the emptyDir sizeLimit bounds disk.
resources:
requests:
cpu: 100m
memory: 256Mi
ephemeral-storage: 256Mi
limits:
cpu: "1"
memory: 2Gi
ephemeral-storage: 12Gi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
# /healthz on the metrics port is answered by the HTTP server itself and must not depend
# on scan state: a long scan or a failed one is reported, never a reason to restart.
startupProbe:
httpGet:
path: /healthz
port: metrics
periodSeconds: 5
failureThreshold: 24
livenessProbe:
httpGet:
path: /healthz
port: metrics
periodSeconds: 20
failureThreshold: 6
readinessProbe:
httpGet:
path: /healthz
port: metrics
periodSeconds: 15
volumeMounts:
- name: src
mountPath: /app
readOnly: true
- name: acceptances
mountPath: /config
readOnly: true
- name: tools
mountPath: /tools
readOnly: true
- name: data
mountPath: /data
- name: cache
mountPath: /cache
# ─────────────────────────── oauth2-proxy reverse-proxy sidecar ───────────────────────
- name: oauth2-proxy
# The per-app sidecar pattern (platform/crafty, catalog/render.py): same tag as the
# renderer's OAUTH2_PROXY_IMAGE so a Renovate bump lands every sidecar in one branch.
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
imagePullPolicy: IfNotPresent
args:
- --http-address=0.0.0.0:4180
- --reverse-proxy=true
- --provider=oidc
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
# The page, bound to loopback in the scanner container; no Service reaches it.
- --upstream=http://127.0.0.1:8080
- --email-domain=*
- --scope=openid email profile
- --oidc-groups-claim=groups
# R-IDP-06, docs/17 "Access": a valid session is not enough, the user must be a
# platform-admin, because this page is the list of what is exploitable where.
- --allowed-group=platform-admins
- --skip-provider-button=true
- --redirect-url=https://cve.${BASE_DOMAIN}/oauth2/callback
# Unique name + host-scoped domain: the shared forward-auth proxy and the other sidecars
# hold different cookie-secrets, so a shared cookie name would evict sessions
# (oauth2-proxy-cookie-names-distinct).
- --cookie-name=_oauth2_proxy_vuln_scanner
- --cookie-domain=cve.${BASE_DOMAIN}
- --whitelist-domain=.${BASE_DOMAIN}
- --cookie-secure=true
- --set-xauthrequest=true
env:
# client-secret MUST equal the Keycloak `vuln-scanner` client's secret (key
# vuln-scanner-client-secret in platform/keycloak/keycloak-realm-clients.sops.yaml; see
# README.md, Operator step). Secrets by name only (R-IDP-04).
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom:
secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-id }
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-secret }
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: cookie-secret }
ports:
- { name: http, containerPort: 4180, protocol: TCP }
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
livenessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 5
periodSeconds: 15
volumes:
- name: src
configMap:
name: vuln-scanner-src
- name: acceptances
configMap:
name: vuln-scanner-acceptances
- name: tools
emptyDir:
sizeLimit: 512Mi
- name: data
persistentVolumeClaim:
claimName: vuln-scanner-data
# Trivy DB + per-image scan cache + TMPDIR (trivy extracts layers here). Size-limited; never
# on a replicated volume or in etcd (docs/17 "Load"). A full run cleans it between images.
- name: cache
emptyDir:
sizeLimit: 10Gi
+34
View File
@@ -0,0 +1,34 @@
---
# The CVE page at cve.<domain>. Internal exposure only (docs/17 "Access"): a list of what is exploitable
# where is sensitive, so this hostname publishes NO public DNS record (the `controller: none` annotation
# below disowns it from external-dns) and the Cloudflare Tunnel 404s it (platform/cloudflared/config.yaml).
# It resolves on the LAN through PiHole's explicit list (catalog/pihole.yaml).
#
# The backend is the `vuln-scanner` Service's `http` port, which fronts the oauth2-proxy sidecar, so the
# SSO gate is in the data path; nothing routes to the page (:8080) directly.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: vuln-scanner
namespace: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
annotations:
homelab.keithjackson.dev/auth-mode: oauth2-proxy
homelab.keithjackson.dev/auth-groups: platform-admins
external-dns.alpha.kubernetes.io/controller: "none"
spec:
parentRefs:
- name: shared
namespace: gateway
sectionName: https
hostnames:
- cve.${BASE_DOMAIN}
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: vuln-scanner
port: 4180
+50
View File
@@ -0,0 +1,50 @@
---
# The CVE visibility scanner (docs/17, ADR-0027). Reconciled by its OWN non-gating Flux Kustomization
# (clusters/base/vuln-scanner.yaml: dependsOn platform-config, wait:false). A scanner that is failing,
# or a pod that cannot get its report store, is a thing to alert on, never a thing that stalls the
# platform. NOT a line in platform/kustomization.yaml.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Required for the configMapGenerator (see platform/cloudflared/kustomization.yaml).
namespace: vuln-scanner
resources:
- namespace.yaml
- rbac.yaml
- pvc.yaml
# The per-pod oauth2-proxy credential. SOPS-encrypted, decrypted per-env by the vuln-scanner
# Kustomization (both overlays patch it). Referenced by name only (R-IDP-04). Its template sibling
# is deliberately not named here (no-plaintext-secrets).
- vuln-scanner-oauth2-proxy-credentials.sops.yaml
- deployment.yaml
- service.yaml
# Namespace default-deny, the three ingress paths and the egress allowances. One file, so a baseline
# without its allows cannot ship alone and empty the TSDB.
- networkpolicy.yaml
- httproute.yaml
- servicemonitor.yaml
- alerts.yaml
# Scanner source and the risk-acceptance file, both as content-hashed ConfigMaps, so a change rolls
# the pod. No `disableNameSuffixHash`: the hash is the rollout mechanism.
configMapGenerator:
- name: vuln-scanner-src
files:
- src/main.py
- src/inventory.py
- src/trivy.py
- src/state.py
- src/report.py
- src/acceptances.py
- src/page.py
- src/metrics.py
- src/server.py
- src/report.schema.json
options:
labels:
app.kubernetes.io/name: vuln-scanner
- name: vuln-scanner-acceptances
files:
- acceptances.yaml
options:
labels:
app.kubernetes.io/name: vuln-scanner
+23
View File
@@ -0,0 +1,23 @@
---
# vuln-scanner — the CVE visibility scanner (R-VULN-01..09, ADR-0027, docs/17). Hand-authored platform
# delivery tooling, not a catalog service: its cluster-wide read identity is something a
# ServiceDefinition cannot express.
#
# gateway-access=shared opts this namespace in to attach the cve.<domain> HTTPRoute to the shared
# Gateway; the route targets the per-pod oauth2-proxy sidecar (:4180), never the page directly.
#
# INTENTIONALLY NOT homelab.keithjackson.dev/app-namespace=true (the crafty/cloudflared/pm-agent
# posture): every Kyverno app policy matches on that label, and the generated default-deny would then
# be double-owned with the one this tree ships (networkpolicy.yaml), the 2026-09-30 drift loop. The
# pod still carries the egress-class and auth-mode labels those policies would require.
#
# PodSecurity `restricted` IS enforced: nothing here needs any privilege.
apiVersion: v1
kind: Namespace
metadata:
name: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
gateway-access: "shared"
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
+119
View File
@@ -0,0 +1,119 @@
---
# Default-deny baseline (R-NET-01): deny all ingress and egress, allow only DNS. A platform namespace,
# so Kyverno does not generate this and Flux is its only writer (namespace.yaml).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: vuln-scanner
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# INGRESS — exactly three paths (docs/17 "Security of the scanner itself"), one client each:
# :4180 from the shared Gateway only (the oauth2-proxy sidecar). The page itself (:8080, loopback)
# has no Service port and no rule.
# :8081 from the CI runner only. Job containers run inside the DinD sidecar of the act-runner pod in
# namespace gitea-actions, so what the network sees is THAT pod's identity (verified live
# 2026-11-09: pods carry app.kubernetes.io/name=act-runner). No wider than the runner pods.
# :9090 from Prometheus only (verified live: app.kubernetes.io/name=prometheus in monitoring).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: vuln-scanner-allow-ingress
namespace: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: vuln-scanner
ingress:
- fromEntities:
- ingress
toPorts:
- ports:
- { port: "4180", protocol: TCP }
- fromEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: gitea-actions
app.kubernetes.io/name: act-runner
toPorts:
- ports:
- { port: "8081", protocol: TCP }
- fromEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: monitoring
app.kubernetes.io/name: prometheus
toPorts:
- ports:
- { port: "9090", protocol: TCP }
---
# EGRESS (DNS is in the baseline above).
# * kube-apiserver: the inventory.
# * tcp/443 to the public internet for the vulnerability database and registries, OUT THE DIRECT
# PATH (egress-class=internet, deployment.yaml; never the VPN gateway). Written as a CIDR rule
# rather than `toEntities: world` because the spec excludes cluster and LAN ranges and `world`
# includes every LAN address: RFC1918 + CGNAT + loopback + link-local + multicast are subtracted.
# Consequence, recorded rather than worked around: the Gitea registry resolves to the LAN VIP, so
# images hosted there are unreachable from here and report `failed` with that reason, until a
# registry credential and a narrower LAN rule are decided (docs/17, "Private registry coverage").
# * The two Keycloak rules below are the ONE addition beyond the spec's list, and they are required:
# the oauth2-proxy sidecar shares this pod's network identity with the scanner, and it must
# reach Keycloak for OIDC discovery and the token exchange. keycloak.<domain> resolves in-cluster
# to the Gateway VIP (split-horizon CoreDNS), which Cilium hands to the Gateway's Envoy
# (reserved:ingress, tcp/443), and Envoy then re-evaluates THIS pod's egress against Keycloak's
# real backend identity (enforce_policy_on_l7lb), the same pair catalog/render.py gives every SSO
# app (buildkit/networkpolicy.yaml has the measurement). Without them login 403s.
# * NOT listed: anything else in-cluster, any node entity, the LAN, the VPN.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: vuln-scanner-allow-egress
namespace: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: vuln-scanner
egress:
- toEntities:
- kube-apiserver
- toCIDRSet:
- cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 100.64.0.0/10
- 127.0.0.0/8
- 169.254.0.0/16
- 172.16.0.0/12
- 192.168.0.0/16
- 224.0.0.0/4
toPorts:
- ports:
- { port: "443", protocol: TCP }
- toEntities:
- ingress
toPorts:
- ports:
- { port: "443", protocol: TCP }
- toEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: keycloak
app: keycloak
+19
View File
@@ -0,0 +1,19 @@
---
# The report store: state.json (firstSeenAt, failed-since, heartbeat, last complete run) and
# report.json. 1Gi on the replicated tier is generous for two JSON files; it is DELIBERATELY NOT where
# the vulnerability database or the per-image scan caches live, because etcd and Longhorn share a
# partition on every node (docs/02, docs/17 "Load"). Those are on the size-limited /cache emptyDir.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: vuln-scanner-data
namespace: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 1Gi
+50
View File
@@ -0,0 +1,50 @@
---
# Scanner identity (R-VULN-09). The whole of its Kubernetes reach is get/list/watch on the objects it
# needs to answer "what is running, and which workload owns it". NO Secret, NO write verb, no
# wildcard. conformance/tests/l0-static/vuln-scanner-supply-chain fails the gate if that changes.
apiVersion: v1
kind: ServiceAccount
metadata:
name: vuln-scanner
namespace: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: vuln-scanner-read
labels:
app.kubernetes.io/name: vuln-scanner
rules:
# Running images, their digests, and the nodes a pod sits on (nodes are inventoried as not-covered).
- apiGroups: [""]
resources: [pods, nodes]
verbs: [get, list, watch]
# The owner chain Pod -> ReplicaSet -> Deployment, and the other two top-level controllers.
- apiGroups: [apps]
resources: [replicasets, deployments, statefulsets, daemonsets]
verbs: [get, list, watch]
# Pod -> Job -> CronJob, and CronJob templates with no running pod.
- apiGroups: [batch]
resources: [jobs, cronjobs]
verbs: [get, list, watch]
# KubeVirt VMs are inventoried as not-covered.
- apiGroups: [kubevirt.io]
resources: [virtualmachineinstances]
verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: vuln-scanner-read
labels:
app.kubernetes.io/name: vuln-scanner
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: vuln-scanner-read
subjects:
- kind: ServiceAccount
name: vuln-scanner
namespace: vuln-scanner
+33
View File
@@ -0,0 +1,33 @@
---
# One Service, three named ports (the page gate, the internal API and metrics).
#
# clusterIP IS PINNED ON PURPOSE (10.43.210.10, verified unused against the live Service list on
# 2026-11-09, inside k3s' default 10.43.0.0/16 Service CIDR). The triage bot runs as a Gitea Actions
# job inside the runner's DinD daemon, and whether in-cluster DNS resolves from a DinD job container
# is UNVERIFIED. The pinned address is the bot's fallback when the Service name does not resolve:
# `http://10.43.210.10:8081` (VULN_API_FALLBACK_URL in .gitea/workflows/cve-triage.yaml).
apiVersion: v1
kind: Service
metadata:
name: vuln-scanner
namespace: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
spec:
type: ClusterIP
clusterIP: 10.43.210.10
selector:
app.kubernetes.io/name: vuln-scanner
ports:
- name: http
port: 4180
targetPort: http
protocol: TCP
- name: internal
port: 8081
targetPort: internal
protocol: TCP
- name: metrics
port: 9090
targetPort: metrics
protocol: TCP
+20
View File
@@ -0,0 +1,20 @@
---
# Prometheus scrape of the aggregate-only /metrics (:9090). Only the `metrics` port of the shared
# Service; networkpolicy.yaml admits only Prometheus on it. kube-prometheus-stack selects
# ServiceMonitors from any namespace; the `release` label is kept for consistency.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: vuln-scanner
namespace: vuln-scanner
labels:
app.kubernetes.io/name: vuln-scanner
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app.kubernetes.io/name: vuln-scanner
endpoints:
- port: metrics
path: /metrics
interval: 60s
@@ -0,0 +1,30 @@
apiVersion: v1
kind: Secret
metadata:
name: vuln-scanner-oauth2-proxy
namespace: vuln-scanner
type: Opaque
stringData:
client-id: ENC[AES256_GCM,data:9n9fMQyIq+JmfgXy,iv:8FLkOTc6Rmck33ecd4A3DXP2CiGrAkxMJ8Q7nmI9naE=,tag:4wb8rAXx+KAIhWAviEKTJQ==,type:str]
client-secret: ENC[AES256_GCM,data:JQCSBroPwd1KxZhdOw9zYkA9krYdK79QpnP/WW3ZIYVoyvHGVKJ2OVPpvgtWy20Ex0yWCTtmpghn87r+4Kkrfw==,iv:RFuDweYB7z5fykB/Ued2Z7MdO9VVY2XoO4gwekxMo0U=,tag:UseTecg5dJMNR1SN6ZUZ7g==,type:str]
cookie-secret: ENC[AES256_GCM,data:F7McZBTr10fnpTEz8cYjXsvrwRgqiKrlcaL/mSZKjOjuPfaSvq4sV3YBuQc=,iv:2vDEqmUHFFpUsRkBSqj3X92eT2bP8VTj2BiAr/8+7ak=,tag:rdHVky9JmlcEEhsxV6OtnA==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGdGErRzEvQWNTcUdYanhG
RGVaYi9CdDNvYVdhL2ViOWI4Z3k0M2pxb0V3ClgzOFNPRXdyZ1pVZTFKMVEyTGVV
bkxQWDdFZUtvME1jZm9VUXBHaVpHM3cKLS0tIDhodHlQVUNhNlpiVFAzU1JZL2d3
cUhWcGNGSThYTkRaV0NSOUZrM2ZmS1UKjFe6iGia0+QOZgWcSFoH0LLEp1f2M2ds
V8bIBIFTtRDTKp6kgt0nee+Sgb7g5D5NIGTrRDGE256I8jdiNDcMpw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-11-09T15:43:11Z"
mac: ENC[AES256_GCM,data:jXBQdruY8yLD4k3iRHqn78Pq0iXzETDk/S8qyXSVFHsacqUvmtzdOw6ajJDg7hAkVGN9PXqH+lsz45wR3a3YVODbQZJyS0+cKuQ1cVWlV/wcWOlNSdeKSLRe8h658KjXGUPQubaDFB4rloBfUr5ebuGQGr4ZEj7g241xjrsjBic=,iv:asoMnTjsIIb1AgrgE9dERixbpw94TpQWCPRwAT3mXYM=,tag:uwCVmjDr3gDQAqWsmiF0tA==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
@@ -0,0 +1,29 @@
# =============================================================================
# TEMPLATE, NOT APPLIED, NOT ENCRYPTED. The OIDC client and cookie secrets for the per-pod oauth2-proxy
# sidecar gating the CVE page (deployment.yaml, container oauth2-proxy). The sidecar reads these three
# keys by name only (R-IDP-04).
#
# The `client-secret` value MUST equal `vuln-scanner-client-secret` in
# platform/keycloak/keycloak-realm-clients.sops.yaml (a cross-namespace mirror, like crafty). See
# platform/vuln-scanner/README.md, "Operator step". Never decrypt this to a terminal or a log.
#
# Regenerate (encrypts to the repo's public recipient via .sops.yaml, values never echoed):
# ./infra/devcontainer/dev.sh run bash -c '
# cs=$(python3 -c "import secrets; print(secrets.token_hex(32))")
# ck=$(python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())")
# sops -e --input-type yaml --output-type yaml \
# --filename-override platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml \
# /dev/stdin <<YAML > platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml
# ...this document with client-secret: $cs and cookie-secret: $ck...
# YAML'
# =============================================================================
apiVersion: v1
kind: Secret
metadata:
name: vuln-scanner-oauth2-proxy
namespace: vuln-scanner
type: Opaque
stringData:
client-id: vuln-scanner
client-secret: REPLACE-ME
cookie-secret: REPLACE-ME