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 @@ -15,5 +15,11 @@ internal sealed record ScoutVulnerabilityResponse
[JsonPropertyName("vulnerabilities")]
public List<ScoutVulnerabilityItem>? Vulnerabilities { get; init; }

/// <summary>
/// Absolute URL of the next vulnerability page, or null when the current page is the last one
/// </summary>
[JsonPropertyName("next")]
public string? Next { get; init; }

#endregion // Properties
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ namespace DockerUpdateGuard.Vulnerabilities;
/// </summary>
public sealed partial class DockerScoutVulnerabilityProvider : IVulnerabilityProvider, IDisposable
{
#region Constants

/// <summary>
/// Maximum number of vulnerability pages fetched per scan to guard against infinite pagination loops
/// </summary>
private const int MaxVulnerabilityPages = 50;

#endregion // Constants

#region Fields

/// <summary>
Expand Down Expand Up @@ -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<VulnerabilityAdvisoryData>();

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<IReadOnlyList<VulnerabilityAdvisoryData>>.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<IReadOnlyList<VulnerabilityAdvisoryData>>.NotFound($"Image {imageReference.FullReference} not found in Docker Scout"), false);
}

return (ExternalOperationResult<IReadOnlyList<VulnerabilityAdvisoryData>>.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<IReadOnlyList<VulnerabilityAdvisoryData>>.Failed("Docker Scout rejected the authentication token"), true);
}

return (ExternalOperationResult<IReadOnlyList<VulnerabilityAdvisoryData>>.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<ScoutVulnerabilityResponse>(cancellationToken).ConfigureAwait(false);
var advisories = MapAdvisories(scoutResponse, _logger);
return (ExternalOperationResult<IReadOnlyList<VulnerabilityAdvisoryData>>.Failed($"Docker Scout request failed with status {response.StatusCode}"), false);
}

_logger.DockerScoutScanCompleted(imageReference.FullReference, advisories.Count);
var scoutResponse = await response.Content.ReadFromJsonAsync<ScoutVulnerabilityResponse>(cancellationToken).ConfigureAwait(false);

return (ExternalOperationResult<IReadOnlyList<VulnerabilityAdvisoryData>>.Succeeded(advisories), false);
advisories.AddRange(MapAdvisories(scoutResponse, _logger));

nextUrl = scoutResponse?.Next;
}
}
}

_logger.DockerScoutScanCompleted(imageReference.FullReference, advisories.Count);

return (ExternalOperationResult<IReadOnlyList<VulnerabilityAdvisoryData>>.Succeeded(advisories), false);
}

#endregion // Methods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Log that the Docker Scout pagination page cap was reached and the remaining pages were not fetched
/// </summary>
/// <param name="logger">Logger</param>
/// <param name="imageReference">Image reference</param>
/// <param name="pageCap">Maximum number of pages that were fetched before the loop was stopped</param>
[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);

/// <summary>
/// Log that Docker Scout returned an unknown severity value that could not be mapped
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ public class DockerScoutVulnerabilityProviderTests
/// </summary>
private const string ScoutVulnerabilitiesUrl = "https://api.scout.docker.com/v1/repositories/library/nginx/tags/latest/vulnerabilities";

/// <summary>
/// Docker Scout vulnerability endpoint pointing to the second page for the image used in the tests
/// </summary>
private const string ScoutVulnerabilitiesPage2Url = "https://api.scout.docker.com/v1/repositories/library/nginx/tags/latest/vulnerabilities?page=2";

/// <summary>
/// Docker Scout vulnerability endpoint that always advertises another page for the page-cap tests
/// </summary>
private const string ScoutVulnerabilitiesLoopUrl = "https://api.scout.docker.com/v1/repositories/library/nginx/tags/latest/vulnerabilities?page=loop";

/// <summary>
/// Maximum number of vulnerability pages the provider fetches before stopping and logging a warning
/// </summary>
private const int MaxVulnerabilityPages = 50;

#endregion // Constants

#region Methods
Expand Down Expand Up @@ -495,6 +510,161 @@ public async Task DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncRefresh
}
}

/// <summary>
/// Verify Docker Scout follows the next-page marker and merges advisories across all pages
/// </summary>
/// <returns>Task</returns>
[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<DockerScoutVulnerabilityProvider>();
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();
}
}

/// <summary>
/// Verify Docker Scout stops after the page cap and logs a warning when pages never stop advertising a successor
/// </summary>
/// <returns>Task</returns>
[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<DockerScoutVulnerabilityProvider>();
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();
}
}

/// <summary>
/// Verify an unauthorized page refreshes the token once and restarts the whole paginated fetch
/// </summary>
/// <returns>Task</returns>
[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<DockerScoutVulnerabilityProvider>();
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();
}
}

/// <summary>
/// Create the image reference used by the tests
/// </summary>
Expand Down
Loading