Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,10 @@ public enum VulnerabilityAssessmentStatus
/// <summary>
/// Vulnerability evaluation failed
/// </summary>
Failed = 5
Failed = 5,

/// <summary>
/// The image is no longer available in the registry and could not be scanned
/// </summary>
Unavailable = 6
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ private static VulnerabilityAssessmentStatus MapAssessmentStatus(ExternalOperati
{
ExternalOperationStatus.NotConfigured => VulnerabilityAssessmentStatus.NotConfigured,
ExternalOperationStatus.Unsupported => VulnerabilityAssessmentStatus.Unsupported,
ExternalOperationStatus.NotFound => VulnerabilityAssessmentStatus.Unavailable,
_ => VulnerabilityAssessmentStatus.Failed,
};
}
Expand Down
1 change: 1 addition & 0 deletions src/DockerUpdateGuard/UI/ApplicationViewService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
Expand Down
1 change: 1 addition & 0 deletions src/DockerUpdateGuard/UI/VulnerabilityDisplayFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,24 @@ private static string GetErrorExcerpt(string standardError)
return string.IsNullOrWhiteSpace(lastLine) ? "no error output" : lastLine;
}

/// <summary>
/// Determine whether the Trivy standard error indicates the image manifest is no longer available in the registry
/// </summary>
/// <param name="standardError">Captured standard error output</param>
/// <returns>True when the manifest could not be found</returns>
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
Expand Down Expand Up @@ -241,6 +259,13 @@ public async Task<ExternalOperationResult<IReadOnlyList<VulnerabilityAdvisoryDat

if (executionResult.ExitCode != 0)
{
if (IsManifestNotFound(executionResult.StandardError))
{
_logger.TrivyManifestUnavailable(imageReference.FullReference);

return ExternalOperationResult<IReadOnlyList<VulnerabilityAdvisoryData>>.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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Log that the Trivy scan target manifest is no longer available in the registry
/// </summary>
/// <param name="logger">Logger</param>
/// <param name="imageReference">Image reference</param>
[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
}
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,48 @@ public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncReturnsFailed
"Trivy scan failures must report the process exit code");
}

/// <summary>
/// Verify a missing registry manifest is reported as not found with a user-friendly message instead of a failure
/// </summary>
/// <returns>Task</returns>
[TestMethod]
public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncReturnsNotFoundForMissingManifestAsync()
{
var processRunner = Substitute.For<IProcessRunner>();

processRunner.RunAsync(Arg.Any<string>(),
Arg.Any<IReadOnlyList<string>>(),
Arg.Any<TimeSpan>(),
Arg.Any<CancellationToken>())
.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");
}

/// <summary>
/// Verify cancellation requested by the caller is propagated instead of being reported as a failed scan
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,78 @@ await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None)
}
}

/// <summary>
/// Verify a not-found provider result marks the image as unavailable and keeps the run partial instead of failed
/// </summary>
/// <returns>Task</returns>
[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<IVulnerabilityProvider>();
var vulnerabilityProviderResolver = Substitute.For<IVulnerabilityProviderResolver>();

vulnerabilityProviderResolver.Resolve().Returns(vulnerabilityProvider);

var optionsMonitor = new TestOptionsMonitor<DockerUpdateGuardOptions>(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<ImageReference>(), Arg.Any<CancellationToken>())
.Returns(ExternalOperationResult<IReadOnlyList<VulnerabilityAdvisoryData>>.NotFound("The image is no longer available in the registry."));

var service = new VulnerabilityEnrichmentService(new ApplicationTelemetry(),
dbContext,
new ImageReferenceParser(),
new LiveImageInventoryQueryService(dbContext),
new TestLogger<VulnerabilityEnrichmentService>(),
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");
}
}
}

/// <summary>
/// Verify a failed vulnerability refresh is not downgraded to partial by a later soft failure
/// </summary>
Expand Down
Loading