diff --git a/acceptance/examples/image_referrers.rego b/acceptance/examples/image_referrers.rego new file mode 100644 index 000000000..414a693f5 --- /dev/null +++ b/acceptance/examples/image_referrers.rego @@ -0,0 +1,79 @@ +package referrers + +import rego.v1 + +# METADATA +# custom: +# short_name: count +deny contains result if { + refs := ec.oci.image_referrers(input.image.ref) + count(refs) != 2 + result := { + "code": "referrers.count", + "msg": sprintf("Expected 2 referrers, got %d: %v", [count(refs), refs]), + } +} + +# METADATA +# custom: +# short_name: format +deny contains result if { + descriptors := ec.oci.image_referrers(input.image.ref) + not all_descriptors_valid_format(descriptors) + result := { + "code": "referrers.format", + "msg": sprintf("Invalid referrer descriptor format in: %v", [descriptors]), + } +} + +# METADATA +# custom: +# short_name: content_types +deny contains result if { + descriptors := ec.oci.image_referrers(input.image.ref) + not has_expected_artifact_types(descriptors) + result := { + "code": "referrers.content_types", + "msg": sprintf("Expected one signature and one attestation artifact type in referrers: %v", [descriptors]), + } +} + +all_descriptors_valid_format(descriptors) if { + every descriptor in descriptors { + # Each descriptor should have required fields + descriptor.digest != "" + descriptor.mediaType != "" + descriptor.size >= 0 + descriptor.artifactType != "" + descriptor.ref != "" + + # Digest should be a digest-only format: sha256: + startswith(descriptor.digest, "sha256:") + not contains(descriptor.digest, "@") + + # Ref should be a full OCI reference with digest format: registry/repo@sha256: + contains(descriptor.ref, "@") + contains(descriptor.ref, "sha256:") + # Split by @ and verify format + parts := split(descriptor.ref, "@") + count(parts) == 2 + # Verify digest format matches + parts[1] == descriptor.digest + } +} + +has_expected_artifact_types(descriptors) if { + # Check that we have one signature artifact directly from descriptors + signature_artifacts := [d | + some d in descriptors + d.artifactType == "application/vnd.dev.cosign.simplesigning.v1+json" + ] + count(signature_artifacts) == 1 + + # Check that we have one attestation artifact directly from descriptors + attestation_artifacts := [d | + some d in descriptors + d.artifactType == "application/vnd.dsse.envelope.v1+json" + ] + count(attestation_artifacts) == 1 +} diff --git a/acceptance/examples/image_tag_refs.rego b/acceptance/examples/image_tag_refs.rego new file mode 100644 index 000000000..8b0decb79 --- /dev/null +++ b/acceptance/examples/image_tag_refs.rego @@ -0,0 +1,75 @@ +package tag_refs + +import rego.v1 + +# METADATA +# custom: +# short_name: count +deny contains result if { + refs := ec.oci.image_tag_refs(input.image.ref) + count(refs) != 2 + result := { + "code": "tag_refs.count", + "msg": sprintf("Expected 2 tag-based artifact references, got %d: %v", [count(refs), refs]), + } +} + +# METADATA +# custom: +# short_name: format +deny contains result if { + refs := ec.oci.image_tag_refs(input.image.ref) + not all_refs_valid_format(refs) + result := { + "code": "tag_refs.format", + "msg": sprintf("Invalid tag reference format in: %v", [refs]), + } +} + +# METADATA +# custom: +# short_name: sig_count +deny contains result if { + refs := ec.oci.image_tag_refs(input.image.ref) + sig_count := count([ref | some ref in refs; contains(ref, ".sig")]) + sig_count != 1 + result := { + "code": "tag_refs.sig_count", + "msg": sprintf("Expected 1 .sig reference, got %d", [sig_count]), + } +} + +# METADATA +# custom: +# short_name: att_count +deny contains result if { + refs := ec.oci.image_tag_refs(input.image.ref) + att_count := count([ref | some ref in refs; contains(ref, ".att")]) + att_count != 1 + result := { + "code": "tag_refs.att_count", + "msg": sprintf("Expected 1 .att reference, got %d", [att_count]), + } +} + +all_refs_valid_format(refs) if { + every ref in refs { + # Each ref should be a valid OCI reference with tag format: registry/repo:sha256-. + contains(ref, ":") + contains(ref, "sha256-") + # Split by : and get the last part (the tag) + parts := split(ref, ":") + tag_part := parts[count(parts) - 1] + # Tag should start with sha256- and end with .sig or .att + startswith(tag_part, "sha256-") + valid_suffix(tag_part) + } +} + +valid_suffix(tag) if { + endswith(tag, ".sig") +} + +valid_suffix(tag) if { + endswith(tag, ".att") +} diff --git a/acceptance/image/image.go b/acceptance/image/image.go index 30774fc43..a6c22125d 100644 --- a/acceptance/image/image.go +++ b/acceptance/image/image.go @@ -91,11 +91,17 @@ type Signature struct { // "registry:port/acceptance/sha256-hash.att" and the Signature values hold more // information about the signature of the image/data itself. type imageState struct { + // Legacy tag-based artifacts (e.g., sha256-.sig, sha256-.att) AttestationSignatures map[string]Signature Attestations map[string]string Images map[string]string ImageSignatures map[string]Signature Signatures map[string]string + // OCI Referrers API artifacts (attached via manifest subject field) + ReferrerAttestationSignatures map[string]Signature + ReferrerAttestations map[string]string + ReferrerImageSignatures map[string]Signature + ReferrerSignatures map[string]string } func (i *imageState) Initialize() { @@ -114,6 +120,18 @@ func (i *imageState) Initialize() { if i.Signatures == nil { i.Signatures = map[string]string{} } + if i.ReferrerAttestationSignatures == nil { + i.ReferrerAttestationSignatures = map[string]Signature{} + } + if i.ReferrerAttestations == nil { + i.ReferrerAttestations = map[string]string{} + } + if i.ReferrerImageSignatures == nil { + i.ReferrerImageSignatures = map[string]Signature{} + } + if i.ReferrerSignatures == nil { + i.ReferrerSignatures = map[string]string{} + } } func (i imageState) Key() any { @@ -140,6 +158,90 @@ func imageFrom(ctx context.Context, imageName string) (v1.Image, error) { // image, same as `cosign sign` or Tekton Chains would, of that named image and pushes it // to the stub registry as a new tag for that image akin to how cosign and Tekton Chains // do it. This implementation includes transparency log upload to generate bundle information. +// signatureData holds the signature payload, layer, annotations and bundle +type signatureData struct { + payload []byte + rawSignature []byte + signatureBase64 string + signatureStruct Signature + signatureLayer v1.Layer + rekorBundle *bundle.RekorBundle + annotations map[string]string +} + +// createSignatureData creates and signs the image signature with bundle information +func createSignatureData(ctx context.Context, imageName string, digestImage name.Digest, signer signature.SignerVerifier) (*signatureData, error) { + // Create the cosign signature payload and sign it + payload, rawSignature, err := signature.SignImage(signer, digestImage, map[string]interface{}{}) + if err != nil { + return nil, err + } + + signatureBase64 := base64.StdEncoding.EncodeToString(rawSignature) + + // Create the signature structure for the stub rekor entry + signatureStruct := Signature{ + KeyID: "", + Signature: signatureBase64, + } + + signatureJSON, err := json.Marshal(signatureStruct) + if err != nil { + return nil, fmt.Errorf("failed to marshal signature structure: %w", err) + } + + // Get the public key from the signer for hashedrekord validation + publicKey, err := signer.PublicKey() + if err != nil { + return nil, fmt.Errorf("failed to get public key: %w", err) + } + + publicKeyBytes, err := cryptoutils.MarshalPublicKeyToPEM(publicKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key: %w", err) + } + + // Create stubs for both Rekor entry signature creation and retrieval endpoints + err = rekor.StubRekorEntryCreationForSignature(ctx, payload, rawSignature, signatureJSON, publicKeyBytes) + if err != nil { + return nil, fmt.Errorf("error stubbing rekor endpoints: %w", err) + } + + // Upload to transparency log to get bundle information like Tekton Chains does + rekorBundle, err := uploadToTransparencyLog(ctx, payload, rawSignature, signer) + if err != nil { + return nil, err + } + + // Create the signature layer with bundle information using static.WithBundle + signatureLayer, err := static.NewSignature(payload, signatureBase64, static.WithBundle(rekorBundle)) + if err != nil { + return nil, err + } + + // Extract bundle information from signatureLayer to include in annotations + annotations := map[string]string{ + static.SignatureAnnotationKey: signatureBase64, + } + + // Add bundle annotation if bundle information exists + bundleJSON, err := json.Marshal(rekorBundle) + if err != nil { + return nil, fmt.Errorf("failed to marshal bundle for annotation: %w", err) + } + annotations[static.BundleAnnotationKey] = string(bundleJSON) + + return &signatureData{ + payload: payload, + rawSignature: rawSignature, + signatureBase64: signatureBase64, + signatureStruct: signatureStruct, + signatureLayer: signatureLayer, + rekorBundle: rekorBundle, + annotations: annotations, + }, nil +} + func CreateAndPushImageSignature(ctx context.Context, imageName string, keyName string) (context.Context, error) { var state *imageState ctx, err := testenv.SetupState(ctx, &state) @@ -152,141 +254,172 @@ func CreateAndPushImageSignature(ctx context.Context, imageName string, keyName return ctx, nil } - image, err := imageFrom(ctx, imageName) + _, digest, digestImage, err := getImageDigestAndRef(ctx, imageName) if err != nil { return ctx, err } - digest, err := image.Digest() + signer, err := crypto.SignerWithKey(ctx, keyName) if err != nil { return ctx, err } - // the name of the image to sign referenced by the digest - digestImage, err := name.NewDigest(fmt.Sprintf("%s@%s", imageName, digest.String())) + sigData, err := createSignatureData(ctx, imageName, digestImage, signer) if err != nil { return ctx, err } - signer, err := crypto.SignerWithKey(ctx, keyName) + // creates the signature image with the correct media type and config and appends + // the signature layer to it + signatureImage := mutate.MediaType(empty.Image, types.OCIManifestSchema1) + signatureImage = mutate.ConfigMediaType(signatureImage, types.OCIConfigJSON) + signatureImage, err = mutate.Append(signatureImage, mutate.Addendum{ + Layer: sigData.signatureLayer, + Annotations: sigData.annotations, + }) if err != nil { return ctx, err } - // Create the cosign signature payload and sign it - payload, rawSignature, err := signature.SignImage(signer, digestImage, map[string]interface{}{}) + // the name of the image + the .sig tag + ref, err := registry.ImageReferenceInStubRegistry(ctx, fmt.Sprintf("%s:%s-%s.sig", imageName, digest.Algorithm, digest.Hex)) if err != nil { return ctx, err } - signatureBase64 := base64.StdEncoding.EncodeToString(rawSignature) - - // Create the signature structure for the stub rekor entry - signature := Signature{ - KeyID: "", - Signature: signatureBase64, - } - - signatureJSON, err := json.Marshal(signature) + // push to the registry + err = remote.Write(ref, signatureImage) if err != nil { - return ctx, fmt.Errorf("failed to marshal signature structure: %w", err) + return ctx, err } - // Get the public key from the signer for hashedrekord validation - publicKey, err := signer.PublicKey() - if err != nil { - return ctx, fmt.Errorf("failed to get public key: %w", err) - } + state.Signatures[imageName] = ref.String() + state.ImageSignatures[imageName] = sigData.signatureStruct - publicKeyBytes, err := cryptoutils.MarshalPublicKeyToPEM(publicKey) - if err != nil { - return ctx, fmt.Errorf("failed to marshal public key: %w", err) - } + return ctx, nil +} - // Create stubs for both Rekor entry signature creation and retrieval endpoints - err = rekor.StubRekorEntryCreationForSignature(ctx, payload, rawSignature, signatureJSON, publicKeyBytes) +// uploadToTransparencyLog uploads a signature to the transparency log and returns the bundle +func uploadToTransparencyLog(ctx context.Context, payload []byte, rawSignature []byte, signer signature.SignerVerifier) (*bundle.RekorBundle, error) { + // Get public key or cert for transparency log upload + pkoc, err := getPublicKeyOrCert(signer) if err != nil { - return ctx, fmt.Errorf("error stubbing rekor endpoints: %w", err) + return nil, fmt.Errorf("failed to get public key or cert: %w", err) } - // Upload to transparency log to get bundle information like Tekton Chains does + // Get Rekor URL rekorURL, err := rekor.StubRekor(ctx) if err != nil { - return ctx, fmt.Errorf("failed to get stub rekor URL: %w", err) + return nil, fmt.Errorf("failed to get stub rekor URL: %w", err) } rekorClient, err := rc.GetRekorClient(rekorURL) if err != nil { - return ctx, fmt.Errorf("failed to get rekor client: %w", err) - } - - // Get public key or cert for transparency log upload - pkoc, err := getPublicKeyOrCert(signer) - if err != nil { - return ctx, fmt.Errorf("failed to get public key or cert: %w", err) + return nil, fmt.Errorf("failed to get rekor client: %w", err) } // Compute payload checksum checksum := sha256.New() if _, err := checksum.Write(payload); err != nil { - return ctx, fmt.Errorf("error checksuming payload: %w", err) + return nil, fmt.Errorf("error checksuming payload: %w", err) } tlogEntry, err := cosign.TLogUpload(ctx, rekorClient, rawSignature, checksum, pkoc) if err != nil { - return ctx, fmt.Errorf("failed to upload to transparency log: %w", err) + return nil, fmt.Errorf("failed to upload to transparency log: %w", err) } // Create bundle from the actual transparency log entry rekorBundle := bundle.EntryToBundle(tlogEntry) if rekorBundle == nil { - return ctx, fmt.Errorf("rekorBundle is nil after EntryToBundle") + return nil, fmt.Errorf("rekorBundle is nil after EntryToBundle") } - // Create the signature layer with bundle information using static.WithBundle - signatureLayer, err := static.NewSignature(payload, signatureBase64, static.WithBundle(rekorBundle)) + return rekorBundle, nil +} + +// getImageDigestAndRef returns the image, its digest, and digest reference for signing +func getImageDigestAndRef(ctx context.Context, imageName string) (v1.Image, v1.Hash, name.Digest, error) { + image, err := imageFrom(ctx, imageName) + if err != nil { + return nil, v1.Hash{}, name.Digest{}, err + } + + digest, err := image.Digest() + if err != nil { + return nil, v1.Hash{}, name.Digest{}, err + } + + // the name of the image to sign referenced by the digest + digestImage, err := name.NewDigest(fmt.Sprintf("%s@%s", imageName, digest.String())) + if err != nil { + return nil, v1.Hash{}, name.Digest{}, err + } + + return image, digest, digestImage, nil +} + +// getDigestRefForImage returns the digest reference for an image in the stub registry +func getDigestRefForImage(ctx context.Context, imageName string, digest v1.Hash) (name.Digest, error) { + // Get the registry reference for the image + ref, err := registry.ImageReferenceInStubRegistry(ctx, imageName) + if err != nil { + return name.Digest{}, err + } + + // Convert to digest reference + return name.NewDigest(fmt.Sprintf("%s@%s", ref.Context().Name(), digest.String())) +} + +// CreateAndPushImageSignatureReferrer creates a signature for a named image using OCI Referrers API +func CreateAndPushImageSignatureReferrer(ctx context.Context, imageName string, keyName string) (context.Context, error) { + var state *imageState + ctx, err := testenv.SetupState(ctx, &state) if err != nil { return ctx, err } - // Extract bundle information from signatureLayer to include in annotations - annotations := map[string]string{ - static.SignatureAnnotationKey: signatureBase64, + if _, ok := state.ReferrerSignatures[imageName]; ok { + // we already created the referrer signature + return ctx, nil } - // Add bundle annotation if bundle information exists - bundleJSON, err := json.Marshal(rekorBundle) + _, digest, digestImage, err := getImageDigestAndRef(ctx, imageName) if err != nil { - return ctx, fmt.Errorf("failed to marshal bundle for annotation: %w", err) + return ctx, err } - annotations[static.BundleAnnotationKey] = string(bundleJSON) - // creates the signature image with the correct media type and config and appends - // the signature layer to it - signatureImage := mutate.MediaType(empty.Image, types.OCIManifestSchema1) - signatureImage = mutate.ConfigMediaType(signatureImage, types.OCIConfigJSON) - signatureImage, err = mutate.Append(signatureImage, mutate.Addendum{ - Layer: signatureLayer, - Annotations: annotations, - }) + signer, err := crypto.SignerWithKey(ctx, keyName) if err != nil { return ctx, err } - // the name of the image + the .sig tag - ref, err := registry.ImageReferenceInStubRegistry(ctx, fmt.Sprintf("%s:%s-%s.sig", imageName, digest.Algorithm, digest.Hex)) + sigData, err := createSignatureData(ctx, imageName, digestImage, signer) if err != nil { return ctx, err } - // push to the registry - err = remote.Write(ref, signatureImage) + digestRef, err := getDigestRefForImage(ctx, imageName, digest) if err != nil { return ctx, err } - state.Signatures[imageName] = ref.String() - state.ImageSignatures[imageName] = signature + // Attach signature using OCI Referrers API + err = cosignRemote.WriteReferrer( + digestRef, + "application/vnd.dev.cosign.simplesigning.v1+json", + []v1.Layer{sigData.signatureLayer}, + sigData.annotations, + cosignRemote.WithRemoteOptions(remote.WithContext(ctx)), + ) + if err != nil { + return ctx, fmt.Errorf("failed to write signature referrer: %w", err) + } + + // NOTE: We store the subject image digest here for deduplication purposes only. + // This is NOT the referrer artifact's digest. + state.ReferrerSignatures[imageName] = digestRef.String() + state.ReferrerImageSignatures[imageName] = sigData.signatureStruct return ctx, nil } @@ -321,7 +454,7 @@ func createAndPushAttestationInternal(ctx context.Context, imageName, keyName st return ctx, nil } - image, err := imageFrom(ctx, imageName) + image, digest, _, err := getImageDigestAndRef(ctx, imageName) if err != nil { return ctx, err } @@ -400,37 +533,9 @@ func createAndPushAttestationInternal(ctx context.Context, imageName, keyName st } // Upload to transparency log to get bundle information like Tekton Chains does - rekorURL, err := rekor.StubRekor(ctx) + rekorBundle, err := uploadToTransparencyLog(ctx, signedAttestation, rawSignature, signer) if err != nil { - return ctx, fmt.Errorf("failed to get stub rekor URL: %w", err) - } - - rekorClient, err := rc.GetRekorClient(rekorURL) - if err != nil { - return ctx, fmt.Errorf("failed to get rekor client: %w", err) - } - - // Get public key or cert for transparency log upload - pkoc, err := getPublicKeyOrCert(signer) - if err != nil { - return ctx, fmt.Errorf("failed to get public key or cert: %w", err) - } - - // Compute payload checksum - checksum := sha256.New() - if _, err := checksum.Write(signedAttestation); err != nil { - return ctx, fmt.Errorf("error checksuming attestation: %w", err) - } - - tlogEntry, err := cosign.TLogUpload(ctx, rekorClient, rawSignature, checksum, pkoc) - if err != nil { - return ctx, fmt.Errorf("failed to upload attestation to transparency log: %w", err) - } - - // Create bundle from the actual transparency log entry - rekorBundle := bundle.EntryToBundle(tlogEntry) - if rekorBundle == nil { - return ctx, fmt.Errorf("rekorBundle is nil after EntryToBundle") + return ctx, err } // Create the attestation layer with bundle information using static.WithBundle @@ -468,11 +573,6 @@ func createAndPushAttestationInternal(ctx context.Context, imageName, keyName st return ctx, err } - digest, err := image.Digest() - if err != nil { - return ctx, err - } - // the name of the image + the .att tag ref, err := registry.ImageReferenceInStubRegistry(ctx, fmt.Sprintf("%s:%s-%s.att", imageName, digest.Algorithm, digest.Hex)) if err != nil { @@ -494,6 +594,129 @@ func createAndPushAttestationInternal(ctx context.Context, imageName, keyName st return ctx, nil } +// CreateAndPushAttestationReferrer creates an attestation for a named image using OCI Referrers API +func CreateAndPushAttestationReferrer(ctx context.Context, imageName, keyName string) (context.Context, error) { + var state *imageState + ctx, err := testenv.SetupState(ctx, &state) + if err != nil { + return ctx, err + } + + if state.ReferrerAttestations[imageName] != "" { + // we already created the referrer attestation + return ctx, nil + } + + image, digest, _, err := getImageDigestAndRef(ctx, imageName) + if err != nil { + return ctx, err + } + + // Create SLSA v0.2 statement + statement, err := attestation.CreateStatementFor(imageName, image) + if err != nil { + return ctx, err + } + + signedAttestation, err := attestation.SignStatement(ctx, keyName, statement) + if err != nil { + return ctx, err + } + + // Extract signature information from the signed attestation + var sig *cosign.Signatures + sig, err = unmarshallSignatures(signedAttestation) + if err != nil { + return ctx, err + } + if sig == nil { + return ctx, fmt.Errorf("failed to extract signature from attestation: no signatures found") + } + + state.ReferrerAttestationSignatures[imageName] = Signature{ + KeyID: sig.KeyID, + Signature: sig.Sig, + } + + // Extract raw signature from the signed attestation for transparency log upload + var rawSignature []byte + if sig.Sig != "" { + rawSignature, err = base64.StdEncoding.DecodeString(sig.Sig) + if err != nil { + return ctx, fmt.Errorf("failed to decode signature: %w", err) + } + } + + // Get the signer for transparency log operations + signer, err := crypto.SignerWithKey(ctx, keyName) + if err != nil { + return ctx, err + } + + // Get the public key from the signer for intoto validation + publicKey, err := signer.PublicKey() + if err != nil { + return ctx, fmt.Errorf("failed to get public key: %w", err) + } + + publicKeyBytes, err := cryptoutils.MarshalPublicKeyToPEM(publicKey) + if err != nil { + return ctx, fmt.Errorf("failed to marshal public key: %w", err) + } + + // Create stubs for both Rekor entry creation and retrieval endpoints for attestations + err = rekor.StubRekorEntryCreationForAttestation(ctx, signedAttestation, publicKeyBytes) + if err != nil { + return ctx, fmt.Errorf("error stubbing rekor endpoints for attestation: %w", err) + } + + // Upload to transparency log to get bundle information + rekorBundle, err := uploadToTransparencyLog(ctx, signedAttestation, rawSignature, signer) + if err != nil { + return ctx, err + } + + // Create the attestation layer with bundle information + attestationLayer, err := static.NewAttestation(signedAttestation, static.WithBundle(rekorBundle)) + if err != nil { + return ctx, err + } + + digestRef, err := getDigestRefForImage(ctx, imageName, digest) + if err != nil { + return ctx, err + } + + // Attach attestation using OCI Referrers API + annotations := map[string]string{ + "predicateType": statement.PredicateType, + } + + // Add bundle annotation if bundle information exists + bundleJSON, err := json.Marshal(rekorBundle) + if err != nil { + return ctx, fmt.Errorf("failed to marshal bundle for annotation: %w", err) + } + annotations[static.BundleAnnotationKey] = string(bundleJSON) + + err = cosignRemote.WriteReferrer( + digestRef, + "application/vnd.dsse.envelope.v1+json", + []v1.Layer{attestationLayer}, + annotations, + cosignRemote.WithRemoteOptions(remote.WithContext(ctx)), + ) + if err != nil { + return ctx, fmt.Errorf("failed to write attestation referrer: %w", err) + } + + // NOTE: We store the subject image digest here for deduplication purposes only. + // This is NOT the referrer artifact's digest. + state.ReferrerAttestations[imageName] = digestRef.String() + + return ctx, nil +} + // CreateAndPushV1Attestation for a named image creates a SLSA v1.0 attestation // and pushes it to the stub registry func CreateAndPushV1Attestation(ctx context.Context, imageName, keyName string) (context.Context, error) { @@ -1187,4 +1410,6 @@ func AddStepsTo(sc *godog.ScenarioContext) { sc.Step(`^an image named "([^"]*)" with attestation from "([^"]*)"$`, steal("att")) sc.Step(`^all images relating to "([^"]*)" are copied to "([^"]*)"$`, copyAllImages) sc.Step(`^an OCI blob with content "([^"]*)" in the repo "([^"]*)"$`, createAndPushLayer) + sc.Step(`^a valid image signature referrer of "([^"]*)" image signed by the "([^"]*)" key$`, CreateAndPushImageSignatureReferrer) + sc.Step(`^a valid attestation referrer of "([^"]*)" signed by the "([^"]*)" key$`, CreateAndPushAttestationReferrer) } diff --git a/acceptance/registry/registry.go b/acceptance/registry/registry.go index 1bd02c272..fa68e92f6 100644 --- a/acceptance/registry/registry.go +++ b/acceptance/registry/registry.go @@ -40,7 +40,8 @@ import ( ) // the image we're using to launch the stub image registry -const registryImage = "docker.io/registry:2.8.1" +// Using Zot which has proper OCI Referrers API support +const registryImage = "ghcr.io/project-zot/zot:v2.1.15" type key int diff --git a/docs/modules/ROOT/pages/ec_oci_image_referrers.adoc b/docs/modules/ROOT/pages/ec_oci_image_referrers.adoc new file mode 100644 index 000000000..9be69da05 --- /dev/null +++ b/docs/modules/ROOT/pages/ec_oci_image_referrers.adoc @@ -0,0 +1,15 @@ += ec.oci.image_referrers + +Discover artifacts attached to an image via OCI Referrers API. + +== Usage + + referrers = ec.oci.image_referrers(ref: string) + +== Parameters + +* `ref` (`string`): OCI image reference + +== Return + +`referrers` (`array>`): list of referrer descriptors discovered via OCI Referrers API diff --git a/docs/modules/ROOT/pages/ec_oci_image_tag_refs.adoc b/docs/modules/ROOT/pages/ec_oci_image_tag_refs.adoc new file mode 100644 index 000000000..2bd6dcea5 --- /dev/null +++ b/docs/modules/ROOT/pages/ec_oci_image_tag_refs.adoc @@ -0,0 +1,15 @@ += ec.oci.image_tag_refs + +Discover artifacts attached to an image via legacy tag-based discovery (cosign .sig, .att, .sbom suffixes). + +== Usage + + refs = ec.oci.image_tag_refs(ref: string) + +== Parameters + +* `ref` (`string`): OCI image reference + +== Return + +`refs` (`array`): list of tag-based artifact references diff --git a/docs/modules/ROOT/pages/rego_builtins.adoc b/docs/modules/ROOT/pages/rego_builtins.adoc index 0a95c0642..0b81fe7fe 100644 --- a/docs/modules/ROOT/pages/rego_builtins.adoc +++ b/docs/modules/ROOT/pages/rego_builtins.adoc @@ -22,6 +22,10 @@ information. |Fetch an Image Manifest from an OCI registry. |xref:ec_oci_image_manifests.adoc[ec.oci.image_manifests] |Fetch Image Manifests from an OCI registry in parallel. +|xref:ec_oci_image_referrers.adoc[ec.oci.image_referrers] +|Discover artifacts attached to an image via OCI Referrers API. +|xref:ec_oci_image_tag_refs.adoc[ec.oci.image_tag_refs] +|Discover artifacts attached to an image via legacy tag-based discovery (cosign .sig, .att, .sbom suffixes). |xref:ec_purl_is_valid.adoc[ec.purl.is_valid] |Determine whether or not a given PURL is valid. |xref:ec_purl_parse.adoc[ec.purl.parse] diff --git a/docs/modules/ROOT/partials/rego_nav.adoc b/docs/modules/ROOT/partials/rego_nav.adoc index 984603ff4..ff1022080 100644 --- a/docs/modules/ROOT/partials/rego_nav.adoc +++ b/docs/modules/ROOT/partials/rego_nav.adoc @@ -6,6 +6,8 @@ ** xref:ec_oci_image_index.adoc[ec.oci.image_index] ** xref:ec_oci_image_manifest.adoc[ec.oci.image_manifest] ** xref:ec_oci_image_manifests.adoc[ec.oci.image_manifests] +** xref:ec_oci_image_referrers.adoc[ec.oci.image_referrers] +** xref:ec_oci_image_tag_refs.adoc[ec.oci.image_tag_refs] ** xref:ec_purl_is_valid.adoc[ec.purl.is_valid] ** xref:ec_purl_parse.adoc[ec.purl.parse] ** xref:ec_sigstore_verify_attestation.adoc[ec.sigstore.verify_attestation] diff --git a/features/__snapshots__/validate_image.snap b/features/__snapshots__/validate_image.snap index 10af79f2b..42d878bb2 100755 --- a/features/__snapshots__/validate_image.snap +++ b/features/__snapshots__/validate_image.snap @@ -5593,3 +5593,187 @@ Error: success criteria not met [happy day with skip-image-sig-check flag:stderr - 1] --- + +[discover tag-based artifact references:stdout - 1] +{ + "success": true, + "components": [ + { + "name": "Unnamed", + "containerImage": "${REGISTRY}/acceptance/image-tag-refs@sha256:${REGISTRY_acceptance/image-tag-refs:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "tag_refs.att_count" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "tag_refs.count" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "tag_refs.format" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "tag_refs.sig_count" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_acceptance/image-tag-refs}" + } + ], + "attestations": [ + { + "type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v0.2", + "predicateBuildType": "https://tekton.dev/attestations/chains/pipelinerun@v2", + "signatures": [ + { + "keyid": "", + "sig": "${ATTESTATION_SIGNATURE_acceptance/image-tag-refs}" + } + ] + } + ] + } + ], + "key": "${known_PUBLIC_KEY_JSON}", + "policy": { + "sources": [ + { + "policy": [ + "git::${GITHOST}/git/image-tag-refs-policy?ref=${LATEST_COMMIT}" + ] + } + ], + "rekorUrl": "${REKOR}", + "publicKey": "${known_PUBLIC_KEY}" + }, + "ec-version": "${EC_VERSION}", + "effective-time": "${TIMESTAMP}" +} +--- + +[discover tag-based artifact references:stderr - 1] + +--- + +[discover artifact referrers via OCI Referrers API:stdout - 1] +{ + "success": true, + "components": [ + { + "name": "Unnamed", + "containerImage": "${REGISTRY}/acceptance/image-referrers@sha256:${REGISTRY_acceptance/image-referrers:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "referrers.content_types" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "referrers.count" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "referrers.format" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_acceptance/image-referrers}" + } + ], + "attestations": [ + { + "type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v0.2", + "predicateBuildType": "https://tekton.dev/attestations/chains/pipelinerun@v2", + "signatures": [ + { + "keyid": "", + "sig": "${ATTESTATION_SIGNATURE_acceptance/image-referrers}" + } + ] + } + ] + } + ], + "key": "${referrers_PUBLIC_KEY_JSON}", + "policy": { + "sources": [ + { + "policy": [ + "git::${GITHOST}/git/image-referrers-policy?ref=${LATEST_COMMIT}" + ] + } + ], + "rekorUrl": "${REKOR}", + "publicKey": "${referrers_PUBLIC_KEY}" + }, + "ec-version": "${EC_VERSION}", + "effective-time": "${TIMESTAMP}" +} +--- + +[discover artifact referrers via OCI Referrers API:stderr - 1] + +--- diff --git a/features/validate_image.feature b/features/validate_image.feature index c0585cb7b..d63228eff 100644 --- a/features/validate_image.feature +++ b/features/validate_image.feature @@ -1151,6 +1151,56 @@ Feature: evaluate enterprise contract Then the exit status should be 0 Then the output should match the snapshot + Scenario: discover tag-based artifact references + Given a key pair named "known" + Given an image named "acceptance/image-tag-refs" + Given a valid image signature of "acceptance/image-tag-refs" image signed by the "known" key + Given a valid attestation of "acceptance/image-tag-refs" signed by the "known" key + Given a git repository named "image-tag-refs-policy" with + | main.rego | examples/image_tag_refs.rego | + Given policy configuration named "ec-policy" with specification + """ + { + "sources": [ + { + "policy": [ + "git::https://${GITHOST}/git/image-tag-refs-policy" + ] + } + ] + } + """ + When ec command is run with "validate image --image ${REGISTRY}/acceptance/image-tag-refs --policy acceptance/ec-policy --public-key ${known_PUBLIC_KEY} --rekor-url ${REKOR} --show-successes --output json" + Then the exit status should be 0 + Then the output should match the snapshot + + Scenario: discover artifact referrers via OCI Referrers API + Given a key pair named "referrers" + Given an image named "acceptance/image-referrers" + # Create referrer-based artifacts using OCI Referrers API - these will be discovered by ec.oci.image_referrers() + Given a valid image signature referrer of "acceptance/image-referrers" image signed by the "referrers" key + Given a valid attestation referrer of "acceptance/image-referrers" signed by the "referrers" key + # Also create legacy tag-based artifacts to satisfy built-in signature/attestation verification + Given a valid image signature of "acceptance/image-referrers" image signed by the "referrers" key + Given a valid attestation of "acceptance/image-referrers" signed by the "referrers" key + Given a git repository named "image-referrers-policy" with + | main.rego | examples/image_referrers.rego | + Given policy configuration named "ec-policy" with specification + """ + { + "sources": [ + { + "policy": [ + "git::https://${GITHOST}/git/image-referrers-policy" + ] + } + ] + } + """ + When ec command is run with "validate image --image ${REGISTRY}/acceptance/image-referrers --policy acceptance/ec-policy --public-key ${referrers_PUBLIC_KEY} --rekor-url ${REKOR} --show-successes --output json" + Then the exit status should be 0 + Then the output should match the snapshot + Scenario: tracing and debug logging Given a key pair named "trace_debug" And an image named "acceptance/trace-debug" diff --git a/internal/rego/oci/oci.go b/internal/rego/oci/oci.go index aa0cc87ed..17c54b8d1 100644 --- a/internal/rego/oci/oci.go +++ b/internal/rego/oci/oci.go @@ -37,10 +37,12 @@ import ( "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/rego" "github.com/open-policy-agent/opa/v1/topdown/builtins" "github.com/open-policy-agent/opa/v1/types" + ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote" log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "golang.org/x/sync/singleflight" @@ -59,6 +61,8 @@ const ( ociImageManifestsBatchName = "ec.oci.image_manifests" ociImageFilesName = "ec.oci.image_files" ociImageIndexName = "ec.oci.image_index" + ociImageTagRefsName = "ec.oci.image_tag_refs" + ociImageReferrersName = "ec.oci.image_referrers" maxTarEntrySizeConst = 500 * 1024 * 1024 // 500MB ) @@ -417,6 +421,65 @@ func registerOCIImageIndex() { }) } +func registerOCIImageTagRefs() { + resultType := types.NewArray([]types.Type{types.S}, nil) + + decl := rego.Function{ + Name: ociImageTagRefsName, + Decl: types.NewFunction( + types.Args( + types.Named("ref", types.S).Description("OCI image reference"), + ), + types.Named("refs", resultType).Description("list of tag-based artifact references"), + ), + Memoize: true, + Nondeterministic: true, + } + + rego.RegisterBuiltin1(&decl, ociImageTagRefs) + ast.RegisterBuiltin(&ast.Builtin{ + Name: decl.Name, + Description: "Discover artifacts attached to an image via legacy tag-based discovery (cosign .sig, .att, .sbom suffixes).", + Decl: decl.Decl, + Nondeterministic: decl.Nondeterministic, + }) +} + +func registerOCIImageReferrers() { + descriptor := types.NewObject( + []*types.StaticProperty{ + {Key: "mediaType", Value: types.S}, + {Key: "size", Value: types.N}, + {Key: "digest", Value: types.S}, + {Key: "artifactType", Value: types.S}, + {Key: "ref", Value: types.S}, + }, + nil, + ) + + resultType := types.NewArray([]types.Type{descriptor}, nil) + + decl := rego.Function{ + Name: ociImageReferrersName, + Decl: types.NewFunction( + types.Args( + types.Named("ref", types.S).Description("OCI image reference"), + ), + types.Named("referrers", resultType).Description("list of referrer descriptors discovered via OCI Referrers API"), + ), + Memoize: true, + Nondeterministic: true, + } + + rego.RegisterBuiltin1(&decl, ociImageReferrers) + ast.RegisterBuiltin(&ast.Builtin{ + Name: decl.Name, + Description: "Discover artifacts attached to an image via OCI Referrers API.", + Decl: decl.Decl, + Nondeterministic: decl.Nondeterministic, + }) +} + func ociBlob(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { return ociBlobInternal(bctx, a, true) } @@ -1299,6 +1362,168 @@ func ociImageIndex(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { return result.(*ast.Term), nil } +// ociImageTagRefs discovers tag-based artifacts attached to an image using legacy cosign conventions. +// It checks for .sig, .att, and .sbom suffixed tags and returns references to any that exist. +// Returns nil if the reference cannot be resolved. +func ociImageTagRefs(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { + logger := log.WithField("function", ociImageTagRefsName) + + uriValue, ok := a.Value.(ast.String) + if !ok { + logger.Error("input is not a string") + return nil, nil + } + refStr := string(uriValue) + logger = logger.WithField("input_ref", refStr) + + client := oci.NewClient(bctx.Context) + + // Resolve to digest if needed + resolvedStr, ref, err := resolveIfNeeded(client, refStr) + if err != nil { + logger.WithError(err).Error("failed to resolve reference") + return nil, nil + } + + // Convert to digest reference (needed for cosign tag functions) + var digestRef name.Digest + if d, ok := ref.(name.Digest); ok { + // Already a digest + digestRef = d + } else { + // Tag reference - parse the resolved string which includes the digest + digestRef, err = name.NewDigest(resolvedStr) + if err != nil { + logger.WithError(err).Error("failed to create digest reference from resolved string") + return nil, nil + } + } + + // Use cosign's tag computation functions with remote options + remoteOpts := oci.CreateRemoteOptions(bctx.Context) + + var tagRefs []*ast.Term + + // Check for tag-based signature artifact (.sig suffix) + if sigTag, err := ociremote.SignatureTag(digestRef, ociremote.WithRemoteOptions(remoteOpts...)); err == nil { + if _, err := client.Head(sigTag); err == nil { + tagRefs = append(tagRefs, ast.StringTerm(sigTag.String())) + logger.WithField("tag", sigTag.String()).Debug("found tag-based signature artifact") + } else if isNotFoundError(err) { + logger.WithField("tag", sigTag.String()).Debug("tag-based signature artifact does not exist") + } else { + logger.WithFields(log.Fields{ + "tag": sigTag.String(), + "error": err, + }).Error("failed to check tag-based signature artifact") + } + } + + // Check for tag-based attestation artifact (.att suffix) + if attTag, err := ociremote.AttestationTag(digestRef, ociremote.WithRemoteOptions(remoteOpts...)); err == nil { + if _, err := client.Head(attTag); err == nil { + tagRefs = append(tagRefs, ast.StringTerm(attTag.String())) + logger.WithField("tag", attTag.String()).Debug("found tag-based attestation artifact") + } else if isNotFoundError(err) { + logger.WithField("tag", attTag.String()).Debug("tag-based attestation artifact does not exist") + } else { + logger.WithFields(log.Fields{ + "tag": attTag.String(), + "error": err, + }).Error("failed to check tag-based attestation artifact") + } + } + + // Check for tag-based SBOM artifact (.sbom suffix) + if sbomTag, err := ociremote.SBOMTag(digestRef, ociremote.WithRemoteOptions(remoteOpts...)); err == nil { + if _, err := client.Head(sbomTag); err == nil { + tagRefs = append(tagRefs, ast.StringTerm(sbomTag.String())) + logger.WithField("tag", sbomTag.String()).Debug("found tag-based SBOM artifact") + } else if isNotFoundError(err) { + logger.WithField("tag", sbomTag.String()).Debug("tag-based SBOM artifact does not exist") + } else { + logger.WithFields(log.Fields{ + "tag": sbomTag.String(), + "error": err, + }).Error("failed to check tag-based SBOM artifact") + } + } + + logger.WithField("found_count", len(tagRefs)).Debug("tag-based artifact discovery complete") + return ast.ArrayTerm(tagRefs...), nil +} + +// ociImageReferrers discovers artifacts attached to an image using the OCI Referrers API. +// It returns a list of referrer references (as digest references) for the given image. +// Returns nil if the reference cannot be resolved or if the Referrers API call fails. +func ociImageReferrers(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { + logger := log.WithField("function", ociImageReferrersName) + + uriValue, ok := a.Value.(ast.String) + if !ok { + logger.Error("input is not a string") + return nil, nil + } + refStr := string(uriValue) + logger = logger.WithField("input_ref", refStr) + + client := oci.NewClient(bctx.Context) + + // Resolve to digest if needed + resolvedStr, ref, err := resolveIfNeeded(client, refStr) + if err != nil { + logger.WithError(err).Error("failed to resolve reference") + return nil, nil + } + + // Convert to digest reference (needed for the Referrers API) + var digestRef name.Digest + if d, ok := ref.(name.Digest); ok { + // Already a digest + digestRef = d + } else { + // Tag reference - parse the resolved string which includes the digest + digestRef, err = name.NewDigest(resolvedStr) + if err != nil { + logger.WithError(err).Error("failed to create digest reference from resolved string") + return nil, nil + } + } + + // Use remote options from context + remoteOpts := oci.CreateRemoteOptions(bctx.Context) + + // Get all referrers (empty string for artifactType means get all types) + indexManifest, err := ociremote.Referrers(digestRef, "", ociremote.WithRemoteOptions(remoteOpts...)) + if err != nil { + logger.WithError(err).Error("failed to get referrers via OCI Referrers API") + return nil, nil + } + + var referrerDescriptors []*ast.Term + for _, descriptor := range indexManifest.Manifests { + // Build a simplified descriptor object with essential fields + referrerRef := fmt.Sprintf("%s@%s", ref.Context().Name(), descriptor.Digest.String()) + + descriptorTerm := ast.ObjectTerm( + ast.Item(ast.StringTerm("mediaType"), ast.StringTerm(string(descriptor.MediaType))), + ast.Item(ast.StringTerm("size"), ast.NumberTerm(json.Number(fmt.Sprintf("%d", descriptor.Size)))), + ast.Item(ast.StringTerm("digest"), ast.StringTerm(descriptor.Digest.String())), + ast.Item(ast.StringTerm("artifactType"), ast.StringTerm(descriptor.ArtifactType)), + ast.Item(ast.StringTerm("ref"), ast.StringTerm(referrerRef)), + ) + + referrerDescriptors = append(referrerDescriptors, descriptorTerm) + logger.WithFields(log.Fields{ + "referrer": referrerRef, + "type": descriptor.ArtifactType, + }).Debug("found referrer via OCI Referrers API") + } + + logger.WithField("found_count", len(referrerDescriptors)).Debug("OCI Referrers API discovery complete") + return ast.ArrayTerm(referrerDescriptors...), nil +} + func newPlatformTerm(p v1.Platform) *ast.Term { osFeatures := []*ast.Term{} for _, f := range p.OSFeatures { @@ -1390,6 +1615,20 @@ func parseReference(uri string) (name.Reference, error) { return ref, nil } +// isNotFoundError checks if an error is a 404 Not Found from the registry. +// Returns true only for genuine "not found" cases, false for auth errors, +// network errors, or other registry failures. +func isNotFoundError(err error) bool { + if err == nil { + return false + } + var terr *transport.Error + if errors.As(err, &terr) { + return terr.StatusCode == 404 + } + return false +} + func init() { registerOCIBlob() registerOCIBlobFiles() @@ -1398,4 +1637,6 @@ func init() { registerOCIImageManifest() registerOCIImageManifestsBatch() registerOCIImageIndex() + registerOCIImageTagRefs() + registerOCIImageReferrers() } diff --git a/internal/rego/oci/oci_test.go b/internal/rego/oci/oci_test.go index 7b56714bd..8156b4d97 100644 --- a/internal/rego/oci/oci_test.go +++ b/internal/rego/oci/oci_test.go @@ -25,13 +25,22 @@ import ( "crypto/sha256" "errors" "fmt" + "net/http/httptest" + "net/url" "strings" "testing" "github.com/gkampitakis/go-snaps/snaps" "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" v1 "github.com/google/go-containerregistry/pkg/v1" v1fake "github.com/google/go-containerregistry/pkg/v1/fake" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" "github.com/google/go-containerregistry/pkg/v1/static" "github.com/google/go-containerregistry/pkg/v1/types" "github.com/open-policy-agent/opa/v1/ast" @@ -1247,6 +1256,8 @@ func TestFunctionsRegistered(t *testing.T) { ociImageManifestName, ociImageManifestsBatchName, ociImageIndexName, + ociImageTagRefsName, + ociImageReferrersName, } for _, name := range names { t.Run(name, func(t *testing.T) { @@ -1371,3 +1382,400 @@ func TestResolveIfNeeded(t *testing.T) { }) } } + +func TestOCIImageTagRefs(t *testing.T) { + t.Cleanup(ClearCaches) + ClearCaches() + + // Known digest for testing + testDigest := "sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b" + testRef := "registry.local/spam@" + testDigest + + // Expected tag-based artifact references (cosign format: sha256-.suffix) + expectedSigRef := "registry.local/spam:sha256-01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b.sig" + expectedAttRef := "registry.local/spam:sha256-01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b.att" + expectedSbomRef := "registry.local/spam:sha256-01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b.sbom" + + // Descriptors for different artifact types + sigDescriptor := &v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Size: 100, + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: "aaaaaa", + }, + } + attDescriptor := &v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Size: 200, + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: "bbbbbb", + }, + } + sbomDescriptor := &v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Size: 300, + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: "cccccc", + }, + } + + type headMock struct { + descriptor *v1.Descriptor + err error + } + + cases := []struct { + name string + ref *ast.Term + resolvedDigest string + resolveErr error + headMocks map[string]headMock + want []string + }{ + { + name: "all artifacts exist", + ref: ast.StringTerm(testRef), + headMocks: map[string]headMock{ + ".sig": {descriptor: sigDescriptor}, + ".att": {descriptor: attDescriptor}, + ".sbom": {descriptor: sbomDescriptor}, + }, + want: []string{expectedSigRef, expectedAttRef, expectedSbomRef}, + }, + { + name: "tag reference resolves to digest-based artifacts", + ref: ast.StringTerm("registry.local/spam:v1.0"), + resolvedDigest: testDigest, + headMocks: map[string]headMock{ + ".sig": {descriptor: sigDescriptor}, + ".att": {descriptor: attDescriptor}, + ".sbom": {descriptor: sbomDescriptor}, + }, + want: []string{expectedSigRef, expectedAttRef, expectedSbomRef}, + }, + { + name: "no artifacts exist (404 not found)", + ref: ast.StringTerm(testRef), + headMocks: map[string]headMock{ + ".sig": {err: &transport.Error{StatusCode: 404}}, + ".att": {err: &transport.Error{StatusCode: 404}}, + ".sbom": {err: &transport.Error{StatusCode: 404}}, + }, + want: []string{}, + }, + { + name: "auth error on signature check - gracefully skips sig, returns others", + ref: ast.StringTerm(testRef), + headMocks: map[string]headMock{ + ".sig": {err: &transport.Error{StatusCode: 401}}, + ".att": {descriptor: attDescriptor}, + ".sbom": {descriptor: sbomDescriptor}, + }, + want: []string{expectedAttRef, expectedSbomRef}, + }, + { + name: "forbidden error on attestation check - gracefully skips att, returns others", + ref: ast.StringTerm(testRef), + headMocks: map[string]headMock{ + ".sig": {descriptor: sigDescriptor}, + ".att": {err: &transport.Error{StatusCode: 403}}, + ".sbom": {descriptor: sbomDescriptor}, + }, + want: []string{expectedSigRef, expectedSbomRef}, + }, + { + name: "registry error on SBOM check - gracefully skips sbom, returns others", + ref: ast.StringTerm(testRef), + headMocks: map[string]headMock{ + ".sig": {descriptor: sigDescriptor}, + ".att": {descriptor: attDescriptor}, + ".sbom": {err: &transport.Error{StatusCode: 500}}, + }, + want: []string{expectedSigRef, expectedAttRef}, + }, + { + name: "network error (non-transport error) - gracefully skips sig, returns others", + ref: ast.StringTerm(testRef), + headMocks: map[string]headMock{ + ".sig": {err: errors.New("network timeout")}, + ".att": {descriptor: attDescriptor}, + ".sbom": {descriptor: sbomDescriptor}, + }, + want: []string{expectedAttRef, expectedSbomRef}, + }, + { + name: "resolve error (returns nil per OPA convention)", + ref: ast.StringTerm("registry.local/spam:latest"), + resolveErr: errors.New("resolve failed"), + want: nil, // Note: wantErr is false, function returns (nil, nil) + }, + { + name: "invalid ref type (returns nil per OPA convention)", + ref: ast.IntNumberTerm(42), + want: nil, // Note: wantErr is false, function returns (nil, nil) + }, + { + name: "invalid reference (returns nil per OPA convention)", + ref: ast.StringTerm("...invalid..."), + resolveErr: errors.New("invalid reference"), + want: nil, // Note: wantErr is false, function returns (nil, nil) + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ClearCaches() + + client := fake.FakeClient{} + + client.On("ResolveDigest", mock.Anything).Return(c.resolvedDigest, c.resolveErr) + + // Mock Head calls for tag-based artifacts + for suffix, headMock := range c.headMocks { + client.On("Head", mock.MatchedBy(func(ref name.Reference) bool { + return strings.Contains(ref.String(), suffix) + })).Return(headMock.descriptor, headMock.err) + } + + ctx := oci.WithClient(context.Background(), &client) + bctx := rego.BuiltinContext{Context: ctx} + + got, err := ociImageTagRefs(bctx, c.ref) + require.NoError(t, err) + + // If want is nil, expect nil result (input validation errors per OPA convention) + if c.want == nil { + require.Nil(t, got) + return + } + + require.NotNil(t, got) + + // Verify it's an array + arr, ok := got.Value.(*ast.Array) + require.True(t, ok, "result should be an array") + + // Collect all returned refs + var gotRefs []string + for i := 0; i < arr.Len(); i++ { + refStr, ok := arr.Elem(i).Value.(ast.String) + require.True(t, ok, "all array elements should be strings") + gotRefs = append(gotRefs, string(refStr)) + } + + // Verify the refs match (order-independent) + require.ElementsMatch(t, c.want, gotRefs, "tag refs mismatch") + }) + } +} + +func TestOCIImageReferrers(t *testing.T) { + t.Cleanup(ClearCaches) + ClearCaches() + + // Create a local OCI registry with Referrers API support + registryServer := httptest.NewServer(registry.New( + registry.WithReferrersSupport(true), + )) + t.Cleanup(registryServer.Close) + + u, err := url.Parse(registryServer.URL) + require.NoError(t, err) + + // Push a base image + img, err := random.Image(1024, 2) + require.NoError(t, err) + + baseRef, err := name.ParseReference(fmt.Sprintf("localhost:%s/test-repo/test-image:latest", u.Port())) + require.NoError(t, err) + + require.NoError(t, remote.Push(baseRef, img)) + + // Get the digest of the pushed image + imgDigest, err := img.Digest() + require.NoError(t, err) + + digestRef := fmt.Sprintf("localhost:%s/test-repo/test-image@%s", u.Port(), imgDigest) + + // Create and attach referrers (signature and attestation) + // Get the base image descriptor for the subject field + imgDescriptor, err := partial.Descriptor(img) + require.NoError(t, err) + + // Create signature referrer image with subject field + sigImg, err := random.Image(512, 1) + require.NoError(t, err) + sigImgWithSubject, ok := mutate.Subject(sigImg, *imgDescriptor).(v1.Image) + require.True(t, ok, "failed to assert signature image type") + + // Get the digest of the signature manifest + sigDigest, err := sigImgWithSubject.Digest() + require.NoError(t, err) + + // Push the signature referrer + sigRef := fmt.Sprintf("localhost:%s/test-repo/test-image@%s", u.Port(), sigDigest) + sigDigestRef, err := name.NewDigest(sigRef) + require.NoError(t, err) + err = remote.Write(sigDigestRef, sigImgWithSubject) + require.NoError(t, err) + + // Create attestation referrer image with subject field + attImg, err := random.Image(512, 1) + require.NoError(t, err) + attImgWithSubject, ok := mutate.Subject(attImg, *imgDescriptor).(v1.Image) + require.True(t, ok, "failed to assert attestation image type") + + // Get the digest of the attestation manifest + attDigest, err := attImgWithSubject.Digest() + require.NoError(t, err) + + // Push the attestation referrer + attRef := fmt.Sprintf("localhost:%s/test-repo/test-image@%s", u.Port(), attDigest) + attDigestRef, err := name.NewDigest(attRef) + require.NoError(t, err) + err = remote.Write(attDigestRef, attImgWithSubject) + require.NoError(t, err) + + // Create a separate registry WITHOUT Referrers API support for testing graceful degradation + registryNoAPI := httptest.NewServer(registry.New( + registry.WithReferrersSupport(false), + )) + t.Cleanup(registryNoAPI.Close) + + uNoAPI, err := url.Parse(registryNoAPI.URL) + require.NoError(t, err) + + // Push an image to the no-API registry + imgNoAPI, err := random.Image(1024, 2) + require.NoError(t, err) + + baseRefNoAPI, err := name.ParseReference(fmt.Sprintf("localhost:%s/no-api-repo/test-image:latest", uNoAPI.Port())) + require.NoError(t, err) + + require.NoError(t, remote.Push(baseRefNoAPI, imgNoAPI)) + + imgNoAPIDigest, err := imgNoAPI.Digest() + require.NoError(t, err) + + digestRefNoAPI := fmt.Sprintf("localhost:%s/no-api-repo/test-image@%s", uNoAPI.Port(), imgNoAPIDigest) + + // Push an image with 0 referrers to the API-enabled registry + imgZeroRefs, err := random.Image(1024, 2) + require.NoError(t, err) + + baseRefZero, err := name.ParseReference(fmt.Sprintf("localhost:%s/test-repo/zero-refs:latest", u.Port())) + require.NoError(t, err) + + require.NoError(t, remote.Push(baseRefZero, imgZeroRefs)) + + imgZeroDigest, err := imgZeroRefs.Digest() + require.NoError(t, err) + + digestRefZero := fmt.Sprintf("localhost:%s/test-repo/zero-refs@%s", u.Port(), imgZeroDigest) + + cases := []struct { + name string + ref *ast.Term + wantErr error + want []string + }{ + { + name: "valid digest reference with referrers", + ref: ast.StringTerm(digestRef), + wantErr: nil, + want: []string{ + fmt.Sprintf("localhost:%s/test-repo/test-image@%s", u.Port(), sigDigest), + fmt.Sprintf("localhost:%s/test-repo/test-image@%s", u.Port(), attDigest), + }, + }, + { + name: "invalid ref type", + ref: ast.IntNumberTerm(42), + wantErr: nil, + want: nil, + }, + { + name: "tag reference resolves to digest and returns referrers", + ref: ast.StringTerm(fmt.Sprintf("localhost:%s/test-repo/test-image:latest", u.Port())), + wantErr: nil, + want: []string{ + fmt.Sprintf("localhost:%s/test-repo/test-image@%s", u.Port(), sigDigest), + fmt.Sprintf("localhost:%s/test-repo/test-image@%s", u.Port(), attDigest), + }, + }, + { + name: "invalid reference format", + ref: ast.StringTerm("...invalid..."), + wantErr: nil, + want: nil, + }, + { + name: "registry without Referrers API support - graceful degradation", + ref: ast.StringTerm(digestRefNoAPI), + wantErr: nil, + want: []string{}, // cosign library falls back to legacy lookup, returns empty array + }, + { + name: "image with 0 referrers returns empty array", + ref: ast.StringTerm(digestRefZero), + wantErr: nil, + want: []string{}, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ClearCaches() + + bctx := rego.BuiltinContext{Context: context.Background()} + + got, err := ociImageReferrers(bctx, c.ref) + + if c.wantErr != nil { + require.Error(t, err) + require.Equal(t, c.wantErr, err) + return + } + + require.NoError(t, err) + + if c.want == nil { + require.Nil(t, got) + } else { + require.NotNil(t, got) + + // Verify it's an array + arr, ok := got.Value.(*ast.Array) + require.True(t, ok, "result should be an array") + + // Extract the actual referrer ref strings from the result + var gotRefs []string + for i := 0; i < arr.Len(); i++ { + // Each element is a descriptor object + obj, ok := arr.Elem(i).Value.(ast.Object) + require.True(t, ok, "referrer should be an object") + + // Get the ref field from the descriptor (full reference) + refTerm := obj.Get(ast.StringTerm("ref")) + require.NotNil(t, refTerm, "descriptor should have ref field") + + refStr, ok := refTerm.Value.(ast.String) + require.True(t, ok, "ref should be a string") + gotRefs = append(gotRefs, string(refStr)) + + // Verify the descriptor has the expected fields + require.NotNil(t, obj.Get(ast.StringTerm("digest")), "descriptor should have digest") + require.NotNil(t, obj.Get(ast.StringTerm("mediaType")), "descriptor should have mediaType") + require.NotNil(t, obj.Get(ast.StringTerm("size")), "descriptor should have size") + require.NotNil(t, obj.Get(ast.StringTerm("artifactType")), "descriptor should have artifactType") + } + + // Verify the referrers match (order-independent) + require.ElementsMatch(t, c.want, gotRefs, "referrers mismatch") + } + }) + } +}