Return VSA and rich errors from verification (#38)

* Start of richer results from verification

* Pull out VSA code from signing

* Expose attestation signing fns

* Add VSA test

* Notes for policy result

* Require separate policy for VSA creation

* Load test signing key from tests

* Return rich object from policy

* Add result object schema and fix tests

* Ensure example test runs

* Remove data.yaml files from mock policies

* Don't run example - TUF policy isn't compatible

* Add attestation to manifests for all subjects

* Ensure adding attestation doesn't touch statements

* Don't export sign function

* Remove attestations from VerificationResult

* Change bool to Outcome enum in result

* Use outputLayout directly

* Make clearer that Outcome strings are for VSA

* Return multiple SLSA levels from policy

* Fix unmarshalling of policy-id (#39)

* Rename function

* Rename policy.VerificationResult -> policy.Result

* Re-add test for canonical input

---------

Co-authored-by: James Carnegie <james.carnegie@docker.com>
Co-authored-by: James Carnegie <kipz@users.noreply.github.com>
This commit is contained in:
Jonny Stoten
2024-05-22 14:49:23 +01:00
committed by GitHub
parent 745eea09e8
commit 1a7897a052
33 changed files with 776 additions and 461 deletions

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"github.com/docker/attest/pkg/oci"
"github.com/open-policy-agent/opa/rego"
)
type policyEvaluatorCtxKeyType struct{}
@@ -27,5 +26,5 @@ func GetPolicyEvaluator(ctx context.Context) (PolicyEvaluator, error) {
}
type PolicyEvaluator interface {
Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error)
Evaluate(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *PolicyInput) (*Result, error)
}

View File

@@ -4,37 +4,29 @@ import (
"context"
"github.com/docker/attest/pkg/oci"
"github.com/open-policy-agent/opa/rego"
)
type MockPolicyEvaluator struct {
EvaluateFunc func(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error)
EvaluateFunc func(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *PolicyInput) (*Result, error)
}
func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error) {
func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *PolicyInput) (*Result, error) {
if pe.EvaluateFunc != nil {
return pe.EvaluateFunc(ctx, resolver, policy, input)
return pe.EvaluateFunc(ctx, resolver, pctx, input)
}
return AllowedResult(), nil
}
func GetMockPolicy() PolicyEvaluator {
return &MockPolicyEvaluator{
EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pfs []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error) {
EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *PolicyInput) (*Result, error) {
return AllowedResult(), nil
},
}
}
func AllowedResult() *rego.ResultSet {
return &rego.ResultSet{
{
Bindings: rego.Vars{},
Expressions: []*rego.ExpressionValue{
{
Value: true,
},
},
},
func AllowedResult() *Result {
return &Result{
Success: true,
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/distribution/reference"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/tuf"
intoto "github.com/in-toto/in-toto-golang/in_toto"
goyaml "gopkg.in/yaml.v3"
)
@@ -20,6 +21,26 @@ const (
PolicyMappingFileName = "mapping.yaml"
)
type Summary struct {
Subjects []intoto.Subject `json:"subjects"`
SLSALevels []string `json:"slsa_levels"`
Verifier string `json:"verifier"`
PolicyURI string `json:"policy_uri"`
}
type Violation struct {
Type string `json:"type"`
Description string `json:"description"`
Attestation *intoto.Statement `json:"attestation"`
Details map[string]any `json:"details"`
}
type Result struct {
Success bool `json:"success"`
Violations []Violation `json:"violations"`
Summary Summary `json:"summary"`
}
type PolicyMappings struct {
Version string `json:"version"`
Kind string `json:"kind"`
@@ -39,7 +60,7 @@ type PolicyMappingFile struct {
}
type PolicyMirror struct {
PolicyId string `json:"policy-id"`
PolicyId string `yaml:"policy-id"`
Mirror MirrorSpec `json:"mirror"`
}
@@ -60,6 +81,11 @@ type PolicyOptions struct {
LocalPolicyDir string
}
type Policy struct {
InputFiles []*PolicyFile
Query string
}
type PolicyInput struct {
Digest string `json:"digest"`
Purl string `json:"purl"`
@@ -71,7 +97,7 @@ type PolicyFile struct {
Content []byte
}
func resolveLocalPolicy(opts *PolicyOptions, mapping *PolicyMapping) ([]*PolicyFile, error) {
func resolveLocalPolicy(opts *PolicyOptions, mapping *PolicyMapping) (*Policy, error) {
if opts.LocalPolicyDir == "" {
return nil, fmt.Errorf("local policy dir not set")
}
@@ -88,10 +114,13 @@ func resolveLocalPolicy(opts *PolicyOptions, mapping *PolicyMapping) ([]*PolicyF
Content: fileContents,
})
}
return files, nil
policy := &Policy{
InputFiles: files,
}
return policy, nil
}
func loadLocalMappings(opts *PolicyOptions) (*PolicyMappings, error) {
func LoadLocalMappings(opts *PolicyOptions) (*PolicyMappings, error) {
if opts.LocalPolicyDir == "" {
return nil, nil
}
@@ -108,7 +137,7 @@ func loadLocalMappings(opts *PolicyOptions) (*PolicyMappings, error) {
return mappings, nil
}
func resolveTufPolicy(opts *PolicyOptions, mapping *PolicyMapping) ([]*PolicyFile, error) {
func resolveTufPolicy(opts *PolicyOptions, mapping *PolicyMapping) (*Policy, error) {
files := make([]*PolicyFile, 0, len(mapping.Files))
for _, f := range mapping.Files {
filename := f.Path
@@ -121,7 +150,10 @@ func resolveTufPolicy(opts *PolicyOptions, mapping *PolicyMapping) ([]*PolicyFil
Content: fileContents,
})
}
return files, nil
policy := &Policy{
InputFiles: files,
}
return policy, nil
}
func loadTufMappings(tufClient tuf.TUFClient, localTargetsDir string) (*PolicyMappings, error) {
@@ -163,7 +195,7 @@ func findPolicyMatch(named reference.Named, mappings *PolicyMappings) (*PolicyMa
return nil, nil
}
func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts *PolicyOptions) ([]*PolicyFile, error) {
func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts *PolicyOptions) (*Policy, error) {
imageName, err := resolver.ImageName(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get image name: %w", err)
@@ -172,7 +204,7 @@ func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts *
if err != nil {
return nil, fmt.Errorf("failed to parse image name: %w", err)
}
localMappings, err := loadLocalMappings(opts)
localMappings, err := LoadLocalMappings(opts)
if err != nil {
return nil, fmt.Errorf("failed to load local policy mappings: %w", err)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/docker/attest/pkg/policy"
"github.com/docker/attest/pkg/tuf"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func loadAttestation(t *testing.T, path string) *attestation.Envelope {
@@ -32,61 +33,37 @@ 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
isCanonical bool
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?digest=sha256%da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620&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},
{repo: "testdata/mock-tuf-allow", expectSuccess: true, isCanonical: false, resolver: defaultResolver},
{repo: "testdata/mock-tuf-deny", expectSuccess: false, isCanonical: false, resolver: defaultResolver},
{repo: "testdata/mock-tuf-verify-sig", expectSuccess: true, isCanonical: false, resolver: defaultResolver},
{repo: "testdata/mock-tuf-wrong-key", expectSuccess: false, isCanonical: false, resolver: defaultResolver},
{repo: "testdata/mock-tuf-allow-canonical", expectSuccess: true, isCanonical: true, resolver: defaultResolver},
{repo: "testdata/mock-tuf-allow-canonical", expectSuccess: false, isCanonical: false, resolver: defaultResolver},
}
for _, tc := range testCases {
t.Run(tc.repo, func(t *testing.T) {
input := &policy.PolicyInput{
Digest: "sha256:test-digest",
Purl: "test-purl",
IsCanonical: tc.isCanonical,
}
tufClient := tuf.NewMockTufClient(tc.repo, test.CreateTempDir(t, "", "tuf-dest"))
if tc.policy == nil {
tc.policy = &policy.PolicyOptions{
@@ -95,17 +72,29 @@ func TestRegoEvaluator_Evaluate(t *testing.T) {
}
}
policyFiles, err := policy.ResolvePolicy(ctx, tc.resolver, tc.policy)
policy, err := policy.ResolvePolicy(ctx, tc.resolver, tc.policy)
assert.NoErrorf(t, err, "failed to resolve policy")
rs, err := re.Evaluate(ctx, tc.resolver, policyFiles, tc.input)
result, err := re.Evaluate(ctx, tc.resolver, policy, input)
require.NoErrorf(t, err, "Evaluate failed")
if tc.expectSuccess {
assert.NoErrorf(t, err, "Evaluate failed")
assert.True(t, rs.Allowed(), "Evaluate should have succeeded")
assert.True(t, result.Success, "Evaluate should have succeeded")
} else {
assert.False(t, rs.Allowed(), "Evaluate should have failed")
assert.False(t, result.Success, "Evaluate should have failed")
}
})
}
}
func TestLoadingMappings(t *testing.T) {
opts := &policy.PolicyOptions{
LocalPolicyDir: filepath.Join("testdata", "mock-tuf-allow"),
}
policyMappings, err := policy.LoadLocalMappings(opts)
require.NoError(t, err)
assert.Equal(t, len(policyMappings.Mirrors), 1)
for _, mirror := range policyMappings.Mirrors {
assert.Equal(t, "docker-official-images", mirror.PolicyId)
}
}

View File

@@ -23,17 +23,20 @@ import (
type regoEvaluator struct {
debug bool
query string
}
const (
DefaultQuery = "result := data.attest.result"
resultBinding = "result"
)
func NewRegoEvaluator(debug bool) PolicyEvaluator {
return &regoEvaluator{
debug: debug,
query: "data.attest.allow",
}
}
func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, files []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error) {
func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *PolicyInput) (*Result, error) {
var regoOpts []func(*rego.Rego)
// Create a new in-memory store
@@ -45,7 +48,7 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR
return nil, err
}
for _, target := range files {
for _, target := range pctx.InputFiles {
// load yaml as data (no rego opt for this!?)
if filepath.Ext(target.Path) == ".yaml" {
yamlData, err := loadYAML(target.Path, target.Content)
@@ -74,12 +77,15 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR
rego.Dump(os.Stderr),
)
}
query := DefaultQuery
if pctx.Query != "" {
query = pctx.Query
}
regoOpts = append(regoOpts,
rego.Query(re.query),
rego.StrictBuiltinErrors(true),
rego.Query(query),
rego.Input(input),
rego.Store(store),
rego.GenerateJSON(jsonGenerator[Result]()),
)
for _, custom := range RegoFunctions(resolver) {
regoOpts = append(regoOpts, custom.Func)
@@ -87,11 +93,50 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR
r := rego.New(regoOpts...)
rs, err := r.Eval(ctx)
return &rs, err
if err != nil {
return nil, err
}
if len(rs) == 0 {
return nil, fmt.Errorf("no policy evaluation result")
}
binding, ok := rs[0].Bindings[resultBinding]
if !ok {
return nil, fmt.Errorf("failed to extract verification result")
}
result, ok := binding.(Result)
if !ok {
return nil, fmt.Errorf("failed to extract verification result")
}
return &result, nil
}
func jsonGenerator[T any]() func(t *ast.Term, ec *rego.EvalContext) (any, error) {
return func(t *ast.Term, ec *rego.EvalContext) (any, error) {
// TODO: this is horrible - we're converting the AST to JSON and then back to AST, then using ast.As to convert it to a struct
// We can't use ast.As directly because it fails if the AST contains a set
json, err := ast.JSON(t.Value)
if err != nil {
return nil, err
}
v, err := ast.InterfaceToValue(json)
if err != nil {
return nil, err
}
var result T
err = ast.As(v, &result)
if err != nil {
return nil, err
}
return result, nil
}
}
var dynamicObj = types.NewObject(nil, types.NewDynamicProperty(types.S, types.A))
var arrayObj = types.NewArray(nil, dynamicObj)
var setObj = types.NewSet(dynamicObj)
var verifyDecl = &ast.Builtin{
Name: "attestations.verify_envelope",
Decl: types.NewFunction(types.Args(dynamicObj, arrayObj), dynamicObj),
@@ -99,7 +144,7 @@ var verifyDecl = &ast.Builtin{
}
var attestDecl = &ast.Builtin{
Name: "attestations.attestation",
Decl: types.NewFunction(types.Args(types.S), dynamicObj),
Decl: types.NewFunction(types.Args(types.S), setObj),
Nondeterministic: true,
}
@@ -153,12 +198,13 @@ func fetchIntotoAttestations(resolver oci.AttestationResolver) func(rego.Builtin
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...))
// Wrap the values in an ast.Set and convert it to an ast.Term.
set := ast.NewTerm(ast.NewSet(values...))
return array, nil
return set, nil
}
}
func verifyIntotoEnvelope(rCtx rego.BuiltinContext, envTerm, keysTerm *ast.Term) (*ast.Term, error) {
env := new(att.Envelope)
var keys att.Keys

View File

@@ -0,0 +1,7 @@
package attest
import rego.v1
result := {
"success": input.isCanonical,
}

View File

@@ -0,0 +1,16 @@
# map repos to policies
version: v1
kind: policy-mapping
policies:
- origin:
domain: docker.io
prefix: library/
id: docker-official-images
description: Docker Official Images
files:
- path: doi/policy.rego
mirrors:
- policy-id: docker-official-images
mirror:
domains: [localhost:5001, registry.local:5000]
prefix: ""

View File

@@ -2,4 +2,6 @@ package attest
import rego.v1
allow := true
result := {
"success": true,
}

View File

@@ -9,3 +9,8 @@ policies:
description: Docker Official Images
files:
- path: doi/policy.rego
mirrors:
- policy-id: docker-official-images
mirror:
domains: [localhost:5001, registry.local:5000]
prefix: ""

View File

@@ -2,4 +2,6 @@ package attest
import rego.v1
allow := false
result := {
"success": false,
}

View File

@@ -3,13 +3,17 @@ package attest
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
"id": "a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgH23D1i2+ZIOtVjmfB7iFvX8AhVN\n9CPJ4ie9axw+WRHozGnRy99U2dRge3zueBBg2MweF0zrToXGig2v3YOrdw==\n-----END PUBLIC KEY-----",
"from": "2023-12-15T14:00:00Z",
"to": null
}]
allow if {
success if {
some env in attestations.attestation("foo")
statement := attestations.verify_envelope(env, keys)
}
result := {
"success": success
}

View File

@@ -2,18 +2,20 @@ package attest
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,
}
}
keys := [{
"id": "a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHyZpSgzvqFqNv7f3x7865OS38rAb\nQMcff55zM2UH/KR3Pr84a8QsGDNgaNGzJQJWjtMSgfV8WnNoffNK+svFNg==\n-----END PUBLIC KEY-----",
"from": "2023-12-15T14:00:00Z",
"to": null,
}]
allow if {
default success := false
success if {
some env in attestations.attestation("foo")
statement := attestations.verify_envelope(env, keys)
}
allow := true
result := {
"success": success
}