diff --git a/decisions/2026-11-09-vuln-visibility.md b/decisions/2026-11-09-vuln-visibility.md new file mode 100644 index 0000000..bef6f11 --- /dev/null +++ b/decisions/2026-11-09-vuln-visibility.md @@ -0,0 +1,84 @@ +--- +date: 2026-11-09 +branch: feat/vuln-visibility +title: Automated CVE visibility for production (R-VULN-01..09, ADR-0027) +--- + +## What changed + +Implements `docs/17-vulnerability-visibility.md` from spec PR Home-Network/home-network-k3s#4 +(commit `0fb44bb`, open, not merged). `ci/SPEC_PIN` is deliberately not bumped: the merge SHA does +not exist yet, and open PR #160 owns the pin backlog. + +- `platform/vuln-scanner/`: stdlib Python scanner in a ConfigMap on a digest-pinned + `python:3.14.9-alpine`, Trivy 0.76.1 by digest copied in by an initContainer, a per-app + oauth2-proxy sidecar for `cve.${BASE_DOMAIN}`, read-only ClusterRole, RWO Longhorn PVC for the + report and state, size-limited emptyDir for the DB and scan cache, pinned Service ClusterIP + `10.43.210.10` as the bot's DNS fallback, five alerts with promtool tests and a runbook. +- `clusters/base/vuln-scanner.yaml`: its own Flux tier, `wait: false`, `dependsOn: platform-config`, + nothing depends on it. +- `.gitea/workflows/cve-triage.yaml` and `scripts/cve_triage.py`: the bot, cron `15 7 * * *`, + counts-only `CVE Dashboard` issue with its own `cve-dashboard` label, approve-row ticks only. +- `renovate.json5`: Trivy on the blast-radius list, 7-day minimum release age, and everything under + `platform/vuln-scanner/**` non-automerging. +- `conformance/`: R-VULN-01..09 in the registry; L0 checks and L1 fixture tests (scanner, page, + bot) wired by scope, L2/L3 authored as scaffolds. +- PiHole LAN names and the Homepage link gained the CVE page. + +### Decisions a future reader will want + +- Page is server-rendered with **no JavaScript**, not inline JS: the CSP has no `script-src`, + escaping is in one place, and the L1 test exercises the real rendered page, which a JS page could + not do without a browser engine. Filters, sort and pagination are URL parameters. +- Three pod ports, not two. Prometheus gets 9090 only, so it cannot read `report.json`. +- A killed image (OOM, eviction) is recorded as failed from an in-flight marker and sits out full + runs for 20h, so it cannot wedge the scan loop. +- Private registries are named in `VULN_PRIVATE_REGISTRIES` and short-circuit to + `credential-required` before Trivy runs (see escalation 1). +- Bot's job container is the repo's toolbox image by digest: `actions/checkout` needs node and the + pull is already proven by the merge gate. + +### Spec escalations + +1. Coverage state `credential-required` is defined as "the registry refused anonymous access", but + the egress contract excludes LAN ranges, so the scanner cannot reach the private registry to be + refused. Implemented by naming private registries in configuration. The spec should define the + state by configuration or admit the registry to egress. +2. The egress contract lists only the Kubernetes API, DNS and public TCP/443, but the SSO sidecar + must reach Keycloak. Egress additionally allows the Gateway on 443 and Keycloak. The spec should + name this path. + +## What I verified + +- Toolbox gate on the integrated tree: **rc 3 (UNDETERMINED, not green)**. Stages that did not run: + `spec-immutability` and `gitea-settings`; `conformance` (L2/L3) logged SKIPPED, no cluster. + `approval-policy`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` reported green. + `approval-policy` is N/A offline and proves nothing locally. +- All `vuln-*` L0 and L1 checks pass in the toolbox, each with a mutated-fixture non-vacuity case. +- `renovate-config-validator` from `ghcr.io/renovatebot/renovate:44.167.0`, the tag `renovate.yaml` + runs: validated successfully. This is a read-only container run of a public image. +- Trivy 0.76.1 digest: registry `created` 2026-10-21, 19 days old at authoring; cosign keyless + verification passed (cosign 3.1.3); `cp` and the CA bundle confirmed from a layer listing. +- ClusterIP `10.43.210.10` confirmed unused against the live Service list, read-only. +- An independent reviewer checked the diff against docs/17 and found no anonymous leak path, no way + for an unscanned image to read as clean, no way for the bot to tick a `rebase-branch` row, and no + platform stall path. One BLOCKER and five other code findings were fixed with tests. + +## What I did NOT verify + +Nothing here has been deployed. No runtime behaviour has been observed. + +- **R-VULN-04 is NOT delivered until the operator adds the Keycloak client.** It needs a new key in + `platform/keycloak/keycloak-realm-clients.sops.yaml`, which needs the age private key, and a + one-time `kcadm` against the live realm. Exact steps: `platform/vuln-scanner/README.md`, "Operator + action required". Until then login fails closed with `invalid_client`. Those steps are UNVERIFIED. +- A real Trivy run: every scanner test uses a recorded fixture and a fake runner. +- The real Kubernetes API, real browser rendering, OIDC login and the `platform-admins` restriction. +- Whether in-cluster DNS resolves from a DinD job container, and whether the pinned ClusterIP + fallback is reachable from one. The workflow has never run; Gitea behaviour is tested against a + fake only. +- The `## Pending Approval` row format is from Renovate's source as recalled, because issue #91 has + no live approve row today. The `approve-branch` marker itself is documented. +- L2 and L3 are scaffolds and do not run in CI. +- Renovate `minimumReleaseAge` behaviour on a real update, as opposed to the config validating. +- Cgroup v2 memory behaviour under the scanner's limits is inferred, not measured.