From a398353bea92c57c88755f872472ec263074c01b Mon Sep 17 00:00:00 2001 From: Luiz Carvalho Date: Fri, 10 Jan 2025 11:17:38 -0500 Subject: [PATCH] Allow unpinned refs in descriptor/image_manifest This commit changes the `ec.oci.descriptor` and the `ec.oci.image_manifest` rego functions to allow image refs without a digest to be used. This allows rego policies to be more granular by splitting the "pinned" checks from the "availability" checks. Ref: EC-1038 Signed-off-by: Luiz Carvalho --- internal/rego/oci/__snapshots__/oci_test.snap | 283 ++++++++++++++++++ internal/rego/oci/oci.go | 52 +++- internal/rego/oci/oci_test.go | 107 +++++-- 3 files changed, 416 insertions(+), 26 deletions(-) diff --git a/internal/rego/oci/__snapshots__/oci_test.snap b/internal/rego/oci/__snapshots__/oci_test.snap index 91c7baa0e..7a6a06106 100755 --- a/internal/rego/oci/__snapshots__/oci_test.snap +++ b/internal/rego/oci/__snapshots__/oci_test.snap @@ -1220,3 +1220,286 @@ ] } --- + +[TestOCIImageManifest/missing_digest - 1] +{ + "type": "object", + "value": [ + [ + { + "type": "string", + "value": "annotations" + }, + { + "type": "object", + "value": [] + } + ], + [ + { + "type": "string", + "value": "config" + }, + { + "type": "object", + "value": [ + [ + { + "type": "string", + "value": "annotations" + }, + { + "type": "object", + "value": [] + } + ], + [ + { + "type": "string", + "value": "artifactType" + }, + { + "type": "string", + "value": "" + } + ], + [ + { + "type": "string", + "value": "data" + }, + { + "type": "string", + "value": "" + } + ], + [ + { + "type": "string", + "value": "digest" + }, + { + "type": "string", + "value": "sha256:4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb" + } + ], + [ + { + "type": "string", + "value": "mediaType" + }, + { + "type": "string", + "value": "application/vnd.oci.image.config.v1+json" + } + ], + [ + { + "type": "string", + "value": "size" + }, + { + "type": "number", + "value": 123 + } + ], + [ + { + "type": "string", + "value": "urls" + }, + { + "type": "array", + "value": [] + } + ] + ] + } + ], + [ + { + "type": "string", + "value": "layers" + }, + { + "type": "array", + "value": [ + { + "type": "object", + "value": [ + [ + { + "type": "string", + "value": "annotations" + }, + { + "type": "object", + "value": [] + } + ], + [ + { + "type": "string", + "value": "artifactType" + }, + { + "type": "string", + "value": "" + } + ], + [ + { + "type": "string", + "value": "data" + }, + { + "type": "string", + "value": "" + } + ], + [ + { + "type": "string", + "value": "digest" + }, + { + "type": "string", + "value": "sha256:325392e8dd2826a53a9a35b7a7f8d71683cd27ebc2c73fee85dab673bc909b67" + } + ], + [ + { + "type": "string", + "value": "mediaType" + }, + { + "type": "string", + "value": "application/vnd.oci.image.layer.v1.tar+gzip" + } + ], + [ + { + "type": "string", + "value": "size" + }, + { + "type": "number", + "value": 9999 + } + ], + [ + { + "type": "string", + "value": "urls" + }, + { + "type": "array", + "value": [] + } + ] + ] + } + ] + } + ], + [ + { + "type": "string", + "value": "mediaType" + }, + { + "type": "string", + "value": "application/vnd.oci.image.manifest.v1+json" + } + ], + [ + { + "type": "string", + "value": "schemaVersion" + }, + { + "type": "number", + "value": 2 + } + ] + ] +} +--- + +[TestOCIDescriptorManifest/missing_digest - 1] +{ + "type": "object", + "value": [ + [ + { + "type": "string", + "value": "annotations" + }, + { + "type": "object", + "value": [] + } + ], + [ + { + "type": "string", + "value": "artifactType" + }, + { + "type": "string", + "value": "" + } + ], + [ + { + "type": "string", + "value": "data" + }, + { + "type": "string", + "value": "" + } + ], + [ + { + "type": "string", + "value": "digest" + }, + { + "type": "string", + "value": "sha256:4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb" + } + ], + [ + { + "type": "string", + "value": "mediaType" + }, + { + "type": "string", + "value": "application/vnd.oci.image.manifest.v1+json" + } + ], + [ + { + "type": "string", + "value": "size" + }, + { + "type": "number", + "value": 123 + } + ], + [ + { + "type": "string", + "value": "urls" + }, + { + "type": "array", + "value": [] + } + ] + ] +} +--- diff --git a/internal/rego/oci/oci.go b/internal/rego/oci/oci.go index 6dcb4f060..76e7eebc9 100644 --- a/internal/rego/oci/oci.go +++ b/internal/rego/oci/oci.go @@ -26,6 +26,7 @@ import ( "encoding/json" "fmt" "io" + "strings" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -36,6 +37,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/enterprise-contract/ec-cli/internal/fetchers/oci/files" + "github.com/enterprise-contract/ec-cli/internal/image" "github.com/enterprise-contract/ec-cli/internal/utils/oci" ) @@ -288,18 +290,28 @@ func ociBlob(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { func ociDescriptor(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { log := log.WithField("rego", ociDescriptor) - uri, ok := a.Value.(ast.String) + + uriValue, ok := a.Value.(ast.String) if !ok { return nil, nil } - ref, err := name.NewDigest(string(uri)) + client := oci.NewClient(bctx.Context) + + uri, err := resolveIfNeeded(client, string(uriValue)) + if err != nil { + log.Error(err) + return nil, nil + } + log = log.WithField("ref", uri) + + ref, err := name.NewDigest(uri) if err != nil { log.Errorf("new digest: %s", err) return nil, nil } - descriptor, err := oci.NewClient(bctx.Context).Head(ref) + descriptor, err := client.Head(ref) if err != nil { log.Errorf("fetch image: %s", err) return nil, nil @@ -310,18 +322,27 @@ func ociDescriptor(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { func ociImageManifest(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { log := log.WithField("rego", ociImageManifestName) - uri, ok := a.Value.(ast.String) + uriValue, ok := a.Value.(ast.String) if !ok { return nil, nil } - ref, err := name.NewDigest(string(uri)) + client := oci.NewClient(bctx.Context) + + uri, err := resolveIfNeeded(client, string(uriValue)) + if err != nil { + log.Error(err) + return nil, nil + } + log = log.WithField("ref", uri) + + ref, err := name.NewDigest(uri) if err != nil { log.Errorf("new digest: %s", err) return nil, nil } - image, err := oci.NewClient(bctx.Context).Image(ref) + image, err := client.Image(ref) if err != nil { log.Errorf("fetch image: %s", err) return nil, nil @@ -459,6 +480,25 @@ func newAnnotationsTerm(annotations map[string]string) *ast.Term { return ast.ObjectTerm(annotationTerms...) } +func resolveIfNeeded(client oci.Client, uri string) (string, error) { + if !strings.Contains(uri, "@") { + original := uri + ref, err := image.NewImageReference(uri) + if err != nil { + return "", fmt.Errorf("unable to parse reference: %w", err) + } + + digest, err := client.ResolveDigest(ref.Ref()) + if err != nil { + return "", fmt.Errorf("unable to resolve digest: %w", err) + } + uri = fmt.Sprintf("%s@%s", uri, digest) + + log.Debugf("resolved image reference %q to %q", original, uri) + } + return uri, nil +} + func init() { registerOCIBlob() registerOCIDescriptor() diff --git a/internal/rego/oci/oci_test.go b/internal/rego/oci/oci_test.go index a4a561a69..b50fd692f 100644 --- a/internal/rego/oci/oci_test.go +++ b/internal/rego/oci/oci_test.go @@ -112,10 +112,13 @@ func TestOCIBlob(t *testing.T) { func TestOCIDescriptorManifest(t *testing.T) { cases := []struct { - name string - ref *ast.Term - descriptor *v1.Descriptor - err error + name string + ref *ast.Term + descriptor *v1.Descriptor + resolvedDigest string + resolveErr error + headErr error + wantErr bool }{ { name: "complete image manifest", @@ -168,22 +171,46 @@ func TestOCIDescriptorManifest(t *testing.T) { }, }, }, + { + name: "missing digest", + ref: ast.StringTerm("registry.local/spam:latest"), + resolvedDigest: "sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", + descriptor: &v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Size: 123, + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: "4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb", + }, + }, + }, + { + name: "resolve error", + ref: ast.StringTerm("registry.local/spam:latest"), + resolveErr: errors.New("kaboom!"), + wantErr: true, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { client := fake.FakeClient{} - if c.err != nil { - client.On("Head", mock.Anything).Return(nil, c.err) + if c.headErr != nil { + client.On("Head", mock.Anything).Return(nil, c.headErr) } else { client.On("Head", mock.Anything).Return(c.descriptor, nil) } + if c.resolveErr != nil { + client.On("ResolveDigest", mock.Anything).Return("", c.resolveErr) + } else if c.resolvedDigest != "" { + client.On("ResolveDigest", mock.Anything).Return(c.resolvedDigest, nil) + } ctx := oci.WithClient(context.Background(), &client) bctx := rego.BuiltinContext{Context: ctx} got, err := ociDescriptor(bctx, c.ref) require.NoError(t, err) - if c.err != nil { + if c.wantErr { require.Nil(t, got) } else { require.NotNil(t, got) @@ -198,14 +225,14 @@ func TestOCIDescriptorErrors(t *testing.T) { name string ref *ast.Term }{ - { - name: "missing digest", - ref: ast.StringTerm("registry.local/spam:latest"), - }, { name: "bad image ref", ref: ast.StringTerm("......registry.local/spam:latest@sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b"), }, + { + name: "bad image ref without digest", + ref: ast.StringTerm("."), + }, { name: "invalid ref type", ref: ast.IntNumberTerm(42), @@ -228,12 +255,14 @@ func TestOCIDescriptorErrors(t *testing.T) { func TestOCIImageManifest(t *testing.T) { cases := []struct { - name string - ref *ast.Term - manifest *v1.Manifest - imageErr error - manifestErr error - wantErr bool + name string + ref *ast.Term + manifest *v1.Manifest + resolvedDigest string + resolveErr error + imageErr error + manifestErr error + wantErr bool }{ { name: "complete image manifest", @@ -345,15 +374,42 @@ func TestOCIImageManifest(t *testing.T) { }, }, { - name: "missing digest", - ref: ast.StringTerm("registry.local/spam:latest"), - wantErr: true, + name: "missing digest", + ref: ast.StringTerm("registry.local/spam:latest"), + resolvedDigest: "sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", + manifest: &v1.Manifest{ + SchemaVersion: 2, + MediaType: types.OCIManifestSchema1, + Config: v1.Descriptor{ + MediaType: types.OCIConfigJSON, + Size: 123, + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: "4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb", + }, + }, + Layers: []v1.Descriptor{ + { + MediaType: types.OCILayer, + Size: 9999, + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: "325392e8dd2826a53a9a35b7a7f8d71683cd27ebc2c73fee85dab673bc909b67", + }, + }, + }, + }, }, { name: "bad image ref", ref: ast.StringTerm("......registry.local/spam:latest@sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b"), wantErr: true, }, + { + name: "bad image ref without digest", + ref: ast.StringTerm("."), + wantErr: true, + }, { name: "invalid ref type", ref: ast.IntNumberTerm(42), @@ -365,6 +421,12 @@ func TestOCIImageManifest(t *testing.T) { manifestErr: errors.New("kaboom!"), wantErr: true, }, + { + name: "resolve error", + ref: ast.StringTerm("registry.local/spam:latest"), + resolveErr: errors.New("kaboom!"), + wantErr: true, + }, { name: "nil manifest", ref: ast.StringTerm("registry.local/spam:latest@sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b"), @@ -383,6 +445,11 @@ func TestOCIImageManifest(t *testing.T) { imageManifest.ManifestReturns(c.manifest, c.manifestErr) client.On("Image", mock.Anything, mock.Anything).Return(&imageManifest, nil) } + if c.resolveErr != nil { + client.On("ResolveDigest", mock.Anything).Return("", c.resolveErr) + } else if c.resolvedDigest != "" { + client.On("ResolveDigest", mock.Anything).Return(c.resolvedDigest, nil) + } ctx := oci.WithClient(context.Background(), &client) bctx := rego.BuiltinContext{Context: ctx}