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 ///