From 7694bca74e4cac291aa07a0d999b059401efc9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:57:48 +0000 Subject: [PATCH] Keep image versions enriched by a run out of its stale finding pass The vulnerability refresh enriched every image version with any historical container snapshot, observed image, or base relationship, while the stale pass only kept the narrower live inventory. Image versions that were enriched a few lines earlier therefore had their fresh findings resolved again in the same run and repopulated on the next one. The identifiers of the processed image versions are now passed into the stale pass and excluded from deactivation, which also protects images whose provider call failed. Refs #205 --- .../Images/VulnerabilityEnrichmentService.cs | 21 +- .../VulnerabilityEnrichmentServiceTests.cs | 384 ++++++++++++++++++ 2 files changed, 400 insertions(+), 5 deletions(-) diff --git a/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs b/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs index 0fb2941..835c8f3 100644 --- a/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs +++ b/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs @@ -323,18 +323,24 @@ private async Task UpsertVulnerabilityFindingsAsync(ScanRun scanRun, } /// - /// Deactivate active vulnerability findings whose image version is no longer part of the live inventory + /// 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 enriched by the current run /// Cancellation token /// Number of deactivated findings - private async Task DeactivateStaleFindingsAsync(ScanRun scanRun, CancellationToken cancellationToken) + private async Task DeactivateStaleFindingsAsync(ScanRun scanRun, + IReadOnlyCollection processedImageVersionIds, + CancellationToken cancellationToken) { var liveImageVersionIds = await _liveImageInventoryQueryService.GetLiveImageVersionIdsAsync(cancellationToken).ConfigureAwait(false); - var liveImageVersionIdList = liveImageVersionIds.ToList(); + + // 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(); var staleFindings = await _dbContext.VulnerabilityFindings.Where(entity => entity.IsActive - && liveImageVersionIdList.Contains(entity.ImageVersionId) == false) + && retainedImageVersionIds.Contains(entity.ImageVersionId) == false) .ToListAsync(cancellationToken) .ConfigureAwait(false); @@ -396,6 +402,9 @@ await _dbContext.SaveChangesAsync(cancellationToken) .ToListAsync(cancellationToken) .ConfigureAwait(false); + var processedImageVersionIds = images.Select(entity => entity.Id) + .ToList(); + processedImageCount = images.Count; if (processedImageCount == 0) @@ -422,7 +431,9 @@ await _dbContext.SaveChangesAsync(cancellationToken) .ConfigureAwait(false); } - var deactivatedFindingCount = await DeactivateStaleFindingsAsync(scanRun, cancellationToken).ConfigureAwait(false); + var deactivatedFindingCount = await DeactivateStaleFindingsAsync(scanRun, + processedImageVersionIds, + cancellationToken).ConfigureAwait(false); if (deactivatedFindingCount > 0) { diff --git a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs index c7202bd..a682abb 100644 --- a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs @@ -1113,5 +1113,389 @@ 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 + /// + /// Task + [TestMethod] + public async Task VulnerabilityEnrichmentServiceRefreshAsyncEnrichedImageVersionSurvivesStalePassAsync() + { + using (var database = new SqliteTestDatabase()) + { + var dbContext = database.CreateDbContext(); + + 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 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 + { + 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 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.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); + 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", + }, + ])); + + var service = new VulnerabilityEnrichmentService(new ApplicationTelemetry(), + dbContext, + new ImageReferenceParser(), + new LiveImageInventoryQueryService(dbContext), + new TestLogger(), + optionsMonitor, + vulnerabilityProviderResolver); + + await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) + .ConfigureAwait(false); + + var persistedPreviousFinding = await dbContext.VulnerabilityFindings.SingleAsync(entity => entity.Id == previousFinding.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"); + } + } + } + + /// + /// Verify an image version whose enrichment failed keeps its existing findings active + /// + /// Task + [TestMethod] + public async Task VulnerabilityEnrichmentServiceRefreshAsyncFailedImageVersionKeepsFindingsActiveAsync() + { + using (var database = new SqliteTestDatabase()) + { + var dbContext = database.CreateDbContext(); + + 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 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 + { + ImageVersionId = previousImageVersion.Id, + AdvisoryId = "CVE-2026-8000", + Title = "Historic advisory", + Severity = VulnerabilitySeverity.Critical, + Source = VulnerabilitySource.Trivy, + AffectedPackage = "openssl", + IsActive = true, + DetectedAtUtc = DateTimeOffset.UtcNow.AddDays(-3), + }; + 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.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); + await dbContext.SaveChangesAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + + vulnerabilityProvider.GetVulnerabilitiesAsync(Arg.Any(), Arg.Any()) + .Returns(ExternalOperationResult>.Failed("provider unavailable")); + + var service = new VulnerabilityEnrichmentService(new ApplicationTelemetry(), + dbContext, + new ImageReferenceParser(), + new LiveImageInventoryQueryService(dbContext), + new TestLogger(), + optionsMonitor, + vulnerabilityProviderResolver); + + await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) + .ConfigureAwait(false); + + var persistedPreviousFinding = await dbContext.VulnerabilityFindings.SingleAsync(entity => entity.Id == previousFinding.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"); + } + } + } + + /// + /// Verify two consecutive refreshes over an unchanged fleet keep every finding active without adding rows + /// + /// Task + [TestMethod] + public async Task VulnerabilityEnrichmentServiceRefreshAsyncConsecutiveRefreshesKeepFindingsActiveAsync() + { + using (var database = new SqliteTestDatabase()) + { + var dbContext = database.CreateDbContext(); + + 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 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 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.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), + }); + await dbContext.SaveChangesAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + + vulnerabilityProvider.GetVulnerabilitiesAsync(Arg.Any(), Arg.Any()) + .Returns(ExternalOperationResult>.Succeeded([ + new VulnerabilityAdvisoryData + { + AdvisoryId = "CVE-2026-9000", + Title = "Stable advisory", + Severity = VulnerabilitySeverity.Medium, + AffectedPackage = "openssl", + }, + ])); + + var service = new VulnerabilityEnrichmentService(new ApplicationTelemetry(), + dbContext, + new ImageReferenceParser(), + new LiveImageInventoryQueryService(dbContext), + new TestLogger(), + optionsMonitor, + vulnerabilityProviderResolver); + + await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) + .ConfigureAwait(false); + + var findingIdsAfterFirstRefresh = await dbContext.VulnerabilityFindings.Select(entity => entity.Id) + .ToListAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + + await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) + .ConfigureAwait(false); + + var findingsAfterSecondRefresh = await dbContext.VulnerabilityFindings.ToListAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + + Assert.HasCount(2, findingIdsAfterFirstRefresh, "Both enriched image versions must store the reported advisory"); + Assert.AreSequenceEqual(findingIdsAfterFirstRefresh, + findingsAfterSecondRefresh.Select(entity => entity.Id) + .ToList(), + Microsoft.VisualStudio.TestTools.UnitTesting.SequenceOrder.InAnyOrder, + "A second refresh over an unchanged fleet must not create additional finding rows"); + Assert.IsTrue(findingsAfterSecondRefresh.TrueForAll(entity => entity.IsActive), + "A second refresh over an unchanged fleet must leave every finding active"); + } + } + } + #endregion // Methods } \ No newline at end of file