# 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.`, 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`). 1. 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. ```bash 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' ``` `D` is the prod `BASE_DOMAIN`; check it against the `cluster-vars` ConfigMap first. 2. On a branch, generate one value and set it in both encrypted files, through pipes so it is never echoed. `client-secret` in the scanner's file is replaced so that the two match: ```bash ./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\""' ``` 3. Add to `platform/keycloak/keycloak-realm-import.yaml`, beside the `crafty` client: ```yaml # 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 ``` 4. Regenerate the derived URL file with `python3 ci/render-keycloak-client-urls.py --write`, and add `vuln-scanner-client-secret: REPLACE-ME` to `keycloak-realm-clients.sops.yaml.example`. Commit all of it in one PR, gate, merge. 5. 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 at `https://cve.`. The `platform-admins` restriction is the sidecar's `--allowed-group`; the `groups` mapper above is what supplies the claim it reads, so a user outside that group gets a 403 from oauth2-proxy.