Verify input image/platform against attestation subjects before passing to rego (#148)
* feat: verify subjects before passing to rego
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user