diff --git a/.serena/project.yml b/.serena/project.yml index 0dbf31d..580c957 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,41 +1,6 @@ # the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: "DockerUpdateGuard" -# list of languages for which language servers are started (LSP backend only); choose from: -# ada al angular ansible bash -# bsl clojure cpp cpp_ccls crystal -# csharp csharp_omnisharp cue dart elixir -# elm erlang fortran fsharp gdscript -# go groovy haskell haxe hlsl -# html java json julia kotlin -# latex lean4 lua luau markdown -# matlab msl nix ocaml pascal -# perl php php_phpactor php_phpantom powershell -# python python_jedi python_pyrefly python_ty r -# rego ruby ruby_solargraph rust scala -# scss solidity svelte swift systemverilog -# terraform toml typescript typescript_vts vue -# yaml zig -# (This list may be outdated; generated with scripts/print_language_list.py; -# For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) -# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) -# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) -# - For Free Pascal/Lazarus, use pascal -# Special requirements: -# Some languages require additional setup/installations. -# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -languages: -- csharp - # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: "utf-8" @@ -173,3 +138,40 @@ activation_command: # maximum time in seconds to wait for activation_command to complete before killing it (default 180s). # must be a positive number. activation_command_timeout: 180.0 + +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart deno +# elixir elm erlang fortran fsharp +# gdscript gleam go groovy haskell +# haxe hlsl html java json +# julia kotlin latex lean4 lua +# luau markdown matlab msl nextflow +# nix ocaml pascal perl php +# php_phpactor php_phpantom powershell python python_basedpyright +# python_jedi python_pyrefly python_ty qml r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# wolfram yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of the LanguageServerId enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some language servers require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: +- csharp diff --git a/LICENSE.md b/LICENSE.md index 398c09f..1c9a52b 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 e-networld +Copyright (c) 2026 e-networld.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/DockerUpdateGuard/Components/Pages/Dashboard.razor b/src/DockerUpdateGuard/Components/Pages/Dashboard.razor index 990e808..72a8d8a 100644 --- a/src/DockerUpdateGuard/Components/Pages/Dashboard.razor +++ b/src/DockerUpdateGuard/Components/Pages/Dashboard.razor @@ -150,7 +150,7 @@ else @_dashboard.ActiveUpdateFindingCount - Active vulnerability findings + Active vulnerability findings (running containers) @_dashboard.ActiveVulnerabilityFindingCount diff --git a/src/DockerUpdateGuard/Components/Pages/MyImages.razor b/src/DockerUpdateGuard/Components/Pages/MyImages.razor index c027605..c631295 100644 --- a/src/DockerUpdateGuard/Components/Pages/MyImages.razor +++ b/src/DockerUpdateGuard/Components/Pages/MyImages.razor @@ -75,6 +75,7 @@ Base images affected @context.BaseImageVulnerabilitySummary } + diff --git a/src/DockerUpdateGuard/Components/Pages/MyImages.razor.cs b/src/DockerUpdateGuard/Components/Pages/MyImages.razor.cs index 8f20afa..e577f52 100644 --- a/src/DockerUpdateGuard/Components/Pages/MyImages.razor.cs +++ b/src/DockerUpdateGuard/Components/Pages/MyImages.razor.cs @@ -55,6 +55,12 @@ public sealed partial class MyImages : IDisposable [Inject] public IOptions 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