Files
home-network-k3s-impl/platform/vuln-scanner
jakman85 1ace03569c
gate / gate (pull_request) Failing after 1m0s
gate / conformance (pull_request) Skipped
Added required keycloak config
2026-11-09 16:29: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.