diff --git a/attestation/sign_test.go b/attestation/sign_test.go index 690998f5..adba016f 100644 --- a/attestation/sign_test.go +++ b/attestation/sign_test.go @@ -117,14 +117,14 @@ func TestSignVerifyAttestation(t *testing.T) { expectedError: "not yet valid", }, { - name: "key already revoked", + name: "key was expired", keyID: keyID, pem: pem, distrust: false, from: time.Time{}, to: new(time.Time), status: "revoked", - expectedError: "already revoked", + expectedError: "was expired", }, { name: "bad key", @@ -144,13 +144,14 @@ func TestSignVerifyAttestation(t *testing.T) { ID: tc.keyID, PEM: string(tc.pem), Distrust: tc.distrust, - From: tc.from, + From: &tc.from, To: tc.to, Status: tc.status, } opts := &attestation.VerifyOptions{ Keys: attestation.Keys{keyMeta}, } + opts.Resolver = &NullAttestationResolver{} getTL := func(_ context.Context, opts *attestation.VerifyOptions) (tlog.TransparencyLog, error) { if opts.SkipTL { return nil, nil diff --git a/attestation/types.go b/attestation/types.go index 043312df..e2fce07b 100644 --- a/attestation/types.go +++ b/attestation/types.go @@ -98,17 +98,28 @@ type VerifyOptions struct { Keys []*KeyMetadata `json:"keys"` SkipTL bool `json:"skip_tl"` TransparencyLog TransparencyLogKind `json:"tl"` + Resolver Resolver } type KeyMetadata struct { - ID string `json:"id"` - PEM string `json:"key"` - From time.Time `json:"from"` - To *time.Time `json:"to"` - Status string `json:"status"` - SigningFormat string `json:"signing-format"` - Distrust bool `json:"distrust,omitempty"` - publicKey crypto.PublicKey + ID string `json:"id" yaml:"id"` + PEM string `json:"key" yaml:"key"` + // From/To are deprecated in favor of Ranges + From *time.Time `json:"from" yaml:"from"` + To *time.Time `json:"to" yaml:"to"` + + Status string `json:"status" yaml:"status"` + SigningFormat string `json:"signing-format" yaml:"signing-format"` + Distrust bool `json:"distrust,omitempty" yaml:"distrust,omitempty"` + publicKey crypto.PublicKey + ValidityRanges []*ValidityRange `json:"validity,omitempty" yaml:"validity,omitempty"` +} + +type ValidityRange struct { + Patterns []string `json:"patterns"` + Platforms []string `json:"platforms"` + To *time.Time `json:"to"` + From *time.Time `json:"from"` } type ( diff --git a/attestation/verifier.go b/attestation/verifier.go index 482aefaa..f72fcaa2 100644 --- a/attestation/verifier.go +++ b/attestation/verifier.go @@ -133,11 +133,10 @@ func (v *verifier) VerifyLog(ctx context.Context, keyMeta *KeyMetadata, encPaylo if err != nil { return fmt.Errorf("TL entry failed verification: %w", err) } - if integratedTime.Before(keyMeta.From) { - return fmt.Errorf("key %s was not yet valid at TL log time %s (key valid from %s)", keyMeta.ID, integratedTime, keyMeta.From) - } - if keyMeta.To != nil && !integratedTime.Before(*keyMeta.To) { - return fmt.Errorf("key %s was already %s at TL log time %s (key %s at %s)", keyMeta.ID, keyMeta.Status, integratedTime, keyMeta.Status, *keyMeta.To) + + err = opts.EnsureValid(ctx, keyMeta, &integratedTime) + if err != nil { + return fmt.Errorf("error key %s was not valid at integrated time: %w", keyMeta.ID, err) } return nil } diff --git a/attestation/verify.go b/attestation/verify.go index fbd47d6c..cf16f1ce 100644 --- a/attestation/verify.go +++ b/attestation/verify.go @@ -5,8 +5,12 @@ import ( "crypto" "encoding/base64" "fmt" + "regexp" + "time" + "github.com/distribution/reference" "github.com/docker/attest/signerverifier" + v1 "github.com/google/go-containerregistry/pkg/v1" intoto "github.com/in-toto/in-toto-golang/in_toto" ociv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/secure-systems-lab/go-securesystemslib/dsse" @@ -22,11 +26,6 @@ func VerifyDSSE(ctx context.Context, verifier Verifier, env *Envelope, opts *Ver return nil, fmt.Errorf("no signatures found") } - keys := make(map[string]*KeyMetadata, len(opts.Keys)) - for _, key := range opts.Keys { - keys[key.ID] = key - } - payload, err := base64Encoding.DecodeString(env.Payload) if err != nil { return nil, fmt.Errorf("error failed to decode payload: %w", err) @@ -36,8 +35,8 @@ func VerifyDSSE(ctx context.Context, verifier Verifier, env *Envelope, opts *Ver // verify signatures and transparency log entry for _, sig := range env.Signatures { // resolve public key used to sign - keyMeta, ok := keys[sig.KeyID] - if !ok { + keyMeta := opts.FindKey(sig.KeyID) + if keyMeta == nil { return nil, fmt.Errorf("error key not found: %s", sig.KeyID) } @@ -81,3 +80,115 @@ func (km *KeyMetadata) ParsedKey() (crypto.PublicKey, error) { km.publicKey = publicKey return publicKey, nil } + +func (km *KeyMetadata) EnsureValid(imageName string, platform *v1.Platform, t *time.Time) error { + // time must always be in the to/from range (if set) + if km.To != nil && !t.Before(*km.To) { + return fmt.Errorf("key %s was expired TL log time %s (key valid to %s)", km.ID, t, km.To) + } + if km.From != nil && t.Before(*km.From) { + return fmt.Errorf("key %s was not yet valid at TL log time %s (key valid from %s)", km.ID, t, km.From) + } + + if len(km.ValidityRanges) == 0 { + return nil + } + parsed, err := reference.ParseNormalizedNamed(imageName) + if err != nil { + return fmt.Errorf("failed to parse image name: %w", err) + } + imageName = parsed.Name() + // check that each range lies within the key's validity at the top level + for _, validity := range km.ValidityRanges { + if validity.To != nil && km.To != nil && !validity.To.Before(*km.To) { + return fmt.Errorf("malformed validity range: %s is not before %s's valid 'to' date %s", validity.To, km.ID, km.To) + } + if validity.From != nil && km.From != nil && validity.From.Before(*km.From) { + return fmt.Errorf("malformed validity range: %s is before %s's valid 'from' date %s", validity.From, km.ID, km.From) + } + } + + // find all validity ranges that match the image name and platform + patternMatches := []*ValidityRange{} + for _, validity := range km.ValidityRanges { + if len(validity.Patterns) == 0 { + return fmt.Errorf("error need at least one validity range pattern") + } + for _, pattern := range validity.Patterns { + if pattern == "" { + return fmt.Errorf("error empty validity pattern") + } + patternRegex, err := regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("error failed to compile validity repo pattern: %w", err) + } + // if there's an image match, then platforms must match too + if patternRegex.MatchString(imageName) { + // either there are no platforms, or at least one must match + if len(validity.Platforms) == 0 { + patternMatches = append(patternMatches, validity) + } + for _, validityPlatform := range validity.Platforms { + parsedPlatform, err := v1.ParsePlatform(validityPlatform) + if err != nil { + return fmt.Errorf("failed to parse platform %s: %w", validityPlatform, err) + } + if parsedPlatform.Equals(*platform) { + patternMatches = append(patternMatches, validity) + } + } + } + } + } + if len(patternMatches) == 0 { + return fmt.Errorf("no matching validity range found for key %s", km.ID) + } + if len(patternMatches) > 1 { + return fmt.Errorf("key %s invalid, multiple matching validity ranges found", km.ID) + } + + // now verify the time is within the validity range + match := patternMatches[0] + if match.To != nil && !t.Before(*match.To) { + return fmt.Errorf("key %s was expired at TL log time %s (valid to %s)", km.ID, t, match.To) + } + if match.From != nil && t.Before(*match.From) { + return fmt.Errorf("key %s was not yet valid at TL log time %s (valid from %s)", km.ID, t, match.From) + } + return nil +} + +func (v *VerifyOptions) EnsureValid(ctx context.Context, km *KeyMetadata, t *time.Time) error { + if v.Resolver == nil { + return fmt.Errorf("error missing resolver") + } + imageName, err := v.Resolver.ImageName(ctx) + if err != nil { + return fmt.Errorf("failed to resolve image name: %w", err) + } + platform, err := v.Resolver.ImagePlatform(ctx) + if err != nil { + return fmt.Errorf("failed to get image platform: %w", err) + } + err = km.EnsureValid(imageName, platform, t) + if err != nil { + return err + } + return nil +} + +func NewVerifyOptions(resolver Resolver) *VerifyOptions { + v := &VerifyOptions{ + Resolver: resolver, + } + return v +} + +func (v *VerifyOptions) FindKey(id string) *KeyMetadata { + for _, key := range v.Keys { + if key.ID == id { + return key + } + } + return nil +} diff --git a/attestation/verify_test.go b/attestation/verify_test.go index 0e3f0fcb..eb819e1a 100644 --- a/attestation/verify_test.go +++ b/attestation/verify_test.go @@ -1,11 +1,14 @@ package attestation_test import ( + "context" "encoding/base64" "testing" + "time" "github.com/docker/attest/attestation" "github.com/docker/attest/internal/test" + v1 "github.com/google/go-containerregistry/pkg/v1" intoto "github.com/in-toto/in-toto-golang/in_toto" ociv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" @@ -39,10 +42,122 @@ func TestVerifyUnsignedAttestation(t *testing.T) { Payload: base64.StdEncoding.EncodeToString(payload), PayloadType: intoto.PayloadType, } - opts := &attestation.VerifyOptions{ - Keys: attestation.Keys{}, - } + opts := &attestation.VerifyOptions{} _, err := attestation.VerifyDSSE(ctx, nil, env, opts) assert.Error(t, err) assert.Contains(t, err.Error(), "no signatures") } + +func TestEnsureValid(t *testing.T) { + now := time.Now() + keyFrom := now.Add(-time.Hour) + keyTo := now.Add(time.Hour) + justBefore := keyTo.Add(-time.Second) + // justAfter := after.Add(time.Second) + + tests := []struct { + name string + imageName string + platform *v1.Platform + key *attestation.KeyMetadata + wantErr bool + expired bool + integratedTime *time.Time + }{ + // {name: "no custom validity", key: &attestation.KeyMetadata{}}, + // {name: "no custom validity", key: &attestation.KeyMetadata{ + // ValidityRanges: []*attestation.ValidityRange{}, + // }}, + // {name: "malformed pattern", key: &attestation.KeyMetadata{ + // ValidityRanges: []*attestation.ValidityRange{ + // {Patterns: []string{"[]"}}, + // }, + // }, wantErr: true}, + // {name: "missing pattern", key: &attestation.KeyMetadata{ + // ValidityRanges: []*attestation.ValidityRange{ + // {To: &now}, + // }, + // }, wantErr: true}, + // {name: "no matching image", key: &attestation.KeyMetadata{ + // ValidityRanges: []*attestation.ValidityRange{ + // {Patterns: []string{"bar"}, To: &now}, + // }, + // }, wantErr: true}, + // {name: "matching image, no platforms", key: &attestation.KeyMetadata{ + // ValidityRanges: []*attestation.ValidityRange{ + // {Patterns: []string{"foo"}, To: &justBefore}, + // }, + // }}, + // {name: "matching image, wrong platform", key: &attestation.KeyMetadata{ + // ValidityRanges: []*attestation.ValidityRange{ + // {Patterns: []string{"foo"}, Platforms: []string{"linux/arm64"}, To: &now}, + // }, + // }, wantErr: true}, + // {name: "matching image, matching platform", key: &attestation.KeyMetadata{ + // ValidityRanges: []*attestation.ValidityRange{ + // {Patterns: []string{"foo"}, Platforms: []string{"linux/amd64"}, To: &justBefore}, + // }, + // }}, + {name: "matching canonical image, matching platform", key: &attestation.KeyMetadata{ + ValidityRanges: []*attestation.ValidityRange{ + {Patterns: []string{"^docker.io/library/foo$"}, Platforms: []string{"linux/amd64"}, To: &justBefore}, + }, + }}, + {name: "matching image, matching platform (on of many)", key: &attestation.KeyMetadata{ + ValidityRanges: []*attestation.ValidityRange{ + {Patterns: []string{"foo"}, Platforms: []string{"linux/amd64", "linux/arm64"}, To: &justBefore}, + }, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + imageName := tt.imageName + if imageName == "" { + imageName = "foo" + } + platform := tt.platform + if platform == nil { + platform = &v1.Platform{OS: "linux", Architecture: "amd64"} + } + tt.key.ID = "TEST_KEY" + tt.key.From = &keyFrom + tt.key.To = &keyTo + integratedTime := tt.integratedTime + if integratedTime == nil { + integratedTime = &now + } + err := tt.key.EnsureValid(imageName, platform, integratedTime) + if !tt.wantErr { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} + +type NullAttestationResolver struct { + called bool + imageName string + platform *v1.Platform +} + +func (r *NullAttestationResolver) ImageName(_ context.Context) (string, error) { + return r.imageName, nil +} + +func (r *NullAttestationResolver) ImagePlatform(_ context.Context) (*v1.Platform, error) { + if r.platform != nil { + return r.platform, nil + } + return v1.ParsePlatform("") +} + +func (r *NullAttestationResolver) ImageDescriptor(_ context.Context) (*v1.Descriptor, error) { + return nil, nil +} + +func (r *NullAttestationResolver) Attestations(_ context.Context, _ string) ([]*attestation.EnvelopeReference, error) { + r.called = true + return nil, nil +} diff --git a/policy/rego.go b/policy/rego.go index ca4b8f91..6f768f9e 100644 --- a/policy/rego.go +++ b/policy/rego.go @@ -259,11 +259,11 @@ func (regoOpts *RegoFnOpts) fetchInTotoAttestations(rCtx rego.BuiltinContext, pr return set, nil } -// because we don't control the signature here (blame rego) +// because we don't control the function signature here (blame rego) // nolint:gocritic func (regoOpts *RegoFnOpts) verifyInTotoEnvelope(rCtx rego.BuiltinContext, envTerm, optsTerm *ast.Term) (*ast.Term, error) { env := new(attestation.Envelope) - opts := new(attestation.VerifyOptions) + opts := attestation.NewVerifyOptions(regoOpts.attestationResolver) err := ast.As(envTerm.Value, env) if err != nil { return nil, fmt.Errorf("failed to cast envelope: %w", err) @@ -272,6 +272,7 @@ func (regoOpts *RegoFnOpts) verifyInTotoEnvelope(rCtx rego.BuiltinContext, envTe if err != nil { return nil, fmt.Errorf("failed to cast verifier options: %w", err) } + payload, err := attestation.VerifyDSSE(rCtx.Context, regoOpts.attestationVerifier, env, opts) if err != nil { return nil, fmt.Errorf("failed to verify envelope: %w", err) @@ -334,3 +335,25 @@ func loadYAML(path string, bs []byte) (interface{}, error) { } return x, nil } + +// func processKeys(ctx context.Context, resolver attestation.Resolver, opts *attestation.VerifyOptions) error { +// imageName, err := resolver.ImageName(ctx) +// if err != nil { +// return fmt.Errorf("failed to resolve image name: %w", err) +// } +// parsed, err := reference.ParseNormalizedNamed(imageName) +// if err != nil { +// return fmt.Errorf("failed to parse image name: %w", err) +// } +// imageName = parsed.Name() +// platform, err := resolver.ImagePlatform(ctx) +// if err != nil { +// return fmt.Errorf("failed to get image platform: %w", err) +// } +// for _, key := range opts.Keys { +// if err := key.UpdateValidity(imageName, platform); err != nil { +// return fmt.Errorf("error failed to process validity ranges for key %s: %w", key.ID, err) +// } +// } +// return nil +// } diff --git a/policy/rego_test.go b/policy/rego_test.go index 5f44083f..666d9727 100644 --- a/policy/rego_test.go +++ b/policy/rego_test.go @@ -68,14 +68,19 @@ func buffer[T any](ch chan T) []T { } type NullAttestationResolver struct { - called bool + called bool + imageName string + platform *v1.Platform } func (r *NullAttestationResolver) ImageName(_ context.Context) (string, error) { - return "", nil + return r.imageName, nil } func (r *NullAttestationResolver) ImagePlatform(_ context.Context) (*v1.Platform, error) { + if r.platform != nil { + return r.platform, nil + } return v1.ParsePlatform("") } diff --git a/policy/types.go b/policy/types.go index 59a0bfb8..047b42ba 100644 --- a/policy/types.go +++ b/policy/types.go @@ -38,8 +38,12 @@ type Options struct { AttestationStyle mapping.AttestationStyle Debug bool AttestationVerifier attestation.Verifier + // extra parameters to pass through to rego as policy inputs + Parameters *Parameters } +type Parameters map[string]string + type Policy struct { InputFiles []*File Query string @@ -50,13 +54,14 @@ type Policy struct { } type Input struct { - Digest string `json:"digest"` - PURL string `json:"purl"` - Tag string `json:"tag,omitempty"` - Domain string `json:"domain"` - NormalizedName string `json:"normalized_name"` - FamiliarName string `json:"familiar_name"` - Platform string `json:"platform"` + Digest string `json:"digest"` + PURL string `json:"purl"` + Tag string `json:"tag,omitempty"` + Domain string `json:"domain"` + NormalizedName string `json:"normalized_name"` + FamiliarName string `json:"familiar_name"` + Platform string `json:"platform"` + Parameters *Parameters `json:"parameters"` } type File struct { diff --git a/signerverifier/parse.go b/signerverifier/parse.go index 93e042e9..7dab7e55 100644 --- a/signerverifier/parse.go +++ b/signerverifier/parse.go @@ -13,10 +13,10 @@ const pemType = "PUBLIC KEY" func ParsePublicKey(pubkeyBytes []byte) (crypto.PublicKey, error) { p, _ := pem.Decode(pubkeyBytes) if p == nil { - return nil, fmt.Errorf("pubkey file does not contain any PEM data") + return nil, fmt.Errorf("pubkeyBytes does not contain any PEM data") } if p.Type != pemType { - return nil, fmt.Errorf("pubkey file does not contain a public key") + return nil, fmt.Errorf("pubkeyBytes does not contain a public key") } return x509.ParsePKIXPublicKey(p.Bytes) } diff --git a/test/testdata/expires/.gitignore b/test/testdata/expires/.gitignore new file mode 100644 index 00000000..5b6b0720 --- /dev/null +++ b/test/testdata/expires/.gitignore @@ -0,0 +1 @@ +config.yaml diff --git a/test/testdata/expires/mapping.yaml b/test/testdata/expires/mapping.yaml new file mode 100644 index 00000000..a3c7c504 --- /dev/null +++ b/test/testdata/expires/mapping.yaml @@ -0,0 +1,14 @@ + +version: v1 +kind: policy-mapping +policies: + - id: test + description: "Example of dual policy with per repo key validity" + files: + - path: policy.rego + - path: config.yaml #auto generated + attestations: + style: attached +rules: + - pattern: "^docker[.]io/library/.*$" + policy-id: test diff --git a/test/testdata/expires/policy.rego b/test/testdata/expires/policy.rego new file mode 100644 index 00000000..3f083f84 --- /dev/null +++ b/test/testdata/expires/policy.rego @@ -0,0 +1,68 @@ +package attest + +import rego.v1 + +import data.keys + +split_digest := split(input.digest, ":") + +digest_type := split_digest[0] + +digest := split_digest[1] + +provs(pred) := p if { + res := attest.fetch(pred) + not res.error + p := res.value +} + +atts := union({ + provs("https://spdx.dev/Document"), +}) + +statements contains merged if { + some att in atts + some key in keys + opts := {"keys": [key], "skip_tl": false} + res := attest.verify(att, opts) + not res.error + s := res.value + # capture the key used to verify the statement for later use + merged = object.union(s, {"key": key}) +} + +subjects contains subject if { + some statement in statements + statement.key.status == "active" + some subject in statement.subject +} + +unsafe_statement_from_attestation(att) := statement if { + payload := att.payload + statement := json.unmarshal(base64.decode(payload)) +} + +violations contains violation if { + some att in atts + statement := unsafe_statement_from_attestation(att) + opts := {"keys": keys, "skip_tl": false} + res := attest.verify(att, opts) + err := res.error + violation := { + "type": "unsigned_statement", + "description": sprintf("Statement is not correctly signed: %v", [err]), + "attestation": statement, + "details": {"error": err}, + } +} + +result := { + "success": count(subjects) > 0, + "violations": violations, + "summary": { + "subjects": subjects, + "slsa_level": "SLSA_BUILD_LEVEL_3", + "verifier": "docker-official-images", + "policy_uri": "https://docker.com/official/policy/v0.1", + }, +} diff --git a/test/testdata/local-policy-param/.gitignore b/test/testdata/local-policy-param/.gitignore new file mode 100644 index 00000000..5b6b0720 --- /dev/null +++ b/test/testdata/local-policy-param/.gitignore @@ -0,0 +1 @@ +config.yaml diff --git a/test/testdata/local-policy-param/mapping.yaml b/test/testdata/local-policy-param/mapping.yaml new file mode 100644 index 00000000..2dacf8a4 --- /dev/null +++ b/test/testdata/local-policy-param/mapping.yaml @@ -0,0 +1,15 @@ +version: v1 +kind: policy-mapping +policies: + - id: test-images + description: Local test images + files: + - path: policy.rego + - path: config.yaml #auto generated + attestations: + style: attached +rules: + - pattern: "^docker[.]io/library/test-image$" + policy-id: test-images + - pattern: "^mirror[.]org/library/(.*)$" + rewrite: docker.io/library/$1 diff --git a/test/testdata/local-policy-param/policy.rego b/test/testdata/local-policy-param/policy.rego new file mode 100644 index 00000000..ed01408c --- /dev/null +++ b/test/testdata/local-policy-param/policy.rego @@ -0,0 +1,61 @@ +package attest + +import rego.v1 + +import data.keys +import input.parameters + +provs(pred) := p if { + res := attest.fetch(pred) + not res.error + p := res.value +} + +atts := union({ + provs("https://slsa.dev/provenance/v0.2"), + provs("https://spdx.dev/Document"), +}) + +opts := {"keys": keys, "skip_tl": true} + +statements contains s if { + parameters.foo == "bar" + some att in atts + res := attest.verify(att, opts) + not res.error + s := res.value +} + +subjects contains subject if { + some statement in statements + some subject in statement.subject +} + +unsafe_statement_from_attestation(att) := statement if { + payload := att.payload + statement := json.unmarshal(base64.decode(payload)) +} + +violations contains violation if { + some att in atts + statement := unsafe_statement_from_attestation(att) + res := attest.verify(att, opts) + err := res.error + violation := { + "type": "unsigned_statement", + "description": sprintf("Statement is not correctly signed: %v", [err]), + "attestation": statement, + "details": {"error": err}, + } +} + +result := { + "success": count(statements) > 0, + "violations": violations, + "summary": { + "subjects": subjects, + "slsa_level": "SLSA_BUILD_LEVEL_3", + "verifier": "docker-official-images", + "policy_uri": "https://docker.com/official/policy/v0.1", + }, +} diff --git a/tlog/mock.go b/tlog/mock.go index 9126718e..47660b92 100644 --- a/tlog/mock.go +++ b/tlog/mock.go @@ -16,12 +16,42 @@ const ( TestEntry = `{"body":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNWEzYmY0ZjFiMmIwYjgyMmNkMTVkNmMxNWIwZjAwYTA4In19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FUUNJQUlyVUZGUzBIYmNzZjc5L08yajVXdHl2R2Vvd1NVSXpZcDlBM2IwWnREVUFpQVQxZU42ZjFyVmVWa011REFlN3dxWkJ2bE5LY2VsajNVVDNmaWhyQjZSY2c9PSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVSlZla05DSzJGQlJFRm5SVU5CWjBWQ1RVRnZSME5EY1VkVFRUUTVRa0ZOUTAxQk9IaEVWRUZNUW1kT1ZrSkJUVlJDU0ZKc1l6TlJkMGhvWTA0S1RXcE5lRTFxU1ROTlZHdDVUWHBWTlZkb1kwNU5hbEY0VFdwSk1rMVVhM2xOZWxVMVYycEJVRTFSTUhkRGQxbEVWbEZSUkVWM1VqQmFXRTR3VFVacmR3cEZkMWxJUzI5YVNYcHFNRU5CVVZsSlMyOWFTWHBxTUVSQlVXTkVVV2RCUlVRMFZpdFNSV2g0SzJGeFYwZzNlV3hOVFVSSVlXaE9UVzVOVEZOUFNsQXZDamxyUVcwNWJIQXJNMjF4V1ZSQmFGVlNjbUUyVDBRMVVYZzRXbUprSzJWMVVIbFFhemw1SzNjdloxZEhSRUk1ZW00dlNXd3hTMDVIVFVWUmQwUm5XVVFLVmxJd1VFRlJTQzlDUVZGRVFXZGxRVTFDVFVkQk1WVmtTbEZSVFUxQmIwZERRM05IUVZGVlJrSjNUVVJOUVhkSFFURlZaRVYzUlVJdmQxRkRUVUZCZHdwRWQxbEVWbEl3VWtKQlozZENiMGxGWkVkV2VtUkVRVXRDWjJkeGFHdHFUMUJSVVVSQlowNUtRVVJDUjBGcFJVRTNOMjFFTDFSbVJtRlJVemxrWlhRMENqbFhaRk41YURKT1VTOUZiMVJtYVVGdFFtaHVWblpEVTNSUVowTkpVVU1yZDNSdllpOU9iMUp4T0c5cU4wZDNibTVKYUZKVGRDOVJNbmtyVXpoUkwzSUthRkpVYW5GaE9HZExRVDA5Q2kwdExTMHRSVTVFSUVORlVsUkpSa2xEUVZSRkxTMHRMUzBLIn19fX0=","integratedTime":1703705039,"logID":"c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d","logIndex":59674396,"verification":{"inclusionProof":{"checkpoint":"rekor.sigstore.dev - 2605736670972794746\n55510966\nJCi1O53Xmdi9lXnui4Q5SQ+MJSMnWr1Bxn+Q2Qf22tU=\nTimestamp: 1703705040158839214\n\n— rekor.sigstore.dev wNI9ajBFAiAXgtjFDVqCSgiSP04TQzELrz4+EyBwyYVL2EEULTCy0AIhAI9peLU76ZUD1tvU8qvzBJBo77IYD1rc+A1MPc35AeVK\n","hashes":["fb77ee213b48f4b18dc81c6e634c570abf99b257713561f174f2e0f4c039af67","6cb113bbefadecbbb8b89b1c08232438a6125071790b6a062cff8c1ccfdcb91e","6fbe1424e264e4590ca502d671b7a036c87f7a90d1f57534b98eb781144160bf","077b606720a6478200f6c3ed08a68e9b01b1cae192cb120888ddcc95521601bd","b6f8e8bc21ae0cde82b92422a4b4f37b28a43185821e468a4e65b6c79ed8f5b7","89332533fac54e9bc68c7353c42f6ebb9fe38039f67910332ff95082072068d4","0814d6f707a75fb3334bab14ab5466bd8b9a64ae7be7cd4d53a428c64932bc66","e883e826f10329c63a4a2ed21156037a050df43b9d74079296beac6968ed4150","d79230703257b7e4a8a61b032b6980d1a0bdbc7ae96ca838b525b3751785fe48","2f4a77e5288462cd3b75084d37f1502dcbe0943d18dd95cb247fc1ebbabc0aad","38562c253d3536d0d00e3547c880b6b0251a25ac69605b50c9eaa1a27186cc7a","9dea192350ff8b3c0f5ccda38261cb38ebd61869281c3928912332d1144e0a04","2c4d25ba59aa573ab2c79c2d3cd9e1d74789b10632432724d63112ce50b44874","98c486feb5d87092a78a46c4b5be04868654900affc2e86ffb20074dc73a883a","6969c49bd73f19bf28a5eaeabd331ddd60502defb2cd3d96e17b741c80adec6c"],"logIndex":55510965,"rootHash":"2428b53b9dd799d8bd9579ee8b8439490f8c2523275abd41c67f90d907f6dad5","treeSize":55510966},"signedEntryTimestamp":"MEUCIQCG9PRI8PcvtJyE9pbcculZipze6NEWR1Nk8EYocto3BwIgYu5gqgjW80HMjSjUxUNJLp0wlVTesnJCeByUBySc59w="}}` ) -func GetMockTL() TransparencyLog { +type MockTLConfig struct { + IntegratedTime time.Time +} + +type MockTLOption func(*MockTLConfig) + +func WithIntegratedTime(t time.Time) MockTLOption { + return func(cfg *MockTLConfig) { + cfg.IntegratedTime = t + } +} + +func GetMockTL(opts ...MockTLOption) TransparencyLog { + cfg := &MockTLConfig{ + IntegratedTime: time.Now(), // default to current time if not set + } + for _, opt := range opts { + opt(cfg) + } + return &MockTransparencyLog{ UploadLogEntryFunc: func(_ context.Context, _ string, _ []byte, _ []byte, _ dsse.SignerVerifier) (*DockerTLExtension, error) { + entry := new(models.LogEntryAnon) + err := json.Unmarshal([]byte(TestEntry), entry) + if err != nil { + return nil, fmt.Errorf("error failed to unmarshal TestEntry: %w", err) + } + integratedTime := cfg.IntegratedTime.Unix() + entry.IntegratedTime = &integratedTime + entryBytes, err := json.Marshal(entry) + if err != nil { + return nil, fmt.Errorf("error failed to marshal TL entry: %w", err) + } return &DockerTLExtension{ Kind: "Mock", - Data: json.RawMessage(TestEntry), + Data: json.RawMessage(entryBytes), }, nil }, VerifyLogEntryFunc: func(_ context.Context, ext *DockerTLExtension, _, _ []byte) (time.Time, error) { diff --git a/verify.go b/verify.go index 04ad31da..be3b6a30 100644 --- a/verify.go +++ b/verify.go @@ -75,7 +75,7 @@ func (verifier *ImageVerifier) Verify(ctx context.Context, src *oci.ImageSpec) ( Outcome: OutcomeNoPolicy, }, nil } - // this is overriding the mapping with a referrers config. Useful for testing if nothing else + // this is overriding the mapping/policy with a referrers config. Useful for testing if nothing else if verifier.opts.ReferrersRepo != "" { resolvedPolicy.Mapping.Attestations = &mapping.AttestationConfig{ Repo: verifier.opts.ReferrersRepo, @@ -93,7 +93,7 @@ func (verifier *ImageVerifier) Verify(ctx context.Context, src *oci.ImageSpec) ( return nil, fmt.Errorf("failed to create attestation resolver: %w", err) } evaluator := policy.NewRegoEvaluator(verifier.opts.Debug, verifier.attestationVerifier) - result, err = VerifyAttestations(ctx, resolver, evaluator, resolvedPolicy) + result, err = verifyAttestations(ctx, resolver, evaluator, resolvedPolicy, verifier.opts) if err != nil { return nil, fmt.Errorf("failed to evaluate policy: %w", err) } @@ -195,7 +195,7 @@ func toVerificationResult(p *policy.Policy, input *policy.Input, result *policy. }, nil } -func VerifyAttestations(ctx context.Context, resolver attestation.Resolver, evaluator policy.Evaluator, resolvedPolicy *policy.Policy) (*VerificationResult, error) { +func verifyAttestations(ctx context.Context, resolver attestation.Resolver, evaluator policy.Evaluator, resolvedPolicy *policy.Policy, opts *policy.Options) (*VerificationResult, error) { desc, err := resolver.ImageDescriptor(ctx) if err != nil { return nil, fmt.Errorf("failed to get image descriptor: %w", err) @@ -247,6 +247,7 @@ func VerifyAttestations(ctx context.Context, resolver attestation.Resolver, eval Domain: reference.Domain(ref), NormalizedName: reference.Path(ref), FamiliarName: reference.FamiliarName(ref), + Parameters: opts.Parameters, } // rego has null strings if tag != "" { diff --git a/verify_test.go b/verify_test.go index dac83ef2..c6ffd24c 100644 --- a/verify_test.go +++ b/verify_test.go @@ -21,12 +21,14 @@ import ( "github.com/secure-systems-lab/go-securesystemslib/dsse" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "sigs.k8s.io/yaml" + "gopkg.in/yaml.v3" ) var ( ExampleAttestation = filepath.Join("test", "testdata", "example_attestation.json") LocalKeysPolicy = filepath.Join("test", "testdata", "local-policy-real") + LocalParamPolicy = filepath.Join("test", "testdata", "local-policy-param") + ExpiresPolicy = filepath.Join("test", "testdata", "expires") ) const ( @@ -60,7 +62,7 @@ func TestVerifyAttestations(t *testing.T) { return policy.AllowedResult(), tc.policyEvaluationError }, } - _, err := VerifyAttestations(ctx, resolver, &mockPE, &policy.Policy{ResolvedName: ""}) + _, err := verifyAttestations(ctx, resolver, &mockPE, &policy.Policy{ResolvedName: ""}, &policy.Options{}) if tc.expectedError != nil { if assert.Error(t, err) { assert.Equal(t, tc.expectedError.Error(), err.Error()) @@ -194,26 +196,21 @@ func TestSignVerify(t *testing.T) { // setup an image with signed attestations outputLayout := test.CreateTempDir(t, "", TestTempDir) - keys, err := GenKeyMetadata(signer) - require.NoError(t, err) - config := struct { - Keys []*attestation.KeyMetadata `json:"keys"` - }{ - Keys: []*attestation.KeyMetadata{keys}, - } - keysYaml, err := yaml.Marshal(config) - require.NoError(t, err) - - // write keysYaml to config.yaml in LocalKeysPolicy. - err = os.WriteFile(filepath.Join(LocalKeysPolicy, "config.yaml"), keysYaml, 0o600) + attIdx, err := oci.IndexFromPath(test.UnsignedTestIndex()) require.NoError(t, err) - + validTime := time.Now().Add(time.Hour) + expiredTime := time.Now().Add(-time.Hour) + integratedTime := time.Now() + testImageName := "test-image" testCases := []struct { name string signTL bool policyDir string imageName string expectedNonSuccess Outcome + spitConfig bool + validity *attestation.ValidityRange + param string }{ {name: "happy path", signTL: true, policyDir: PassNoTLPolicyDir}, {name: "sign tl, verify no tl", signTL: true, policyDir: PassPolicyDir}, @@ -221,17 +218,41 @@ func TestSignVerify(t *testing.T) { {name: "mirror", signTL: false, policyDir: PassMirrorPolicyDir, imageName: "mirror.org/library/test-image:test"}, {name: "mirror no match", signTL: false, policyDir: PassMirrorPolicyDir, imageName: "incorrect.org/library/test-image:test", expectedNonSuccess: OutcomeNoPolicy}, {name: "verify inputs", signTL: false, policyDir: InputsPolicyDir}, - {name: "mirror with verification", signTL: false, policyDir: LocalKeysPolicy, imageName: "mirror.org/library/test-image:test"}, + {name: "mirror with verification", signTL: false, policyDir: LocalKeysPolicy, imageName: "mirror.org/library/test-image:test", spitConfig: true}, + {name: "with per repo validity (valid)", signTL: true, policyDir: ExpiresPolicy, spitConfig: true, validity: &attestation.ValidityRange{To: &validTime, Patterns: []string{testImageName}, Platforms: []string{"linux/amd64"}}}, + {name: "with per repo validity (expired)", signTL: true, policyDir: ExpiresPolicy, spitConfig: true, validity: &attestation.ValidityRange{To: &expiredTime, Patterns: []string{testImageName}, Platforms: []string{"linux/amd64"}}, expectedNonSuccess: OutcomeFailure}, + {name: "with per repo validity (not yet valid)", signTL: true, policyDir: ExpiresPolicy, spitConfig: true, validity: &attestation.ValidityRange{From: &validTime, Patterns: []string{testImageName}, Platforms: []string{"linux/amd64"}}, expectedNonSuccess: OutcomeFailure}, + {name: "no match should fail closed", signTL: true, policyDir: ExpiresPolicy, spitConfig: true, validity: &attestation.ValidityRange{To: &validTime, Patterns: []string{"nomatch"}, Platforms: []string{"linux/arm64"}}, expectedNonSuccess: OutcomeFailure}, + {name: "policy with input params", spitConfig: true, signTL: false, policyDir: LocalParamPolicy, param: "bar"}, + {name: "policy without expected param", spitConfig: true, signTL: false, policyDir: LocalParamPolicy, param: "baz", expectedNonSuccess: OutcomeFailure}, } - attIdx, err := oci.IndexFromPath(test.UnsignedTestIndex()) - assert.NoError(t, err) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + key, err := GenKeyMetadata(signer) + require.NoError(t, err) + if tc.validity != nil { + key.ValidityRanges = []*attestation.ValidityRange{tc.validity} + // now allowed together + key.To = nil + key.From = nil + } + require.NoError(t, err) + config := struct { + Keys []*attestation.KeyMetadata `yaml:"keys"` + }{ + Keys: []*attestation.KeyMetadata{key}, + } + keysYaml, err := yaml.Marshal(config) + require.NoError(t, err) opts := &attestation.SigningOptions{} if tc.signTL { opts.TransparencyLog = tlog.GetMockTL() } + if tc.spitConfig { + err = os.WriteFile(filepath.Join(tc.policyDir, "config.yaml"), keysYaml, 0o600) + require.NoError(t, err) + } signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts) require.NoError(t, err) @@ -254,6 +275,17 @@ func TestSignVerify(t *testing.T) { DisableTUF: true, Debug: true, } + if tc.signTL { + getTL := func(_ context.Context, _ *attestation.VerifyOptions) (tlog.TransparencyLog, error) { + return tlog.GetMockTL(tlog.WithIntegratedTime(integratedTime)), nil + } + verifier, err := attestation.NewVerfier(attestation.WithLogVerifierFactory(getTL)) + require.NoError(t, err) + policyOpts.AttestationVerifier = verifier + } + if tc.param != "" { + policyOpts.Parameters = &policy.Parameters{"foo": tc.param} + } results, err := Verify(ctx, spec, policyOpts) require.NoError(t, err) if tc.expectedNonSuccess != "" { @@ -357,12 +389,14 @@ func GenKeyMetadata(sv dsse.SignerVerifier) (*attestation.KeyMetadata, error) { if err != nil { return nil, err } - + earlier := time.Now().Add(-time.Hour) + later := time.Now().Add(time.Hour) return &attestation.KeyMetadata{ ID: id, Status: "active", SigningFormat: "dssev1", - From: time.Now(), + From: &earlier, + To: &later, PEM: pem, }, nil }