diff --git a/src/DockerUpdateGuard/Vulnerabilities/Data/ScoutVulnerabilityResponse.cs b/src/DockerUpdateGuard/Vulnerabilities/Data/ScoutVulnerabilityResponse.cs index d599595..cba7bd5 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/Data/ScoutVulnerabilityResponse.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/Data/ScoutVulnerabilityResponse.cs @@ -15,5 +15,11 @@ internal sealed record ScoutVulnerabilityResponse [JsonPropertyName("vulnerabilities")] public List? Vulnerabilities { get; init; } + /// + /// Absolute URL of the next vulnerability page, or null when the current page is the last one + /// + [JsonPropertyName("next")] + public string? Next { get; init; } + #endregion // Properties } \ No newline at end of file diff --git a/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs b/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs index 2fe0c1d..8da1907 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs @@ -16,6 +16,15 @@ namespace DockerUpdateGuard.Vulnerabilities; /// public sealed partial class DockerScoutVulnerabilityProvider : IVulnerabilityProvider, IDisposable { + #region Constants + + /// + /// Maximum number of vulnerability pages fetched per scan to guard against infinite pagination loops + /// + private const int MaxVulnerabilityPages = 50; + + #endregion // Constants + #region Fields /// @@ -291,41 +300,59 @@ private DateTimeOffset ResolveTokenExpiryUtc(string jwtToken) var (ns, repo) = ParseNamespaceAndRepo(imageReference.Repository); var scoutBaseUrl = _optionsMonitor.CurrentValue.Vulnerabilities.DockerScoutBaseUrl.TrimEnd('/'); - var url = $"{scoutBaseUrl}/v1/repositories/{ns}/{repo}/tags/{imageReference.Tag}/vulnerabilities"; + var advisories = new List(); - using (var request = new HttpRequestMessage(HttpMethod.Get, url)) + var nextUrl = $"{scoutBaseUrl}/v1/repositories/{ns}/{repo}/tags/{imageReference.Tag}/vulnerabilities"; + var pageCount = 0; + + while (string.IsNullOrEmpty(nextUrl) == false) { - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwtToken); + if (pageCount >= MaxVulnerabilityPages) + { + _logger.DockerScoutPageCapReached(imageReference.FullReference, MaxVulnerabilityPages); + + break; + } + + pageCount++; - using (var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false)) + using (var request = new HttpRequestMessage(HttpMethod.Get, nextUrl)) { - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - return (ExternalOperationResult>.NotFound($"Image {imageReference.FullReference} not found in Docker Scout"), false); - } + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwtToken); - if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + using (var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false)) { - _logger.DockerScoutRequestFailed(imageReference.FullReference, (int)response.StatusCode); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return (ExternalOperationResult>.NotFound($"Image {imageReference.FullReference} not found in Docker Scout"), false); + } - return (ExternalOperationResult>.Failed("Docker Scout rejected the authentication token"), true); - } + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + _logger.DockerScoutRequestFailed(imageReference.FullReference, (int)response.StatusCode); - if (response.IsSuccessStatusCode == false) - { - _logger.DockerScoutRequestFailed(imageReference.FullReference, (int)response.StatusCode); + return (ExternalOperationResult>.Failed("Docker Scout rejected the authentication token"), true); + } - return (ExternalOperationResult>.Failed($"Docker Scout request failed with status {response.StatusCode}"), false); - } + if (response.IsSuccessStatusCode == false) + { + _logger.DockerScoutRequestFailed(imageReference.FullReference, (int)response.StatusCode); - var scoutResponse = await response.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); - var advisories = MapAdvisories(scoutResponse, _logger); + return (ExternalOperationResult>.Failed($"Docker Scout request failed with status {response.StatusCode}"), false); + } - _logger.DockerScoutScanCompleted(imageReference.FullReference, advisories.Count); + var scoutResponse = await response.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); - return (ExternalOperationResult>.Succeeded(advisories), false); + advisories.AddRange(MapAdvisories(scoutResponse, _logger)); + + nextUrl = scoutResponse?.Next; + } } } + + _logger.DockerScoutScanCompleted(imageReference.FullReference, advisories.Count); + + return (ExternalOperationResult>.Succeeded(advisories), false); } #endregion // Methods diff --git a/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProviderLogging.cs b/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProviderLogging.cs index 4640156..a138598 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProviderLogging.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProviderLogging.cs @@ -92,6 +92,17 @@ internal static partial class DockerScoutVulnerabilityProviderLogging Message = "Docker Scout rejected the cached token for {ImageReference}; refreshing the Docker Hub token and retrying once")] public static partial void DockerScoutTokenRefreshAfterUnauthorized(this ILogger logger, string imageReference); + /// + /// Log that the Docker Scout pagination page cap was reached and the remaining pages were not fetched + /// + /// Logger + /// Image reference + /// Maximum number of pages that were fetched before the loop was stopped + [LoggerMessage(EventId = 3419, + Level = LogLevel.Warning, + Message = "Docker Scout pagination for {ImageReference} hit the page cap of {PageCap}; returning partial vulnerability data")] + public static partial void DockerScoutPageCapReached(this ILogger logger, string imageReference, int pageCap); + /// /// Log that Docker Scout returned an unknown severity value that could not be mapped /// diff --git a/src/Tests/DockerUpdateGuard.Tests/DockerScoutVulnerabilityProviderTests.cs b/src/Tests/DockerUpdateGuard.Tests/DockerScoutVulnerabilityProviderTests.cs index 5401a2e..7ebf9dc 100644 --- a/src/Tests/DockerUpdateGuard.Tests/DockerScoutVulnerabilityProviderTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/DockerScoutVulnerabilityProviderTests.cs @@ -30,6 +30,21 @@ public class DockerScoutVulnerabilityProviderTests /// private const string ScoutVulnerabilitiesUrl = "https://api.scout.docker.com/v1/repositories/library/nginx/tags/latest/vulnerabilities"; + /// + /// Docker Scout vulnerability endpoint pointing to the second page for the image used in the tests + /// + private const string ScoutVulnerabilitiesPage2Url = "https://api.scout.docker.com/v1/repositories/library/nginx/tags/latest/vulnerabilities?page=2"; + + /// + /// Docker Scout vulnerability endpoint that always advertises another page for the page-cap tests + /// + private const string ScoutVulnerabilitiesLoopUrl = "https://api.scout.docker.com/v1/repositories/library/nginx/tags/latest/vulnerabilities?page=loop"; + + /// + /// Maximum number of vulnerability pages the provider fetches before stopping and logging a warning + /// + private const int MaxVulnerabilityPages = 50; + #endregion // Constants #region Methods @@ -495,6 +510,161 @@ public async Task DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncRefresh } } + /// + /// Verify Docker Scout follows the next-page marker and merges advisories across all pages + /// + /// Task + [TestMethod] + public async Task DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncMergesPaginatedResultsAsync() + { + var handler = new SequenceHttpMessageHandler(); + var httpClient = new HttpClient(handler); + + try + { + handler.AddJsonResponse(HubLoginUrl, """{"token":"jwt-token"}"""); + handler.AddJsonResponse(ScoutVulnerabilitiesUrl, + $$""" + { + "vulnerabilities": [ + { "id": "CVE-2026-0001", "severity": "critical" }, + { "id": "CVE-2026-0002", "severity": "high" } + ], + "next": "{{ScoutVulnerabilitiesPage2Url}}" + } + """); + handler.AddJsonResponse(ScoutVulnerabilitiesPage2Url, + """ + { + "vulnerabilities": [ + { "id": "CVE-2026-0003", "severity": "medium" } + ] + } + """); + + var logger = new TestLogger(); + var provider = CreateProvider(httpClient, logger); + + var result = await provider.GetVulnerabilitiesAsync(CreateImageReference(), CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(ExternalOperationStatus.Succeeded, + result.Status, + "Docker Scout must succeed when the server paginates the vulnerability response"); + Assert.IsNotNull(result.Data, "Docker Scout scans must expose advisory data"); + Assert.HasCount(3, + result.Data, + "Docker Scout must merge advisories from every page instead of only the first page"); + Assert.ContainsSingle(handler.Requests.Where(request => request.RequestUri == ScoutVulnerabilitiesUrl), + "Docker Scout must request the first vulnerability page"); + Assert.ContainsSingle(handler.Requests.Where(request => request.RequestUri == ScoutVulnerabilitiesPage2Url), + "Docker Scout must follow the next-page marker to the second vulnerability page"); + } + finally + { + httpClient.Dispose(); + handler.Dispose(); + } + } + + /// + /// Verify Docker Scout stops after the page cap and logs a warning when pages never stop advertising a successor + /// + /// Task + [TestMethod] + public async Task DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncStopsAtPageCapAndWarnsAsync() + { + var handler = new SequenceHttpMessageHandler(); + var httpClient = new HttpClient(handler); + + try + { + handler.AddJsonResponse(HubLoginUrl, """{"token":"jwt-token"}"""); + handler.AddJsonResponse(ScoutVulnerabilitiesUrl, + $$"""{"vulnerabilities":[{"id":"CVE-2026-0001","severity":"high"}],"next":"{{ScoutVulnerabilitiesLoopUrl}}"}"""); + + for (var page = 1; page < MaxVulnerabilityPages; page++) + { + handler.AddJsonResponse(ScoutVulnerabilitiesLoopUrl, + $$"""{"vulnerabilities":[{"id":"CVE-2026-0001","severity":"high"}],"next":"{{ScoutVulnerabilitiesLoopUrl}}"}"""); + } + + var logger = new TestLogger(); + var provider = CreateProvider(httpClient, logger); + + var result = await provider.GetVulnerabilitiesAsync(CreateImageReference(), CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(ExternalOperationStatus.Succeeded, + result.Status, + "Docker Scout must return the collected partial data when the page cap is reached"); + Assert.IsNotNull(result.Data, "Docker Scout scans must expose advisory data"); + Assert.HasCount(MaxVulnerabilityPages, + result.Data, + "Docker Scout must fetch exactly the capped number of pages before stopping"); + Assert.AreEqual(MaxVulnerabilityPages, + handler.Requests.Count(request => request.RequestUri == ScoutVulnerabilitiesUrl || request.RequestUri == ScoutVulnerabilitiesLoopUrl), + "Docker Scout must not exceed the page cap when the server never stops advertising a next page"); + Assert.Contains(entry => entry.EventId.Id == 3419, + logger.Entries, + "Docker Scout must log a warning when the pagination page cap is reached"); + } + finally + { + httpClient.Dispose(); + handler.Dispose(); + } + } + + /// + /// Verify an unauthorized page refreshes the token once and restarts the whole paginated fetch + /// + /// Task + [TestMethod] + public async Task DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncRefreshesTokenOncePerPaginatedFetchAsync() + { + var handler = new SequenceHttpMessageHandler(); + var httpClient = new HttpClient(handler); + + try + { + handler.AddJsonResponse(HubLoginUrl, """{"token":"stale-token"}"""); + handler.AddJsonResponse(HubLoginUrl, """{"token":"fresh-token"}"""); + handler.AddJsonResponse(ScoutVulnerabilitiesUrl, + $$"""{"vulnerabilities":[{"id":"CVE-2026-0001","severity":"high"}],"next":"{{ScoutVulnerabilitiesPage2Url}}"}"""); + handler.AddResponse(ScoutVulnerabilitiesPage2Url, new HttpResponseMessage(HttpStatusCode.Unauthorized)); + handler.AddJsonResponse(ScoutVulnerabilitiesUrl, + $$"""{"vulnerabilities":[{"id":"CVE-2026-0001","severity":"high"}],"next":"{{ScoutVulnerabilitiesPage2Url}}"}"""); + handler.AddJsonResponse(ScoutVulnerabilitiesPage2Url, + """{"vulnerabilities":[{"id":"CVE-2026-0002","severity":"low"}]}"""); + + var logger = new TestLogger(); + var provider = CreateProvider(httpClient, logger); + + var result = await provider.GetVulnerabilitiesAsync(CreateImageReference(), CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(ExternalOperationStatus.Succeeded, + result.Status, + "An unauthorized page must refresh the token and the paginated fetch must succeed on retry"); + Assert.IsNotNull(result.Data, "The retried Docker Scout scan must expose advisory data"); + Assert.HasCount(2, + result.Data, + "The retried paginated fetch must merge advisories from every page"); + Assert.AreEqual(2, + handler.Requests.Count(request => request.RequestUri == HubLoginUrl), + "An unauthorized page must trigger exactly one fresh Docker Hub login for the whole fetch"); + Assert.AreEqual("fresh-token", + handler.Requests.Last(request => request.RequestUri == ScoutVulnerabilitiesPage2Url).AuthorizationParameter, + "The restarted paginated fetch must use the refreshed token on every page"); + Assert.Contains(entry => entry.EventId.Id == 3417, + logger.Entries, + "Refreshing a rejected token during pagination must be logged"); + } + finally + { + httpClient.Dispose(); + handler.Dispose(); + } + } + /// /// Create the image reference used by the tests ///