Skip to content

[BUG] Docker Scout provider skips transient-failure retry and scans by tag instead of digest #267

Open

Description

@LarsLaskowski

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

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:

  1. A runtime container runs docker.io/library/nginx:1.27 with digest sha256:A. The image version (nginx:1.27@sha256:A) is stored.
  2. Upstream republishes nginx:1.27 as sha256:B, which fixes CVE-X and introduces CVE-Y.
  3. The vulnerability refresh assesses image version sha256:A and receives the advisories of sha256: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

  1. Docker Scout requests (the Docker Hub login and every vulnerability page) go through TransientHttpRetryHandler like all other outbound HTTP integrations. The rules are the same: 408/429/5xx and transport errors, up to Scanning:RetryCount retries, Retry-After honored, a 401 is not retried by the handler and is still handled by the provider's re-login logic.
  2. RequestTimeoutSeconds (Vulnerabilities:RequestTimeoutSeconds) stays the limit per request. It is documented whether it covers all retries and waits, which is what HttpClient.Timeout does when the handler sits inside the client, as for Docker Hub.
  3. 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 NotFound and 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.
  4. Image versions without a digest keep the current tag lookup.
  5. 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 TransientHttpRetryHandler in its pipeline, for example a named client DockerScoutVulnerabilityProvider.HttpClientName registered with .AddHttpMessageHandler<TransientHttpRetryHandler>() in ServiceCollectionExtensions.
  • Unit test ServiceCollectionExtensionsTests: a test such as ServiceCollectionExtensionsAddDockerUpdateGuardDockerScoutClientUsesRetryHandler asserts that the Docker Scout client's handler chain contains TransientHttpRetryHandler (for example via IHttpMessageHandlerFactory / HttpClientFactoryOptions).
  • Unit test DockerScoutVulnerabilityProviderTests: DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncRequestsNamedClient. The provider calls CreateClient(<name>), not the default client (NSubstitute on IHttpClientFactory).
  • Unit test: a page answered with 503 and then 200 through a retry handler yields Succeeded with all advisories, for example DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncTransientFailureIsRetried.
  • 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.md describes 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

  • DockerScoutVulnerabilityProvider is a singleton that takes IHttpClientFactory. Register a named client, for example services.AddHttpClient(DockerScoutVulnerabilityProvider.HttpClientName).AddHttpMessageHandler<TransientHttpRetryHandler>();, and call CreateClient(HttpClientName). This follows the existing PortainerClient.HttpClientName pattern (ServiceCollectionExtensions.cs:100-101). Do not switch to a typed client: the provider is a singleton, and typed clients are transient.
  • Keep setting client.Timeout per call from the current options, so a reloaded RequestTimeoutSeconds still 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 OutboundUserAgent to this client, as the other integration clients do (optional).

Digest lookup: what was verified

  • api.scout.docker.com is 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 at https://api.scout.docker.com/v1/graphql (used by Docker's own docker/hub-mcp), but its schema is undocumented as well. The docker scout cves CLI does accept digest references (for example docker 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:…/vulnerabilities or 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 in docs/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 scoped VulnerabilityEnrichmentService before 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: return Failed with 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


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions