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