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