feat(triage): cluster-scoped postpone/suppress for attack paths #47

Open
jonas wants to merge 38 commits from feat/attack-path-suppression into main
Owner

Implements #22 — operational triage decisions (postpone / suppress) on an asset's attack path, scoped to a set of clusters.

Design

  • Attack-path identity = a banded weakness snapshot. Built from pluggable contributors (cve + secret today; archived/abandoned deps slot in later without touching the suppress plumbing). CVEs are grouped into 3 likelihood bandsimmediate (KEV), likely (EPSS ≥ 0.5 or CRITICAL on an internet-exposed digest), possible (EPSS ≥ 0.1) — so raw EPSS jitter never churns the identity.
  • Escalation-only resurface. A decision breaks only on a real worsening: a new on-path weakness, a band climb, a new KEV listing, or a new secret. De-escalation, a weakness disappearing, or off-path CVE churn keep it hidden.
  • Cluster-scoped, team-shared. One active row per (asset, cluster); hides the path for everyone who can read that cluster; actor + reason recorded and shown to all cluster viewers. A user can never suppress a cluster they can't access (hard 403), and may target all or a subset of their accessible clusters.

Changes

  • New api/internal/attackpath package (model, contributor registry, band logic, CVE + secret contributors).
  • POST/DELETE /api/triage/attack-path/suppress[/{id}] + GET .../suppressions, ACL-enforced and audited (triage.suppress).
  • Migration 20260709attack_path_suppressions partial unique index (soft-revoked history preserved).
  • Read-time dashboard filtering: fully-suppressed paths move to a new Suppressed section; partial ones stay in-tier with an N/M badge. Bounded to assets that actually carry a suppression.
  • Frontend: AttackPathSuppressDialog.svelte (kind toggle, duration, required comment, cluster multiselect), inline history with revoke, header badges, Suppressed section.

Coexists with component_vex and secret_dismissals; does not replace them. First cut covers image (digest) attack paths; repos/clusters-as-assets are a follow-up (model is generic on asset_type).

Verification

  • go build ./..., go vet ./... clean; go test ./internal/attackpath/... (bands, escalation both directions, postpone window).
  • npm run check — changed files error-free.
  • CVE + secret contributor SQL and the ON-CONFLICT upsert validated against a real Postgres planner.

Closes #22

🤖 Generated with Claude Code

Implements #22 — operational triage decisions (**postpone** / **suppress**) on an asset's *attack path*, scoped to a set of clusters. ## Design - **Attack-path identity = a banded weakness snapshot.** Built from pluggable *contributors* (`cve` + `secret` today; archived/abandoned deps slot in later without touching the suppress plumbing). CVEs are grouped into **3 likelihood bands** — `immediate` (KEV), `likely` (EPSS ≥ 0.5 or CRITICAL on an internet-exposed digest), `possible` (EPSS ≥ 0.1) — so raw EPSS jitter never churns the identity. - **Escalation-only resurface.** A decision breaks only on a *real worsening*: a new on-path weakness, a band climb, a new KEV listing, or a new secret. De-escalation, a weakness disappearing, or off-path CVE churn keep it hidden. - **Cluster-scoped, team-shared.** One active row per `(asset, cluster)`; hides the path for everyone who can read that cluster; actor + reason recorded and shown to all cluster viewers. A user can **never** suppress a cluster they can't access (hard 403), and may target all or a subset of their accessible clusters. ## Changes - New `api/internal/attackpath` package (model, contributor registry, band logic, CVE + secret contributors). - `POST/DELETE /api/triage/attack-path/suppress[/{id}]` + `GET .../suppressions`, ACL-enforced and audited (`triage.suppress`). - Migration `20260709` — `attack_path_suppressions` partial unique index (soft-revoked history preserved). - Read-time dashboard filtering: fully-suppressed paths move to a new **Suppressed** section; partial ones stay in-tier with an `N/M` badge. Bounded to assets that actually carry a suppression. - Frontend: `AttackPathSuppressDialog.svelte` (kind toggle, duration, required comment, cluster multiselect), inline history with revoke, header badges, Suppressed section. Coexists with `component_vex` and `secret_dismissals`; does not replace them. First cut covers image (digest) attack paths; repos/clusters-as-assets are a follow-up (model is generic on `asset_type`). ## Verification - `go build ./...`, `go vet ./...` clean; `go test ./internal/attackpath/...` (bands, escalation both directions, postpone window). - `npm run check` — changed files error-free. - CVE + secret contributor SQL and the ON-CONFLICT upsert validated against a real Postgres planner. Closes #22 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(triage): cluster-scoped postpone/suppress for attack paths (#22)
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m26s
Build and Deploy / build-sbom-scanner (push) Successful in 1m26s
Build and Deploy / build-image-scanner (push) Successful in 1m27s
Build and Deploy / build-app (push) Successful in 4m10s
Build and Deploy / deploy-helm (push) Successful in 20s
57d5ff568b
Adds operational triage decisions — postpone (time-boxed) and suppress
(permanent) — attached to an asset's attack path for a set of clusters.

- attackpath package: a banded weakness snapshot (three likelihood bands)
  built from pluggable contributors (CVEs + secrets today; archived/
  abandoned deps later) with escalation-only resurface — EPSS jitter
  within a band and off-path churn never break a decision
- cluster-scoped ACL: never suppress a cluster you cannot read; all or a
  subset of your accessible clusters; team-shared and actor-attributed
- read-time dashboard filtering: fully-suppressed paths move to a new
  Suppressed section, partial ones stay in-tier with a badge
- suppress/revoke/list API (audited) + migration with a partial unique
  index (one active decision per asset+cluster, soft-revoked for history)
- frontend suppress dialog, inline history with revoke, header badges

Coexists with component_vex and secret_dismissals; does not replace them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(triage): extend attack-path suppression to repos + hide parked items
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m16s
Build and Deploy / build-image-scanner (push) Successful in 1m20s
Build and Deploy / build-repo-runner (push) Successful in 1m25s
Build and Deploy / build-app (push) Successful in 3m56s
Build and Deploy / deploy-helm (push) Successful in 20s
fcdc43a4d8
- Repos are now suppressible (secrets and/or vulns), alongside images. The
  weakness contributors branch on asset type; repos have no cluster
  dimension, so a repo decision is a single asset-wide row.
- Repo authorization flows through one seam (canSuppressRepo) so a future
  repo ACL — write-grants, scope/prefix, external RBAC — slots in without
  touching the handlers.
- Suppressed/acknowledged paths are now off the to-do: fully-suppressed
  rows leave the tiers and counts and move into a collapsed
  "Postponed / Suppressed" disclosure (expand to review, Revoke to bring
  back); partial ones stay in-tier with an N/M badge.
- Harden the triage page against a null/partial wire shape (normalize the
  response so no tier array is ever dereferenced null).
- Add an opt-in dev demo seed for attack-path triage data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(triage): stop rendering suppression history while it is still loading
All checks were successful
Build and Deploy / build-app (push) Successful in 3m44s
Build and Deploy / deploy-helm (push) Successful in 19s
Build and Deploy / build-sbom-scanner (push) Successful in 1m17s
Build and Deploy / build-image-scanner (push) Successful in 1m20s
Build and Deploy / build-repo-runner (push) Successful in 1m20s
a393ff2fee
The history {#each} was guarded by
`(suppressionHistory.get(id) as SuppressionRow[] | undefined)?.length`, but the
map value is `SuppressionRow[] | 'loading' | null`. During the fetch the value is
the string 'loading', whose `.length` is 7 (truthy), so the guard passed and
{#each hist as h (h.id)} iterated the string's characters — 7 items each with
`h.id === undefined` → each_key_duplicate on first expand. This is the real cause
(the earlier id-dedupe only touched the resolved array, never the 'loading'
sentinel). Guard with Array.isArray so only a real array ever renders, which also
removes the misleading cast. Repos hit this reliably; a previously-loaded image
skips the 'loading' state, which is why images appeared to work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(triage): surface resolved (mitigated) attack-path decisions
All checks were successful
Build and Deploy / build-image-scanner (push) Successful in 1m0s
Build and Deploy / build-sbom-scanner (push) Successful in 1m15s
Build and Deploy / build-repo-runner (push) Successful in 1m19s
Build and Deploy / build-app (push) Successful in 3m31s
Build and Deploy / deploy-helm (push) Successful in 19s
414ac1eecb
When a suppressed/postponed path is mitigated — the weakness is fixed or the
image no longer runs where the caller can see it — the asset drops out of
asset_risk and the decision silently vanished from the dashboard while its row
lingered non-revoked in the DB.

Classify these at read time (no new column, no sweep) by reusing the existing
suppression builders: addImageSuppressions / addRepoSuppressions take an optional
resolved collector and, at the points where they already drop a decision from the
active set, emit a ResolvedDecision instead — "fixed" when Capture returns an empty
path, "not_running" when the digest runs in none of the caller's readable clusters.
Same scoping + Capture, opposite bucket. GET /api/triage/attack-path/resolved
exposes them; the active suppression map passes nil so the hot path is unchanged.

UI: a collapsible, lazy-loaded "Resolved" section under the Suppressed panel shows
them read-only (kind, label, why/who/when, fixed vs no-longer-deployed) — there is
no live path left to revoke. Active suppressions still hide as before.

Note: without a persisted close, a permanently-suppressed weakness that regresses
re-hides under the original decision (postpones still expire). Acceptable for the
simple version; a persisted mitigated_at + sweep would make regressions resurface
fresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(triage): optional "keep hidden regardless of new findings" suppress
All checks were successful
Build and Deploy / build-image-scanner (push) Successful in 1m9s
Build and Deploy / build-sbom-scanner (push) Successful in 1m21s
Build and Deploy / build-repo-runner (push) Successful in 1m24s
Build and Deploy / build-app (push) Successful in 3m43s
Build and Deploy / deploy-helm (push) Successful in 20s
1d995e8fc7
The escalation-only rule resurfaces a postpone/suppress as soon as the attack
path worsens (new on-path CVE, band climb, new KEV, or new secret) — which is
why a decision reappears in fix_now after a re-scan turns up new weaknesses.
That's the right default, but sometimes you've accepted an asset's risk as-is
and don't want it to keep coming back.

Add an opt-out: attack_path_suppressions.ignore_escalation (bool, default
false; AutoMigrate adds the column). When set, the effective check skips
CompareEscalated in all three read paths (history GET, image + repo suppression
maps), so the decision stays hidden until it expires (postpone) or is revoked.
Carried through the create body + upsert. Escalation-aware stays the default.

UI: a "If the risk grows" toggle in the suppress dialog — Re-surface
(recommended) vs Keep hidden — and the history line shows "· ignores new
findings" so it's clear why such a decision doesn't resurface.

Also: the dialog's textarea and ButtonGroups were transparent / near-transparent
and blended into the panel; give them a solid var(--card-bg) fill (scoped to the
dialog) so they read as distinct fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(triage): explain what changed when a suppression resurfaces
Some checks failed
Build and Deploy / build-repo-runner (push) Successful in 1m2s
Build and Deploy / build-image-scanner (push) Successful in 1m5s
Build and Deploy / build-sbom-scanner (push) Successful in 1m19s
Build and Deploy / build-app (push) Successful in 4m1s
Build and Deploy / deploy-helm (push) Failing after 46s
6c53f40abe
"· resurfaced" told you a postpone/suppress broke but not why. Surface the
specific escalation: each Contributor gains an Explain(baseline, current) that
mirrors Escalated but returns human lines — CVEs read "New immediate (KEV) CVE
on the path: CVE-X" or "CVE-Y escalated: possible → likely"; secrets read
"N new leaked secrets appeared on the path". ExplainEscalation aggregates them.

The suppressions history endpoint computes these for a row that resurfaced and
returns them as `escalation` on the view; the expanded card renders them as a
warning-bordered list under the decision, so you can see exactly which metric
moved. Escalated (the bool hot path) is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into feat/attack-path-suppression
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m8s
Build and Deploy / build-repo-runner (push) Successful in 1m8s
Build and Deploy / build-image-scanner (push) Successful in 1m9s
Build and Deploy / build-app (push) Successful in 3m13s
Build and Deploy / deploy-helm (push) Successful in 17s
1f275ce7bd
# Conflicts:
#	api/cmd/server/main.go
feat(triage): flag new/escalated CVEs with a pill in the attack-path list
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m4s
Build and Deploy / build-repo-runner (push) Successful in 1m10s
Build and Deploy / build-image-scanner (push) Successful in 1m8s
Build and Deploy / build-app (push) Successful in 3m18s
Build and Deploy / deploy-helm (push) Successful in 17s
950eebcc1e
Building on the resurfaced-explanation lines: make the escalation data
structured (EscalationChange{kind, id, detail}) instead of plain strings, so the
frontend can map the exact canonical ids that changed. Contributor.Explain now
returns []EscalationChange — cve_new / cve_climb carry the canonical id, secret_new
carries just the detail line.

UI: the expanded attack-path CVE list shows a small pill after the fix column —
"new" (warning) for a CVE newly on the path, "escalated" (accent) for one that
rose a band — for exactly the CVEs that made the suppression resurface. The
resurfaced summary lines still render (change.detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(triage): hover tooltip explaining the new/escalated CVE pills
Some checks failed
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-sbom-scanner (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
Build and Deploy / build-repo-runner (push) Has been cancelled
Build and Deploy / build-image-scanner (push) Has been cancelled
3344b73b58
The new/escalated pills said what changed but not what it means. Wrap each in
the existing Tooltip component (matching the EPSS-badge tooltip style): a titled
explanation of new vs escalated — new = wasn't on the likely-exploited path at
decision time; escalated = already on the path but climbed a band (new KEV, or
EPSS crossing a threshold) — plus the exact change line underneath.

escalatedCves now carries {label, detail, climb} per canonical id so the pill
can render the term, tooltip copy, and specific detail without re-deriving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(triage): keep new/escalated CVE pill inside the fix column, use chip styling
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m30s
Build and Deploy / build-image-scanner (push) Successful in 1m54s
Build and Deploy / build-sbom-scanner (push) Successful in 2m11s
Build and Deploy / build-app (push) Successful in 4m45s
Build and Deploy / deploy-helm (push) Successful in 17s
6eff55effb
The attack-path CVE list is a 5-column grid (.vuln-line uses display:contents),
so the extra pill became a stray 6th cell and wrapped onto its own row. Wrap the
fix span + pill in a single .vuln-fix-cell (inline-flex) so each row keeps exactly
five cells and the pill sits beside the fix version. Swap the bespoke .vuln-new
styling for the shared chips — chip-warning for "new", chip-error for "escalated".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(triage): vertically center the new/escalated pill with the fix text
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m7s
Build and Deploy / build-repo-runner (push) Successful in 1m7s
Build and Deploy / build-image-scanner (push) Successful in 1m9s
Build and Deploy / build-app (push) Successful in 3m10s
Build and Deploy / deploy-helm (push) Successful in 17s
356153e87b
The pill read as sitting low: default line-height in the fix cell left
descender space, so the chip centred on the line-box rather than the glyphs.
line-height: 1 on .vuln-fix-cell aligns the chip with the fix version text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(attackpath): stop serializing the suppression Snapshot to clients
Some checks failed
Build and Deploy / build-sbom-scanner (push) Successful in 1m7s
Build and Deploy / build-repo-runner (push) Successful in 1m8s
Build and Deploy / build-image-scanner (push) Successful in 1m10s
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
db47bc7967
The suppressions endpoint embeds attackpath.Suppression in its response
row, whose Snapshot field carried json:"snapshot" — so every history row
shipped the full internal weakness baseline (the entire CVE→band map plus
every active secret's sha256 hash) to the browser. No API consumer reads
it (the frontend SuppressionRow type has no snapshot field), and it puts
internal scoring and leaked-secret hashes needlessly on the wire.

Tag it json:"-". Persistence uses the gorm tag, so DB storage and the
escalation comparison (which unmarshals the stored bytes directly) are
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(triage): reset the suppress dialog to a clean slate on reopen
Some checks failed
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
Build and Deploy / build-repo-runner (push) Has been cancelled
Build and Deploy / build-sbom-scanner (push) Has been cancelled
Build and Deploy / build-image-scanner (push) Has been cancelled
50e3a0adb1
AttackPathSuppressDialog is a single persistent instance reused across
assets. The reopen effect reset the cluster picker, escalation mode and
error, but left the action (kind), justification (comment) and postpone
window untouched — so opening the dialog on asset B still showed asset
A's typed reason and previously chosen action, which could then be saved
as B's audited decision (wrong justification, and possibly a permanent
suppress where a postpone was intended).

Reset every user-entered field when the dialog opens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(triage): parse custom postpone date in local time, not UTC
Some checks failed
Build and Deploy / build-sbom-scanner (push) Successful in 1m5s
Build and Deploy / build-repo-runner (push) Successful in 1m9s
Build and Deploy / build-image-scanner (push) Successful in 1m17s
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
706d2c77a5
A custom postpone date comes from <input type="date"> as "YYYY-MM-DD".
new Date("YYYY-MM-DD") parses that as UTC midnight, so for any viewer
behind UTC the stored expiry rendered a day early (toLocaleDateString
shifts the UTC instant back into local time), and picking today produced
an instant already in the past — leaving the Postpone button silently
disabled via the canSubmit future-date check.

Parse the y/m/d parts as a local date and snooze to end-of-day, so the
chosen day is honored inclusively regardless of the viewer's timezone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(auth): make the login screen follow the OS theme (auto), share theme logic
Some checks failed
Build and Deploy / build-repo-runner (push) Successful in 53s
Build and Deploy / build-sbom-scanner (push) Successful in 1m6s
Build and Deploy / build-image-scanner (push) Successful in 1m9s
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
744ec269b5
The login page lives outside the (app) route group, so the app layout's
theme controller never runs there. Its own inline copy honored a stored
in-app theme choice — so a user who had toggled dark inside the app still
saw a dark login screen on a light OS, and it lacked the layout's
addListener fallback for older Safari.

Pre-auth, default to auto: always follow prefers-color-scheme and ignore
any stored in-app preference (that's a per-user, post-login setting and
shouldn't pin the public login screen away from the OS).

Extract the shared plumbing (storage key, applyThemeClass, storedTheme,
watchSystemTheme with the addEventListener→addListener fallback) into
$lib/theme.ts and route both the login page and the app layout through
it, so the two can't drift again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(triage): extract the vuln-fix escalation cell into a snippet
Some checks failed
Build and Deploy / build-image-scanner (push) Successful in 1m0s
Build and Deploy / build-sbom-scanner (push) Successful in 1m10s
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
3d2b630530
The fix-version cell with its escalated/new attack-path pill and the long
identical tooltip copy was duplicated verbatim between the image vuln
list and the repo vuln list. Extract a single vulnFixCell(v, esc) snippet
and render it in both, so the pill wording, chip severity classes and the
esc lookup live in one place and can't drift apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(triage): cap the parked-suppressions list in the triage payload
All checks were successful
Build and Deploy / build-image-scanner (push) Successful in 56s
Build and Deploy / build-sbom-scanner (push) Successful in 1m7s
Build and Deploy / build-repo-runner (push) Successful in 1m10s
Build and Deploy / build-app (push) Successful in 3m38s
Build and Deploy / deploy-helm (push) Successful in 17s
33fc2ca22a
resp.Suppressed was appended unbounded and always serialized in full,
unlike the actionable tiers (FixNow/ThisWeek capped, Watch/Deprioritized
paginated) and the cluster lens (clusterLensCap). The Postponed/Suppressed
section renders collapsed, so shipping every parked asset as a complete
row on every /api/triage load grows the payload without bound as
suppression adoption accumulates.

Cap it at suppressedCap (200) after ranking, keeping the most urgent
parked items — mirroring the clusterLensCap treatment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(vuln): purge stale image-vuln rows when a scanner re-scans to zero findings
Some checks failed
Build and Deploy / build-sbom-scanner (push) Successful in 1m7s
Build and Deploy / build-repo-runner (push) Successful in 1m10s
Build and Deploy / build-image-scanner (push) Successful in 1m10s
Build and Deploy / build-app (push) Successful in 3m20s
Build and Deploy / deploy-helm (push) Has been cancelled
bee141f56b
The incremental image-vuln extract scopes its DELETE to (image_id, source)
pairs that have a finding newer than the watermark. That correctly avoids
dropping other scanners' rows, but it can't see a scanner that re-ran and
found ZERO vulnerabilities: grype_parser deletes the scanner's findings
and inserts none, so no row carries a fresh created_at. The scanner's
now-fixed CVEs kept showing on the image's vuln list and attack path until
the next daily full rebuild.

Add a second DELETE clause keyed on scan completion: for images whose scan
finished since the watermark, drop any view row whose (image, scanner)
pair no longer has a backing finding. image_scan_runs has no scanner
column (one run can carry several scanners), so anti-join the findings
table per pair. Index image_scan_runs.finished_at to keep that probe off a
sequential scan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(clusters): dedup every cluster_summary reader after dropping the unique index
All checks were successful
Build and Deploy / build-image-scanner (push) Successful in 53s
Build and Deploy / build-sbom-scanner (push) Successful in 1m10s
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-app (push) Successful in 2m55s
Build and Deploy / deploy-helm (push) Successful in 18s
6c13572773
Migration 20260710 made cluster_summary.cluster_id non-unique (an ROR
cutover can transiently leave two rows per cluster_id) and re-established
dedup only in ClusterSummaryHandler via DISTINCT ON. Six other readers
still assumed one row per cluster_id, so during a cutover they fan out or
pick the wrong row:

- assetrisk/metrics.go: cluster lens lists the cluster twice
- uiapi/triage_image_detail.go (AllClusters): inflates "runs in N clusters"
- uiapi/triage_image_detail.go (hosts): lists each exposed host twice
- scam/handler.go: image-facet cluster dropdown shows the cluster twice
  (differing labels defeat SELECT DISTINCT)
- uiapi/advanced_search.go: cluster search returns the cluster twice
- scam/cluster_detail.go: bare LIMIT 1 could return the stale pre-cutover
  row (outdated counts)

Apply the same canonical dedup as ClusterSummaryHandler everywhere: pick
the own-ror_slug (post-cutover) row per cluster_id, then most-recent —
via a DISTINCT ON subquery for the joins/scan, and an ORDER BY on the
single-row point lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(triage): share the escalation/effective-rows logic across suppression builders
Some checks failed
Build and Deploy / build-repo-runner (push) Successful in 1m7s
Build and Deploy / build-sbom-scanner (push) Successful in 1m9s
Build and Deploy / build-image-scanner (push) Successful in 1m11s
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
fb2796032f
addImageSuppressions and addRepoSuppressions each open-coded the same
group-by-asset bucketing and the same "effective rows" filter (StoredActive
+ escalation-only resurfacing via json.Unmarshal + CompareEscalated),
differing only by the image running-cluster check. That copy-paste meant a
change to the resurfacing semantics applied to one loop and missed in the
other would silently diverge image- from repo-suppression behavior.

Extract groupByAsset and effectiveRows (running==nil skips the running
check, for the cluster-less repo path). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
perf(triage): resolve the caller's cluster set once per triage load
All checks were successful
Build and Deploy / build-image-scanner (push) Successful in 55s
Build and Deploy / build-sbom-scanner (push) Successful in 56s
Build and Deploy / build-repo-runner (push) Successful in 1m10s
Build and Deploy / build-app (push) Successful in 3m16s
Build and Deploy / deploy-helm (push) Successful in 17s
3552217841
triageClusterFragment and addImageSuppressions each called
readableClusterIDSet independently, so every restricted-caller /api/triage
load ran the ACL grant fetch plus the clusters-table identifier
translation twice. Resolve it once in TriageHandler and thread it into
both the cluster-tier fragment and the suppression map.

Behavior-preserving: the two sites always computed the identical set, and
the cluster-ACL error path still drops all suppressions (the handler skips
the suppression map when the set can't be resolved, matching the prior
behavior where addImageSuppressions erroring aborted the whole map).
resolvedSuppressions resolves the set the same way for its own callers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
perf(triage): cache the attack-path capture behind the suppression map
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m5s
Build and Deploy / build-repo-runner (push) Successful in 1m7s
Build and Deploy / build-image-scanner (push) Successful in 1m10s
Build and Deploy / build-app (push) Successful in 3m11s
Build and Deploy / deploy-helm (push) Successful in 17s
6728659db5
attackPathSuppressionMap ran attackpath.Capture — a full no-LIMIT on-path
CVE query plus a leaked-secret decode — uncached on every /api/triage load,
once per asset carrying an active suppression. On a deployment with many
postponed/suppressed assets that fans the entry-point load out to several
uncached queries per parked asset on every poll, the same cost the
image-detail panel was hardened against with a cache.

The snapshot depends only on the asset (image content digest / repo id),
never the caller, so cache it by asset with a short TTL and share it across
callers — the same pattern as the image-detail payload cache. TriageHandler
now receives the app cache and threads it through; the cold resolved
endpoint passes a nil cache and captures directly. The TTL bounds how stale
escalation / fixed detection can be, matching the panel cache's tradeoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(triage): redesign the suppress dialog around action cards and an escalation opt-out
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m7s
Build and Deploy / build-repo-runner (push) Successful in 1m8s
Build and Deploy / build-image-scanner (push) Successful in 1m9s
Build and Deploy / build-app (push) Successful in 3m11s
Build and Deploy / deploy-helm (push) Successful in 17s
6946a4551f
Postpone/Suppress become two selectable cards with always-visible
descriptions instead of a segmented pill with swapping captions; the
escalation toggle becomes an opt-out checkbox (resurface stays the
implicit default); a live footer summary spells out the consequence
next to the confirm button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Custom date sits beside the duration pills instead of below (no
vertical growth), the escalation opt-out uses the shared Checkbox,
and the dialog widens to max-w-2xl with a taller reason field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(triage): resurfaced indicator, revocable resolved decisions, suppressed tab
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m9s
Build and Deploy / build-sbom-scanner (push) Successful in 1m10s
Build and Deploy / build-image-scanner (push) Successful in 1m12s
Build and Deploy / build-app (push) Successful in 3m13s
Build and Deploy / deploy-helm (push) Successful in 18s
348fff43b2
A still-open decision that expired or escalated now marks its tier row
with a dismissible "Resurfaced" pill (dismissal is per-browser, keyed
on the event so a new resurface shows it again). Resolved decisions
expose their row ids and gain a revoke control, closing the silent
reactivation hole when a fixed path comes back. The parked/resolved
sections get a dedicated Suppressed tab instead of living only at the
bottom of the All tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(triage): make the escalation opt-out card fully clickable
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m8s
Build and Deploy / build-image-scanner (push) Successful in 1m10s
Build and Deploy / build-sbom-scanner (push) Successful in 1m11s
Build and Deploy / build-app (push) Successful in 3m8s
Build and Deploy / deploy-helm (push) Successful in 18s
acc19c3000
The whole card toggles the checkbox (with hover feedback), not just
the checkbox label — clicks on the inner label are left to the native
toggle so they don't double-fire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six review findings, each reproduced by a test first:

- Key the capture cache by asset id, not content digest: Capture computes
  findings per image_digests row, so two rows sharing a digest (same image
  mirrored under another registry/repo) cross-contaminated each other's
  snapshots — misclassifying decisions as fixed or inheriting a sibling's
  CVE set for the cache TTL.
- Soft-revoke a prior active decision (superseded, attributed) instead of
  overwriting it in place via ON CONFLICT DO UPDATE, which silently
  destroyed the previous who/when/why audit record.
- Keep empty-baseline decisions active: a suppression captured when the
  asset's weaknesses are all off-band was instantly classified resolved
  "fixed" and dropped, putting the row straight back on the operator's list.
- Intersect explicitly requested cluster_ids with the running set, as the
  handler contract states — a row written for a non-running cluster hides
  nothing while the API reports the decision effective.
- Write the fresh baseline through the capture cache on suppress so a
  stale cached "current" can't diff against it as a phantom escalation
  that bounces the just-parked row back into the tiers.
- Propagate Capture/digest-lookup errors in the history endpoint as 500s
  instead of annotating every row Effective=true on a transient DB error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The zero-findings purge clause (bee141f) was dead in exactly its target
scenario: a re-scan-to-zero deletes an image's findings and inserts none,
so MAX(created_at) over image_vuln_findings never moves (other images'
rows pin it) and extractOne short-circuits at newWM == prevWM before the
DELETE runs — the fixed CVEs lingered until the daily rebuild. Cover
image_scan_runs.finished_at in the watermark so scan completions trigger
the extract too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The resolver-routing fix (canonicalEcosystem) only helps packages the
discovery sweep still returns, but pre-fix sweeps stamped every
golang/gem package with a "no resolver for ecosystem" marker whose
fetched_at renews weekly — so the fix would stay inert for up to 7 days
after deploy. Delete exactly those markers; genuinely unsupported
ecosystems keep theirs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ClusterSummaryHandler applied the free-text search inside the
DISTINCT ON subquery, so mid-ROR-cutover a term matching only the stale
pre-cutover row served that row's outdated counts as the cluster's
single canonical entry — disagreeing with the detail page. Search now
filters the deduped canonical rows (ACL and liveness are
cluster_id-invariant and stay inside).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(web): surface attack-path revoke failures instead of swallowing them
All checks were successful
Build and Deploy / build-image-scanner (push) Successful in 56s
Build and Deploy / build-sbom-scanner (push) Successful in 1m9s
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-app (push) Successful in 2m58s
Build and Deploy / deploy-helm (push) Successful in 18s
1356dd3462
A failed revoke (expired session, vanished row, server error, network)
left the inline reason form open with no feedback, so the user had no way
to tell whether the decision was reverted. Show an error line in both the
history and resolved revoke forms; partial failures on a resolved
decision report how many rows are left.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(triage): two-column action cards and a 7-day default postpone
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m5s
Build and Deploy / build-repo-runner (push) Successful in 1m8s
Build and Deploy / build-image-scanner (push) Successful in 1m11s
Build and Deploy / build-app (push) Successful in 3m13s
Build and Deploy / deploy-helm (push) Successful in 18s
2cffca1c7f
Postpone/Suppress cards move the icon into a large tinted tile on the
left with title + description stacked beside it; the duration chooser
defaults to 7 days instead of 30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(triage): drop the tile fill behind the action-card icons
All checks were successful
Build and Deploy / build-app (push) Successful in 3m8s
Build and Deploy / build-sbom-scanner (push) Successful in 25s
Build and Deploy / build-image-scanner (push) Successful in 27s
Build and Deploy / build-repo-runner (push) Successful in 27s
Build and Deploy / deploy-helm (push) Successful in 18s
f87ed994de
Bare icons on the card, muted normally and accent when selected; the
44px box stays so the text column keeps its alignment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three verified review follow-ups on the /api/triage hot path:

- addImageSuppressions resolved each suppressed asset's digest and running
  clusters with two per-asset SELECTs — an N+1 that grew with parked
  assets. Both are now one batched query across the asset set.
- addRepoSuppressions materialized the caller's full readable-repo ACL set
  before checking whether any repo suppression exists; the common
  zero-suppression load now pays one indexed SELECT and returns.
- AttackPathSuppressionsHandler captured the current path directly instead
  of riding the shared capture cache, so the history annotation could both
  recompute needlessly and disagree with the dashboard's suppression map;
  it now uses capturedSnapshot (including the suppress write-through).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refactor(attackpath): drop the unused Escalated convenience wrapper
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m6s
Build and Deploy / build-repo-runner (push) Successful in 1m9s
Build and Deploy / build-image-scanner (push) Successful in 1m11s
Build and Deploy / build-app (push) Successful in 3m14s
Build and Deploy / deploy-helm (push) Successful in 18s
292b5292f3
Production code only ever uses CompareEscalated / ExplainEscalation; the
package-level Capture-then-compare wrapper had zero callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m6s
Build and Deploy / build-repo-runner (push) Successful in 1m9s
Build and Deploy / build-image-scanner (push) Successful in 1m11s
Build and Deploy / build-app (push) Successful in 3m14s
Build and Deploy / deploy-helm (push) Successful in 18s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/attack-path-suppression:feat/attack-path-suppression
git switch feat/attack-path-suppression

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff feat/attack-path-suppression
git switch feat/attack-path-suppression
git rebase main
git switch main
git merge --ff-only feat/attack-path-suppression
git switch feat/attack-path-suppression
git rebase main
git switch main
git merge --no-ff feat/attack-path-suppression
git switch main
git merge --squash feat/attack-path-suppression
git switch main
git merge --ff-only feat/attack-path-suppression
git switch main
git merge feat/attack-path-suppression
git push origin main
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Motstandskraft/spam!47
No description provided.