AppOptions { get; set; } = null!;
+ ///
+ /// Dashboard refresh state
+ ///
+ [Inject]
+ public DashboardRefreshState DashboardRefreshState { get; set; } = null!;
+
///
/// Persistent component state used to reuse the prerendered data on the interactive render
///
@@ -136,6 +142,32 @@ private Task PersistImages()
return Task.CompletedTask;
}
+ ///
+ /// Load the discovery-owned image list
+ ///
+ /// Task
+ private async Task LoadAsync()
+ {
+ var images = await ViewService.GetDiscoveryObservedImagesAsync()
+ .ConfigureAwait(false);
+
+ await InvokeAsync(() =>
+ {
+ _images = images;
+ }).ConfigureAwait(false);
+ }
+
+ ///
+ /// Refresh the image list and dashboard-derived widgets after a manual vulnerability rescan
+ ///
+ /// Task
+ private async Task HandleRescannedAsync()
+ {
+ DashboardRefreshState.NotifyChanged();
+
+ await LoadAsync().ConfigureAwait(false);
+ }
+
#endregion // Methods
#region ComponentBase
@@ -154,13 +186,7 @@ protected override async Task OnInitializedAsync()
return;
}
- var images = await ViewService.GetDiscoveryObservedImagesAsync()
- .ConfigureAwait(false);
-
- await InvokeAsync(() =>
- {
- _images = images;
- }).ConfigureAwait(false);
+ await LoadAsync().ConfigureAwait(false);
}
#endregion // ComponentBase
diff --git a/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor b/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor
index 700a4d0..dbbda14 100644
--- a/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor
+++ b/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor
@@ -126,6 +126,7 @@
Base images affected
@context.BaseImageVulnerabilitySummary
}
+
diff --git a/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor.cs b/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor.cs
index d6bf898..941b5bb 100644
--- a/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor.cs
+++ b/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor.cs
@@ -207,6 +207,17 @@ await InvokeAsync(() =>
}
}
+ ///
+ /// Refresh the image list and dashboard-derived widgets after a manual vulnerability rescan
+ ///
+ /// Task
+ private async Task HandleRescannedAsync()
+ {
+ DashboardRefreshState.NotifyChanged();
+
+ await LoadAsync().ConfigureAwait(false);
+ }
+
#endregion // Methods
#region ComponentBase
diff --git a/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor b/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor
index e6f0f38..f40ab18 100644
--- a/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor
+++ b/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor
@@ -129,6 +129,7 @@
@($"{context.ActiveVulnerabilityFindingCount} active ยท {context.VulnerabilitySummary}")
}
+
@if (context.ActiveBaseImageVulnerabilityFindingCount > 0)
{
Base images affected
diff --git a/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor.cs b/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor.cs
index e8e66d5..4349884 100644
--- a/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor.cs
+++ b/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor.cs
@@ -398,6 +398,17 @@ await InvokeAsync(() =>
}
}
+ ///
+ /// Refresh the container list and dashboard-derived widgets after a manual vulnerability rescan
+ ///
+ /// Task
+ private async Task HandleRescannedAsync()
+ {
+ DashboardRefreshState.NotifyChanged();
+
+ await LoadAsync().ConfigureAwait(false);
+ }
+
#endregion // Methods
#region ComponentBase
diff --git a/src/DockerUpdateGuard/Components/Shared/VulnerabilityRescanControl.razor b/src/DockerUpdateGuard/Components/Shared/VulnerabilityRescanControl.razor
new file mode 100644
index 0000000..624ac0b
--- /dev/null
+++ b/src/DockerUpdateGuard/Components/Shared/VulnerabilityRescanControl.razor
@@ -0,0 +1,13 @@
+
+ @(CheckedAtUtc is DateTimeOffset checkedAtUtc ? $"Checked {checkedAtUtc.ToLocalTime():g}" : "Never scanned")
+
+
+
+
+@if (string.IsNullOrWhiteSpace(_errorMessage) == false)
+{
+ @_errorMessage
+}
diff --git a/src/DockerUpdateGuard/Components/Shared/VulnerabilityRescanControl.razor.cs b/src/DockerUpdateGuard/Components/Shared/VulnerabilityRescanControl.razor.cs
new file mode 100644
index 0000000..964c034
--- /dev/null
+++ b/src/DockerUpdateGuard/Components/Shared/VulnerabilityRescanControl.razor.cs
@@ -0,0 +1,104 @@
+using DockerUpdateGuard.Data.Entities;
+using DockerUpdateGuard.Images.Interfaces;
+
+using Microsoft.AspNetCore.Components;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DockerUpdateGuard.Components.Shared;
+
+///
+/// Shows the last vulnerability scan timestamp of an image version and lets the user trigger a manual rescan
+///
+public partial class VulnerabilityRescanControl
+{
+ #region Fields
+
+ ///
+ /// Busy-state flag for the manual rescan
+ ///
+ private bool _isBusy;
+
+ ///
+ /// Current error message
+ ///
+ private string? _errorMessage;
+
+ #endregion // Fields
+
+ #region Properties
+
+ ///
+ /// Image version to rescan
+ ///
+ [Parameter]
+ public Guid ImageVersionId { get; set; }
+
+ ///
+ /// Timestamp of the last vulnerability scan of the image version
+ ///
+ [Parameter]
+ public DateTimeOffset? CheckedAtUtc { get; set; }
+
+ ///
+ /// Raised after a manual rescan completed
+ ///
+ [Parameter]
+ public EventCallback OnRescanned { get; set; }
+
+ ///
+ /// Service-scope factory
+ ///
+ [Inject]
+ public IServiceScopeFactory ServiceScopeFactory { get; set; } = null!;
+
+ #endregion // Properties
+
+ #region Methods
+
+ ///
+ /// Trigger a manual vulnerability rescan of the image version
+ ///
+ /// Task
+ private async Task RescanAsync()
+ {
+ await InvokeAsync(() =>
+ {
+ _isBusy = true;
+ _errorMessage = null;
+ }).ConfigureAwait(false);
+
+ try
+ {
+ var scope = ServiceScopeFactory.CreateAsyncScope();
+
+ await using (scope.ConfigureAwait(false))
+ {
+ var enrichmentService = scope.ServiceProvider.GetRequiredService();
+
+ await enrichmentService.RefreshImageVersionAsync(ImageVersionId, ScanTriggerSource.Manual)
+ .ConfigureAwait(false);
+ }
+
+ await OnRescanned.InvokeAsync()
+ .ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ {
+ await InvokeAsync(() =>
+ {
+ _errorMessage = exception.Message;
+ }).ConfigureAwait(false);
+ }
+ finally
+ {
+ await InvokeAsync(() =>
+ {
+ _isBusy = false;
+
+ StateHasChanged();
+ }).ConfigureAwait(false);
+ }
+ }
+
+ #endregion // Methods
+}
\ No newline at end of file
diff --git a/src/DockerUpdateGuard/Images/ImageHostLoggingExtensions.cs b/src/DockerUpdateGuard/Images/ImageHostLoggingExtensions.cs
index a2caf18..308ca39 100644
--- a/src/DockerUpdateGuard/Images/ImageHostLoggingExtensions.cs
+++ b/src/DockerUpdateGuard/Images/ImageHostLoggingExtensions.cs
@@ -583,6 +583,16 @@ public static partial void VulnerabilityRefreshImageIncomplete(this ILogger logg
ExternalOperationStatus operationStatus,
string? message);
+ ///
+ /// Log that a manual single-image vulnerability rescan targeted an image version that no longer exists
+ ///
+ /// Logger
+ /// Image version identifier
+ [LoggerMessage(EventId = 2083,
+ Level = LogLevel.Warning,
+ Message = "Vulnerability refresh could not find image version {ImageVersionId}")]
+ public static partial void VulnerabilityRefreshImageVersionNotFound(this ILogger logger, Guid imageVersionId);
+
///
/// Log that Docker instance synchronization has started
///
diff --git a/src/DockerUpdateGuard/Images/Interfaces/IVulnerabilityEnrichmentService.cs b/src/DockerUpdateGuard/Images/Interfaces/IVulnerabilityEnrichmentService.cs
index 6062120..6480935 100644
--- a/src/DockerUpdateGuard/Images/Interfaces/IVulnerabilityEnrichmentService.cs
+++ b/src/DockerUpdateGuard/Images/Interfaces/IVulnerabilityEnrichmentService.cs
@@ -17,5 +17,14 @@ public interface IVulnerabilityEnrichmentService
/// Task
Task RefreshAsync(ScanTriggerSource triggerSource, CancellationToken cancellationToken = default);
+ ///
+ /// Refresh vulnerability findings for a single image version, without deactivating findings of other image versions
+ ///
+ /// Image version identifier
+ /// Trigger source
+ /// Cancellation token
+ /// Task
+ Task RefreshImageVersionAsync(Guid imageVersionId, ScanTriggerSource triggerSource, CancellationToken cancellationToken = default);
+
#endregion // Methods
}
\ No newline at end of file
diff --git a/src/DockerUpdateGuard/Images/ScanCleanupBackgroundService.cs b/src/DockerUpdateGuard/Images/ScanCleanupBackgroundService.cs
index 077062f..f3af853 100644
--- a/src/DockerUpdateGuard/Images/ScanCleanupBackgroundService.cs
+++ b/src/DockerUpdateGuard/Images/ScanCleanupBackgroundService.cs
@@ -1,6 +1,7 @@
using DockerUpdateGuard.Configuration;
using DockerUpdateGuard.Data;
using DockerUpdateGuard.Data.Entities;
+using DockerUpdateGuard.Data.Queries;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@@ -175,6 +176,7 @@ protected override async Task ExecuteCoreAsync(CancellationToken stoppingToken)
{
var dbContext = scope.ServiceProvider.GetRequiredService();
var applicationTelemetry = scope.ServiceProvider.GetRequiredService();
+ var liveImageInventoryQueryService = scope.ServiceProvider.GetRequiredService();
var cleanupStartedAtUtc = DateTimeOffset.UtcNow;
await RepairStaleRunningScanRunsAsync(dbContext,
@@ -182,6 +184,8 @@ await RepairStaleRunningScanRunsAsync(dbContext,
stoppingToken).ConfigureAwait(false);
var cutoff = cleanupStartedAtUtc.AddDays(-_optionsMonitor.CurrentValue.Scanning.RetainScanRunsDays);
+ var liveImageVersionIds = await liveImageInventoryQueryService.GetLiveImageVersionIdsAsync(stoppingToken).ConfigureAwait(false);
+ var liveImageVersionIdList = liveImageVersionIds.ToList();
var completedUnreferencedScanRuns = dbContext.ScanRuns
.Where(entity => entity.CompletedAtUtc != null
@@ -215,10 +219,12 @@ await RepairStaleRunningScanRunsAsync(dbContext,
.ToListAsync(stoppingToken)
.ConfigureAwait(false);
+ // Findings of image versions no longer part of the live fleet are purged immediately once inactive;
+ // findings resolved on still-live image versions (e.g. a patched CVE) keep the age-based retention below
var oldVulnerabilityFindings = await dbContext.VulnerabilityFindings
.Where(entity => entity.IsActive == false
- && entity.ResolvedAtUtc != null
- && entity.ResolvedAtUtc < cutoff)
+ && (liveImageVersionIdList.Contains(entity.ImageVersionId) == false
+ || (entity.ResolvedAtUtc != null && entity.ResolvedAtUtc < cutoff)))
.ToListAsync(stoppingToken)
.ConfigureAwait(false);
diff --git a/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs b/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs
index 1f52643..eafb127 100644
--- a/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs
+++ b/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs
@@ -350,15 +350,15 @@ private Task>>
/// Deactivate active vulnerability findings whose image version is neither part of the live inventory nor enriched by the current run
///
/// Owning scan run
+ /// Identifiers of the image versions currently relevant to the fleet
/// Identifiers of the image versions enriched by the current run
/// Cancellation token
/// Number of deactivated findings
private async Task DeactivateStaleFindingsAsync(ScanRun scanRun,
+ IReadOnlySet liveImageVersionIds,
IReadOnlyCollection processedImageVersionIds,
CancellationToken cancellationToken)
{
- var liveImageVersionIds = await _liveImageInventoryQueryService.GetLiveImageVersionIdsAsync(cancellationToken).ConfigureAwait(false);
-
// Image versions enriched by this run are never stale for this run, no matter whether their enrichment succeeded or failed
var retainedImageVersionIds = liveImageVersionIds.Union(processedImageVersionIds)
.ToList();
@@ -385,20 +385,20 @@ private async Task DeactivateStaleFindingsAsync(ScanRun scanRun,
return staleFindings.Count;
}
- #endregion // Methods
-
- #region IVulnerabilityEnrichmentService
-
- ///
- public async Task RefreshAsync(ScanTriggerSource triggerSource, CancellationToken cancellationToken = default)
+ ///
+ /// Run the scan-run lifecycle shared by the fleet-wide refresh and a single-image manual rescan: create the scan
+ /// run, enrich the supplied images, optionally deactivate stale findings, and finalize the run
+ ///
+ /// Image versions to enrich during this run
+ /// Trigger source recorded on the scan run
+ /// Live image version identifiers to deactivate stale findings against, or null to skip that pass
+ /// Cancellation token
+ /// Task
+ private async Task RunRefreshAsync(List images,
+ ScanTriggerSource triggerSource,
+ IReadOnlySet? liveImageVersionIdsForDeactivation,
+ CancellationToken cancellationToken)
{
- if (_optionsMonitor.CurrentValue.Vulnerabilities.Enabled == false)
- {
- _logger.VulnerabilityRefreshSkipped();
-
- return;
- }
-
var processedImageCount = 0;
var vulnerabilityProvider = _vulnerabilityProviderResolver.Resolve();
var scanRun = new ScanRun
@@ -419,13 +419,6 @@ await _dbContext.SaveChangesAsync(cancellationToken)
try
{
- var images = await _dbContext.ImageVersions.Include(entity => entity.RegistryRepository)
- .Where(entity => entity.ObservedImages.Any()
- || entity.ContainerSnapshots.Any()
- || entity.BaseRelationships.Any())
- .ToListAsync(cancellationToken)
- .ConfigureAwait(false);
-
var processedImageVersionIds = images.Select(entity => entity.Id)
.ToList();
@@ -473,16 +466,20 @@ await _dbContext.SaveChangesAsync(cancellationToken)
}
}
- var deactivatedFindingCount = await DeactivateStaleFindingsAsync(scanRun,
- processedImageVersionIds,
- cancellationToken).ConfigureAwait(false);
-
- if (deactivatedFindingCount > 0)
+ if (liveImageVersionIdsForDeactivation is not null)
{
- await _dbContext.SaveChangesAsync(cancellationToken)
- .ConfigureAwait(false);
+ var deactivatedFindingCount = await DeactivateStaleFindingsAsync(scanRun,
+ liveImageVersionIdsForDeactivation,
+ processedImageVersionIds,
+ cancellationToken).ConfigureAwait(false);
- _logger.VulnerabilityStaleFindingsDeactivated(deactivatedFindingCount);
+ if (deactivatedFindingCount > 0)
+ {
+ await _dbContext.SaveChangesAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ _logger.VulnerabilityStaleFindingsDeactivated(deactivatedFindingCount);
+ }
}
}
catch (Exception exception)
@@ -532,5 +529,53 @@ await _applicationTelemetry.RefreshInventoryMetricsAsync(_dbContext, cancellatio
stopwatch.ElapsedMilliseconds);
}
+ #endregion // Methods
+
+ #region IVulnerabilityEnrichmentService
+
+ ///
+ public async Task RefreshAsync(ScanTriggerSource triggerSource, CancellationToken cancellationToken = default)
+ {
+ if (_optionsMonitor.CurrentValue.Vulnerabilities.Enabled == false)
+ {
+ _logger.VulnerabilityRefreshSkipped();
+
+ return;
+ }
+
+ var liveImageVersionIds = await _liveImageInventoryQueryService.GetLiveImageVersionIdsAsync(cancellationToken).ConfigureAwait(false);
+ var liveImageVersionIdList = liveImageVersionIds.ToList();
+ var images = await _dbContext.ImageVersions.Include(entity => entity.RegistryRepository)
+ .Where(entity => liveImageVersionIdList.Contains(entity.Id))
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ await RunRefreshAsync(images, triggerSource, liveImageVersionIds, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task RefreshImageVersionAsync(Guid imageVersionId, ScanTriggerSource triggerSource, CancellationToken cancellationToken = default)
+ {
+ if (_optionsMonitor.CurrentValue.Vulnerabilities.Enabled == false)
+ {
+ _logger.VulnerabilityRefreshSkipped();
+
+ return;
+ }
+
+ var image = await _dbContext.ImageVersions.Include(entity => entity.RegistryRepository)
+ .FirstOrDefaultAsync(entity => entity.Id == imageVersionId, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (image is null)
+ {
+ _logger.VulnerabilityRefreshImageVersionNotFound(imageVersionId);
+
+ return;
+ }
+
+ await RunRefreshAsync([image], triggerSource, liveImageVersionIdsForDeactivation: null, cancellationToken).ConfigureAwait(false);
+ }
+
#endregion // IVulnerabilityEnrichmentService
}
\ No newline at end of file
diff --git a/src/DockerUpdateGuard/UI/ApplicationViewService.cs b/src/DockerUpdateGuard/UI/ApplicationViewService.cs
index e93426e..b34dd4a 100644
--- a/src/DockerUpdateGuard/UI/ApplicationViewService.cs
+++ b/src/DockerUpdateGuard/UI/ApplicationViewService.cs
@@ -836,6 +836,7 @@ private async Task> GetRuntimeContai
ContainerId = entity.ContainerId,
ContainerName = entity.Name,
DockerInstanceName = dockerInstance.Name,
+ ImageVersionId = entity.ImageVersionId,
ImageReference = _imageReferenceParser.Format(imageVersion),
CurrentTag = imageVersion.Tag,
ResolvedVersionTag = resolvedVersionTag,
@@ -848,6 +849,7 @@ private async Task> GetRuntimeContai
VulnerabilitySeveritySummary = GetSummaryOrEmpty(activeVulnerabilityFindingLookup, entity.ImageVersionId),
VulnerabilityStatus = FormatVulnerabilityAssessmentStatus(imageVersion.VulnerabilityAssessmentStatus),
VulnerabilitySummary = imageVersion.VulnerabilityAssessmentMessage,
+ VulnerabilityCheckedAtUtc = imageVersion.VulnerabilityAssessmentCheckedAtUtc,
ActiveBaseImageVulnerabilityFindingCount = baseImageVulnerabilitySummary.ActiveFindingCount,
BaseImageVulnerabilitySummary = baseImageVulnerabilitySummary.Summary,
RecordedAtUtc = entity.RecordedAtUtc,
@@ -1611,6 +1613,7 @@ private async Task> GetObservedImagesCo
Id = entity.Id,
Name = entity.Name,
Description = entity.Description,
+ CurrentImageVersionId = entity.CurrentImageVersionId,
ImageReference = _imageReferenceParser.Format(entity.CurrentImageVersion),
LatestScanStatus = GetLatestObservedScanStatus(entity.Id),
LatestScanMessage = GetLatestObservedScanMessage(entity.Id),
@@ -1619,6 +1622,7 @@ private async Task> GetObservedImagesCo
VulnerabilitySeveritySummary = GetSummaryOrEmpty(activeVulnerabilityFindingLookup, entity.CurrentImageVersionId),
VulnerabilityStatus = FormatVulnerabilityAssessmentStatus(entity.CurrentImageVersion.VulnerabilityAssessmentStatus),
VulnerabilityMessage = entity.CurrentImageVersion.VulnerabilityAssessmentMessage,
+ VulnerabilityCheckedAtUtc = entity.CurrentImageVersion.VulnerabilityAssessmentCheckedAtUtc,
ActiveBaseImageVulnerabilityFindingCount = baseImageVulnerabilitySummary.ActiveFindingCount,
BaseImageVulnerabilitySummary = baseImageVulnerabilitySummary.Summary,
IsOwnImage = entity.Source == RegistrationSource.Discovery,
@@ -1749,7 +1753,7 @@ public async Task GetDashboardAsync(CancellationToken cancell
var observedImageCount = await _dbContext.ObservedImages.CountAsync(entity => entity.Source == RegistrationSource.Manual, cancellationToken).ConfigureAwait(false);
var myImageCount = await _dbContext.ObservedImages.CountAsync(entity => entity.Source == RegistrationSource.Discovery, cancellationToken).ConfigureAwait(false);
var dockerInstanceCount = await _dbContext.DockerInstances.CountAsync(cancellationToken).ConfigureAwait(false);
- var runtimeContainers = await GetRuntimeContainersCoreAsync(cancellationToken).ConfigureAwait(false);
+ var latestRuntimeContainerSnapshots = await GetLatestContainerSnapshotsAsync(cancellationToken).ConfigureAwait(false);
var activeUpdateFindingCount = await _dbContext.UpdateFindings.CountAsync(entity => entity.IsActive, cancellationToken).ConfigureAwait(false);
var ownImageBaseRuntimeWarningCount = await _dbContext.UpdateFindings.Join(_dbContext.ObservedImages.Where(entity => entity.Source == RegistrationSource.Discovery),
finding => finding.ObservedImageId,
@@ -1763,16 +1767,21 @@ public async Task GetDashboardAsync(CancellationToken cancell
&& entity.Type == UpdateFindingType.DerivedBaseRuntimeUpdate,
cancellationToken)
.ConfigureAwait(false);
- var activeVulnerabilitySeverityCounts = await _dbContext.VulnerabilityFindings.Where(entity => entity.IsActive)
- .GroupBy(entity => entity.Severity)
- .Select(group => new
- {
- Severity = group.Key,
- ActiveFindingCount = group.Count(),
- })
- .ToListAsync(cancellationToken)
- .ConfigureAwait(false);
- var vulnerabilitySeveritySummary = CreateSeveritySummary(activeVulnerabilitySeverityCounts.Select(entity => new KeyValuePair(entity.Severity, entity.ActiveFindingCount)));
+ var runtimeImageVersionIds = latestRuntimeContainerSnapshots.Select(entity => entity.ImageVersionId)
+ .Distinct()
+ .ToList();
+
+ // Scoped to current runtime containers so the dashboard reflects live exposure; the fleet-wide
+ // vulnerability inventory (including retired image versions) remains available on /vulnerabilities.
+ var runtimeVulnerabilitySeveritySummaries = await LoadActiveVulnerabilitySeveritySummariesAsync(runtimeImageVersionIds, cancellationToken).ConfigureAwait(false);
+ var vulnerabilitySeveritySummary = new VulnerabilitySeveritySummaryViewData
+ {
+ CriticalCount = runtimeVulnerabilitySeveritySummaries.Values.Sum(summary => summary.CriticalCount),
+ HighCount = runtimeVulnerabilitySeveritySummaries.Values.Sum(summary => summary.HighCount),
+ MediumCount = runtimeVulnerabilitySeveritySummaries.Values.Sum(summary => summary.MediumCount),
+ LowCount = runtimeVulnerabilitySeveritySummaries.Values.Sum(summary => summary.LowCount),
+ OtherCount = runtimeVulnerabilitySeveritySummaries.Values.Sum(summary => summary.OtherCount),
+ };
var vulnerabilityConfigurationHint = await GetVulnerabilityConfigurationHintCoreAsync(cancellationToken).ConfigureAwait(false);
return new DashboardViewData
@@ -1780,7 +1789,7 @@ public async Task GetDashboardAsync(CancellationToken cancell
ObservedImageCount = observedImageCount,
MyImageCount = myImageCount,
DockerInstanceCount = dockerInstanceCount,
- RuntimeContainerCount = runtimeContainers.Count,
+ RuntimeContainerCount = latestRuntimeContainerSnapshots.Count,
BaseImageCount = baseImages.Count,
ActiveUpdateFindingCount = activeUpdateFindingCount,
OwnImageBaseRuntimeWarningCount = ownImageBaseRuntimeWarningCount,
diff --git a/src/DockerUpdateGuard/UI/DashboardViewData.cs b/src/DockerUpdateGuard/UI/DashboardViewData.cs
index 42ee118..85f07a7 100644
--- a/src/DockerUpdateGuard/UI/DashboardViewData.cs
+++ b/src/DockerUpdateGuard/UI/DashboardViewData.cs
@@ -43,12 +43,12 @@ public class DashboardViewData
public int OwnImageBaseRuntimeWarningCount { get; set; }
///
- /// Active vulnerability finding count
+ /// Active vulnerability finding count for image versions currently backing a runtime container
///
public int ActiveVulnerabilityFindingCount { get; set; }
///
- /// Active vulnerability finding counts per severity
+ /// Active vulnerability finding counts per severity for image versions currently backing a runtime container
///
public VulnerabilitySeveritySummaryViewData VulnerabilitySeveritySummary { get; set; } = new();
diff --git a/src/DockerUpdateGuard/UI/ObservedImageListItemData.cs b/src/DockerUpdateGuard/UI/ObservedImageListItemData.cs
index 34a092c..424258b 100644
--- a/src/DockerUpdateGuard/UI/ObservedImageListItemData.cs
+++ b/src/DockerUpdateGuard/UI/ObservedImageListItemData.cs
@@ -22,6 +22,11 @@ public class ObservedImageListItemData
///
public string? Description { get; set; }
+ ///
+ /// Current image version identifier
+ ///
+ public Guid CurrentImageVersionId { get; set; }
+
///
/// Image reference
///
@@ -62,6 +67,11 @@ public class ObservedImageListItemData
///
public string? VulnerabilityMessage { get; set; }
+ ///
+ /// Timestamp when the current image version was last checked for vulnerabilities
+ ///
+ public DateTimeOffset? VulnerabilityCheckedAtUtc { get; set; }
+
///
/// Number of active base-image vulnerability findings
///
diff --git a/src/DockerUpdateGuard/UI/RuntimeContainerListItemData.cs b/src/DockerUpdateGuard/UI/RuntimeContainerListItemData.cs
index 8cb153b..bcf9223 100644
--- a/src/DockerUpdateGuard/UI/RuntimeContainerListItemData.cs
+++ b/src/DockerUpdateGuard/UI/RuntimeContainerListItemData.cs
@@ -27,6 +27,11 @@ public class RuntimeContainerListItemData
///
public string DockerInstanceName { get; set; } = string.Empty;
+ ///
+ /// Image version identifier
+ ///
+ public Guid ImageVersionId { get; set; }
+
///
/// Image reference
///
@@ -87,6 +92,11 @@ public class RuntimeContainerListItemData
///
public string? VulnerabilitySummary { get; set; }
+ ///
+ /// Timestamp when the image version was last checked for vulnerabilities
+ ///
+ public DateTimeOffset? VulnerabilityCheckedAtUtc { get; set; }
+
///
/// Number of active base-image vulnerability findings
///
diff --git a/src/DockerUpdateGuard/wwwroot/app.css b/src/DockerUpdateGuard/wwwroot/app.css
index fc83095..985826a 100644
--- a/src/DockerUpdateGuard/wwwroot/app.css
+++ b/src/DockerUpdateGuard/wwwroot/app.css
@@ -618,6 +618,18 @@ a {
font-size: 0.82rem;
}
+.vulnerability-rescan-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ margin-top: 0.15rem;
+}
+
+.vulnerability-rescan-control .table-secondary {
+ display: inline;
+ margin-top: 0;
+}
+
.section-table .mud-table-container {
border-radius: var(--dug-radius-md);
border: 1px solid rgba(226, 232, 240, 0.92);
diff --git a/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs
index 9a76361..49a6767 100644
--- a/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs
+++ b/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs
@@ -189,6 +189,9 @@ public async Task ApplicationViewServiceRuntimeContainersExposePortainerAvailabi
"sha256:worker",
cancellationToken: CancellationToken.None);
var disabledImageVersion = await disabledImageVersionTask.ConfigureAwait(false);
+
+ enabledImageVersion.VulnerabilityAssessmentCheckedAtUtc = DateTimeOffset.UtcNow;
+
var enabledInstance = new DockerInstance
{
Name = "With Portainer",
@@ -249,6 +252,12 @@ await dbContext.SaveChangesAsync(TestContext.CancellationToken)
Assert.IsTrue(withPortainer.PortainerAvailable, "Runtime containers must surface Portainer availability when the instance endpoint is enabled");
Assert.IsFalse(withoutPortainer.PortainerAvailable, "Runtime containers must hide Portainer availability when the instance endpoint is disabled");
+ Assert.AreEqual(enabledImageVersion.Id,
+ withPortainer.ImageVersionId,
+ "Runtime containers must expose the image version identifier backing the container");
+ Assert.AreEqual(enabledImageVersion.VulnerabilityAssessmentCheckedAtUtc,
+ withPortainer.VulnerabilityCheckedAtUtc,
+ "Runtime containers must expose when the image version was last checked for vulnerabilities");
}
}
@@ -395,6 +404,99 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
}
}
+ ///
+ /// Verify dashboard vulnerability totals only include image versions used by current runtime containers
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ApplicationViewServiceDashboardExcludesFindingsForInactiveRuntimeImageVersionsAsync()
+ {
+ var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+
+ var dbContext = new DockerUpdateGuardDbContext(options);
+
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var imageCatalogRepository = new ImageCatalogRepository(dbContext);
+ var currentImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ "company/api",
+ "2.0.0",
+ "sha256:current",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+ var retiredImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ "company/api",
+ "1.0.0",
+ "sha256:retired",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+ var dockerInstance = new DockerInstance
+ {
+ Name = "Production Engine",
+ EndpointUri = "https://docker.example.test",
+ ConnectionKind = DockerConnectionKind.Https,
+ };
+ var runtimeScanRun = new ScanRun
+ {
+ Type = ScanRunType.RuntimeContainer,
+ Status = ScanRunStatus.Succeeded,
+ TriggerSource = ScanTriggerSource.Scheduled,
+ };
+
+ dbContext.DockerInstances.Add(dockerInstance);
+ dbContext.ScanRuns.Add(runtimeScanRun);
+ dbContext.ContainerSnapshots.Add(new ContainerSnapshot
+ {
+ DockerInstance = dockerInstance,
+ ImageVersionId = currentImageVersion.Id,
+ ScanRun = runtimeScanRun,
+ ContainerId = "container-a",
+ Name = "api",
+ Status = ContainerRuntimeStatus.Running,
+ IsRunning = true,
+ });
+ dbContext.VulnerabilityFindings.AddRange(new VulnerabilityFinding
+ {
+ ImageVersionId = currentImageVersion.Id,
+ AdvisoryId = "CVE-2026-0001",
+ Title = "Current runtime vulnerability",
+ Severity = VulnerabilitySeverity.High,
+ Source = VulnerabilitySource.Trivy,
+ IsActive = true,
+ },
+ new VulnerabilityFinding
+ {
+ ImageVersionId = retiredImageVersion.Id,
+ AdvisoryId = "CVE-2026-0002",
+ Title = "Retired runtime vulnerability",
+ Severity = VulnerabilitySeverity.Critical,
+ Source = VulnerabilitySource.Trivy,
+ IsActive = true,
+ });
+
+ await dbContext.SaveChangesAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var service = new ApplicationViewService(dbContext,
+ new ImageReferenceParser(),
+ CreateOptionsMonitor(),
+ new SharedBaseImageQueryService(dbContext));
+ var dashboard = await service.GetDashboardAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(1,
+ dashboard.ActiveVulnerabilityFindingCount,
+ "The dashboard must exclude active findings for image versions not used by current runtime containers");
+ Assert.AreEqual(0,
+ dashboard.VulnerabilitySeveritySummary.CriticalCount,
+ "The dashboard must exclude the critical finding from the retired image version");
+ Assert.AreEqual(1,
+ dashboard.VulnerabilitySeveritySummary.HighCount,
+ "The dashboard must retain the high finding from the current runtime image version");
+ }
+ }
+
///
/// Verify base images are exposed through the application view service
///
@@ -1259,6 +1361,12 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
Assert.AreEqual("Trivy returned 500",
listItem.VulnerabilityMessage,
"The observed image list must expose the assessment message");
+ Assert.AreEqual(imageVersion.Id,
+ listItem.CurrentImageVersionId,
+ "The observed image list must expose the current image version identifier");
+ Assert.AreEqual(imageVersion.VulnerabilityAssessmentCheckedAtUtc,
+ listItem.VulnerabilityCheckedAtUtc,
+ "The observed image list must expose when the current image version was last checked for vulnerabilities");
Assert.IsNotNull(detail, "The observed image detail must be returned for a stored observed image");
Assert.AreEqual("Failed",
detail.VulnerabilityAssessment.Status,
diff --git a/src/Tests/DockerUpdateGuard.Tests/MyImagesPersistentStateTests.cs b/src/Tests/DockerUpdateGuard.Tests/MyImagesPersistentStateTests.cs
index b600c56..89147bc 100644
--- a/src/Tests/DockerUpdateGuard.Tests/MyImagesPersistentStateTests.cs
+++ b/src/Tests/DockerUpdateGuard.Tests/MyImagesPersistentStateTests.cs
@@ -110,6 +110,7 @@ private static void RegisterServices(Bunit.BunitContext testContext, IApplicatio
{
testContext.Services.AddSingleton(viewService);
testContext.Services.AddSingleton>(Options.Create(new DockerUpdateGuardOptions()));
+ testContext.Services.AddSingleton(new DashboardRefreshState());
}
#endregion // Methods
diff --git a/src/Tests/DockerUpdateGuard.Tests/ScanCleanupBackgroundServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/ScanCleanupBackgroundServiceTests.cs
index 414ab16..8bf8072 100644
--- a/src/Tests/DockerUpdateGuard.Tests/ScanCleanupBackgroundServiceTests.cs
+++ b/src/Tests/DockerUpdateGuard.Tests/ScanCleanupBackgroundServiceTests.cs
@@ -1,6 +1,8 @@
using DockerUpdateGuard.Configuration;
using DockerUpdateGuard.Data;
using DockerUpdateGuard.Data.Entities;
+using DockerUpdateGuard.Data.Queries;
+using DockerUpdateGuard.Data.Repositories;
using DockerUpdateGuard.Images;
using DockerUpdateGuard.Tests.Data;
using DockerUpdateGuard.Tests.Helper;
@@ -36,6 +38,7 @@ public async Task ScanCleanupBackgroundServiceExecuteCoreAsyncRemovesUnreference
return new DockerUpdateGuardDbContext(options);
});
serviceCollection.AddScoped(_ => new ApplicationTelemetry());
+ serviceCollection.AddScoped();
var serviceProvider = serviceCollection.BuildServiceProvider();
@@ -123,6 +126,7 @@ public async Task ScanCleanupBackgroundServiceExecuteCoreAsyncKeepsRunningScanRu
return new DockerUpdateGuardDbContext(options);
});
serviceCollection.AddScoped(_ => new ApplicationTelemetry());
+ serviceCollection.AddScoped();
var serviceProvider = serviceCollection.BuildServiceProvider();
@@ -215,6 +219,7 @@ public async Task ScanCleanupBackgroundServiceExecuteCoreAsyncMarksStaleRunningS
return new DockerUpdateGuardDbContext(options);
});
serviceCollection.AddScoped(_ => new ApplicationTelemetry());
+ serviceCollection.AddScoped();
var serviceProvider = serviceCollection.BuildServiceProvider();
@@ -336,6 +341,7 @@ public async Task ScanCleanupBackgroundServiceExecuteCoreAsyncLongIntervalStretc
return new DockerUpdateGuardDbContext(options);
});
serviceCollection.AddScoped(_ => new ApplicationTelemetry());
+ serviceCollection.AddScoped();
var serviceProvider = serviceCollection.BuildServiceProvider();
@@ -425,6 +431,7 @@ public async Task ScanCleanupBackgroundServiceExecuteStartupAsyncMarksStaleRunni
return new DockerUpdateGuardDbContext(options);
});
serviceCollection.AddScoped(_ => new ApplicationTelemetry());
+ serviceCollection.AddScoped();
var serviceProvider = serviceCollection.BuildServiceProvider();
@@ -481,5 +488,122 @@ await service.ExecuteStartupOnceAsync(CancellationToken.None)
}
}
+ ///
+ /// Verify deactivated vulnerability findings of image versions no longer part of the live fleet are purged immediately,
+ /// while deactivated findings of still-live image versions keep the age-based retention
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ScanCleanupBackgroundServiceExecuteCoreAsyncPurgesFindingsOfNonLiveImageVersionsImmediatelyAsync()
+ {
+ var databaseName = Guid.NewGuid().ToString();
+ var serviceCollection = new ServiceCollection();
+
+ serviceCollection.AddScoped(_ =>
+ {
+ var options = new DbContextOptionsBuilder().UseInMemoryDatabase(databaseName)
+ .Options;
+
+ return new DockerUpdateGuardDbContext(options);
+ });
+ serviceCollection.AddScoped(_ => new ApplicationTelemetry());
+ serviceCollection.AddScoped();
+
+ var serviceProvider = serviceCollection.BuildServiceProvider();
+
+ await using (serviceProvider.ConfigureAwait(false))
+ {
+ Guid retiredFindingId;
+ Guid liveImageFindingId;
+
+ var scope = serviceProvider.CreateAsyncScope();
+
+ await using (scope.ConfigureAwait(false))
+ {
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ var imageCatalogRepository = new ImageCatalogRepository(dbContext);
+ var retiredImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ "company/retired",
+ "1.0.0",
+ "sha256:retired",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+ var liveImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ "company/live",
+ "1.0.0",
+ "sha256:live",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+ var observedImage = new ObservedImage
+ {
+ Name = "Company Live",
+ CurrentImageVersionId = liveImageVersion.Id,
+ };
+ var retiredFinding = new VulnerabilityFinding
+ {
+ ImageVersionId = retiredImageVersion.Id,
+ AdvisoryId = "CVE-2026-3000",
+ Title = "Retired image finding",
+ Severity = VulnerabilitySeverity.High,
+ Source = VulnerabilitySource.Trivy,
+ AffectedPackage = "openssl",
+ IsActive = false,
+ ResolvedAtUtc = DateTimeOffset.UtcNow.AddDays(-1),
+ };
+ var liveImageFinding = new VulnerabilityFinding
+ {
+ ImageVersionId = liveImageVersion.Id,
+ AdvisoryId = "CVE-2026-4000",
+ Title = "Patched finding on a live image",
+ Severity = VulnerabilitySeverity.Medium,
+ Source = VulnerabilitySource.Trivy,
+ AffectedPackage = "zlib",
+ IsActive = false,
+ ResolvedAtUtc = DateTimeOffset.UtcNow.AddDays(-1),
+ };
+
+ dbContext.ObservedImages.Add(observedImage);
+ dbContext.VulnerabilityFindings.AddRange(retiredFinding, liveImageFinding);
+ await dbContext.SaveChangesAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ retiredFindingId = retiredFinding.Id;
+ liveImageFindingId = liveImageFinding.Id;
+ }
+
+ var options = new DockerUpdateGuardOptions
+ {
+ Scanning = new ScanningOptions
+ {
+ CleanupIntervalMinutes = 60,
+ RetainScanRunsDays = 30,
+ },
+ };
+ var service = new TestScanCleanupBackgroundService(new TestLogger(),
+ new TestOptionsMonitor(options),
+ serviceProvider.GetRequiredService());
+
+ await service.ExecuteOnceAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var verificationScope = serviceProvider.CreateAsyncScope();
+
+ await using (verificationScope.ConfigureAwait(false))
+ {
+ var dbContext = verificationScope.ServiceProvider.GetRequiredService();
+ var remainingFindingIds = await dbContext.VulnerabilityFindings.Select(entity => entity.Id)
+ .ToListAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.DoesNotContain(retiredFindingId,
+ remainingFindingIds,
+ "A deactivated finding of an image version no longer part of the live fleet must be purged immediately, regardless of its age");
+ Assert.Contains(liveImageFindingId,
+ remainingFindingIds,
+ "A deactivated finding of a still-live image version must keep the age-based retention window");
+ }
+ }
+ }
+
#endregion // Methods
}
\ No newline at end of file
diff --git a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs
index 877ee47..392f2d3 100644
--- a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs
+++ b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs
@@ -1358,11 +1358,12 @@ await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None)
}
///
- /// Verify an image version that was enriched during the run is not deactivated by the stale pass of the same run
+ /// Verify an image version whose only container snapshot belongs to a superseded runtime scan run is excluded from
+ /// scanning and has its previously active finding deactivated
///
/// Task
[TestMethod]
- public async Task VulnerabilityEnrichmentServiceRefreshAsyncEnrichedImageVersionSurvivesStalePassAsync()
+ public async Task VulnerabilityEnrichmentServiceRefreshAsyncExcludesSupersededContainerImageVersionFromEligibilityAsync()
{
using (var database = new SqliteTestDatabase())
{
@@ -1371,12 +1372,12 @@ public async Task VulnerabilityEnrichmentServiceRefreshAsyncEnrichedImageVersion
await using (dbContext.ConfigureAwait(false))
{
var imageCatalogRepository = new ImageCatalogRepository(dbContext);
- var previousImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
- "company/app",
- "1.0.0",
- "sha256:previous",
- cancellationToken: CancellationToken.None)
- .ConfigureAwait(false);
+ var retiredImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ "company/app",
+ "1.0.0",
+ "sha256:previous",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
var runningImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
"company/app",
"1.1.0",
@@ -1405,17 +1406,17 @@ public async Task VulnerabilityEnrichmentServiceRefreshAsyncEnrichedImageVersion
StartedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
CompletedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
};
- var previousFinding = new VulnerabilityFinding
- {
- ImageVersionId = previousImageVersion.Id,
- AdvisoryId = "CVE-2026-7000",
- Title = "Historic advisory",
- Severity = VulnerabilitySeverity.High,
- Source = VulnerabilitySource.Trivy,
- AffectedPackage = "openssl",
- IsActive = true,
- DetectedAtUtc = DateTimeOffset.UtcNow.AddDays(-3),
- };
+ var retiredFinding = new VulnerabilityFinding
+ {
+ ImageVersionId = retiredImageVersion.Id,
+ AdvisoryId = "CVE-2026-7000",
+ Title = "Historic advisory",
+ Severity = VulnerabilitySeverity.High,
+ Source = VulnerabilitySource.Trivy,
+ AffectedPackage = "openssl",
+ IsActive = true,
+ DetectedAtUtc = DateTimeOffset.UtcNow.AddDays(-3),
+ };
var vulnerabilityProvider = Substitute.For();
var vulnerabilityProviderResolver = Substitute.For();
@@ -1433,7 +1434,7 @@ public async Task VulnerabilityEnrichmentServiceRefreshAsyncEnrichedImageVersion
dbContext.ContainerSnapshots.AddRange(new ContainerSnapshot
{
DockerInstance = dockerInstance,
- ImageVersionId = previousImageVersion.Id,
+ ImageVersionId = retiredImageVersion.Id,
ScanRun = previousRuntimeScanRun,
ContainerId = "container-a",
Name = "api",
@@ -1452,20 +1453,12 @@ public async Task VulnerabilityEnrichmentServiceRefreshAsyncEnrichedImageVersion
IsRunning = true,
RecordedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
});
- dbContext.VulnerabilityFindings.Add(previousFinding);
+ dbContext.VulnerabilityFindings.Add(retiredFinding);
await dbContext.SaveChangesAsync(TestContext.CancellationToken)
.ConfigureAwait(false);
vulnerabilityProvider.GetVulnerabilitiesAsync(Arg.Any(), Arg.Any())
- .Returns(ExternalOperationResult>.Succeeded([
- new VulnerabilityAdvisoryData
- {
- AdvisoryId = "CVE-2026-7000",
- Title = "Historic advisory",
- Severity = VulnerabilitySeverity.High,
- AffectedPackage = "openssl",
- },
- ]));
+ .Returns(ExternalOperationResult>.Succeeded([]));
var service = new VulnerabilityEnrichmentService(new ApplicationTelemetry(),
dbContext,
@@ -1478,12 +1471,15 @@ await dbContext.SaveChangesAsync(TestContext.CancellationToken)
await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None)
.ConfigureAwait(false);
- var persistedPreviousFinding = await dbContext.VulnerabilityFindings.SingleAsync(entity => entity.Id == previousFinding.Id, TestContext.CancellationToken)
- .ConfigureAwait(false);
+ var persistedRetiredFinding = await dbContext.VulnerabilityFindings.SingleAsync(entity => entity.Id == retiredFinding.Id, TestContext.CancellationToken)
+ .ConfigureAwait(false);
- Assert.IsTrue(persistedPreviousFinding.IsActive, "An image version enriched during the run must not be treated as stale by the same run");
- Assert.IsNull(persistedPreviousFinding.ResolvedAtUtc, "A finding of an enriched image version must not record a resolution timestamp");
- Assert.IsNull(persistedPreviousFinding.ResolvedByScanRunId, "A finding of an enriched image version must not record a resolving scan run");
+ await vulnerabilityProvider.DidNotReceive()
+ .GetVulnerabilitiesAsync(Arg.Is(reference => reference.Repository == "company/app" && reference.Tag == "1.0.0"),
+ Arg.Any())
+ .ConfigureAwait(false);
+ Assert.IsFalse(persistedRetiredFinding.IsActive, "A finding of an image version no longer backing any running container or observed image must be deactivated");
+ Assert.IsNotNull(persistedRetiredFinding.ResolvedAtUtc, "Deactivated findings of a retired image version must record a resolution timestamp");
}
}
}
@@ -1502,43 +1498,20 @@ public async Task VulnerabilityEnrichmentServiceRefreshAsyncFailedImageVersionKe
await using (dbContext.ConfigureAwait(false))
{
var imageCatalogRepository = new ImageCatalogRepository(dbContext);
- var previousImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ var observedImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
"company/app",
"1.0.0",
- "sha256:previous",
+ "sha256:current",
cancellationToken: CancellationToken.None)
.ConfigureAwait(false);
- var runningImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
- "company/app",
- "1.1.0",
- "sha256:running",
- cancellationToken: CancellationToken.None)
- .ConfigureAwait(false);
- var dockerInstance = new DockerInstance
- {
- Name = "Docker",
- EndpointUri = "https://docker.example.test",
- ConnectionKind = DockerConnectionKind.Https,
- };
- var previousRuntimeScanRun = new ScanRun
- {
- Type = ScanRunType.RuntimeContainer,
- Status = ScanRunStatus.Succeeded,
- TriggerSource = ScanTriggerSource.Scheduled,
- StartedAtUtc = DateTimeOffset.UtcNow.AddDays(-2),
- CompletedAtUtc = DateTimeOffset.UtcNow.AddDays(-2),
- };
- var currentRuntimeScanRun = new ScanRun
- {
- Type = ScanRunType.RuntimeContainer,
- Status = ScanRunStatus.Succeeded,
- TriggerSource = ScanTriggerSource.Scheduled,
- StartedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
- CompletedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
- };
- var previousFinding = new VulnerabilityFinding
+ var observedImage = new ObservedImage
+ {
+ Name = "Company App",
+ CurrentImageVersionId = observedImageVersion.Id,
+ };
+ var existingFinding = new VulnerabilityFinding
{
- ImageVersionId = previousImageVersion.Id,
+ ImageVersionId = observedImageVersion.Id,
AdvisoryId = "CVE-2026-8000",
Title = "Historic advisory",
Severity = VulnerabilitySeverity.Critical,
@@ -1561,29 +1534,8 @@ public async Task VulnerabilityEnrichmentServiceRefreshAsyncFailedImageVersionKe
},
});
- dbContext.ContainerSnapshots.AddRange(new ContainerSnapshot
- {
- DockerInstance = dockerInstance,
- ImageVersionId = previousImageVersion.Id,
- ScanRun = previousRuntimeScanRun,
- ContainerId = "container-a",
- Name = "api",
- Status = ContainerRuntimeStatus.Running,
- IsRunning = true,
- RecordedAtUtc = DateTimeOffset.UtcNow.AddDays(-2),
- },
- new ContainerSnapshot
- {
- DockerInstance = dockerInstance,
- ImageVersionId = runningImageVersion.Id,
- ScanRun = currentRuntimeScanRun,
- ContainerId = "container-a",
- Name = "api",
- Status = ContainerRuntimeStatus.Running,
- IsRunning = true,
- RecordedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
- });
- dbContext.VulnerabilityFindings.Add(previousFinding);
+ dbContext.ObservedImages.Add(observedImage);
+ dbContext.VulnerabilityFindings.Add(existingFinding);
await dbContext.SaveChangesAsync(TestContext.CancellationToken)
.ConfigureAwait(false);
@@ -1601,11 +1553,11 @@ await dbContext.SaveChangesAsync(TestContext.CancellationToken)
await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None)
.ConfigureAwait(false);
- var persistedPreviousFinding = await dbContext.VulnerabilityFindings.SingleAsync(entity => entity.Id == previousFinding.Id, TestContext.CancellationToken)
- .ConfigureAwait(false);
+ var persistedFinding = await dbContext.VulnerabilityFindings.SingleAsync(entity => entity.Id == existingFinding.Id, TestContext.CancellationToken)
+ .ConfigureAwait(false);
- Assert.IsTrue(persistedPreviousFinding.IsActive, "A provider outage must not resolve the findings of the image versions it failed to enrich");
- Assert.IsNull(persistedPreviousFinding.ResolvedByScanRunId, "A finding kept by a failed enrichment must not record a resolving scan run");
+ Assert.IsTrue(persistedFinding.IsActive, "A provider outage must not resolve the findings of the image versions it failed to enrich");
+ Assert.IsNull(persistedFinding.ResolvedByScanRunId, "A finding kept by a failed enrichment must not record a resolving scan run");
}
}
}
@@ -1624,40 +1576,32 @@ public async Task VulnerabilityEnrichmentServiceRefreshAsyncConsecutiveRefreshes
await using (dbContext.ConfigureAwait(false))
{
var imageCatalogRepository = new ImageCatalogRepository(dbContext);
- var previousImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
- "company/app",
- "1.0.0",
- "sha256:previous",
- cancellationToken: CancellationToken.None)
- .ConfigureAwait(false);
- var runningImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
- "company/app",
- "1.1.0",
- "sha256:running",
- cancellationToken: CancellationToken.None)
- .ConfigureAwait(false);
+ var firstImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ "company/api",
+ "1.0.0",
+ "sha256:api",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+ var secondImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ "company/worker",
+ "1.0.0",
+ "sha256:worker",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
var dockerInstance = new DockerInstance
{
Name = "Docker",
EndpointUri = "https://docker.example.test",
ConnectionKind = DockerConnectionKind.Https,
};
- var previousRuntimeScanRun = new ScanRun
- {
- Type = ScanRunType.RuntimeContainer,
- Status = ScanRunStatus.Succeeded,
- TriggerSource = ScanTriggerSource.Scheduled,
- StartedAtUtc = DateTimeOffset.UtcNow.AddDays(-2),
- CompletedAtUtc = DateTimeOffset.UtcNow.AddDays(-2),
- };
- var currentRuntimeScanRun = new ScanRun
- {
- Type = ScanRunType.RuntimeContainer,
- Status = ScanRunStatus.Succeeded,
- TriggerSource = ScanTriggerSource.Scheduled,
- StartedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
- CompletedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
- };
+ var runtimeScanRun = new ScanRun
+ {
+ Type = ScanRunType.RuntimeContainer,
+ Status = ScanRunStatus.Succeeded,
+ TriggerSource = ScanTriggerSource.Scheduled,
+ StartedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
+ CompletedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
+ };
var vulnerabilityProvider = Substitute.For();
var vulnerabilityProviderResolver = Substitute.For();
@@ -1675,21 +1619,21 @@ public async Task VulnerabilityEnrichmentServiceRefreshAsyncConsecutiveRefreshes
dbContext.ContainerSnapshots.AddRange(new ContainerSnapshot
{
DockerInstance = dockerInstance,
- ImageVersionId = previousImageVersion.Id,
- ScanRun = previousRuntimeScanRun,
+ ImageVersionId = firstImageVersion.Id,
+ ScanRun = runtimeScanRun,
ContainerId = "container-a",
Name = "api",
Status = ContainerRuntimeStatus.Running,
IsRunning = true,
- RecordedAtUtc = DateTimeOffset.UtcNow.AddDays(-2),
+ RecordedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
},
new ContainerSnapshot
{
DockerInstance = dockerInstance,
- ImageVersionId = runningImageVersion.Id,
- ScanRun = currentRuntimeScanRun,
- ContainerId = "container-a",
- Name = "api",
+ ImageVersionId = secondImageVersion.Id,
+ ScanRun = runtimeScanRun,
+ ContainerId = "container-b",
+ Name = "worker",
Status = ContainerRuntimeStatus.Running,
IsRunning = true,
RecordedAtUtc = DateTimeOffset.UtcNow.AddHours(-1),
@@ -1827,5 +1771,149 @@ await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None)
}
}
+ ///
+ /// Verify a manual single-image rescan enriches only the targeted image version and does not deactivate findings elsewhere
+ ///
+ /// Task
+ [TestMethod]
+ public async Task VulnerabilityEnrichmentServiceRefreshImageVersionAsyncEnrichesOnlyTheTargetImageVersionAsync()
+ {
+ using (var database = new SqliteTestDatabase())
+ {
+ var dbContext = database.CreateDbContext();
+
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var imageCatalogRepository = new ImageCatalogRepository(dbContext);
+ var targetImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ "company/api",
+ "1.0.0",
+ "sha256:api",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+ var otherImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ "company/retired",
+ "1.0.0",
+ "sha256:retired",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+ var otherFinding = new VulnerabilityFinding
+ {
+ ImageVersionId = otherImageVersion.Id,
+ AdvisoryId = "CVE-2026-1000",
+ Title = "Unrelated advisory",
+ Severity = VulnerabilitySeverity.Medium,
+ Source = VulnerabilitySource.Trivy,
+ AffectedPackage = "zlib",
+ IsActive = true,
+ };
+ var vulnerabilityProvider = Substitute.For();
+ var vulnerabilityProviderResolver = Substitute.For();
+
+ vulnerabilityProviderResolver.Resolve().Returns(vulnerabilityProvider);
+
+ var optionsMonitor = new TestOptionsMonitor(new DockerUpdateGuardOptions
+ {
+ Vulnerabilities = new VulnerabilityOptions
+ {
+ Enabled = true,
+ Provider = VulnerabilityProviderKind.Trivy,
+ },
+ });
+
+ dbContext.VulnerabilityFindings.Add(otherFinding);
+ await dbContext.SaveChangesAsync(TestContext.CancellationToken)
+ .ConfigureAwait(false);
+
+ vulnerabilityProvider.GetVulnerabilitiesAsync(Arg.Any(), Arg.Any())
+ .Returns(ExternalOperationResult>.Succeeded([
+ new VulnerabilityAdvisoryData
+ {
+ AdvisoryId = "CVE-2026-2000",
+ Title = "Targeted advisory",
+ Severity = VulnerabilitySeverity.High,
+ AffectedPackage = "openssl",
+ },
+ ]));
+
+ var service = new VulnerabilityEnrichmentService(new ApplicationTelemetry(),
+ dbContext,
+ new ImageReferenceParser(),
+ new LiveImageInventoryQueryService(dbContext),
+ new TestLogger(),
+ optionsMonitor,
+ vulnerabilityProviderResolver);
+
+ await service.RefreshImageVersionAsync(targetImageVersion.Id, ScanTriggerSource.Manual, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var persistedTargetImageVersion = await dbContext.ImageVersions.SingleAsync(entity => entity.Id == targetImageVersion.Id, TestContext.CancellationToken)
+ .ConfigureAwait(false);
+ var targetFinding = await dbContext.VulnerabilityFindings.SingleAsync(entity => entity.ImageVersionId == targetImageVersion.Id, TestContext.CancellationToken)
+ .ConfigureAwait(false);
+ var persistedOtherFinding = await dbContext.VulnerabilityFindings.SingleAsync(entity => entity.Id == otherFinding.Id, TestContext.CancellationToken)
+ .ConfigureAwait(false);
+ var scanRun = await dbContext.ScanRuns.SingleAsync(entity => entity.Type == ScanRunType.Vulnerability, TestContext.CancellationToken)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual("CVE-2026-2000", targetFinding.AdvisoryId, "The targeted image version must store the advisory reported for it");
+ Assert.IsTrue(targetFinding.IsActive, "The finding created for the targeted image version must be active");
+ Assert.IsNotNull(persistedTargetImageVersion.VulnerabilityAssessmentCheckedAtUtc, "The targeted image version must record when it was last checked");
+ Assert.AreEqual(ScanTriggerSource.Manual, scanRun.TriggerSource, "A manual single-image rescan must record its trigger source");
+ Assert.IsTrue(persistedOtherFinding.IsActive, "A single-image rescan must not deactivate findings of image versions it did not target");
+ }
+ }
+ }
+
+ ///
+ /// Verify a manual single-image rescan is skipped without contacting the provider when vulnerability scanning is disabled
+ ///
+ /// Task
+ [TestMethod]
+ public async Task VulnerabilityEnrichmentServiceRefreshImageVersionAsyncSkippedWhenDisabledAsync()
+ {
+ using (var database = new SqliteTestDatabase())
+ {
+ var dbContext = database.CreateDbContext();
+
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var imageCatalogRepository = new ImageCatalogRepository(dbContext);
+ var targetImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io",
+ "company/api",
+ "1.0.0",
+ "sha256:api",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+ var vulnerabilityProviderResolver = Substitute.For();
+ var optionsMonitor = new TestOptionsMonitor(new DockerUpdateGuardOptions
+ {
+ Vulnerabilities = new VulnerabilityOptions
+ {
+ Enabled = false,
+ Provider = VulnerabilityProviderKind.Trivy,
+ },
+ });
+
+ var service = new VulnerabilityEnrichmentService(new ApplicationTelemetry(),
+ dbContext,
+ new ImageReferenceParser(),
+ new LiveImageInventoryQueryService(dbContext),
+ new TestLogger(),
+ optionsMonitor,
+ vulnerabilityProviderResolver);
+
+ await service.RefreshImageVersionAsync(targetImageVersion.Id, ScanTriggerSource.Manual, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var scanRunCount = await dbContext.ScanRuns.CountAsync(TestContext.CancellationToken)
+ .ConfigureAwait(false);
+
+ vulnerabilityProviderResolver.DidNotReceive().Resolve();
+ Assert.AreEqual(0, scanRunCount, "A disabled single-image rescan must not create a scan run");
+ }
+ }
+ }
+
#endregion // Methods
}
\ No newline at end of file
diff --git a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityRescanControlTests.cs b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityRescanControlTests.cs
new file mode 100644
index 0000000..bc27ea7
--- /dev/null
+++ b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityRescanControlTests.cs
@@ -0,0 +1,98 @@
+using Bunit;
+
+using DockerUpdateGuard.Components.Shared;
+using DockerUpdateGuard.Data.Entities;
+using DockerUpdateGuard.Images.Interfaces;
+using DockerUpdateGuard.Tests.Helper;
+
+using Microsoft.Extensions.DependencyInjection;
+
+using NSubstitute;
+
+namespace DockerUpdateGuard.Tests;
+
+///
+/// Tests for
+///
+[TestClass]
+public class VulnerabilityRescanControlTests
+{
+ #region Methods
+
+ ///
+ /// Verify clicking the rescan button triggers a manual rescan of the bound image version and raises OnRescanned
+ ///
+ /// Task
+ [TestMethod]
+ public async Task VulnerabilityRescanControlClickingRescanTriggersImageVersionRefreshAsync()
+ {
+ var testContext = BlazorTestContextFactory.Create();
+
+ await using (testContext)
+ {
+ var enrichmentService = Substitute.For();
+ var imageVersionId = Guid.NewGuid();
+ var rescannedRaised = false;
+
+ testContext.Services.AddSingleton(enrichmentService);
+
+ var component = testContext.Render(parameters => parameters.Add(control => control.ImageVersionId, imageVersionId)
+ .Add(control => control.OnRescanned, () => rescannedRaised = true));
+
+ await component.Find("button").ClickAsync().ConfigureAwait(false);
+
+ await enrichmentService.Received(1).RefreshImageVersionAsync(imageVersionId, ScanTriggerSource.Manual, Arg.Any()).ConfigureAwait(false);
+ Assert.IsTrue(rescannedRaised, "A successful rescan must raise the OnRescanned callback");
+ }
+ }
+
+ ///
+ /// Verify a failed rescan surfaces its error message instead of raising OnRescanned
+ ///
+ /// Task
+ [TestMethod]
+ public async Task VulnerabilityRescanControlFailedRescanShowsErrorMessageAsync()
+ {
+ var testContext = BlazorTestContextFactory.Create();
+
+ await using (testContext)
+ {
+ var enrichmentService = Substitute.For();
+ var rescannedRaised = false;
+
+ enrichmentService.RefreshImageVersionAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromException(new InvalidOperationException("rescan boom")));
+
+ testContext.Services.AddSingleton(enrichmentService);
+
+ var component = testContext.Render(parameters => parameters.Add(control => control.ImageVersionId, Guid.NewGuid())
+ .Add(control => control.OnRescanned, () => rescannedRaised = true));
+
+ await component.Find("button").ClickAsync().ConfigureAwait(false);
+
+ Assert.Contains("rescan boom", component.Markup, "A failed rescan must surface its error message");
+ Assert.IsFalse(rescannedRaised, "A failed rescan must not raise the OnRescanned callback");
+ }
+ }
+
+ ///
+ /// Verify the never-scanned hint is rendered when no checked-at timestamp is provided
+ ///
+ /// Task
+ [TestMethod]
+ public async Task VulnerabilityRescanControlWithoutCheckedAtShowsNeverScannedAsync()
+ {
+ var testContext = BlazorTestContextFactory.Create();
+
+ await using (testContext)
+ {
+ testContext.Services.AddSingleton(Substitute.For());
+
+ var component = testContext.Render(parameters => parameters.Add(control => control.ImageVersionId, Guid.NewGuid()));
+
+ Assert.Contains("Never scanned", component.Markup, "The control must show a never-scanned hint when no checked-at timestamp is available");
+ }
+ }
+
+ #endregion // Methods
+}
\ No newline at end of file