Skip to content

Commit b574fb1

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 b574fb1

6 files changed

Lines changed: 305 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: 83 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,57 @@ 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+
# Split sync into two parts, first all non-cosign tags, then cosign tags
113+
exclude_tags_and_cosign = (self.remote.exclude_tags or []) + ["sha256-*"]
114+
tag_list = filter_resources(
115+
self._full_tag_list, self.remote.include_tags, exclude_tags_and_cosign
116+
)
117+
else:
118+
tag_list = self._full_tag_list
111119
await pb.aincrement()
112120

121+
await self._process_tags(tag_list, signature_source)
122+
123+
if self.remote.include_tags or self.remote.exclude_tags:
124+
# Process cosign companion tags after all non-cosign tags are synced
125+
companion_tags = self._find_cosign_companion_tags()
126+
if companion_tags:
127+
log.info(
128+
"Syncing %d cosign companion tag(s) for filtered images",
129+
len(companion_tags),
130+
)
131+
await self._process_tags(
132+
companion_tags, signature_source, msg="Processing Cosign Companion Tags"
133+
)
134+
135+
def _find_cosign_companion_tags(self):
136+
"""Find cosign companion tags for synced digests."""
137+
companion_tags = []
138+
for tag in self._cosign_tags:
139+
# Convert sha256-<digest>[.sig|.att|.sbom] to sha256:<digest>
140+
tag_without_suffix = tag.rsplit(".", 1)[0]
141+
digest = tag_without_suffix.replace("-", ":", 1)
142+
if digest in self._synced_digests:
143+
companion_tags.append(tag)
144+
return companion_tags
145+
146+
async def _process_tags(self, tag_list, signature_source, msg="Processing Tags"):
147+
"""Download and process a batch of tags, creating declarative content objects."""
148+
BATCH_SIZE = 500
149+
to_download = []
150+
113151
for tag_name in tag_list:
114152
relative_url = "/v2/{name}/manifests/{tag}".format(
115153
name=self.remote.namespaced_upstream_name, tag=tag_name
@@ -121,7 +159,7 @@ async def run(self):
121159
)
122160

123161
async with ProgressReport(
124-
message="Processing Tags",
162+
message=msg,
125163
code="sync.processing.tag",
126164
total=len(tag_list),
127165
) as pb_parsed_tags:
@@ -135,25 +173,21 @@ async def run(self):
135173

136174
digest = calculate_digest(raw_text_data)
137175
tag_name = response.url.split("/")[-1]
176+
media_type = determine_media_type(content_data, response)
138177

139-
# Look for cosign signatures
140-
# cosign signature has a tag convention 'sha256-1234.sig'
141178
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
179+
if not (
180+
self._is_cosign_companion_tag(tag_name, media_type, content_data)
181+
or await self._has_cosign_signature(digest)
145182
):
146-
# skip this tag, there is no corresponding signature
147183
log.info(
148184
"The unsigned image {digest} can't be synced "
149185
"due to a requirement to sync signed content "
150186
"only.".format(digest=digest)
151187
)
152-
# Count the skipped tagks as parsed too.
153188
await pb_parsed_tags.aincrement()
154189
continue
155190

156-
media_type = determine_media_type(content_data, response)
157191
validate_manifest(content_data, media_type, digest)
158192

159193
tag_dc = DeclarativeContent(Tag(name=tag_name))
@@ -183,23 +217,21 @@ async def run(self):
183217
tag=tag_name,
184218
)
185219
)
186-
# do not pass down the pipeline a manifest list with unsigned
187-
# manifests.
188220
break
189221
self.signature_dcs.extend(man_sig_dcs)
190222
list_dc.extra_data["listed_manifests"].append(listed_manifest)
191223

192224
else:
193225
# Manifest indices can be signed too. It is not mandatory.
194226
# If signature is available mirror it.
227+
self._synced_digests.add(digest)
195228
if signature_source is not None:
196229
list_sig_dcs = await self.create_signatures(list_dc, signature_source)
197230
if list_sig_dcs:
198231
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)
201232
tag_dc.extra_data["tagged_manifest_dc"] = list_dc
202233
for listed_manifest in list_dc.extra_data["listed_manifests"]:
234+
self._synced_digests.add(listed_manifest["manifest_dc"].content.digest)
203235
await self.handle_blobs(
204236
listed_manifest["manifest_dc"], listed_manifest["content_data"]
205237
)
@@ -215,9 +247,9 @@ async def run(self):
215247
if signature_source is not None:
216248
man_sig_dcs = await self.create_signatures(man_dc, signature_source)
217249
if self.signed_only and not man_sig_dcs:
218-
# do not pass down the pipeline unsigned manifests
219250
continue
220251
self.signature_dcs.extend(man_sig_dcs)
252+
self._synced_digests.add(digest)
221253
tag_dc.extra_data["tagged_manifest_dc"] = man_dc
222254
await self.handle_blobs(man_dc, content_data)
223255
self.tag_dcs.append(tag_dc)
@@ -239,6 +271,35 @@ async def run(self):
239271

240272
await self.resolve_flush()
241273

274+
async def _has_cosign_signature(self, digest):
275+
"""Check if a digest has a cosign signature."""
276+
cosign_digest = digest.replace("sha256:", "sha256-")
277+
if f"{cosign_digest}.sig" in self._cosign_tags:
278+
return True
279+
if cosign_digest in self._cosign_tags:
280+
# Potential V3 cosign tag needs to be checked if it is a cosign companion tag
281+
relative_url = f"/v2/{self.remote.namespaced_upstream_name}/manifests/{cosign_digest}"
282+
tag_url = urljoin(self.remote.url, relative_url)
283+
content_data, raw_text_data, response = await self._download_manifest_data(tag_url)
284+
media_type = determine_media_type(content_data, response)
285+
if self._is_cosign_companion_tag(cosign_digest, media_type, content_data):
286+
return True
287+
return False
288+
289+
def _is_cosign_companion_tag(self, tag_name, media_type, content_data):
290+
"""Check if a fetched tag is a cosign companion tag."""
291+
if tag_name.startswith("sha256-"):
292+
if len(tag_name) == 71:
293+
# V3 cosign companion tags are index lists with each entry having an artifactType
294+
if media_type == MEDIA_TYPE.INDEX_OCI:
295+
if manifests := content_data.get("manifests", []):
296+
if all("artifactType" in entry for entry in manifests):
297+
return True
298+
elif any(tag_name.endswith(s) for s in COSIGN_TAG_SUFFIXES):
299+
# V2 cosign companion tags are in the format sha256-<digest>.<suffix>
300+
return True
301+
return False
302+
242303
async def get_signature_source(self):
243304
"""
244305
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)