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
@@ -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
|
||||
Reference in new issue
Block a user