feat(scanning): continuous SBOM + image scanners via Deployments, unified jobs + retention #15
No reviewers
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Motstandskraft/spam!15
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/continuous-sbom-scanning"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Converts both vulnerability scanners from batch/CronJob models to long-running poller Deployments, unifies SBOM scanning into the jobs table, and centralizes grype into a single image — so repo runs, image scans, and SBOM scans share one queue/
/runsview, and grype runs in exactly one place. Continuous scanning also brings retention sweeps (to keep the now-constant artifacts bounded) and a batch of hardening fixes shaken out by the live lab deploy.Why
Vulnerability counts only refreshed after the nightly
sbom-scannerCronJob; image-scanner pods lingered ~30 min just to keep grype's DB warm; and grype was installed in two images. SBOMs/images are produced continuously — scanning should be too.Phase 1 — SBOM scanning (commit 1)
SBOM_SCANjob type — peer ofCREATE_RUN/IMAGE_SCAN.GET /api/sbom-scans/next(claim viaClaimNextJobOfType) +POST /api/sbom-scans/{id}/complete. Jobs-tableRUNNING/locked_by+ stale-requeue replaces the bespokesbom_scan_leasesqueue (retired).internal/sbomscan): enqueue on ingest + periodic re-sweep over the bounded active set (latest-per-repo commits + image digests still present in a cluster), deduped persbom_id.scan_config+ adminGET/PUT /api/admin/sbom/configfor the UI-tunable re-sweep interval (default 24h); "Run Scan Now" forces a full re-sweep.sbomfilter on/runs.Phase 2 — image scanning → Deployment (commit 2)
operator.go+ thescanner_controller_leasetable, removed the operator ticker, dropped thePodControllerimpl.imagescan.Reconcilerstays theIMAGE_SCANproducer.cronjobs:getRBAC removed.Phase 3 — one grype (commit 3)
Dockerfile.sbom-scanner.SBOM_SCANis enqueued soimage_vuln_findingsare produced within seconds.imageScanner.restrictEgresstoggle drops the image-scanner's public-443 egress once images route through an in-cluster registry (e.g. Harbor) — leaving DNS + worker + that registry.Phase 4 — retention & cleanup
Continuous re-scanning produces a superseded copy every cycle and nothing pruned them — re-scan artifacts, old grype results, orphaned SBOMs and non-latest manifests piled up unbounded (a ~1.9GB DB). Four daily, self-rescheduling, batched prune sweeps keep the latest artifact per asset and delete only older copies past a configurable minimum-age window:
PRUNE_IMAGE_SCANS— artifacts (+empty runs) of non-latest runs per digestPRUNE_SBOM_SCANS— grype results that aren't the latest per SBOMPRUNE_SBOMS— SBOMs bound to no asset (cascade results/state/leases)PRUNE_MANIFESTS— non-latest manifest per repo (cascade dependencies)The current artifact per asset is always kept (even if months old), and the historic vuln graph is untouched — it reads
vuln_dashboard_snapshots/vuln_canonical_*, never the raw scan history these prune. Windows live onscan_config(default 7d, retention on), editable in Settings → Database → "Retention & cleanup" dialog with a "Run cleanup now" button (POST /api/admin/retention/run) that expedites/enqueues all four immediately.Registry pull-through proxy
New
registry_proxiesmappings let an admin route upstream registries through one internal pull-through proxy, so the (now egress-pinned) image-scanner only talks to a single host. One credential row fronts many upstreams (e.g.ghcr.io → example.com/ghcr,docker.io → example.com/dockerhub).ResolveForImageprefers a proxy mapping over a direct credential, rewriting only the scanner's pull coordinates (newpull_registry/pull_repositorylease fields, omitempty → backward compatible) — the image's stored identity is never rewritten. Admin UI gains a Credential/Proxy tab group.Production hardening (shaken out by the live lab deploy)
23505on a singleton type can't poison the whole batch); scanners sendX-Scanner-Id+ new/heartbeat(RenewLease), so a dead scanner is caught in ~minutes instead of the 45m backstop.VULN_META_FETCHcooldown — 72h re-fetch window stops permanent-miss CVEs (vuldb-only, OSV-404) being re-fetched on every scan-completion (~800× fewer upstream calls).asset_riskguard —asset_risk_srconly explodesfindingswhen it is genuinely a JSON array, so one'null'findings row no longer fails everyREFRESH_MV.dbutil.SanitizeUTF8at the raw-byte sinks (runner.storeLog, secretprobe audit log) so binary tool output / response bodies no longer fail the insert./runs/<id>SBOM detail —RunGetHandlernow servesSBOM_SCAN(404'd before); web renders asset + severity grid + View SBOM / View vulnerabilities links.name|version|digestand report to/api/tool-versions.alert()/confirm()→ styled dialogs); devcontainer/seed fixes.Scoping note
Re-sweeps cover only the active set (latest-per-repo + running-in-cluster images), so they stay bounded. New assets are scanned immediately on ingest; already-scanned assets are re-evaluated against a newer grype DB on the interval — for images via the cached SBOM (no re-pull).
NB: fixed scanner replicas hold standing per-pod scratch reservations — size
replicaCountaccordingly.Verification
go vet ./...(golang:1.26.4) clean on bothapiandrunnermodules (incl. tests).helm templaterenders four Deployments and no scanner CronJobs;restrictEgress=truedrops the image-scanner's public-443 rule.🤖 Generated with Claude Code
Replace the nightly sbom-scanner CronJob with a long-running poller Deployment and unify SBOM vulnerability scanning into the jobs table, so newly-ingested SBOMs are scanned within seconds and appear on /runs and the admin jobs page alongside repo runs (CREATE_RUN) and image scans (IMAGE_SCAN). - New SBOM_SCAN job type; consumer endpoints GET /api/sbom-scans/next (claim via ClaimNextJobOfType) and POST /api/sbom-scans/{id}/complete. The jobs-table RUNNING/locked_by + stale-requeue replaces the bespoke sbom_scan_leases pull queue (now retired). - Producer (internal/sbomscan): enqueue on ingest + periodic re-sweep over the bounded active set — latest-per-repo commit SBOMs plus image digests still present in a cluster. Deduped per sbom_id. - scan_config table + admin GET/PUT /api/admin/sbom/config for the UI-tunable full re-sweep interval (default 24h); "Run Scan Now" repurposed to force a full re-sweep immediately. - sbom-scanner binary: long-running poll loop with backoff, periodic grype db update, graceful SIGTERM drain. - Helm: sbom-scanner is now a Deployment (ephemeral per-replica grype cache, no RWX); worker gets SBOM_SCAN_ENABLED to drive the producer. Image-scanner conversion (Deployment + retire operator) is a separate follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat(sbom-scan): continuous scanning via SBOM_SCAN jobs + Deploymentto feat(scanning): continuous SBOM + image scanners via Deployments + unified jobsupand fix host access ea16ab3b0aThe stale-job reaper requeued every stale RUNNING job in one transaction. A singleton type (e.g. FETCH_EPSS, whose ux_jobs_fetch_epss_active index covers QUEUED/RETRY but not RUNNING) flipping RUNNING->RETRY collides with an already-queued instance and raises 23505, rolling back the reclaim of EVERY other stale job. In prod a couple of stuck FETCH_EPSS rows kept zombie IMAGE_SCAN / VULN_META_FETCH locks (held by long-dead pods) RUNNING indefinitely; those locks block re-enqueue of the same image/CVE via the active unique indexes, so those assets silently stopped scanning. - Reclaim each stale job in its own transaction (one bad row can't poison the batch); a 23505 on the RETRY means an active instance already exists, so retire the stale duplicate as FAILED (in no active partial index). Status-guarded UPDATE keeps concurrent replicas safe. Leased scanner jobs also had no liveness signal: locked_by was the worker that brokered the lease, not the scanner pod running it, and scanners never heartbeat, so a dead scanner was only caught by the 45m backstop. - Scanners send X-Scanner-Id; lease handlers record it as locked_by so the job is attributable to its real executor. - New /api/{image,sbom}-scans/{id}/heartbeat (RenewLease) bumps locked_at while a scan runs; 409 when the lease was lost so the scanner stops and doesn't clobber the new owner's completion. - Reaper reclassified: IMAGE_SCAN/SBOM_SCAN reclaim on a short 2m heartbeat window; CREATE_RUN keeps the long backstop (reconciled separately). A dead scanner is now caught in ~minutes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Postgres rejects any text/jsonb value that isn't valid UTF-8 with `invalid byte sequence for encoding "UTF8"`, failing the whole INSERT. Prod logged this (byte 0x89, a binary/non-UTF-8 byte) for an unnamed portal parameter — a string built from raw external bytes hitting a text column with no sanitization. Add a shared dbutil.SanitizeUTF8 (strip NUL + drop invalid UTF-8 runs — the same logic assets.sanitizeForDB already applied to git author/commit fields) and apply it at the two raw-byte sinks that lacked it: - runner.storeLog: RunLog.Line/Container are raw process output and can carry binary bytes a scanned tool printed; an unsanitized line failed the insert and the log line was silently dropped ("failed to store log"). Best fit for the observed burst (websocket-fed log inserts on the API pool while a run is in progress). - secretprobe audit log: ResponseBody is a raw HTTP response body (a probed URL may return an image/gzip), and the existing byte-slice truncation at 4096 can itself split a UTF-8 rune. Sanitize after truncating so both cases are storable. assets.sanitizeForDB now delegates to the shared helper (identical behavior, no duplication). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat(scanning): continuous SBOM + image scanners via Deployments + unified jobsto feat(scanning): continuous SBOM + image scanners via Deployments, unified jobs + retention