From 33325747eed6e66bf31bf5d46571f73969af78de Mon Sep 17 00:00:00 2001 From: Lars Laskowski <26182282+LarsLaskowski@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:50:39 +0200 Subject: [PATCH] Handle unavailable images in vulnerability assessment Added VulnerabilityAssessmentStatus.Unavailable for images missing from the registry. Mapped ExternalOperationStatus.NotFound to this status in VulnerabilityEnrichmentService. Updated ApplicationViewService and VulnerabilityDisplayFormatter to support the new status. Enhanced TrivyVulnerabilityProvider to detect manifest-not-found errors, log them, and return NotFound with a user-friendly message. Added unit tests for both TrivyVulnerabilityProvider and VulnerabilityEnrichmentService to verify correct handling of unavailable images. --- .../Entities/VulnerabilityAssessmentStatus.cs | 7 +- .../Images/VulnerabilityEnrichmentService.cs | 1 + .../UI/ApplicationViewService.cs | 1 + .../UI/VulnerabilityDisplayFormatter.cs | 1 + .../TrivyVulnerabilityProvider.cs | 25 +++++++ .../TrivyVulnerabilityProviderLogging.cs | 10 +++ .../TrivyVulnerabilityProviderTests.cs | 42 +++++++++++ .../VulnerabilityEnrichmentServiceTests.cs | 72 +++++++++++++++++++ 8 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/DockerUpdateGuard.Data/Entities/VulnerabilityAssessmentStatus.cs b/src/DockerUpdateGuard.Data/Entities/VulnerabilityAssessmentStatus.cs index a51c079..7056ada 100644 --- a/src/DockerUpdateGuard.Data/Entities/VulnerabilityAssessmentStatus.cs +++ b/src/DockerUpdateGuard.Data/Entities/VulnerabilityAssessmentStatus.cs @@ -33,5 +33,10 @@ public enum VulnerabilityAssessmentStatus /// /// Vulnerability evaluation failed /// - Failed = 5 + Failed = 5, + + /// + /// The image is no longer available in the registry and could not be scanned + /// + Unavailable = 6 } \ No newline at end of file diff --git a/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs b/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs index d4985c7..536f900 100644 --- a/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs +++ b/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs @@ -117,6 +117,7 @@ private static VulnerabilityAssessmentStatus MapAssessmentStatus(ExternalOperati { ExternalOperationStatus.NotConfigured => VulnerabilityAssessmentStatus.NotConfigured, ExternalOperationStatus.Unsupported => VulnerabilityAssessmentStatus.Unsupported, + ExternalOperationStatus.NotFound => VulnerabilityAssessmentStatus.Unavailable, _ => VulnerabilityAssessmentStatus.Failed, }; } diff --git a/src/DockerUpdateGuard/UI/ApplicationViewService.cs b/src/DockerUpdateGuard/UI/ApplicationViewService.cs index 1920761..bacb547 100644 --- a/src/DockerUpdateGuard/UI/ApplicationViewService.cs +++ b/src/DockerUpdateGuard/UI/ApplicationViewService.cs @@ -707,6 +707,7 @@ private static string FormatVulnerabilityAssessmentStatus(VulnerabilityAssessmen VulnerabilityAssessmentStatus.FindingsDetected => "Findings detected", VulnerabilityAssessmentStatus.NotConfigured => "Not configured", VulnerabilityAssessmentStatus.Unsupported => "Unsupported", + VulnerabilityAssessmentStatus.Unavailable => "Unavailable", VulnerabilityAssessmentStatus.Failed => "Failed", _ => "Not scanned", }; diff --git a/src/DockerUpdateGuard/UI/VulnerabilityDisplayFormatter.cs b/src/DockerUpdateGuard/UI/VulnerabilityDisplayFormatter.cs index 7d19fed..e676f3a 100644 --- a/src/DockerUpdateGuard/UI/VulnerabilityDisplayFormatter.cs +++ b/src/DockerUpdateGuard/UI/VulnerabilityDisplayFormatter.cs @@ -51,6 +51,7 @@ public static Color GetStatusColor(string? status, VulnerabilitySeveritySummaryV "FAILED" => Color.Error, "NOT CONFIGURED" => Color.Default, "UNSUPPORTED" => Color.Default, + "UNAVAILABLE" => Color.Default, "NOT SCANNED" => Color.Default, _ => Color.Info, }; diff --git a/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs b/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs index ac98f05..9174df1 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs @@ -200,6 +200,24 @@ private static string GetErrorExcerpt(string standardError) return string.IsNullOrWhiteSpace(lastLine) ? "no error output" : lastLine; } + /// + /// Determine whether the Trivy standard error indicates the image manifest is no longer available in the registry + /// + /// Captured standard error output + /// True when the manifest could not be found + private static bool IsManifestNotFound(string standardError) + { + if (string.IsNullOrWhiteSpace(standardError)) + { + return false; + } + + return standardError.Contains("MANIFEST_UNKNOWN", StringComparison.OrdinalIgnoreCase) + || standardError.Contains("manifest unknown", StringComparison.OrdinalIgnoreCase) + || (standardError.Contains("manifest", StringComparison.OrdinalIgnoreCase) + && standardError.Contains("not found", StringComparison.OrdinalIgnoreCase)); + } + #endregion // Static methods #region IVulnerabilityProvider @@ -241,6 +259,13 @@ public async Task>.NotFound("The image is no longer available in the registry. The tag was likely re-published with a newer digest, leaving this version orphaned."); + } + var error = GetErrorExcerpt(executionResult.StandardError); _logger.TrivyCommandFailed(imageReference.FullReference, diff --git a/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProviderLogging.cs b/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProviderLogging.cs index c9239df..42cda84 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProviderLogging.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProviderLogging.cs @@ -65,5 +65,15 @@ public static partial void TrivyCommandFailed(this ILogger logger, Message = "Trivy scan timed out for {ImageReference} after {TimeoutSeconds} seconds")] public static partial void TrivyScanTimedOut(this ILogger logger, string imageReference, int timeoutSeconds); + /// + /// Log that the Trivy scan target manifest is no longer available in the registry + /// + /// Logger + /// Image reference + [LoggerMessage(EventId = 3425, + Level = LogLevel.Information, + Message = "Trivy could not scan {ImageReference} because its manifest is no longer available in the registry")] + public static partial void TrivyManifestUnavailable(this ILogger logger, string imageReference); + #endregion // Methods } \ No newline at end of file diff --git a/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs b/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs index e0ac6fd..8b9cf2c 100644 --- a/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs @@ -213,6 +213,48 @@ public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncReturnsFailed "Trivy scan failures must report the process exit code"); } + /// + /// Verify a missing registry manifest is reported as not found with a user-friendly message instead of a failure + /// + /// Task + [TestMethod] + public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncReturnsNotFoundForMissingManifestAsync() + { + var processRunner = Substitute.For(); + + processRunner.RunAsync(Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ProcessExecutionResult + { + ExitCode = 1, + StandardError = "2026-07-18T10:00:00Z\tFATAL\tremote error: GET https://mcr.microsoft.com/v2/dotnet/aspnet/manifests/sha256:9297ae3050f7e80428142f784a6d016f80bc8b5cd54e7fc1b596935911b1e58e: MANIFEST_UNKNOWN: manifest sha256:9297ae3050f7e80428142f784a6d016f80bc8b5cd54e7fc1b596935911b1e58e is not found", + }); + + var provider = CreateProvider(processRunner, "http://trivy.local:4954"); + + var result = await provider.GetVulnerabilitiesAsync(new ImageReference + { + Registry = "mcr.microsoft.com", + Repository = "dotnet/aspnet", + Tag = "8.0", + }, + CancellationToken.None) + .ConfigureAwait(false); + + Assert.AreEqual(ExternalOperationStatus.NotFound, + result.Status, + "Trivy scans must report not found when the registry manifest is no longer available"); + Assert.IsNotNull(result.Message, "Trivy manifest-not-found results must expose a result message"); + Assert.DoesNotContain("MANIFEST_UNKNOWN", + result.Message, + "Trivy manifest-not-found results must not surface the raw registry error to the user"); + Assert.Contains("no longer available", + result.Message, + "Trivy manifest-not-found results must explain that the image is no longer available"); + } + /// /// Verify cancellation requested by the caller is propagated instead of being reported as a failed scan /// diff --git a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs index d9e4954..b53fa95 100644 --- a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs @@ -472,6 +472,78 @@ await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) } } + /// + /// Verify a not-found provider result marks the image as unavailable and keeps the run partial instead of failed + /// + /// Task + [TestMethod] + public async Task VulnerabilityEnrichmentServiceRefreshAsyncNotFoundResultMarksImageUnavailableAsync() + { + using (var database = new SqliteTestDatabase()) + { + var dbContext = database.CreateDbContext(); + + await using (dbContext.ConfigureAwait(false)) + { + var imageCatalogRepository = new ImageCatalogRepository(dbContext); + var currentImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("mcr.microsoft.com", + "dotnet/aspnet", + "8.0", + "sha256:aspnet", + cancellationToken: CancellationToken.None) + .ConfigureAwait(false); + 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.ObservedImages.Add(new ObservedImage + { + Name = "Dotnet Aspnet", + CurrentImageVersionId = currentImageVersion.Id, + }); + + await dbContext.SaveChangesAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + + vulnerabilityProvider.GetVulnerabilitiesAsync(Arg.Any(), Arg.Any()) + .Returns(ExternalOperationResult>.NotFound("The image is no longer available in the registry.")); + + 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 scanRun = await dbContext.ScanRuns.SingleAsync(entity => entity.Type == ScanRunType.Vulnerability, TestContext.CancellationToken) + .ConfigureAwait(false); + var refreshedImageVersion = await dbContext.ImageVersions.SingleAsync(entity => entity.Id == currentImageVersion.Id, TestContext.CancellationToken) + .ConfigureAwait(false); + + Assert.AreEqual(ScanRunStatus.Partial, + scanRun.Status, + "A not-found provider result must not fail the whole vulnerability scan run"); + Assert.AreEqual(VulnerabilityAssessmentStatus.Unavailable, + refreshedImageVersion.VulnerabilityAssessmentStatus, + "A not-found provider result must mark the image version as unavailable"); + } + } + } + /// /// Verify a failed vulnerability refresh is not downgraded to partial by a later soft failure ///