Files
home-network-k3s-impl/platform/vuln-scanner
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
..

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.

    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:

    ./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:

    # 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.