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]>
280 lines
12 KiB
YAML
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
|