Review findings on the integrated scanner: - An OOM kill no longer wedges the loop: inFlight is persisted before each scan, and on restart that image is marked failed and sits out full runs for 20h. - One bad upstream string can no longer freeze the report: safe_url requires an exact lowercase scheme, acceptance reasons are bounded, /triage urls use the same rule, and report.build repairs or drops any entry the schema would refuse. lastCompleteRunAt advances only after a report is actually published. - The private registry reports credential-required, not failed: VULN_PRIVATE_REGISTRIES names it, and Trivy is not invoked. - loadError clears after a full run against a good inventory. - Every image under platform/vuln-scanner stays out of Renovate auto-merge. - The page labels status and target state per line, and mixed acceptance reads as mixed. The Keycloak client is still an operator action: its key must go in an existing encrypted file that an agent must not modify. platform/vuln-scanner/README.md has the exact steps. Co-Authored-By: Claude Opus 5.5 <[email protected]>
114 lines
5.9 KiB
Markdown
114 lines
5.9 KiB
Markdown
# 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`).
|
|
|
|
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.<domain>`. 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.
|