From 25213e3584f429b9dba2efcbfe1d22a6639ccac7 Mon Sep 17 00:00:00 2001 From: Stefano Pentassuglia Date: Mon, 9 Mar 2026 17:51:22 +0100 Subject: [PATCH 1/2] Add builtin func for tag-based artifact discovery Implement ec.oci.image_tag_refs() builtin to discover artifacts attached to images using legacy cosign tag conventions (.sig, .att, .sbom suffixes). This enables policy rules to locate and validate tag-based signatures, attestations, and SBOMs alongside the newer OCI Referrers API approach. Co-Authored-By: Claude Sonnet 4.5 Ref: https://issues.redhat.com/browse/EC-1655 --- acceptance/examples/image_tag_refs.rego | 75 +++++++ .../ROOT/pages/ec_oci_image_tag_refs.adoc | 15 ++ docs/modules/ROOT/pages/rego_builtins.adoc | 2 + docs/modules/ROOT/partials/rego_nav.adoc | 1 + features/__snapshots__/validate_image.snap | 95 +++++++++ features/validate_image.feature | 23 +++ internal/rego/oci/oci.go | 133 ++++++++++++ internal/rego/oci/oci_test.go | 192 ++++++++++++++++++ 8 files changed, 536 insertions(+) create mode 100644 acceptance/examples/image_tag_refs.rego create mode 100644 docs/modules/ROOT/pages/ec_oci_image_tag_refs.adoc 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/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..0156eb6a9 100644 --- a/docs/modules/ROOT/pages/rego_builtins.adoc +++ b/docs/modules/ROOT/pages/rego_builtins.adoc @@ -22,6 +22,8 @@ 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_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..21485f094 100644 --- a/docs/modules/ROOT/partials/rego_nav.adoc +++ b/docs/modules/ROOT/partials/rego_nav.adoc @@ -6,6 +6,7 @@ ** 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_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..b99e5936a 100755 --- a/features/__snapshots__/validate_image.snap +++ b/features/__snapshots__/validate_image.snap @@ -5593,3 +5593,98 @@ 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] + +--- diff --git a/features/validate_image.feature b/features/validate_image.feature index c0585cb7b..9fc4bea9c 100644 --- a/features/validate_image.feature +++ b/features/validate_image.feature @@ -1151,6 +1151,29 @@ 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: 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..8776b5a1e 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,7 @@ const ( ociImageManifestsBatchName = "ec.oci.image_manifests" ociImageFilesName = "ec.oci.image_files" ociImageIndexName = "ec.oci.image_index" + ociImageTagRefsName = "ec.oci.image_tag_refs" maxTarEntrySizeConst = 500 * 1024 * 1024 // 500MB ) @@ -417,6 +420,30 @@ 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 ociBlob(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { return ociBlobInternal(bctx, a, true) } @@ -1299,6 +1326,97 @@ 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 +} + func newPlatformTerm(p v1.Platform) *ast.Term { osFeatures := []*ast.Term{} for _, f := range p.OSFeatures { @@ -1390,6 +1508,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 +1530,5 @@ func init() { registerOCIImageManifest() registerOCIImageManifestsBatch() registerOCIImageIndex() + registerOCIImageTagRefs() } diff --git a/internal/rego/oci/oci_test.go b/internal/rego/oci/oci_test.go index 7b56714bd..34cafcd5a 100644 --- a/internal/rego/oci/oci_test.go +++ b/internal/rego/oci/oci_test.go @@ -30,8 +30,10 @@ import ( "github.com/gkampitakis/go-snaps/snaps" "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" v1fake "github.com/google/go-containerregistry/pkg/v1/fake" + "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 +1249,7 @@ func TestFunctionsRegistered(t *testing.T) { ociImageManifestName, ociImageManifestsBatchName, ociImageIndexName, + ociImageTagRefsName, } for _, name := range names { t.Run(name, func(t *testing.T) { @@ -1371,3 +1374,192 @@ 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") + }) + } +} From a6fb24e89255555fe4489a6c4d0be934bea3dc02 Mon Sep 17 00:00:00 2001 From: Stefano Pentassuglia Date: Tue, 10 Mar 2026 11:40:32 +0100 Subject: [PATCH 2/2] Add builtin func for OCI Referrers API discovery Implement ec.oci.image_referrers() builtin to discover artifacts attached to images via the OCI Referrers API. Returns descriptors with mediaType, size, digest, and artifactType for all referrers, enabling policy rules to validate modern OCI artifact associations without relying on legacy tag-based conventions. Co-Authored-By: Claude Sonnet 4.5 Ref: https://issues.redhat.com/browse/EC-1655 --- acceptance/examples/image_referrers.rego | 79 ++++ acceptance/image/image.go | 425 +++++++++++++----- acceptance/registry/registry.go | 31 +- .../ROOT/pages/ec_oci_image_referrers.adoc | 15 + docs/modules/ROOT/pages/rego_builtins.adoc | 2 + docs/modules/ROOT/partials/rego_nav.adoc | 1 + features/__snapshots__/validate_image.snap | 89 ++++ features/validate_image.feature | 27 ++ internal/rego/oci/oci.go | 108 +++++ internal/rego/oci/oci_test.go | 216 +++++++++ 10 files changed, 892 insertions(+), 101 deletions(-) create mode 100644 acceptance/examples/image_referrers.rego create mode 100644 docs/modules/ROOT/pages/ec_oci_image_referrers.adoc 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/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..b8abdb9f2 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 @@ -71,10 +72,38 @@ func startStubRegistry(ctx context.Context) (context.Context, error) { return ctx, nil } + // Create a minimal Zot configuration with error-only logging to reduce I/O overhead + // This dramatically reduces disk I/O and prevents DNS timeouts under load + // Using the exact same structure as Zot's minimal example config but with error-level logging + // The "compat": ["docker2s2"] enables Docker v2 Schema 2 manifest compatibility + zotConfig := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/var/lib/registry" + }, + "http": { + "address": "0.0.0.0", + "port": "5000", + "compat": ["docker2s2"] + }, + "log": { + "level": "error" + } + }` + req := testenv.TestContainersRequest(ctx, testcontainers.ContainerRequest{ Image: registryImage, ExposedPorts: []string{"0.0.0.0::5000/tcp"}, WaitingFor: wait.ForHTTP("/v2/").WithPort("5000/tcp"), + Cmd: []string{"serve", "/config/config.json"}, + Files: []testcontainers.ContainerFile{ + { + HostFilePath: "", + ContainerFilePath: "/config/config.json", + FileMode: 0644, + Reader: strings.NewReader(zotConfig), + }, + }, }) logger, ctx := log.LoggerFor(ctx) 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/rego_builtins.adoc b/docs/modules/ROOT/pages/rego_builtins.adoc index 0156eb6a9..0b81fe7fe 100644 --- a/docs/modules/ROOT/pages/rego_builtins.adoc +++ b/docs/modules/ROOT/pages/rego_builtins.adoc @@ -22,6 +22,8 @@ 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] diff --git a/docs/modules/ROOT/partials/rego_nav.adoc b/docs/modules/ROOT/partials/rego_nav.adoc index 21485f094..ff1022080 100644 --- a/docs/modules/ROOT/partials/rego_nav.adoc +++ b/docs/modules/ROOT/partials/rego_nav.adoc @@ -6,6 +6,7 @@ ** 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] diff --git a/features/__snapshots__/validate_image.snap b/features/__snapshots__/validate_image.snap index b99e5936a..42d878bb2 100755 --- a/features/__snapshots__/validate_image.snap +++ b/features/__snapshots__/validate_image.snap @@ -5688,3 +5688,92 @@ Error: success criteria not met [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 9fc4bea9c..d63228eff 100644 --- a/features/validate_image.feature +++ b/features/validate_image.feature @@ -1174,6 +1174,33 @@ Feature: evaluate enterprise contract 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 8776b5a1e..17c54b8d1 100644 --- a/internal/rego/oci/oci.go +++ b/internal/rego/oci/oci.go @@ -62,6 +62,7 @@ const ( 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 ) @@ -444,6 +445,41 @@ func registerOCIImageTagRefs() { }) } +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) } @@ -1417,6 +1453,77 @@ func ociImageTagRefs(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { 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 { @@ -1531,4 +1638,5 @@ func init() { registerOCIImageManifestsBatch() registerOCIImageIndex() registerOCIImageTagRefs() + registerOCIImageReferrers() } diff --git a/internal/rego/oci/oci_test.go b/internal/rego/oci/oci_test.go index 34cafcd5a..8156b4d97 100644 --- a/internal/rego/oci/oci_test.go +++ b/internal/rego/oci/oci_test.go @@ -25,14 +25,21 @@ 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" @@ -1250,6 +1257,7 @@ func TestFunctionsRegistered(t *testing.T) { ociImageManifestsBatchName, ociImageIndexName, ociImageTagRefsName, + ociImageReferrersName, } for _, name := range names { t.Run(name, func(t *testing.T) { @@ -1563,3 +1571,211 @@ func TestOCIImageTagRefs(t *testing.T) { }) } } + +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") + } + }) + } +}