--- # 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 # Registries that need a credential this scanner deliberately does not hold (R-VULN-09). # Comma-separated hostnames. They report credential-required WITHOUT invoking Trivy: # the egress policy excludes the LAN, so a real attempt ends in a connection error and # would read as `failed`, firing VulnCoverageGap permanently. Flux substitutes the domain. - name: VULN_PRIVATE_REGISTRIES value: gitea.${BASE_DOMAIN} - 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 action required). 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