From 7ec083c92e067e26f1da9bb29f6f7a4f5fded05f Mon Sep 17 00:00:00 2001 From: Lars Laskowski <26182282+LarsLaskowski@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:33:35 +0200 Subject: [PATCH 1/2] Add manual vulnerability rescan and refine dashboard logic Introduce VulnerabilityRescanControl for image/container tables to allow manual rescans and display last scan timestamps. Update ApplicationViewService and view models to expose image version IDs and scan times. Adjust dashboard to count vulnerabilities only for images backing running containers, excluding retired images. Refine ScanCleanupBackgroundService to purge findings for deactivated, non-live images immediately. Extend IVulnerabilityEnrichmentService for single-image rescans. Improve logging for rescans targeting missing images. Add and update unit tests for new features and logic. Enhance UI hints and styling for scan status and rescans. Update project configuration and copyright. --- .serena/project.yml | 72 ++-- LICENSE.md | 2 +- .../Components/Pages/Dashboard.razor | 2 +- .../Components/Pages/MyImages.razor | 1 + .../Components/Pages/MyImages.razor.cs | 40 +- .../Components/Pages/ObservedImages.razor | 1 + .../Components/Pages/ObservedImages.razor.cs | 11 + .../Components/Pages/RuntimeContainers.razor | 1 + .../Pages/RuntimeContainers.razor.cs | 11 + .../Shared/VulnerabilityRescanControl.razor | 13 + .../VulnerabilityRescanControl.razor.cs | 104 ++++++ .../Images/ImageHostLoggingExtensions.cs | 10 + .../IVulnerabilityEnrichmentService.cs | 9 + .../Images/ScanCleanupBackgroundService.cs | 10 +- .../Images/VulnerabilityEnrichmentService.cs | 105 ++++-- .../UI/ApplicationViewService.cs | 33 +- src/DockerUpdateGuard/UI/DashboardViewData.cs | 4 +- .../UI/ObservedImageListItemData.cs | 10 + .../UI/RuntimeContainerListItemData.cs | 10 + src/DockerUpdateGuard/wwwroot/app.css | 12 + .../ApplicationViewServiceTests.cs | 108 ++++++ .../MyImagesPersistentStateTests.cs | 1 + .../ScanCleanupBackgroundServiceTests.cs | 124 +++++++ .../VulnerabilityEnrichmentServiceTests.cs | 346 +++++++++++------- .../VulnerabilityRescanControlTests.cs | 98 +++++ 25 files changed, 919 insertions(+), 219 deletions(-) create mode 100644 src/DockerUpdateGuard/Components/Shared/VulnerabilityRescanControl.razor create mode 100644 src/DockerUpdateGuard/Components/Shared/VulnerabilityRescanControl.razor.cs create mode 100644 src/Tests/DockerUpdateGuard.Tests/VulnerabilityRescanControlTests.cs 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..6328b93 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(IReadOnlyList 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 From 3b441baa468566dcea4cc6f12aa2fc22f6903d4f Mon Sep 17 00:00:00 2001 From: Lars Laskowski <26182282+LarsLaskowski@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:53:07 +0200 Subject: [PATCH 2/2] Change RunRefreshAsync to require List The images parameter in RunRefreshAsync now requires a List instead of IReadOnlyList. This narrows the accepted argument type and requires callers to provide a List, not just any IReadOnlyList implementation. --- src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs b/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs index 6328b93..eafb127 100644 --- a/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs +++ b/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs @@ -394,7 +394,7 @@ private async Task DeactivateStaleFindingsAsync(ScanRun scanRun, /// Live image version identifiers to deactivate stale findings against, or null to skip that pass /// Cancellation token /// Task - private async Task RunRefreshAsync(IReadOnlyList images, + private async Task RunRefreshAsync(List images, ScanTriggerSource triggerSource, IReadOnlySet? liveImageVersionIdsForDeactivation, CancellationToken cancellationToken)