Files
home-network-k3s-impl/platform/vuln-scanner/deployment.yaml
T
jakman85andClaude Opus 5.5 b85d1e3ed7 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]>
2026-11-09 09:42:52 -06:00

280 lines
12 KiB
YAML

---
# 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