feat: add policy, oci, attestation

This commit is contained in:
mrjoelkamp
2024-04-22 12:22:15 -05:00
parent 20f83f6189
commit a3422b5331
78 changed files with 2021 additions and 25 deletions

41
pkg/policy/evaluator.go Normal file
View File

@@ -0,0 +1,41 @@
package policy
import (
"context"
"fmt"
"github.com/docker/attest/internal/oci"
)
type policyEvaluatorCtxKeyType struct{}
var PolicyEvaluatorCtxKey policyEvaluatorCtxKeyType
// sets PolicyEvaluator in context
func WithPolicyEvaluator(ctx context.Context, pe PolicyEvaluator) context.Context {
return context.WithValue(ctx, PolicyEvaluatorCtxKey, pe)
}
// gets PolicyEvaluator from context, defaults to Rego PolicyEvaluator if not set
func GetPolicyEvaluator(ctx context.Context) (PolicyEvaluator, error) {
t, ok := ctx.Value(PolicyEvaluatorCtxKey).(PolicyEvaluator)
if !ok {
return nil, fmt.Errorf("no policy evaluator client set on context (set one with policy.WithPolicyEvaluator)")
}
return t, nil
}
type PolicyEvaluator interface {
Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) error
}
type MockPolicyEvaluator struct {
EvaluateFunc func(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) error
}
func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) error {
if pe.EvaluateFunc != nil {
return pe.EvaluateFunc(ctx, resolver, policy, input)
}
return nil
}

203
pkg/policy/policy.go Normal file
View File

@@ -0,0 +1,203 @@
package policy
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"github.com/distribution/reference"
"github.com/docker/attest/internal/oci"
"github.com/docker/attest/internal/util"
"github.com/docker/attest/pkg/tuf"
goyaml "gopkg.in/yaml.v3"
)
const (
PolicyMappingFileName = "mapping.yaml"
)
var (
PolicyFileNames = []string{"data.yaml", "policy.rego"}
)
type PolicyMappings struct {
Version string `json:"version"`
Kind string `json:"kind"`
Policies []PolicyMapping `json:"policies"`
Mirrors []PolicyMirror `json:"mirrors"`
}
type PolicyMapping struct {
Name string `json:"namespace"`
Location string `json:"location"`
Description string `json:"description"`
Origin PolicyOrigin `json:"origin"`
}
type PolicyMirror struct {
Name string `json:"name"`
Mirror MirrorSpec `json:"mirror"`
}
type MirrorSpec struct {
Domains []string `json:"domains"`
Prefix string `json:"prefix"`
}
type PolicyOrigin struct {
Name string `json:"name"`
Prefix string `json:"prefix"`
Domain string `json:"domain"`
}
type PolicyOptions struct {
TufClient tuf.TUFClient
LocalTargetsDir string
LocalPolicyDir string
}
type PolicyInput struct {
Digest string `json:"digest"`
Purl string `json:"purl"`
IsCanonical bool `json:"isCanonical"`
}
type PolicyFile struct {
Path string
Content []byte
}
func resolveLocalPolicy(opts *PolicyOptions, mapping *PolicyMapping) ([]*PolicyFile, error) {
if opts.LocalPolicyDir == "" {
return nil, fmt.Errorf("local policy dir not set")
}
files := make([]*PolicyFile, 0, len(PolicyFileNames))
for _, filename := range PolicyFileNames {
filePath := path.Join(opts.LocalPolicyDir, mapping.Location, filename)
fileContents, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read policy file %s: %w", filename, err)
}
files = append(files, &PolicyFile{
Path: filename,
Content: fileContents,
})
}
return files, nil
}
func loadLocalMappings(opts *PolicyOptions) (*PolicyMappings, error) {
if opts.LocalPolicyDir == "" {
return nil, nil
}
mappings := &PolicyMappings{}
path := path.Join(opts.LocalPolicyDir, PolicyMappingFileName)
mappingFile, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read policy mapping file %s: %w", path, err)
}
err = goyaml.Unmarshal(mappingFile, mappings)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal policy mapping file %s: %w", path, err)
}
return mappings, nil
}
func resolveTufPolicy(opts *PolicyOptions, mapping *PolicyMapping) ([]*PolicyFile, error) {
files := make([]*PolicyFile, 0, len(PolicyFileNames))
for _, filename := range PolicyFileNames {
filePath := path.Join(mapping.Location, filename)
_, fileContents, err := opts.TufClient.DownloadTarget(filePath, filepath.Join(opts.LocalTargetsDir, filePath))
if err != nil {
return nil, fmt.Errorf("failed to download policy file %s: %w", filename, err)
}
files = append(files, &PolicyFile{
Path: filename,
Content: fileContents,
})
}
return files, nil
}
func loadTufMappings(tufClient tuf.TUFClient, localTargetsDir string) (*PolicyMappings, error) {
filename := PolicyMappingFileName
_, fileContents, err := tufClient.DownloadTarget(filename, filepath.Join(localTargetsDir, filename))
if err != nil {
return nil, fmt.Errorf("failed to download policy file %s: %w", filename, err)
}
mappings := &PolicyMappings{}
err = goyaml.Unmarshal(fileContents, mappings)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal policy mapping file %s: %w", filename, err)
}
return mappings, nil
}
func findPolicyMatch(named reference.Named, mappings *PolicyMappings) (*PolicyMapping, *PolicyMirror) {
if mappings != nil {
for _, mapping := range mappings.Policies {
if mapping.Origin.Domain == reference.Domain(named) &&
strings.HasPrefix(reference.Path(named), mapping.Origin.Prefix) {
return &mapping, nil
}
}
// now search mirrors
for _, mirror := range mappings.Mirrors {
if util.StringInSlice(reference.Domain(named), mirror.Mirror.Domains) &&
strings.HasPrefix(reference.Path(named), mirror.Mirror.Prefix) {
for _, mapping := range mappings.Policies {
if mapping.Name == mirror.Name {
return &mapping, nil
}
}
return nil, &mirror
}
}
}
return nil, nil
}
func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts *PolicyOptions) ([]*PolicyFile, error) {
imageName, err := resolver.ImageName(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get image name: %w", err)
}
named, err := reference.ParseNormalizedNamed(imageName)
if err != nil {
return nil, fmt.Errorf("failed to parse image name: %w", err)
}
localMappings, err := loadLocalMappings(opts)
if err != nil {
return nil, fmt.Errorf("failed to load local policy mappings: %w", err)
}
mapping, mirror := findPolicyMatch(named, localMappings)
if mapping != nil {
return resolveLocalPolicy(opts, mapping)
}
// must check tuf
tufMappings, err := loadTufMappings(opts.TufClient, opts.LocalTargetsDir)
if err != nil {
return nil, fmt.Errorf("failed to load tuf policy mappings: %w", err)
}
// it's a mirror of a tuf policy
if mirror != nil {
for _, mapping := range tufMappings.Policies {
if mapping.Name == mirror.Name {
return resolveTufPolicy(opts, &mapping)
}
}
}
// try to resolve a tuf policy directly
mapping, _ = findPolicyMatch(named, tufMappings)
if mapping == nil {
return nil, nil
}
return resolveTufPolicy(opts, mapping)
}

110
pkg/policy/policy_test.go Normal file
View File

@@ -0,0 +1,110 @@
package policy_test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/docker/attest/internal/oci"
"github.com/docker/attest/internal/test"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/policy"
"github.com/docker/attest/pkg/tuf"
"github.com/stretchr/testify/assert"
)
func loadAttestation(t *testing.T, path string) *attestation.Envelope {
ex, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var env = new(attestation.Envelope)
err = json.Unmarshal(ex, env)
if err != nil {
t.Fatal(err)
}
return env
}
func TestRegoEvaluator_Evaluate(t *testing.T) {
ctx, _ := test.Setup(t)
TestDataPath := filepath.Join("..", "..", "test", "testdata")
MockTufRepo := filepath.Join(TestDataPath, "local-policy")
ExampleAttestation := filepath.Join(TestDataPath, "example_attestation.json")
VSA := filepath.Join(TestDataPath, "vsa.json")
re := policy.NewRegoEvaluator(true)
defaultInput := &policy.PolicyInput{
Digest: "sha256:test-digest",
Purl: "test-purl",
IsCanonical: true,
}
defaultResolver := oci.MockResolver{
Envs: []*attestation.Envelope{loadAttestation(t, ExampleAttestation)},
}
vsaResolver := oci.MockResolver{
Envs: []*attestation.Envelope{loadAttestation(t, ExampleAttestation), loadAttestation(t, VSA)},
}
testCases := []struct {
repo string
expectSuccess bool
input *policy.PolicyInput
resolver oci.AttestationResolver
policy *policy.PolicyOptions
}{
{repo: "testdata/mock-tuf-allow", expectSuccess: true, input: defaultInput, resolver: defaultResolver},
{repo: "testdata/mock-tuf-deny", expectSuccess: false, input: defaultInput, resolver: defaultResolver},
{repo: "testdata/mock-tuf-verify-sig", expectSuccess: true, input: defaultInput, resolver: defaultResolver},
{repo: "testdata/mock-tuf-wrong-key", expectSuccess: false, input: defaultInput, resolver: defaultResolver},
{repo: MockTufRepo, expectSuccess: true, input: &policy.PolicyInput{
Digest: "sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620",
Purl: "pkg:docker/test-image@test?platform=linux%2Famd64",
IsCanonical: true,
}, resolver: vsaResolver},
{repo: MockTufRepo, expectSuccess: true, input: &policy.PolicyInput{
Digest: "sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620",
Purl: "pkg:docker/test-image@test?platform=linux%2Famd64",
IsCanonical: false,
}, resolver: vsaResolver},
// not a doi
{repo: MockTufRepo, expectSuccess: false, input: defaultInput, resolver: vsaResolver, policy: &policy.PolicyOptions{
LocalPolicyDir: "testdata/mock-tuf-deny",
}},
// digest mismatch
{repo: MockTufRepo, expectSuccess: false, input: &policy.PolicyInput{
Digest: "sha256:test-digest-wrong",
Purl: "test-purl",
IsCanonical: false,
}, resolver: vsaResolver},
}
for _, tc := range testCases {
t.Run(tc.repo, func(t *testing.T) {
tufClient := tuf.NewMockTufClient(tc.repo, test.CreateTempDir(t, "", "tuf-dest"))
if tc.policy == nil {
tc.policy = &policy.PolicyOptions{
TufClient: tufClient,
LocalTargetsDir: test.CreateTempDir(t, "", "tuf-targets"),
}
}
policyFiles, err := policy.ResolvePolicy(ctx, tc.resolver, tc.policy)
assert.NoErrorf(t, err, "failed to resolve policy")
err = re.Evaluate(ctx, tc.resolver, policyFiles, tc.input)
if tc.expectSuccess {
assert.NoErrorf(t, err, "Evaluate failed")
} else {
assert.Errorf(t, err, "Evaluate should have failed")
}
})
}
}

225
pkg/policy/rego.go Normal file
View File

@@ -0,0 +1,225 @@
package policy
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/docker/attest/internal/oci"
att "github.com/docker/attest/pkg/attestation"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/rego"
"github.com/open-policy-agent/opa/storage"
"github.com/open-policy-agent/opa/storage/inmem"
"github.com/open-policy-agent/opa/tester"
"github.com/open-policy-agent/opa/topdown"
"github.com/open-policy-agent/opa/types"
opa "github.com/open-policy-agent/opa/util"
"sigs.k8s.io/yaml"
)
type regoEvaluator struct {
debug bool
}
func NewRegoEvaluator(debug bool) PolicyEvaluator {
return &regoEvaluator{
debug: debug,
}
}
func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, files []*PolicyFile, input *PolicyInput) error {
var regoOpts []func(*rego.Rego)
// Create a new in-memory store
store := inmem.New()
params := storage.TransactionParams{}
params.Write = true
txn, err := store.NewTransaction(ctx, params)
if err != nil {
return err
}
for _, target := range files {
// load yaml as data (no rego opt for this!?)
if filepath.Ext(target.Path) == ".yaml" {
yamlData, err := loadYAML(target.Path, target.Content)
if err != nil {
return err
}
err = store.Write(ctx, txn, storage.AddOp, storage.Path{}, yamlData)
if err != nil {
return err
}
} else {
regoOpts = append(regoOpts, rego.Module(target.Path, string(target.Content)))
}
}
err = store.Commit(ctx, txn)
if err != nil {
store.Abort(ctx, txn)
return err
}
if re.debug {
regoOpts = append(regoOpts,
rego.EnablePrintStatements(true),
rego.PrintHook(topdown.NewPrintHook(os.Stderr)),
rego.Dump(os.Stderr),
)
}
regoOpts = append(regoOpts,
rego.Query("data.docker.allow"),
rego.StrictBuiltinErrors(true),
rego.Input(input),
rego.Store(store),
)
for _, custom := range RegoFunctions(resolver) {
regoOpts = append(regoOpts, custom.Func)
}
r := rego.New(regoOpts...)
rs, err := r.Eval(ctx)
if err != nil {
return fmt.Errorf("error from Eval: %w", err)
}
if !rs.Allowed() {
return fmt.Errorf("policy evaluation failed")
}
return nil
}
var dynamicObj = types.NewObject(nil, types.NewDynamicProperty(types.S, types.A))
var arrayObj = types.NewArray(nil, dynamicObj)
var verifyDecl = &ast.Builtin{
Name: "attestations.verify_envelope",
Decl: types.NewFunction(types.Args(dynamicObj, arrayObj), dynamicObj),
Nondeterministic: true,
}
var attestDecl = &ast.Builtin{
Name: "attestations.attestation",
Decl: types.NewFunction(types.Args(types.S), dynamicObj),
Nondeterministic: true,
}
func RegoFunctions(resolver oci.AttestationResolver) []*tester.Builtin {
return []*tester.Builtin{
{
Decl: verifyDecl,
Func: rego.Function2(
&rego.Function{
Name: verifyDecl.Name,
Decl: verifyDecl.Decl,
Memoize: true,
Nondeterministic: verifyDecl.Nondeterministic,
},
verifyIntotoEnvelope),
},
{
Decl: attestDecl,
Func: rego.Function1(
&rego.Function{
Name: attestDecl.Name,
Decl: attestDecl.Decl,
Memoize: true,
Nondeterministic: attestDecl.Nondeterministic,
},
fetchIntotoAttestations(resolver)),
},
}
}
func fetchIntotoAttestations(resolver oci.AttestationResolver) func(rego.BuiltinContext, *ast.Term) (*ast.Term, error) {
return func(rCtx rego.BuiltinContext, predicateTypeTerm *ast.Term) (*ast.Term, error) {
predicateTypeStr, ok := predicateTypeTerm.Value.(ast.String)
if !ok {
return nil, fmt.Errorf("predicateTypeTerm is not a string")
}
predicateType := string(predicateTypeStr)
envelopes, err := resolver.Attestations(rCtx.Context, predicateType)
if err != nil {
return nil, err
}
// Convert each envelope to an ast.Value.
values := make([]*ast.Term, len(envelopes))
for i, envelope := range envelopes {
value, err := ast.InterfaceToValue(envelope)
if err != nil {
return nil, err
}
values[i] = ast.NewTerm(value)
}
// Wrap the values in an ast.Array and convert it to an ast.Term.
array := ast.NewTerm(ast.NewArray(values...))
return array, nil
}
}
func verifyIntotoEnvelope(rCtx rego.BuiltinContext, envTerm, keysTerm *ast.Term) (*ast.Term, error) {
env := new(att.Envelope)
var keys att.Keys
err := ast.As(envTerm.Value, env)
if err != nil {
return nil, fmt.Errorf("failed to cast envelope: %w", err)
}
err = ast.As(keysTerm.Value, &keys)
if err != nil {
return nil, fmt.Errorf("failed to cast keys: %w", err)
}
keysmap := make(map[string]att.KeyMetadata, len(keys))
for _, key := range keys {
keysmap[key.ID] = key
}
payload, err := att.VerifyDSSE(rCtx.Context, env, keysmap)
if err != nil {
return nil, 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)
}
value, err := ast.InterfaceToValue(statement)
if err != nil {
return nil, err
}
return ast.NewTerm(value), nil
}
// copied from opa
func loadYAML(path string, bs []byte) (interface{}, error) {
bs, err := yaml.YAMLToJSON(bs)
if err != nil {
return nil, fmt.Errorf("%v: error converting YAML to JSON: %v", path, err)
}
return loadJSON(path, bs)
}
func loadJSON(path string, bs []byte) (interface{}, error) {
var x interface{}
err := opa.UnmarshalJSON(bs, &x)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return x, nil
}

View File

@@ -0,0 +1 @@
config:

View File

@@ -0,0 +1,5 @@
package docker
import rego.v1
allow := true

View File

@@ -0,0 +1,10 @@
# map repos to policies
version: v1
kind: policy-mapping
policies:
- origin:
domain: docker.io
prefix: library/
name: docker-official-images
description: Docker Official Images
location: doi

View File

@@ -0,0 +1 @@
config:

View File

@@ -0,0 +1,5 @@
package docker
import rego.v1
allow := false

View File

@@ -0,0 +1,10 @@
# map repos to policies
version: v1
kind: policy-mapping
policies:
- origin:
domain: docker.io
prefix: library/
name: docker-official-images
description: Docker Official Images
location: doi

View File

@@ -0,0 +1 @@
config:

View File

@@ -0,0 +1,15 @@
package docker
import rego.v1
keys := [{
"id": "a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgH23D1i2+ZIOtVjmfB7iFvX8AhVN\n9CPJ4ie9axw+WRHozGnRy99U2dRge3zueBBg2MweF0zrToXGig2v3YOrdw==\n-----END PUBLIC KEY-----",
"from": "2023-12-15T14:00:00Z",
"to": null
}]
allow if {
some env in attestations.attestation("foo")
statement := attestations.verify_envelope(env, keys)
}

View File

@@ -0,0 +1,10 @@
# map repos to policies
version: v1
kind: policy-mapping
policies:
- origin:
domain: docker.io
prefix: library/
name: docker-official-images
description: Docker Official Images
location: doi

View File

@@ -0,0 +1 @@
config:

View File

@@ -0,0 +1,19 @@
package docker
import rego.v1
keys := {
"a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4": {
"id": "a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHyZpSgzvqFqNv7f3x7865OS38rAb\nQMcff55zM2UH/KR3Pr84a8QsGDNgaNGzJQJWjtMSgfV8WnNoffNK+svFNg==\n-----END PUBLIC KEY-----",
"from": "2023-12-15T14:00:00Z",
"to": null,
}
}
allow if {
some env in attestations.attestation("foo")
statement := attestations.verify_envelope(env, keys)
}
allow := true

View File

@@ -0,0 +1,10 @@
# map repos to policies
version: v1
kind: policy-mapping
policies:
- origin:
domain: docker.io
prefix: library/
name: docker-official-images
description: Docker Official Images
location: doi