From 48e58a9115fb01bdbbfa1f6ced09ec9991a23aa8 Mon Sep 17 00:00:00 2001 From: James Carnegie Date: Wed, 4 Sep 2024 10:20:00 +0100 Subject: [PATCH] Verify input image/platform against attestation subjects before passing to rego (#148) * feat: verify subjects before passing to rego --- attestation/mock.go | 21 +++- attestation/referrers.go | 3 + attestation/registry.go | 3 + oci/registry.go | 3 + policy/policy.go | 54 ++++++++++ policy/policy_test.go | 209 +++++++++++++++++++++++++++++++++++++++ policy/rego.go | 75 +++++++------- 7 files changed, 333 insertions(+), 35 deletions(-) diff --git a/attestation/mock.go b/attestation/mock.go index 59508fe..9fb8401 100644 --- a/attestation/mock.go +++ b/attestation/mock.go @@ -7,8 +7,15 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" ) +// ensure MockResolver implements Resolver. +var _ oci.ImageDetailsResolver = MockResolver{} + type MockResolver struct { - Envs []*Envelope + Envs []*Envelope + Image string + PlatformFn func() (*v1.Platform, error) + DescriptorFn func() (*v1.Descriptor, error) + ImangeNameFn func() (string, error) } func (r MockResolver) Attestations(_ context.Context, _ string) ([]*Envelope, error) { @@ -16,10 +23,19 @@ func (r MockResolver) Attestations(_ context.Context, _ string) ([]*Envelope, er } func (r MockResolver) ImageName(_ context.Context) (string, error) { + if r.Image != "" { + return r.Image, nil + } + if r.ImangeNameFn != nil { + return r.ImangeNameFn() + } return "library/alpine:latest", nil } func (r MockResolver) ImageDescriptor(_ context.Context) (*v1.Descriptor, error) { + if r.DescriptorFn != nil { + return r.DescriptorFn() + } digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620") if err != nil { return nil, err @@ -32,6 +48,9 @@ func (r MockResolver) ImageDescriptor(_ context.Context) (*v1.Descriptor, error) } func (r MockResolver) ImagePlatform(_ context.Context) (*v1.Platform, error) { + if r.PlatformFn != nil { + return r.PlatformFn() + } return oci.ParsePlatform("linux/amd64") } diff --git a/attestation/referrers.go b/attestation/referrers.go index b9d33c1..a804dfe 100644 --- a/attestation/referrers.go +++ b/attestation/referrers.go @@ -10,6 +10,9 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" ) +// ensure ReferrersResolver implements Resolver. +var _ Resolver = &ReferrersResolver{} + type ReferrersResolver struct { referrersRepo string oci.ImageDetailsResolver diff --git a/attestation/registry.go b/attestation/registry.go index a4375a8..c65b94e 100644 --- a/attestation/registry.go +++ b/attestation/registry.go @@ -10,6 +10,9 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" ) +// ensure RegistryResolver implements Resolver. +var _ Resolver = &RegistryResolver{} + type RegistryResolver struct { *oci.RegistryImageDetailsResolver *Manifest diff --git a/oci/registry.go b/oci/registry.go index c2d19aa..b461b4b 100644 --- a/oci/registry.go +++ b/oci/registry.go @@ -9,6 +9,9 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" ) +// ensure RegistryImageDetailsResolver implements ImageDetailsResolver. +var _ ImageDetailsResolver = &RegistryImageDetailsResolver{} + type RegistryImageDetailsResolver struct { *ImageSpec descriptor *v1.Descriptor diff --git a/policy/policy.go b/policy/policy.go index a03f89a..039a194 100644 --- a/policy/policy.go +++ b/policy/policy.go @@ -1,11 +1,15 @@ package policy import ( + "context" "fmt" + "github.com/distribution/reference" "github.com/docker/attest/attestation" "github.com/docker/attest/config" "github.com/docker/attest/oci" + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/package-url/packageurl-go" ) func CreateImageDetailsResolver(imageSource *oci.ImageSpec) (oci.ImageDetailsResolver, error) { @@ -36,3 +40,53 @@ func CreateAttestationResolver(resolver oci.ImageDetailsResolver, mapping *confi } return attestation.NewReferrersResolver(resolver) } + +// VerifySubject verifies if any of the given subject PURLs matches the image name and platform from resolver. +// Tags are not taken into account when attempting to match because sometimes the user may not have specified a tag, and maybe there +// isn't a purl subject with that particular tag (because of post build tagging?). +func VerifySubject(ctx context.Context, subject []intoto.Subject, resolver attestation.Resolver) error { + img, err := resolver.ImageName(ctx) + if err != nil { + return err + } + inputName, err := reference.ParseNormalizedNamed(img) + if err != nil { + return err + } + descriptor, err := resolver.ImageDescriptor(ctx) + if err != nil { + return err + } + platform, err := resolver.ImagePlatform(ctx) + if err != nil { + return err + } + for _, sub := range subject { + if sub.Digest[descriptor.Digest.Algorithm] != descriptor.Digest.Hex { + continue + } + purl, err := packageurl.FromString(sub.Name) + if err != nil { + continue + } + if purl.Type != "docker" { + continue + } + if purl.Qualifiers.Map()["platform"] != platform.String() { + continue + } + // ensure reference is normalized before comparing + subjectName, err := reference.ParseNormalizedNamed(purl.Name) + if err != nil { + continue + } + + // this assumes that domain is part of the package URL (some say it should be a qualifier) + // buildkit puts the domain in the name, e.g. pkg:docker/ecr.io/foobar/alpine@latest?platform=linux%2Famd64 + if inputName.Name() == subjectName.Name() { + // found a match + return nil + } + } + return fmt.Errorf("no matching subject found for image: %s", img) +} diff --git a/policy/policy_test.go b/policy/policy_test.go index 634b38f..2e1e034 100644 --- a/policy/policy_test.go +++ b/policy/policy_test.go @@ -2,6 +2,7 @@ package policy_test import ( "encoding/json" + "fmt" "os" "path/filepath" "testing" @@ -11,6 +12,8 @@ import ( "github.com/docker/attest/internal/test" "github.com/docker/attest/oci" "github.com/docker/attest/policy" + v1 "github.com/google/go-containerregistry/pkg/v1" + intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -173,3 +176,209 @@ func TestCreateAttestationResolver(t *testing.T) { }) } } + +func TestVerifySubject(t *testing.T) { + ctx, _ := test.Setup(t) + defaultResolver := attestation.MockResolver{} + testCases := []struct { + name string + subject []intoto.Subject + img string + expectError bool + digest string + }{ + { + name: "library short", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "alpine", + }, + { + name: "with domain and namespace", + subject: []intoto.Subject{ + { + Name: "pkg:docker/docker.io/library/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "alpine", + }, + { + name: "with library", + subject: []intoto.Subject{ + { + Name: "pkg:docker/library/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "alpine", + }, + { + name: "library short with tag", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "alpine:foo", + }, + { + name: "library with namespace", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "library/alpine:foo", + }, + { + name: "library with domain", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "docker.io/library/alpine:foo", + }, + { + name: "domain mismatch", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "ecr.io/library/alpine:foo", + expectError: true, + }, + { + name: "type mismatch", + subject: []intoto.Subject{ + { + Name: "pkg:node/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "alpine", + expectError: true, + }, + { + name: "name mismatch", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "library/debian:latest", + expectError: true, + }, + { + name: "namespace mismatch", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "unsupported/alpine:latest", + expectError: true, + }, + { + name: "digest mismatch", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "alpine", + digest: "1234", + expectError: true, + }, + { + name: "platform mismatch", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Farm64", + }, + }, + img: "alpine", + expectError: true, + }, + { + name: "malformed purl", + subject: []intoto.Subject{ + { + Name: "not-a-purl", + }, + }, + img: "alpine", + expectError: true, + }, + { + name: "malformed image in valid purl", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine,bar@latest?platform=linux%2Famd64", + }, + }, + img: "alpine-broken", + expectError: true, + }, + { + name: "malformed image name", + subject: []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Famd64", + }, + }, + img: "foo bar", + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defaultResolver.Image = tc.img + // digest from mock resolver + tc.subject[0].Digest = map[string]string{"sha256": "da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620"} + if tc.digest != "" { + tc.subject[0].Digest = map[string]string{"sha256": tc.digest} + } + err := policy.VerifySubject(ctx, tc.subject, defaultResolver) + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } + defaultResolver.Image = "alpine" + subject := []intoto.Subject{ + { + Name: "pkg:docker/alpine@latest?platform=linux%2Famd64", + Digest: map[string]string{"sha256": "da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620"}, + }, + } + + // error getting descriptor + defaultResolver.DescriptorFn = func() (*v1.Descriptor, error) { + return nil, fmt.Errorf("error") + } + err := policy.VerifySubject(ctx, subject, defaultResolver) + require.Error(t, err) + + // error getting platform + defaultResolver.DescriptorFn = nil + defaultResolver.PlatformFn = func() (*v1.Platform, error) { + return nil, fmt.Errorf("error") + } + err = policy.VerifySubject(ctx, subject, defaultResolver) + require.Error(t, err) + + // error getting image name + defaultResolver.PlatformFn = nil + defaultResolver.Image = "" + defaultResolver.ImangeNameFn = func() (string, error) { + return "", fmt.Errorf("error") + } + err = policy.VerifySubject(ctx, subject, defaultResolver) + require.Error(t, err) +} diff --git a/policy/rego.go b/policy/rego.go index b99518b..c87ea36 100644 --- a/policy/rego.go +++ b/policy/rego.go @@ -163,9 +163,9 @@ func handleErrors1(f func(rCtx rego.BuiltinContext, a *ast.Term) (*ast.Term, err } } -func handleErrors2(f func(rCtx *rego.BuiltinContext, a, b *ast.Term) (*ast.Term, error)) rego.Builtin2 { +func handleErrors2(f func(rCtx rego.BuiltinContext, a, b *ast.Term) (*ast.Term, error)) rego.Builtin2 { return func(rCtx rego.BuiltinContext, a, b *ast.Term) (*ast.Term, error) { - return wrapFunctionResult(f(&rCtx, a, b)) + return wrapFunctionResult(f(rCtx, a, b)) } } @@ -180,7 +180,7 @@ func RegoFunctions(resolver attestation.Resolver) []*tester.Builtin { Memoize: true, Nondeterministic: verifyDecl.Nondeterministic, }, - handleErrors2(verifyInTotoEnvelope)), + handleErrors2(verifyInTotoEnvelope(resolver))), }, { Decl: attestDecl, @@ -226,41 +226,48 @@ func fetchInTotoAttestations(resolver attestation.Resolver) rego.Builtin1 { } } -func verifyInTotoEnvelope(rCtx *rego.BuiltinContext, envTerm, optsTerm *ast.Term) (*ast.Term, error) { - env := new(attestation.Envelope) - opts := new(attestation.VerifyOptions) - err := ast.As(envTerm.Value, env) - if err != nil { - return nil, fmt.Errorf("failed to cast envelope: %w", err) - } - err = ast.As(optsTerm.Value, &opts) - if err != nil { - return nil, fmt.Errorf("failed to cast verifier options: %w", err) - } - - payload, err := attestation.VerifyDSSE(rCtx.Context, env, opts) - if err != nil { - return nil, err - } - - statement := new(intoto.Statement) - - switch env.PayloadType { - case intoto.PayloadType: - err = json.Unmarshal(payload, statement) +func verifyInTotoEnvelope(resolver attestation.Resolver) rego.Builtin2 { + return func(rCtx rego.BuiltinContext, envTerm, optsTerm *ast.Term) (*ast.Term, error) { + env := new(attestation.Envelope) + opts := new(attestation.VerifyOptions) + err := ast.As(envTerm.Value, env) if err != nil { - return nil, fmt.Errorf("failed to unmarshal statement: %w", err) + return nil, fmt.Errorf("failed to cast envelope: %w", err) + } + err = ast.As(optsTerm.Value, &opts) + if err != nil { + return nil, fmt.Errorf("failed to cast verifier options: %w", err) } - // TODO: implement other types of envelope - default: - return nil, fmt.Errorf("unsupported payload type: %s", env.PayloadType) - } - value, err := ast.InterfaceToValue(statement) - if err != nil { - return nil, err + payload, err := attestation.VerifyDSSE(rCtx.Context, env, opts) + if err != nil { + return nil, fmt.Errorf("failed to verify envelope: %w", err) + } + + statement := new(intoto.Statement) + + switch env.PayloadType { + case intoto.PayloadType: + err = json.Unmarshal(payload, statement) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal statement: %w", err) + } + // TODO: implement other types of envelope + default: + return nil, fmt.Errorf("unsupported payload type: %s", env.PayloadType) + } + + err = VerifySubject(rCtx.Context, statement.Subject, resolver) + if err != nil { + return nil, fmt.Errorf("failed to verify subject: %w", err) + } + + value, err := ast.InterfaceToValue(statement) + if err != nil { + return nil, err + } + return ast.NewTerm(value), nil } - return ast.NewTerm(value), nil } func loadYAML(path string, bs []byte) (interface{}, error) {