Repository navigation
[BUG] OCI registry client may pick attestation manifests and rarely knows publication times #277
Description
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), thenSelectFirstManifestDigest(:327-340), which returns the first entry with any digest, attestations included.GetImageConfigurationAsync(:1340-1398) never passes a platform (IRegistryMetadataClient.GetImageConfigurationAsynchas no platform parameter), so for a multi-platform index it always uses the first entry.GetTagAsyncis called without a platform fromsrc/DockerUpdateGuard/Images/ImageScanOrchestrator.cs:583-585and:721.- Docker Hub (
src/DockerUpdateGuard/DockerHub/DockerHubClient.cs:266-305,SelectManifestDigest) skips entries whoseosorarchitectureisunknown, returns the firstlinuxentry, 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 awindowsimage 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) setsPublishedAtUtc = TryReadCreatedAtUtc(jsonDocument.RootElement)(:878), i.e. the manifest's own top-levelcreated. Image manifests per the OCI image spec carry no such field (only an optionalorg.opencontainers.image.createdannotation); 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 byParseImageConfiguration,:611-620), but the tag lookup never reads it. - Consequence:
GetTagAsync/GetTagsAsyncreturnPublishedAtUtc = nullfor 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 storedImageVersion.PublishedAtUtcshow 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
osorarchitectureisunknownare never selected. - Without a requested platform: the first
linuxentry; 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
unknownskip and thelinuxpreference 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
createdthat 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 before1970-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-Digestof the first response (the index digest for a multi-platform image).
Acceptance criteria
- For an index
[attestation(unknown/unknown), linux/amd64],OciRegistryClient.GetImageConfigurationAsyncreads the config of thelinux/amd64manifest. - For an index
[windows/amd64, linux/arm64]without a requested platform, thelinux/arm64entry is selected; with requestedwindows/amd64the Windows entry is selected. - With a requested platform that no entry matches, the fallback skips
unknownentries. - An index whose only entries are
unknownreturnsFailed. -
GetTagAsyncreturnsPublishedAtUtcfrom the config blob'screatedwhen the manifest has none, for both a single-platform manifest and a multi-platform index; the manifest's top-levelcreatedstill wins when present. - An epoch or missing
created, or a failing blob request, yieldsPublishedAtUtc = nullwith a successful tag lookup. -
GetTagsAsyncwith 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 perdocs/UNIT_TESTS.md), for exampleOciRegistryClientGetImageConfigurationAttestationFirstSelectsLinuxManifest,OciRegistryClientGetImageConfigurationNoPlatformPrefersLinux,OciRegistryClientGetImageConfigurationOnlyUnknownPlatformsReturnsFailed,OciRegistryClientGetTagManifestWithoutCreatedUsesConfigCreated,OciRegistryClientGetTagEpochCreatedReturnsUnknownPublishedTime,OciRegistryClientGetTagConfigBlobFailureReturnsSucceededWithoutPublishedTime, and tests for the shared selection helper; existingDockerHubClientselection tests stay green. - Format, build, tests, Analyzer gate and Coverage gate from
.squad/stack.mdpass. - The area document
docs/areas/registries.mddescribes the new behavior and its Known gaps entries for this issue (platform selection without platform; publication time) are removed.
Implementation notes
- Affected:
OciRegistryClient(SelectPlatformManifestDigestand helpers,GetManifestMetadataAsync,GetConfigDigestFromManifestListAsync,GetTagAsync), possiblyDockerHubClient.SelectManifestDigestif the rule moves into a shared helper (for examplesrc/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 toMaximumTags(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 nocreated; 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 configcreatedis the build time. Document that for other registries the publication time is the image's build time. GetImageConfigurationAsynchas no platform parameter; adding one toIRegistryMetadataClientis out of scope. The new default rule (linuxfirst, skipunknown) 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, andIUpdateDetectionService/VersionTagResolutionHelperstaying pure. - No configuration or migration impact;
ImageVersion.PublishedAtUtcalready exists.
Related
- 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)
- PR [Docs] Describe the current behavior by area #263 (area documents)
Generated by Claude Code
Activity