Verify input image/platform against attestation subjects before passing to rego (#148)

* feat: verify subjects before passing to rego
This commit is contained in:
James Carnegie
2024-09-04 10:20:00 +01:00
committed by GitHub
parent 41847ef238
commit 48e58a9115
7 changed files with 333 additions and 35 deletions

View File

@@ -7,8 +7,15 @@ import (
v1 "github.com/google/go-containerregistry/pkg/v1" v1 "github.com/google/go-containerregistry/pkg/v1"
) )
// ensure MockResolver implements Resolver.
var _ oci.ImageDetailsResolver = MockResolver{}
type MockResolver struct { 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) { 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) { 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 return "library/alpine:latest", nil
} }
func (r MockResolver) ImageDescriptor(_ context.Context) (*v1.Descriptor, error) { func (r MockResolver) ImageDescriptor(_ context.Context) (*v1.Descriptor, error) {
if r.DescriptorFn != nil {
return r.DescriptorFn()
}
digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620") digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620")
if err != nil { if err != nil {
return nil, err 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) { func (r MockResolver) ImagePlatform(_ context.Context) (*v1.Platform, error) {
if r.PlatformFn != nil {
return r.PlatformFn()
}
return oci.ParsePlatform("linux/amd64") return oci.ParsePlatform("linux/amd64")
} }

View File

@@ -10,6 +10,9 @@ import (
"github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote"
) )
// ensure ReferrersResolver implements Resolver.
var _ Resolver = &ReferrersResolver{}
type ReferrersResolver struct { type ReferrersResolver struct {
referrersRepo string referrersRepo string
oci.ImageDetailsResolver oci.ImageDetailsResolver

View File

@@ -10,6 +10,9 @@ import (
"github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote"
) )
// ensure RegistryResolver implements Resolver.
var _ Resolver = &RegistryResolver{}
type RegistryResolver struct { type RegistryResolver struct {
*oci.RegistryImageDetailsResolver *oci.RegistryImageDetailsResolver
*Manifest *Manifest

View File

@@ -9,6 +9,9 @@ import (
"github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote"
) )
// ensure RegistryImageDetailsResolver implements ImageDetailsResolver.
var _ ImageDetailsResolver = &RegistryImageDetailsResolver{}
type RegistryImageDetailsResolver struct { type RegistryImageDetailsResolver struct {
*ImageSpec *ImageSpec
descriptor *v1.Descriptor descriptor *v1.Descriptor

View File

@@ -1,11 +1,15 @@
package policy package policy
import ( import (
"context"
"fmt" "fmt"
"github.com/distribution/reference"
"github.com/docker/attest/attestation" "github.com/docker/attest/attestation"
"github.com/docker/attest/config" "github.com/docker/attest/config"
"github.com/docker/attest/oci" "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) { func CreateImageDetailsResolver(imageSource *oci.ImageSpec) (oci.ImageDetailsResolver, error) {
@@ -36,3 +40,53 @@ func CreateAttestationResolver(resolver oci.ImageDetailsResolver, mapping *confi
} }
return attestation.NewReferrersResolver(resolver) 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)
}

View File

@@ -2,6 +2,7 @@ package policy_test
import ( import (
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -11,6 +12,8 @@ import (
"github.com/docker/attest/internal/test" "github.com/docker/attest/internal/test"
"github.com/docker/attest/oci" "github.com/docker/attest/oci"
"github.com/docker/attest/policy" "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/assert"
"github.com/stretchr/testify/require" "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)
}

View File

@@ -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 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, Memoize: true,
Nondeterministic: verifyDecl.Nondeterministic, Nondeterministic: verifyDecl.Nondeterministic,
}, },
handleErrors2(verifyInTotoEnvelope)), handleErrors2(verifyInTotoEnvelope(resolver))),
}, },
{ {
Decl: attestDecl, 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) { func verifyInTotoEnvelope(resolver attestation.Resolver) rego.Builtin2 {
env := new(attestation.Envelope) return func(rCtx rego.BuiltinContext, envTerm, optsTerm *ast.Term) (*ast.Term, error) {
opts := new(attestation.VerifyOptions) env := new(attestation.Envelope)
err := ast.As(envTerm.Value, env) opts := new(attestation.VerifyOptions)
if err != nil { err := ast.As(envTerm.Value, env)
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)
if err != nil { 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) payload, err := attestation.VerifyDSSE(rCtx.Context, env, opts)
if err != nil { if err != nil {
return nil, err 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) { func loadYAML(path string, bs []byte) (interface{}, error) {