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 action required: the Keycloak client (R-VULN-04 is NOT delivered until this is done)
Until these steps are done the page cannot complete a login (invalid_client, fail-closed). The
scanner, its metrics and its alerts are unaffected.
An agent cannot do this, because every route runs through one encrypted file. The client's secret
must be the key vuln-scanner-client-secret in platform/keycloak/keycloak-realm-clients.sops.yaml:
the reconciler mounts only that Secret, and the L0 check keycloak-client-secret-reconciler rejects a
placeholder that reads any other (a second source would be declared but never healed). That check
also requires the client and the key to land in the same change. Adding a key needs the age private
key. Separately, a completed realm import never re-runs, so a new client has to be created in the
live realm once whatever git says. *.sops.yaml is a sensitive path, so the PR needs an approval or
an operator-posted /approve-override (infra/gitea/approval-policy.json).
Order matters. Create the live client first: the reconciler treats every *-client-secret key as
a client that must exist, and fails every 15-minute run with NOT FOUND otherwise
(KeycloakClientSecretReconcilerFailing).
-
Create the client in the live realm. No secret is passed: the reconciler sets it from git after the merge, and it reads the admin credential from the pod's own environment, so nothing is echoed.
kubectl --context k3s-prod -n keycloak exec keycloak-0 -- bash -c ' K="/opt/keycloak/bin/kcadm.sh"; C="--config /tmp/kcadm-vuln.config" $K config credentials $C --server http://localhost:8080 --realm master \ --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" D=home.keithjackson.dev ID=$($K create clients $C -r homelab -i -s clientId=vuln-scanner -s "name=CVE report (oauth2-proxy)" \ -s protocol=openid-connect -s enabled=true -s publicClient=false -s standardFlowEnabled=true \ -s directAccessGrantsEnabled=false -s "redirectUris=[\"https://cve.$D/oauth2/callback\"]" \ -s "webOrigins=[\"https://cve.$D\"]") $K create clients/$ID/protocol-mappers/models $C -r homelab -s name=groups -s protocol=openid-connect \ -s protocolMapper=oidc-group-membership-mapper -s "config.\"full.path\"=false" \ -s "config.\"id.token.claim\"=true" -s "config.\"access.token.claim\"=true" \ -s "config.\"userinfo.token.claim\"=true" -s "config.\"claim.name\"=groups" rm -f /tmp/kcadm-vuln.config'Dis the prodBASE_DOMAIN; check it against thecluster-varsConfigMap first. -
On a branch, generate one value and set it in both encrypted files, through pipes so it is never echoed.
client-secretin the scanner's file is replaced so that the two match:./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\""' -
Add to
platform/keycloak/keycloak-realm-import.yaml, beside thecraftyclient:# 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 -
Regenerate the derived URL file with
python3 ci/render-keycloak-client-urls.py --write, and addvuln-scanner-client-secret: REPLACE-MEtokeycloak-realm-clients.sops.yaml.example. Commit all of it in one PR, gate, merge. -
After Flux applies it, the next reconciler run sets the client's secret and URLs from git. Prove it with the L2 test
keycloak-client-secrets-match-git, then log in athttps://cve.<domain>. Theplatform-adminsrestriction is the sidecar's--allowed-group; thegroupsmapper above is what supplies the claim it reads, so a user outside that group gets a 403 from oauth2-proxy.