Skip to content

Commit a501b16

Browse files
committed
Fix syncing cosign signature tags when using remote include_tags field
fixes: #2096 Assisted by: claude-opus-4.6 (cherry picked from commit 99f4472)
1 parent 2910217 commit a501b16

6 files changed

Lines changed: 356 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: 84 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,36 @@ 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+
# V3 tags have format: sha256-<digest> with no suffixes
293+
if len(tag_name) == 71:
294+
# V3 tags are index lists with each entry having an artifactType
295+
if media_type == MEDIA_TYPE.INDEX_OCI:
296+
if manifests := content_data.get("manifests", []):
297+
if all("artifactType" in entry for entry in manifests):
298+
return True
299+
# V2 tags have format: sha256-<digest>.<suffix>
300+
elif any(tag_name.endswith(s) for s in COSIGN_TAG_SUFFIXES):
301+
return True
302+
return False
303+
242304
async def get_signature_source(self):
243305
"""
244306
Find out where signatures come from: sigstore, extension API or not available at all.

pulp_container/tests/functional/api/test_sync.py

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,22 @@
88

99
from pulp_container.constants import MANIFEST_TYPE, MEDIA_TYPE
1010
from pulp_container.tests.functional.constants import (
11+
PULP_COSIGN_COMPANION_TAGS,
12+
PULP_COSIGN_TAGS_MANIFEST_A_DIGEST,
13+
PULP_COSIGN_TAGS_MANIFEST_B_DIGEST,
14+
PULP_COSIGN_TAGS_MANIFEST_C_DIGEST,
1115
PULP_FIXTURE_1,
1216
PULP_HELLO_WORLD_LINUX_AMD64_DIGEST,
1317
PULP_LABELED_FIXTURE,
1418
REGISTRY_V2_FEED_URL,
1519
)
1620

21+
22+
def _cosign_registry_tag_name(image_digest: str) -> str:
23+
"""Cosign companion tags use ``sha256-<hex>`` instead of ``sha256:<hex>``."""
24+
return image_digest.replace("sha256:", "sha256-", 1)
25+
26+
1727
# there is a manifest list and a listed manifest
1828
BOOTABLE_MANIFESTS_COUNT = 2
1929

@@ -23,12 +33,12 @@ def synced_container_repository_factory(
2333
container_repository_factory, container_remote_factory, container_repository_api, container_sync
2434
):
2535
def _synced_container_repository_factory(
26-
url=REGISTRY_V2_FEED_URL, include_tags=None, exclude_tags=None
36+
url=REGISTRY_V2_FEED_URL, include_tags=None, exclude_tags=None, upstream_name=PULP_FIXTURE_1
2737
):
2838
"""Sync a new repository with the included tags passed as an argument."""
2939
remote = container_remote_factory(
3040
url,
31-
upstream_name=PULP_FIXTURE_1,
41+
upstream_name=upstream_name,
3242
include_tags=include_tags,
3343
exclude_tags=exclude_tags,
3444
)
@@ -187,3 +197,81 @@ def test_sync_with_complex_filtering(
187197
tags = container_tag_api.list(repository_version=synced_repo.latest_version_href).results
188198

189199
assert sorted(include_tags) == sorted(tag.name for tag in tags)
200+
201+
202+
@pytest.mark.parallel
203+
def test_sync_cosign_companion_tags(
204+
synced_container_repository_factory, container_tag_api, container_manifest_api
205+
):
206+
"""Test syncing a repository with cosign companion tags."""
207+
synced_repo = synced_container_repository_factory(upstream_name=PULP_COSIGN_COMPANION_TAGS)
208+
209+
tags = container_tag_api.list(repository_version=synced_repo.latest_version_href)
210+
manifests = container_manifest_api.list(repository_version=synced_repo.latest_version_href)
211+
assert tags.count == 9
212+
cr = _cosign_registry_tag_name
213+
expected_tag_names = {
214+
"manifest_a",
215+
"manifest_b",
216+
"manifest_c",
217+
"manifest_d",
218+
f"{cr(PULP_COSIGN_TAGS_MANIFEST_A_DIGEST)}.sig",
219+
f"{cr(PULP_COSIGN_TAGS_MANIFEST_A_DIGEST)}.att",
220+
f"{cr(PULP_COSIGN_TAGS_MANIFEST_B_DIGEST)}.sig",
221+
cr(PULP_COSIGN_TAGS_MANIFEST_B_DIGEST),
222+
cr(PULP_COSIGN_TAGS_MANIFEST_C_DIGEST),
223+
}
224+
assert {t.name for t in tags.results} == expected_tag_names
225+
assert manifests.count == 13
226+
227+
228+
@pytest.mark.parallel
229+
def test_sync_cosign_companion_tags_with_filtering(
230+
synced_container_repository_factory, container_tag_api, container_manifest_api
231+
):
232+
"""Test syncing a repository with cosign companion tags and filtering."""
233+
synced_repo = synced_container_repository_factory(
234+
upstream_name=PULP_COSIGN_COMPANION_TAGS, include_tags=["manifest_a"]
235+
)
236+
237+
tags = container_tag_api.list(repository_version=synced_repo.latest_version_href)
238+
manifests = container_manifest_api.list(repository_version=synced_repo.latest_version_href)
239+
assert tags.count == 3
240+
cr = _cosign_registry_tag_name
241+
assert {t.name for t in tags.results} == {
242+
"manifest_a",
243+
f"{cr(PULP_COSIGN_TAGS_MANIFEST_A_DIGEST)}.sig",
244+
f"{cr(PULP_COSIGN_TAGS_MANIFEST_A_DIGEST)}.att",
245+
}
246+
assert manifests.count == 3
247+
248+
synced_repo = synced_container_repository_factory(
249+
upstream_name=PULP_COSIGN_COMPANION_TAGS, include_tags=["manifest_b"]
250+
)
251+
252+
tags = container_tag_api.list(repository_version=synced_repo.latest_version_href)
253+
manifests = container_manifest_api.list(repository_version=synced_repo.latest_version_href)
254+
assert tags.count == 3
255+
assert {t.name for t in tags.results} == {
256+
"manifest_b",
257+
f"{cr(PULP_COSIGN_TAGS_MANIFEST_B_DIGEST)}.sig",
258+
cr(PULP_COSIGN_TAGS_MANIFEST_B_DIGEST),
259+
}
260+
assert manifests.count == 5 # The V3 sig is a manifest list with 2 manifests
261+
262+
synced_repo = synced_container_repository_factory(
263+
upstream_name=PULP_COSIGN_COMPANION_TAGS, exclude_tags=["manifest_a"]
264+
)
265+
266+
tags = container_tag_api.list(repository_version=synced_repo.latest_version_href)
267+
manifests = container_manifest_api.list(repository_version=synced_repo.latest_version_href)
268+
assert tags.count == 6
269+
assert {t.name for t in tags.results} == {
270+
"manifest_b",
271+
"manifest_c",
272+
"manifest_d",
273+
f"{cr(PULP_COSIGN_TAGS_MANIFEST_B_DIGEST)}.sig",
274+
cr(PULP_COSIGN_TAGS_MANIFEST_B_DIGEST),
275+
cr(PULP_COSIGN_TAGS_MANIFEST_C_DIGEST),
276+
}
277+
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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,26 @@
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
39+
40+
PULP_COSIGN_TAGS_MANIFEST_A_DIGEST = PULP_FIXTURE_1_MANIFEST_A_DIGEST
41+
PULP_COSIGN_TAGS_MANIFEST_B_DIGEST = (
42+
"sha256:f8634bb68dccf0dc2a3113933a67f91dc10c4ac17dee90988cb6bc4ae55cf802"
43+
)
44+
PULP_COSIGN_TAGS_MANIFEST_C_DIGEST = (
45+
"sha256:6489ee892f64e59755435ee53f7d10cce5588a7788b4b2ae4a510a8bbc92704d"
46+
)
47+
PULP_COSIGN_TAGS_MANIFEST_D_DIGEST = (
48+
"sha256:badde852ff2ee4daeff0cf1c2b1e9c01a193ca6e93e0fce8acce8a7d6a4ade06"
49+
)

0 commit comments

Comments
 (0)