Skip to content

Commit 2c2c13e

Browse files
committed
Fix syncing cosign signature tags when using remote include_tags field
fixes: #2096 Assisted by: claude-opus-4.6
1 parent 79d79f9 commit 2c2c13e

5 files changed

Lines changed: 146 additions & 24 deletions

File tree

CHANGES/2096.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed syncing of cosign signatures, attestations, and SBOMs (stored as companion tags) being silently skipped when `include_tags` was set on the remote.

pulp_container/app/tasks/sync_stages.py

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040

4141
log = logging.getLogger(__name__)
4242

43+
COSIGN_TAG_SUFFIXES = (".sig", ".att", ".sbom")
44+
4345

4446
class ContainerFirstStage(Stage):
4547
"""
@@ -60,6 +62,9 @@ def __init__(self, remote, signed_only):
6062
self.manifest_list_dcs = []
6163
self.manifest_dcs = []
6264
self.signature_dcs = []
65+
self._synced_digests = set()
66+
self._full_tag_list = []
67+
self._cosign_tags = []
6368

6469
async def _download_manifest_data(self, manifest_url):
6570
downloader = self.remote.get_downloader(url=manifest_url)
@@ -92,24 +97,54 @@ async def run(self):
9297
"""
9398
ContainerFirstStage.
9499
"""
95-
96-
to_download = []
97-
BATCH_SIZE = 500
98-
99-
# it can be whether a separate sigstore location or registry with extended signatures API
100100
signature_source = await self.get_signature_source()
101101

102102
async with ProgressReport(
103103
message="Downloading tag list", code="sync.downloading.tag_list", total=1
104104
) as pb:
105105
repo_name = self.remote.namespaced_upstream_name
106106
tag_list_url = "/v2/{name}/tags/list".format(name=repo_name)
107-
tag_list = await self.get_paginated_tag_list(tag_list_url, repo_name)
108-
tag_list = filter_resources(
109-
tag_list, self.remote.include_tags, self.remote.exclude_tags
107+
self._full_tag_list = await self.get_paginated_tag_list(tag_list_url, repo_name)
108+
self._cosign_tags = filter_resources(
109+
self._full_tag_list, ["sha256-*"], self.remote.exclude_tags
110110
)
111+
if self.remote.include_tags or self.remote.exclude_tags:
112+
exclude_tags_and_cosign = (self.remote.exclude_tags or []) + ["sha256-*"]
113+
tag_list = filter_resources(
114+
self._full_tag_list, self.remote.include_tags, exclude_tags_and_cosign
115+
)
116+
else:
117+
tag_list = self._full_tag_list
111118
await pb.aincrement()
112119

120+
await self._process_tags(tag_list, signature_source)
121+
122+
if self.remote.include_tags or self.remote.exclude_tags:
123+
companion_tags = self._find_cosign_companion_tags()
124+
if companion_tags:
125+
log.info(
126+
"Syncing %d cosign companion tag(s) for filtered images",
127+
len(companion_tags),
128+
)
129+
await self._process_tags(
130+
companion_tags, signature_source, msg="Processing Cosign Companion Tags"
131+
)
132+
133+
def _find_cosign_companion_tags(self):
134+
"""Find cosign companion tags for synced digests."""
135+
companion_tags = []
136+
for tag in self._cosign_tags:
137+
tag_without_suffix = tag.rsplit(".", 1)[0]
138+
digest = tag_without_suffix.replace("-", ":", 1)
139+
if digest in self._synced_digests:
140+
companion_tags.append(tag)
141+
return companion_tags
142+
143+
async def _process_tags(self, tag_list, signature_source, msg="Processing Tags"):
144+
"""Download and process a batch of tags, creating declarative content objects."""
145+
BATCH_SIZE = 500
146+
to_download = []
147+
113148
for tag_name in tag_list:
114149
relative_url = "/v2/{name}/manifests/{tag}".format(
115150
name=self.remote.namespaced_upstream_name, tag=tag_name
@@ -121,7 +156,7 @@ async def run(self):
121156
)
122157

123158
async with ProgressReport(
124-
message="Processing Tags",
159+
message=msg,
125160
code="sync.processing.tag",
126161
total=len(tag_list),
127162
) as pb_parsed_tags:
@@ -135,25 +170,21 @@ async def run(self):
135170

136171
digest = calculate_digest(raw_text_data)
137172
tag_name = response.url.split("/")[-1]
173+
media_type = determine_media_type(content_data, response)
138174

139-
# Look for cosign signatures
140-
# cosign signature has a tag convention 'sha256-1234.sig'
141175
if self.signed_only and not signature_source:
142-
if (
143-
not (tag_name.endswith(".sig") and tag_name.startswith("sha256-"))
144-
and f"sha256-{digest.removeprefix('sha256:')}.sig" not in tag_list
176+
if not (
177+
self._is_cosign_companion_tag(tag_name, media_type, content_data)
178+
or await self._has_cosign_signature(digest)
145179
):
146-
# skip this tag, there is no corresponding signature
147180
log.info(
148181
"The unsigned image {digest} can't be synced "
149182
"due to a requirement to sync signed content "
150183
"only.".format(digest=digest)
151184
)
152-
# Count the skipped tagks as parsed too.
153185
await pb_parsed_tags.aincrement()
154186
continue
155187

156-
media_type = determine_media_type(content_data, response)
157188
validate_manifest(content_data, media_type, digest)
158189

159190
tag_dc = DeclarativeContent(Tag(name=tag_name))
@@ -183,23 +214,21 @@ async def run(self):
183214
tag=tag_name,
184215
)
185216
)
186-
# do not pass down the pipeline a manifest list with unsigned
187-
# manifests.
188217
break
189218
self.signature_dcs.extend(man_sig_dcs)
190219
list_dc.extra_data["listed_manifests"].append(listed_manifest)
191220

192221
else:
193222
# Manifest indices can be signed too. It is not mandatory.
194223
# If signature is available mirror it.
224+
self._synced_digests.add(digest)
195225
if signature_source is not None:
196226
list_sig_dcs = await self.create_signatures(list_dc, signature_source)
197227
if list_sig_dcs:
198228
self.signature_dcs.extend(list_sig_dcs)
199-
# only pass the manifest list and tag down the pipeline if there were no
200-
# issues with signatures (no `break` in the `for` loop)
201229
tag_dc.extra_data["tagged_manifest_dc"] = list_dc
202230
for listed_manifest in list_dc.extra_data["listed_manifests"]:
231+
self._synced_digests.add(listed_manifest["manifest_dc"].content.digest)
203232
await self.handle_blobs(
204233
listed_manifest["manifest_dc"], listed_manifest["content_data"]
205234
)
@@ -215,9 +244,9 @@ async def run(self):
215244
if signature_source is not None:
216245
man_sig_dcs = await self.create_signatures(man_dc, signature_source)
217246
if self.signed_only and not man_sig_dcs:
218-
# do not pass down the pipeline unsigned manifests
219247
continue
220248
self.signature_dcs.extend(man_sig_dcs)
249+
self._synced_digests.add(digest)
221250
tag_dc.extra_data["tagged_manifest_dc"] = man_dc
222251
await self.handle_blobs(man_dc, content_data)
223252
self.tag_dcs.append(tag_dc)
@@ -239,6 +268,35 @@ async def run(self):
239268

240269
await self.resolve_flush()
241270

271+
async def _has_cosign_signature(self, digest):
272+
"""Check if a digest has a cosign signature."""
273+
cosign_digest = digest.replace("sha256:", "sha256-")
274+
if f"{cosign_digest}.sig" in self.cosign_tags:
275+
return True
276+
if cosign_digest in self.cosign_tags:
277+
# Potential V3 cosign tag needs to be checked if it is a cosign companion tag
278+
relative_url = f"/v2/{self.remote.namespaced_upstream_name}/manifests/{cosign_digest}"
279+
tag_url = urljoin(self.remote.url, relative_url)
280+
content_data, raw_text_data, response = await self._download_manifest_data(tag_url)
281+
media_type = determine_media_type(content_data, response)
282+
if self._is_cosign_companion_tag(cosign_digest, media_type, content_data):
283+
return True
284+
return False
285+
286+
def _is_cosign_companion_tag(self, tag_name, media_type, content_data):
287+
"""Check if a fetched tag is a cosign companion tag."""
288+
if tag_name.startswith("sha256-"):
289+
if len(tag_name) == 71:
290+
# V3 cosign companion tags are index lists with each entry having an artifactType
291+
if media_type == MEDIA_TYPE.INDEX_OCI:
292+
if manifests := content_data.get("manifests", []):
293+
if all(entry.has("artifactType") for entry in manifests):
294+
return True
295+
elif any(tag_name.endswith(s) for s in COSIGN_TAG_SUFFIXES):
296+
# V2 cosign companion tags are in the format sha256-<digest>.<suffix>
297+
return True
298+
return False
299+
242300
async def get_signature_source(self):
243301
"""
244302
Find out where signatures come from: sigstore, extension API or not available at all.

pulp_container/tests/functional/api/test_sync.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from pulp_container.constants import MANIFEST_TYPE, MEDIA_TYPE
1010
from pulp_container.tests.functional.constants import (
11+
PULP_COSIGN_COMPANION_TAGS,
1112
PULP_FIXTURE_1,
1213
PULP_HELLO_WORLD_LINUX_AMD64_DIGEST,
1314
PULP_LABELED_FIXTURE,
@@ -23,12 +24,12 @@ def synced_container_repository_factory(
2324
container_repository_factory, container_remote_factory, container_repository_api, container_sync
2425
):
2526
def _synced_container_repository_factory(
26-
url=REGISTRY_V2_FEED_URL, include_tags=None, exclude_tags=None
27+
url=REGISTRY_V2_FEED_URL, include_tags=None, exclude_tags=None, upstream_name=PULP_FIXTURE_1
2728
):
2829
"""Sync a new repository with the included tags passed as an argument."""
2930
remote = container_remote_factory(
3031
url,
31-
upstream_name=PULP_FIXTURE_1,
32+
upstream_name=upstream_name,
3233
include_tags=include_tags,
3334
exclude_tags=exclude_tags,
3435
)
@@ -187,3 +188,51 @@ def test_sync_with_complex_filtering(
187188
tags = container_tag_api.list(repository_version=synced_repo.latest_version_href).results
188189

189190
assert sorted(include_tags) == sorted(tag.name for tag in tags)
191+
192+
193+
@pytest.mark.parallel
194+
def test_sync_cosign_companion_tags(
195+
synced_container_repository_factory, container_tag_api, container_manifest_api
196+
):
197+
"""Test syncing a repository with cosign companion tags."""
198+
synced_repo = synced_container_repository_factory(upstream_name=PULP_COSIGN_COMPANION_TAGS)
199+
200+
tags = container_tag_api.list(repository_version=synced_repo.latest_version_href)
201+
manifests = container_manifest_api.list(repository_version=synced_repo.latest_version_href)
202+
assert tags.count == 9
203+
assert manifests.count == 13
204+
205+
206+
@pytest.mark.parallel
207+
def test_sync_cosign_companion_tags_with_filtering(
208+
synced_container_repository_factory, container_tag_api, container_manifest_api
209+
):
210+
"""Test syncing a repository with cosign companion tags and filtering."""
211+
synced_repo = synced_container_repository_factory(
212+
upstream_name=PULP_COSIGN_COMPANION_TAGS, include_tags=["manifest_a"]
213+
)
214+
215+
tags = container_tag_api.list(repository_version=synced_repo.latest_version_href)
216+
manifests = container_manifest_api.list(repository_version=synced_repo.latest_version_href)
217+
assert tags.count == 3 # manifest_a, sha256-<a-digest>.sig, sha256-<a-digest>.att
218+
assert manifests.count == 3
219+
220+
synced_repo = synced_container_repository_factory(
221+
upstream_name=PULP_COSIGN_COMPANION_TAGS, include_tags=["manifest_b"]
222+
)
223+
224+
tags = container_tag_api.list(repository_version=synced_repo.latest_version_href)
225+
manifests = container_manifest_api.list(repository_version=synced_repo.latest_version_href)
226+
assert tags.count == 3 # manifest_b, sha256-<b-digest>.sig, sha256-<b-digest>
227+
assert manifests.count == 5 # The V3 sig is a manifest list with 2 manifests
228+
229+
synced_repo = synced_container_repository_factory(
230+
upstream_name=PULP_COSIGN_COMPANION_TAGS, exclude_tags=["manifest_a"]
231+
)
232+
233+
tags = container_tag_api.list(repository_version=synced_repo.latest_version_href)
234+
manifests = container_manifest_api.list(repository_version=synced_repo.latest_version_href)
235+
assert (
236+
tags.count == 6
237+
) # manifest_b, manifest_c, manifest_d, sha256-<b-digest>.sig, sha256-<b-digest>, sha256-<c-digest>
238+
assert manifests.count == 10

pulp_container/tests/functional/api/test_sync_signatures.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def synced_repository(
2727
upstream_name=DEPRECATED_REPOSITORY_NAME,
2828
policy="on_demand",
2929
include_tags=[MANIFEST_LIST_TAG, IMAGE_MANIFEST_TAG],
30+
exclude_tags=["sha256-*"], # exclude cosign companion tags
3031
)
3132

3233
if request.param["sigstore"]:
@@ -137,6 +138,7 @@ def test_sync_image_with_pqc_signatures(
137138
upstream_name=UBI10_MICRO_REPOSITORY_NAME,
138139
policy="on_demand",
139140
include_tags=[UBI10_MICRO_TAG],
141+
exclude_tags=["sha256-*"],
140142
sigstore=SIGSTORE_URL,
141143
)
142144
remote = container_remote_factory(**data)

pulp_container/tests/functional/constants.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,15 @@
2424

2525
REGISTRY_V2_REPO_PULP = f"{REGISTRY_V2}/{PULP_FIXTURE_1}"
2626
REGISTRY_V2_REPO_HELLO_WORLD = f"{REGISTRY_V2}/{PULP_HELLO_WORLD_REPO}"
27+
28+
# a repository containing cosign companion tags
29+
PULP_COSIGN_COMPANION_TAGS = "pulp/cosign-tags"
30+
# It contains 4 normal tags:
31+
# manifest_a, manifest_b, manifest_c, manifest_d
32+
# and 5 cosign companion tags:
33+
# 2 for manifest_a: sha256-<digest>.sig (v2: 1 signature), sha256-<digest>.att (v2: 1 attestation)
34+
# 2 for manifest_b: sha256-<digest>.sig (v2: 2 signatures), sha256-<digest> (v3: 2 signatures)
35+
# 1 for manifest_c: sha256-<digest> (v3: 1 signature, 1 attestation)
36+
# V2 signatures are stored in one manifest with each signature in a separate layer
37+
# V3 signatures are collected in one manifest list with each signature getting its own manifest
38+
# Repo total contains 2 manifest lists and 11 manifests

0 commit comments

Comments
 (0)