Skip to content

[BUG] OCI registry client may pick attestation manifests and rarely knows publication times #277

Open

Description

@LarsLaskowski

Summary

The generic OCI registry client (all registries other than Docker Hub, for example ghcr.io, quay.io, mcr.microsoft.com, self-hosted registries) has two related gaps in how it reads multi-platform images. Without a platform it takes the first entry of an image index, which can be an attestation manifest (os/architecture = unknown) or a non-Linux image, while the Docker Hub client skips unknown entries and prefers linux. And it reads a tag's publication time only from a top-level created field of the manifest, which most registries do not send, so tags from these registries usually have no publication time; update detection and the bounded tag listings then lack data they get for Docker Hub.

Area

  • docs/areas/registries.md (added in [Docs] Describe the current behavior by area #263): What can be read (tag lookup, image configuration), Bounded tag listings
  • Security area (.squad/project.md): Registry and Docker Hub calls (OciRegistryClient, untrusted JSON); no credential handling changes

Current behavior

Platform selection: src/DockerUpdateGuard/Images/OciRegistryClient.cs

  • SelectPlatformManifestDigest (:226-252): exact OS+architecture match (:261-292), then architecture match (:300-319), then SelectFirstManifestDigest (:327-340), which returns the first entry with any digest, attestations included.
  • GetImageConfigurationAsync (:1340-1398) never passes a platform (IRegistryMetadataClient.GetImageConfigurationAsync has no platform parameter), so for a multi-platform index it always uses the first entry. GetTagAsync is called without a platform from src/DockerUpdateGuard/Images/ImageScanOrchestrator.cs:583-585 and :721.
  • Docker Hub (src/DockerUpdateGuard/DockerHub/DockerHubClient.cs:266-305, SelectManifestDigest) skips entries whose os or architecture is unknown, returns the first linux entry, otherwise the first remaining entry.
  • Failure scenario: an index produced by BuildKit with provenance/SBOM attestations can list an attestation manifest ("platform": {"os": "unknown", "architecture": "unknown"}) before a real image, or a windows image first. The configuration lookup then reads the attestation's or the Windows image's config: labels (and so base-image detection), environment variables, OS/architecture and creation time come from the wrong manifest, or the lookup fails because the attestation has no usable image config.

Publication time:

  • GetManifestMetadataAsync (:831-881) sets PublishedAtUtc = TryReadCreatedAtUtc(jsonDocument.RootElement) (:878), i.e. the manifest's own top-level created. Image manifests per the OCI image spec carry no such field (only an optional org.opencontainers.image.created annotation); for an index the value comes from the selected platform manifest (:893-925), which has none either.
  • The image configuration blob, which every image has, carries created (already parsed by ParseImageConfiguration, :611-620), but the tag lookup never reads it.
  • Consequence: GetTagAsync/GetTagsAsync return PublishedAtUtc = null for nearly every tag. RegistryTagQueryHelper (src/DockerUpdateGuard/Images/Helper/RegistryTagQueryHelper.cs:27-29, :107-109) keeps tags with unknown time, so the published-since bound has no effect for these registries; the UI and stored ImageVersion.PublishedAtUtc show no date.

Expected behavior

Decision (product manager): the OCI client uses the same platform selection rule as Docker Hub, and falls back to the image configuration blob's created for the publication time.

Platform selection (multi-platform index):

  • Entries without a digest and entries whose os or architecture is unknown are never selected.
  • Without a requested platform: the first linux entry; otherwise the first remaining entry.
  • With a requested platform (OS and/or architecture): exact OS+architecture match, then the first entry with that architecture, then the rule above. (This keeps the existing preference and only adds the unknown skip and the linux preference to the fallback.)
  • No usable entry: failed, "Manifest list for '' did not expose a target manifest digest" (unchanged).
  • Recommendation: one shared static helper used by both clients, so the rule cannot drift again.

Publication time (tag lookup):

  • If the manifest has a top-level created that parses, use it (unchanged).
  • Otherwise read the configuration blob of the selected image manifest and use its created.
  • A missing or unparseable created, or a value at or before 1970-01-01T00:00:00Z (reproducible builds set the Unix epoch on purpose), means unknown publication time.
  • If the blob request fails, the tag lookup still succeeds with digest and an unknown publication time (the time is best effort; the digest is the essential part).
  • The digest of a tag is still the Docker-Content-Digest of the first response (the index digest for a multi-platform image).

Acceptance criteria

  • For an index [attestation(unknown/unknown), linux/amd64], OciRegistryClient.GetImageConfigurationAsync reads the config of the linux/amd64 manifest.
  • For an index [windows/amd64, linux/arm64] without a requested platform, the linux/arm64 entry is selected; with requested windows/amd64 the Windows entry is selected.
  • With a requested platform that no entry matches, the fallback skips unknown entries.
  • An index whose only entries are unknown returns Failed.
  • GetTagAsync returns PublishedAtUtc from the config blob's created when the manifest has none, for both a single-platform manifest and a multi-platform index; the manifest's top-level created still wins when present.
  • An epoch or missing created, or a failing blob request, yields PublishedAtUtc = null with a successful tag lookup.
  • GetTagsAsync with a published-since bound drops OCI tags older than the bound (now that times are known), keeping the current tag and the tag with the current digest as documented.
  • Unit tests (MSTest, stub HttpMessageHandler, names per docs/UNIT_TESTS.md), for example OciRegistryClientGetImageConfigurationAttestationFirstSelectsLinuxManifest, OciRegistryClientGetImageConfigurationNoPlatformPrefersLinux, OciRegistryClientGetImageConfigurationOnlyUnknownPlatformsReturnsFailed, OciRegistryClientGetTagManifestWithoutCreatedUsesConfigCreated, OciRegistryClientGetTagEpochCreatedReturnsUnknownPublishedTime, OciRegistryClientGetTagConfigBlobFailureReturnsSucceededWithoutPublishedTime, and tests for the shared selection helper; existing DockerHubClient selection tests stay green.
  • Format, build, tests, Analyzer gate and Coverage gate from .squad/stack.md pass.
  • The area document docs/areas/registries.md describes the new behavior and its Known gaps entries for this issue (platform selection without platform; publication time) are removed.

Implementation notes

  • Affected: OciRegistryClient (SelectPlatformManifestDigest and helpers, GetManifestMetadataAsync, GetConfigDigestFromManifestListAsync, GetTagAsync), possibly DockerHubClient.SelectManifestDigest if the rule moves into a shared helper (for example src/DockerUpdateGuard/Images/Helper/ManifestPlatformSelector.cs; one type per file, regions, XML docs).
  • Cost: the fallback adds one blob request per tag lookup whose manifest lacks created (almost all). OCI tag listings inspect up to MaximumTags (default 250 when bounded) tags, so a listing can now issue up to that many extra requests. Fetch the blob only when the manifest has no created; the config digest is already known from the manifest, and the cached bearer token is reused. Mention the cost in the area document.
  • Semantics: Docker Hub's publication time is the push time (last_pushed); the config created is the build time. Document that for other registries the publication time is the image's build time.
  • GetImageConfigurationAsync has no platform parameter; adding one to IRegistryMetadataClient is out of scope. The new default rule (linux first, skip unknown) is what makes this call correct.
  • Base-image resolution (ResolveBaseImageChainAsync, ExtractBaseImageFromConfigAsync) uses the same manifest path and benefits automatically; check its tests.
  • Must not change: digest semantics of the tag lookup (index digest), the outcome model (.squad/project.md, Guarantees), Docker Hub behavior, and IUpdateDetectionService / VersionTagResolutionHelper staying pure.
  • No configuration or migration impact; ImageVersion.PublishedAtUtc already exists.

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