Skip to content

Notifications 4/6: critical-CVE notifications for running containers (trigger B) #223

Description

@LarsLaskowski

Fourth stage of the notification feature set: notify when a new critical CVE affects an image that a running container actually uses. Depends on #220#222.

Full design: notification concept

Context

VulnerabilityEnrichmentService already maintains VulnerabilityFinding rows with a full lifecycle: UpsertVulnerabilityFindingsAsync adds new findings, reactivates previously resolved ones (ReactivateFinding sets IsActive = true and refreshes DetectedAtUtc on the same row), and resolves stale ones. The finding identity it uses is ImageVersionId + AdvisoryId + AffectedPackage — enforced by a unique filtered index in VulnerabilityFindingConfiguration. The notification dedup key reuses exactly that identity, which is what makes reactivation of a known CVE silent while a genuinely new CVE notifies.

The requirement is explicitly about running containers, not the whole image catalog: a critical CVE in an image that nothing is running is not an incident worth waking someone for.

Scope

Detection query

Active VulnerabilityFindings where

  • IsActive
  • Severity >= Notifications.MinimumVulnerabilitySeverity (default Critical, configurable down to High)
  • ImageVersionId is used by a currently running container

ContainerSnapshot rows are written per scan, so a naive IsRunning == true filter would also match stale historical rows. "Running" must therefore be evaluated on the latest snapshot per container:

  1. group ContainerSnapshots by (DockerInstanceId, ContainerId)
  2. take the row with the newest RecordedAtUtc in each group
  3. keep those with IsRunning == true
  4. collect their ImageVersionId values

This is the same grouping ApplicationTelemetry.RefreshInventoryMetricsAsync already uses for its inventory gauges — reuse that shape rather than inventing a second definition of "running". Guarded by the NotifyVulnerabilities toggle; Kind = CriticalVulnerability.

Dedup key

cve:{ImageVersionId}:{AdvisoryId}:{AffectedPackage ?? "-"}

Deliberately identical to the enrichment service's upsert identity, and deliberately without severity — see the escalation case below.

Lifecycle rules

Situation Outcome Why
New critical CVE on a running image Notified once The core requirement
Same CVE resolved and later reactivated (ReactivateFinding) Not notified again Same known CVE, no news for the operator
Severity escalates into the threshold (High → Critical) Notified The finding never matched before, so no ledger record exists; correct by construction because the key carries no severity
A container starts running an already-vulnerable image Notified Its ImageVersionId newly enters the running set
CVE only on an image with no running container Not notified Matches the stated requirement
Container stopped Its image drops out of the running set; no new notifications

Severity, advisory ID, affected package, and installed/fixed versions are denormalized into Subject/Details at insert time, so the history stays readable after ScanCleanupBackgroundService removes the underlying findings.

Digest integration

Vulnerability items flow into the same aggregated digest as update items (VulnerabilityItems on NotificationMessageData), share the MaxItemsPerNotification cap, and drive the priority escalation in the ntfy payload format from #221. A digest containing both kinds produces a title such as DockerUpdateGuard: 2 updates, 1 critical vulnerability.

Testing

Extend src/Tests/DockerUpdateGuard.Tests/NotificationDispatchServiceTests.cs; seed image versions, container snapshots, and vulnerability findings through the SQLite test database:

  • critical CVE on a running container → notified exactly once; a second dispatch run sends nothing
  • the same CVE on an image version with no container snapshot → not notified
  • latest-snapshot semantics: two snapshots for the same (DockerInstanceId, ContainerId), the older one IsRunning = true and the newer IsRunning = false → not notified; and the reverse ordering → notified
  • multiple containers on different instances sharing one image version → one notification, not one per container
  • High finding with default threshold → not notified; after escalation to Critical → notified
  • MinimumVulnerabilitySeverity = High → the High finding notifies
  • reactivation of an already-notified finding (IsActive flipped back on the same row) → suppressed
  • a container starting on an already-vulnerable image version → notified
  • NotifyVulnerabilities = false → no vulnerability items, while update items from Notifications 3/6: update-availability notifications (trigger A) #222 still flow
  • mixed digest containing updates and vulnerabilities → both lists populated, title reflects both counts

Acceptance criteria

  • A critical CVE affecting a running container notifies exactly once
  • A CVE on an image no running container uses never notifies
  • The running-container join uses latest-snapshot-per-container semantics, verified against multiple snapshots per container
  • Severity escalation into the threshold notifies; reactivation of a known finding does not
  • MinimumVulnerabilitySeverity = High widens detection accordingly
  • Vulnerability and update items share one digest with correct counts and cap behavior
  • Severity and advisory details survive finding cleanup because they are denormalized on the record
  • reihitsu-format ./ clean, build without new analyzer findings, all tests green

Out of scope

  • New vulnerability providers or advisory sources — this issue consumes what VulnerabilityEnrichmentService already persists
  • "Resolved" notifications when a CVE is fixed
  • Per-image or per-CVE mute lists

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions