Skip to content

Commit d38ba80

Browse files
author
Tobias Pfaffelmoser
committed
fix(triage): fetch tag-based attestation via oras .att tag, not cosign
The tag-based triage path called `cosign download attestation`, whose attestation discovery is cosign-version-dependent: newer cosign resolves attestations through the OCI Referrers API, which JFrog does not implement, so it returns zero attestations even though the `sha256-<digest>.att` tag exists. retrieve-triage then succeeded or failed purely based on which cosign was installed — v2.4.2 reads the .att tag, newer versions return nothing, so every JFrog image was reported as having no triage. Fetch the .att manifest directly with oras and decode its DSSE envelope layers (most recent triage predicate wins), falling back to cosign only if that finds nothing. oras/crane are already prerequisites and work against JFrog, so the result no longer depends on the local cosign version. Verified by stubbing 'cosign download attestation' to return nothing (the reported failure mode): all images still fetch triage via the oras .att path.
1 parent 7a50e8c commit d38ba80

1 file changed

Lines changed: 90 additions & 0 deletions

File tree

scanner_py/core/attestation.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,13 +545,27 @@ def extract_triage_tag_based(
545545
(e.g. JFrog remote/virtual repos). Tag-based attestations are stored as
546546
regular OCI tags that any registry can proxy.
547547
548+
Tries two ways, in order:
549+
1. Fetch the `sha256-<digest>.att` manifest directly via oras and decode
550+
its DSSE layers. This is independent of the local cosign version.
551+
Newer cosign releases resolve `download attestation` through the OCI
552+
Referrers API, which JFrog does not implement, so cosign can return
553+
zero attestations even when the .att tag exists — making the result
554+
depend on whatever cosign happens to be installed.
555+
2. `cosign download attestation` as a fallback.
556+
548557
Args:
549558
image: Image reference
550559
output_file: Path to save triage file
551560
552561
Returns:
553562
True if successful
554563
"""
564+
# 1) Direct .att tag fetch via oras — cosign-version-independent.
565+
if self._extract_triage_from_att_tag(image, output_file):
566+
return True
567+
568+
# 2) Fall back to cosign's own attestation download.
555569
triage_pred_type = PREDICATE_TYPE_MAP[AttestationTypeEnum.TRIAGE]
556570

557571
result = run_command(
@@ -580,6 +594,82 @@ def extract_triage_tag_based(
580594
logger.debug(f"No triage predicate in tag-based attestations for {image}")
581595
return False
582596

597+
def _extract_triage_from_att_tag(
598+
self, image: str, output_file: str
599+
) -> bool:
600+
"""
601+
Fetch a tag-based attestation by pulling the `sha256-<digest>.att` OCI
602+
image directly with oras and decoding its DSSE layers — no cosign.
603+
604+
Cosign stores tag-based attestations as an image tagged
605+
`sha256-<image-digest>.att` whose layers are
606+
`application/vnd.dsse.envelope.v1+json` blobs. We resolve the image
607+
digest, fetch that manifest, and scan its layers for a triage predicate.
608+
If the image was re-attested multiple times there are several layers;
609+
the most recent matching one wins.
610+
611+
Unlike `cosign download attestation`, this only uses oras/crane registry
612+
reads, so it works against registries without the Referrers API (JFrog)
613+
regardless of the installed cosign version.
614+
615+
Returns True if a triage attestation was found and written.
616+
"""
617+
triage_pred_type = PREDICATE_TYPE_MAP[AttestationTypeEnum.TRIAGE]
618+
619+
digest = self.resolve_digest(image)
620+
if not digest:
621+
return False
622+
623+
image_name = self._strip_tag(image)
624+
att_tag = f"{image_name}:{digest.replace('sha256:', 'sha256-')}.att"
625+
626+
result = run_command(
627+
["oras", "manifest", "fetch", att_tag],
628+
timeout=self.timeout,
629+
)
630+
if not result.success:
631+
logger.debug(f"No .att attestation tag for {image} ({att_tag})")
632+
return False
633+
634+
try:
635+
layers = json.loads(result.stdout).get("layers", [])
636+
except json.JSONDecodeError:
637+
return False
638+
639+
found_payload = None
640+
for layer in layers:
641+
layer_digest = layer.get("digest")
642+
if not layer_digest:
643+
continue
644+
with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as f:
645+
blob_file = f.name
646+
try:
647+
blob_result = run_command(
648+
["oras", "blob", "fetch", f"{image_name}@{layer_digest}",
649+
"--output", blob_file],
650+
timeout=self.timeout,
651+
)
652+
if not blob_result.success:
653+
continue
654+
with open(blob_file) as bf:
655+
envelope = json.load(bf)
656+
payload = json.loads(base64.b64decode(envelope.get("payload", "")))
657+
if payload.get("predicateType") == triage_pred_type:
658+
found_payload = payload # keep scanning; last match = most recent
659+
except (json.JSONDecodeError, KeyError, ValueError, OSError):
660+
continue
661+
finally:
662+
Path(blob_file).unlink(missing_ok=True)
663+
664+
if found_payload is None:
665+
logger.debug(f"No triage predicate in .att tag for {image}")
666+
return False
667+
668+
with open(output_file, "w") as f:
669+
json.dump(found_payload, f, indent=2)
670+
logger.debug(f"Tag-based triage attestation (oras .att) written to {output_file}")
671+
return True
672+
583673

584674
class LegacyTriageExtractor:
585675
"""

0 commit comments

Comments
 (0)