Files
home-network-k3s-impl/platform/vuln-scanner/README.md
T
jakman85andClaude Opus 5.5 d8b25a67fe fix(vuln): survive OOM kills, isolate bad upstream strings, label findings per line
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]>
2026-11-09 12:28:29 -06:00

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.