Acknowledge / suppress / postpone vulnerabilities in triage #22

Open
opened 2026-06-30 13:51:08 +02:00 by jonas · 0 comments
Owner

Goal

Let users acknowledge / suppress vulnerabilities surfaced in the triage buckets, with two modes:

  • Postpone — snooze a finding for a time interval (e.g. 7d / 30d / 90d / until date). It reappears automatically when the interval lapses.
  • Suppress — durably hide a finding, with a required comment (justification). Optionally tied to a VEX justification.

Today there is no per-user acknowledge/postpone concept. The only durable override is VEX (component_vex, keyed by (PURL, VulnID), statuses affected / not_affected / fixed / under_investigation) set via POST /api/dependencies/vex (api/internal/uiapi/vulnerabilities.go:42). VEX filters findings out of counts and the triage detail panel (api/internal/uiapi/triage_image_detail.go:80). The LLM advisory verdict (keep/suppress) is shadow-mode / display-only (api/internal/assetrisk/metrics.go:93).

Why VEX alone is not enough

VEX is a global statement about a component+CVE pair ("this PURL is not_affected by this CVE"). Acknowledge/postpone is an operational, scoped, time-boxed decision ("I've seen this on this image, deal with it later"). These are different axes — we need a new suppression record that can be scoped and time-boxed, and can optionally emit a VEX record when the justification is a true VEX statement.

Proposed model

New table finding_suppression (GORM in a new internal/triage or extend internal/vulnerabilities):

field notes
id uuid PK
kind postpone | suppress
scope_type global | asset | vuln | asset_vuln
asset_type / asset_ref_id nullable — IMAGE_DIGEST / REPO_COMMIT (matches SBOMBinding)
vuln_id nullable — CVE/GHSA
purl nullable — for component-grain
comment required for suppress; optional for postpone
vex_justification nullable; if set, also upsert component_vex
snooze_until timestamp; null for suppress
created_by_user_id, created_at audit
revoked_by_user_id, revoked_at soft-revoke, keep history

Suppressions are applied as a filter in the triage queries (triage_dashboard.go, triage_image_detail.go, vulnmetrics.LoadListPage) and must be re-evaluated against snooze_until at read time so postponed items resurface.

Edge cases to define (the hard part)

  1. Scope granularity. Suppress this CVE on one image digest vs all digests of a repo vs fleet-wide? A new digest of the same image gets a new image_digests.id — does a digest-scoped suppression carry forward? Proposal: allow asset_vuln (this digest) and a repo/image-name-scoped option that survives re-tags.
  2. Re-introduction after fix-then-regress. A CVE is suppressed, the image is rebuilt clean (CVE gone), then a later build reintroduces it. Should the old suppression apply? Proposal: suppressions auto-expire when the finding disappears for N scans, OR carry a installed_version predicate so a different vulnerable version re-surfaces.
  3. Tier escalation overrides snooze. A watch CVE is postponed 90d, then lands on CISA KEV / becomes internet-exposed / EPSS spikes. Should a postpone be auto-broken when the finding would now be fix_now? Proposal: yes — postpone suppresses display, but a configurable severity-escalation rule re-surfaces it early and notes "re-opened: now actively exploited".
  4. Postpone expiry semantics. On lapse: silently reappear, or notify? (ties into the notifications epic.)
  5. Suppress vs VEX divergence. If a user suppresses with justification code_not_reachable, do we also write component_vex? That makes it global across all assets — may be wider than intended. Proposal: suppression stays scoped; only emit VEX when the user explicitly picks "apply as VEX (all assets)".
  6. Counts & metrics. Do suppressed findings drop out of dashboard severity counts and the trend snapshots (vuln_dashboard_snapshots), or just out of the actionable triage view? Proposal: keep them in raw counts, exclude from actionable tiers, expose a "suppressed" filter/badge.
  7. ACL. Who may suppress? Must respect the same repo/image/cluster scope filter the reader has (acl.Provider). A user can only suppress findings on assets they can see. Revoking another user's suppression — admin only? team-wide?
  8. Audit. All suppress/postpone/revoke actions go through internal/audit (append-only), since this is a risk-acceptance decision.
  9. Concurrency. Multi-replica (no in-memory state) — suppression lives in Postgres; reads must invalidate the relevant vulnmetrics / triage caches via TriggerRefresh().

API sketch

  • POST /api/triage/suppress{scope, asset_type?, asset_ref_id?, vuln_id?, purl?, kind, snooze_until?, comment, vex_justification?}
  • DELETE /api/triage/suppress/{id} — revoke
  • GET /api/triage/suppressions?asset_type=&asset_ref_id= — list (for "show suppressed" toggle)
  • Triage responses gain a suppressed section + per-finding suppression metadata.

Tasks

  • Decide scope model + escalation policy (resolve edge cases above)
  • Migration + GORM model finding_suppression
  • Apply suppression filter + snooze re-evaluation across triage queries
  • Endpoints + ACL + audit wiring + cache invalidation
  • Optional VEX emission path
  • Web: suppress/postpone dialog (required comment, interval picker), "show suppressed" toggle, suppression badges
  • Background sweep / read-time check for snooze expiry (+ escalation re-open)

Depends on / relates to: notifications epic (postpone-expiry + escalation notifications).

## Goal Let users **acknowledge / suppress** vulnerabilities surfaced in the triage buckets, with two modes: - **Postpone** — snooze a finding for a time interval (e.g. 7d / 30d / 90d / until date). It reappears automatically when the interval lapses. - **Suppress** — durably hide a finding, with a **required comment** (justification). Optionally tied to a VEX justification. Today there is no per-user acknowledge/postpone concept. The only durable override is **VEX** (`component_vex`, keyed by `(PURL, VulnID)`, statuses `affected / not_affected / fixed / under_investigation`) set via `POST /api/dependencies/vex` (`api/internal/uiapi/vulnerabilities.go:42`). VEX filters findings out of counts and the triage detail panel (`api/internal/uiapi/triage_image_detail.go:80`). The LLM advisory verdict (keep/suppress) is **shadow-mode / display-only** (`api/internal/assetrisk/metrics.go:93`). ## Why VEX alone is not enough VEX is a *global statement about a component+CVE pair* ("this PURL is not_affected by this CVE"). Acknowledge/postpone is an *operational, scoped, time-boxed decision* ("I've seen this on **this image**, deal with it later"). These are different axes — we need a new suppression record that can be **scoped** and **time-boxed**, and can optionally *emit* a VEX record when the justification is a true VEX statement. ## Proposed model New table `finding_suppression` (GORM in a new `internal/triage` or extend `internal/vulnerabilities`): | field | notes | |---|---| | `id` uuid PK | | | `kind` | `postpone` \| `suppress` | | `scope_type` | `global` \| `asset` \| `vuln` \| `asset_vuln` | | `asset_type` / `asset_ref_id` | nullable — `IMAGE_DIGEST` / `REPO_COMMIT` (matches `SBOMBinding`) | | `vuln_id` | nullable — CVE/GHSA | | `purl` | nullable — for component-grain | | `comment` | **required for `suppress`**; optional for `postpone` | | `vex_justification` | nullable; if set, also upsert `component_vex` | | `snooze_until` | timestamp; null for `suppress` | | `created_by_user_id`, `created_at` | audit | | `revoked_by_user_id`, `revoked_at` | soft-revoke, keep history | Suppressions are applied as a filter in the triage queries (`triage_dashboard.go`, `triage_image_detail.go`, `vulnmetrics.LoadListPage`) and must be **re-evaluated against `snooze_until`** at read time so postponed items resurface. ## Edge cases to define (the hard part) 1. **Scope granularity.** Suppress this CVE on *one image digest* vs *all digests of a repo* vs *fleet-wide*? A new digest of the same image gets a new `image_digests.id` — does a digest-scoped suppression carry forward? Proposal: allow `asset_vuln` (this digest) **and** a repo/image-name-scoped option that survives re-tags. 2. **Re-introduction after fix-then-regress.** A CVE is suppressed, the image is rebuilt clean (CVE gone), then a later build reintroduces it. Should the old suppression apply? Proposal: suppressions auto-expire when the finding disappears for N scans, OR carry a `installed_version` predicate so a *different* vulnerable version re-surfaces. 3. **Tier escalation overrides snooze.** A `watch` CVE is postponed 90d, then lands on CISA KEV / becomes internet-exposed / EPSS spikes. Should a postpone be **auto-broken** when the finding would now be `fix_now`? Proposal: yes — postpone suppresses *display*, but a configurable severity-escalation rule re-surfaces it early and notes "re-opened: now actively exploited". 4. **Postpone expiry semantics.** On lapse: silently reappear, or notify? (ties into the notifications epic.) 5. **Suppress vs VEX divergence.** If a user suppresses with justification `code_not_reachable`, do we also write `component_vex`? That makes it global across all assets — may be wider than intended. Proposal: suppression stays scoped; only emit VEX when the user explicitly picks "apply as VEX (all assets)". 6. **Counts & metrics.** Do suppressed findings drop out of dashboard severity counts and the trend snapshots (`vuln_dashboard_snapshots`), or just out of the actionable triage view? Proposal: keep them in raw counts, exclude from *actionable* tiers, expose a "suppressed" filter/badge. 7. **ACL.** Who may suppress? Must respect the same repo/image/cluster scope filter the reader has (`acl.Provider`). A user can only suppress findings on assets they can see. Revoking another user's suppression — admin only? team-wide? 8. **Audit.** All suppress/postpone/revoke actions go through `internal/audit` (append-only), since this is a risk-acceptance decision. 9. **Concurrency.** Multi-replica (no in-memory state) — suppression lives in Postgres; reads must invalidate the relevant `vulnmetrics` / triage caches via `TriggerRefresh()`. ## API sketch - `POST /api/triage/suppress` — `{scope, asset_type?, asset_ref_id?, vuln_id?, purl?, kind, snooze_until?, comment, vex_justification?}` - `DELETE /api/triage/suppress/{id}` — revoke - `GET /api/triage/suppressions?asset_type=&asset_ref_id=` — list (for "show suppressed" toggle) - Triage responses gain a `suppressed` section + per-finding `suppression` metadata. ## Tasks - [ ] Decide scope model + escalation policy (resolve edge cases above) - [ ] Migration + GORM model `finding_suppression` - [ ] Apply suppression filter + snooze re-evaluation across triage queries - [ ] Endpoints + ACL + audit wiring + cache invalidation - [ ] Optional VEX emission path - [ ] Web: suppress/postpone dialog (required comment, interval picker), "show suppressed" toggle, suppression badges - [ ] Background sweep / read-time check for snooze expiry (+ escalation re-open) **Depends on / relates to:** notifications epic (postpone-expiry + escalation notifications).
Sign in to join this conversation.
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#22
No description provided.