Repository navigation
[BUG] Docker Scout provider skips transient-failure retry and scans by tag instead of digest #267
Description
Summary
DockerScoutVulnerabilityProvider has two related defects. First, its HttpClient is created from the default IHttpClientFactory client, which has no TransientHttpRetryHandler, so a single 429/5xx or transport hiccup from Docker Hub login or Docker Scout fails the image version's assessment. All other outbound HTTP integrations retry these. Second, the provider looks up vulnerabilities by tag and ignores the digest. When a tag has been republished, the stored image version is assessed with the advisories of the newer image. That produces wrong findings: they get attached to, or resolved on, an image that is actually running something else.
Area
docs/areas/vulnerabilities.md(Docker Scout), added in [Docs] Describe the current behavior by area #263docs/areas/registries.md(Retry of transient HTTP failures), added in [Docs] Describe the current behavior by area #263- Security area (
.squad/project.md): Registry and Docker Hub calls, which coversDockerScoutVulnerabilityProviderandTransientHttpRetryHandler
Current behavior
1. No retry handler
The named and typed clients get the retry handler in src/DockerUpdateGuard/ServiceCollectionExtensions.cs:72-101: DockerHubClient, OciRegistryClient, the .NET and nginx release feeds, and Portainer. The Scout provider is registered as a plain singleton (ServiceCollectionExtensions.cs:108) and asks the factory for the default unnamed client:
// src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs:400-402
using (var client = _httpClientFactory.CreateClient())
{
client.Timeout = timeout;As a result, neither the Docker Hub login POST (AuthenticateAsync, line 226) nor the vulnerability GETs (FetchVulnerabilitiesAsync, line 323) are retried. docs/areas/registries.md states this explicitly ("It does not apply to … Docker Scout").
Failure scenario: Provider = DockerScout, MaxParallelScans = 4. Docker Scout answers one page request with 503 Service Unavailable, or Docker Hub answers the login with 429. The image version is assessed Failed ("Docker Scout request failed with status ServiceUnavailable" or "Docker Hub authentication failed"), and the whole vulnerability scan run ends Failed. The same request through any other integration would have been retried up to Scanning:RetryCount times.
2. Lookup by tag, digest ignored
// src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs:305
var nextUrl = $"{scoutBaseUrl}/v1/repositories/{ns}/{repo}/tags/{imageReference.Tag}/vulnerabilities";The provider receives the digest. VulnerabilityEnrichmentService.ScanImageVulnerabilitiesAsync parses the formatted image version, and ImageReference.Digest is set when the image version has one (src/DockerUpdateGuard/Images/Data/ImageReference.cs:28-33). The digest is then only used in log messages. By contrast, Trivy appends @<digest> to its target and reports Unavailable when the digest is gone (docs/areas/vulnerabilities.md, Trivy).
Reproduction:
- A runtime container runs
docker.io/library/nginx:1.27with digestsha256:A. The image version (nginx:1.27@sha256:A) is stored. - Upstream republishes
nginx:1.27assha256:B, which fixes CVE-X and introduces CVE-Y. - The vulnerability refresh assesses image version
sha256:Aand receives the advisories ofsha256:B. CVE-X is resolved on the running image although it is still vulnerable, and CVE-Y is reported although it is not present.
Expected behavior
- Docker Scout requests (the Docker Hub login and every vulnerability page) go through
TransientHttpRetryHandlerlike all other outbound HTTP integrations. The rules are the same: 408/429/5xx and transport errors, up toScanning:RetryCountretries,Retry-Afterhonored, a 401 is not retried by the handler and is still handled by the provider's re-login logic. RequestTimeoutSeconds(Vulnerabilities:RequestTimeoutSeconds) stays the limit per request. It is documented whether it covers all retries and waits, which is whatHttpClient.Timeoutdoes when the handler sits inside the client, as for Docker Hub.- When the image version has a digest, its assessment describes that digest, never whatever the tag points to now:
- Preferred: query Docker Scout by digest, if the API supports it (see Implementation notes).
- Otherwise: before using the tag lookup, check that the tag still resolves to the image version's digest. If it does not, the result is
NotFoundand the assessment is Unavailable, with a message analogous to Trivy's (for example "The tag now points to a different digest; Docker Scout cannot assess this version."). Stored findings are not touched, because failed or unavailable assessments leave findings unchanged.
- Image versions without a digest keep the current tag lookup.
- No credentials or tokens appear in messages or logs. The retry handler's warning log already strips the query string.
Acceptance criteria
- The Docker Scout provider obtains a client that has
TransientHttpRetryHandlerin its pipeline, for example a named clientDockerScoutVulnerabilityProvider.HttpClientNameregistered with.AddHttpMessageHandler<TransientHttpRetryHandler>()inServiceCollectionExtensions. - Unit test
ServiceCollectionExtensionsTests: a test such asServiceCollectionExtensionsAddDockerUpdateGuardDockerScoutClientUsesRetryHandlerasserts that the Docker Scout client's handler chain containsTransientHttpRetryHandler(for example viaIHttpMessageHandlerFactory/HttpClientFactoryOptions). - Unit test
DockerScoutVulnerabilityProviderTests:DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncRequestsNamedClient. The provider callsCreateClient(<name>), not the default client (NSubstitute onIHttpClientFactory). - Unit test: a page answered with 503 and then 200 through a retry handler yields
Succeededwith all advisories, for exampleDockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncTransientFailureIsRetried. - Unit test: a 401 is still answered with exactly one re-login and one re-read, and the handler does not retry it.
- For an image reference with a digest, unit tests prove that the assessment cannot be taken from a different digest. Either the request URL contains the digest (digest endpoint), or a tag whose current digest differs gives
NotFound/Unavailable without reading Scout advisories. Example names:DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncWithDigestQueriesByDigest/…WithRepublishedTagReturnsNotFound. - Unit test: an image reference without a digest still uses the tag endpoint.
-
docs/areas/vulnerabilities.mddescribes the new Docker Scout behavior (retry, digest handling, the Unavailable message), and its Known gaps / open-question entries for the missing retry and the tag-only lookup are removed. -
docs/areas/registries.md, Retry of transient HTTP failures, lists Docker Scout among the integrations the retry applies to. - Format, build, tests, analyzer gate and coverage gate pass (
.squad/stack.md).
Implementation notes
Retry handler
DockerScoutVulnerabilityProvideris a singleton that takesIHttpClientFactory. Register a named client, for exampleservices.AddHttpClient(DockerScoutVulnerabilityProvider.HttpClientName).AddHttpMessageHandler<TransientHttpRetryHandler>();, and callCreateClient(HttpClientName). This follows the existingPortainerClient.HttpClientNamepattern (ServiceCollectionExtensions.cs:100-101). Do not switch to a typed client: the provider is a singleton, and typed clients are transient.- Keep setting
client.Timeoutper call from the current options, so a reloadedRequestTimeoutSecondsstill applies. With the handler inside the client, the timeout is the total for all attempts. That matches Docker Hub's semantics (registries.md); document it. - The login POST body is buffered by the handler (
TransientHttpRetryHandler.SendAsync), so it can be resent safely. The body is never logged. - Consider adding the shared
OutboundUserAgentto this client, as the other integration clients do (optional).
Digest lookup: what was verified
api.scout.docker.comis the documented Docker Scout API host (docs.docker.com, Integrate Docker Scout with a container registry). The REST path used today,/v1/repositories/{namespace}/{repository}/tags/{tag}/vulnerabilities, is not publicly documented, and neither is any digest variant of it. Docker also exposes a GraphQL endpoint athttps://api.scout.docker.com/v1/graphql(used by Docker's owndocker/hub-mcp), but its schema is undocumented as well. Thedocker scout cvesCLI does accept digest references (for exampledocker scout cves registry://docker.io/library/nginx@sha256:…), so the backend can resolve images by digest. Whether the REST API offers it could not be confirmed from public documentation.- Therefore: first probe the live API with a Docker Hub account, for example
GET /v1/repositories/library/nginx/digests/sha256:…/vulnerabilitiesor a?digest=parameter, or inspect the CLI's requests. Use a digest endpoint only if one is confirmed. Record the outcome in the area document, and add a decision record indocs/decisions/if the digest-mismatch guard is chosen. - Fallback (recommended if no digest endpoint exists): guard the tag lookup with a digest comparison. The current digest of a tag is available through
IRegistryMetadataService.GetTagAsync→DockerHubTagData.Digest. That service is scoped, while the provider is a singleton, so either do the check in the scopedVulnerabilityEnrichmentServicebefore calling the provider (only when the provider is Docker Scout), or let the provider create a scope. Prefer the enrichment-service option: it keeps the provider free of registry dependencies. - Pitfall: compare like with like. The stored digest of an image version can be the manifest-list (index) digest or a platform manifest digest. Docker Hub's tag API reports the index digest plus per-platform digests (
images[].digest). Treat a match against either as "same image" to avoid false Unavailable results for multi-arch images. A failed tag lookup (registry down) must not be turned into Unavailable: returnFailedwith the registry's message.
Must not change
- Providers never throw for expected failures; they return
ExternalOperationResult<T>(guarantee in.squad/project.md). - The 50-page cap, the token cache and the single-login semaphore stay as they are.
- No new configuration key is needed, so there is no README/appsettings impact. If a new key is introduced anyway, follow the Integration surface checklist in
.squad/project.md. - No migration needed.
Related
- [Docs] Describe the current behavior by area #263 (area documents)
- F-047 (P7) — Tag/digest correctness core: pre-release/overflow edge cases untested; cross-year restriction (F-028) pinned as expected #15 (tag/digest edge cases untested)
- F-009 (P1) — DockerHub access token cache tied to scope lifetime #25 (Docker Hub token cache scope; similar token-handling area)
Generated by Claude Code
Activity