You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
group ContainerSnapshots by (DockerInstanceId, ContainerId)
take the row with the newest RecordedAtUtc in each group
keep those with IsRunning == true
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.
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
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
VulnerabilityEnrichmentServicealready maintainsVulnerabilityFindingrows with a full lifecycle:UpsertVulnerabilityFindingsAsyncadds new findings, reactivates previously resolved ones (ReactivateFindingsetsIsActive = trueand refreshesDetectedAtUtcon the same row), and resolves stale ones. The finding identity it uses isImageVersionId+AdvisoryId+AffectedPackage— enforced by a unique filtered index inVulnerabilityFindingConfiguration. 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
VulnerabilityFindingswhereIsActiveSeverity >= Notifications.MinimumVulnerabilitySeverity(defaultCritical, configurable down toHigh)ImageVersionIdis used by a currently running containerContainerSnapshotrows are written per scan, so a naiveIsRunning == truefilter would also match stale historical rows. "Running" must therefore be evaluated on the latest snapshot per container:ContainerSnapshotsby(DockerInstanceId, ContainerId)RecordedAtUtcin each groupIsRunning == trueImageVersionIdvaluesThis is the same grouping
ApplicationTelemetry.RefreshInventoryMetricsAsyncalready uses for its inventory gauges — reuse that shape rather than inventing a second definition of "running". Guarded by theNotifyVulnerabilitiestoggle;Kind = CriticalVulnerability.Dedup key
Deliberately identical to the enrichment service's upsert identity, and deliberately without severity — see the escalation case below.
Lifecycle rules
ReactivateFinding)ImageVersionIdnewly enters the running setSeverity, advisory ID, affected package, and installed/fixed versions are denormalized into
Subject/Detailsat insert time, so the history stays readable afterScanCleanupBackgroundServiceremoves the underlying findings.Digest integration
Vulnerability items flow into the same aggregated digest as update items (
VulnerabilityItemsonNotificationMessageData), share theMaxItemsPerNotificationcap, and drive the priority escalation in the ntfy payload format from #221. A digest containing both kinds produces a title such asDockerUpdateGuard: 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:(DockerInstanceId, ContainerId), the older oneIsRunning = trueand the newerIsRunning = false→ not notified; and the reverse ordering → notifiedHighfinding with default threshold → not notified; after escalation toCritical→ notifiedMinimumVulnerabilitySeverity = High→ theHighfinding notifiesIsActiveflipped back on the same row) → suppressedNotifyVulnerabilities = false→ no vulnerability items, while update items from Notifications 3/6: update-availability notifications (trigger A) #222 still flowAcceptance criteria
MinimumVulnerabilitySeverity = Highwidens detection accordinglyreihitsu-format ./clean, build without new analyzer findings, all tests greenOut of scope
VulnerabilityEnrichmentServicealready persists