diff --git a/acceptance/kubernetes/stub/stub.go b/acceptance/kubernetes/stub/stub.go index e2f0de75d..73bb9624e 100644 --- a/acceptance/kubernetes/stub/stub.go +++ b/acceptance/kubernetes/stub/stub.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "os" + "regexp" "strings" "k8s.io/client-go/tools/clientcmd" @@ -46,12 +47,56 @@ func (s stubCluster) CreateNamespace(ctx context.Context) (context.Context, erro return ctx, nil } +func expandSpecification(ctx context.Context, specification string) (string, error) { + vars := make(map[string]string) + if registry.IsRunning(ctx) { + digests, err := registry.AllDigests(ctx) + if err != nil { + return "", err + } + + for repositoryAndTag, digest := range digests { + vars[fmt.Sprintf("REGISTRY_%s_DIGEST", repositoryAndTag)] = digest + } + } + + return os.Expand(specification, func(key string) string { + // Handle predefined keys + switch key { + case "GITHOST": + return git.Host(ctx) + case "REGISTRY": + uri, err := registry.Url(ctx) + if err != nil { + panic(err) + } + return uri + } + + // Use a regular expression to match and extract dynamic keys + re := regexp.MustCompile(`^REGISTRY_(.+)_DIGEST$`) + matches := re.FindStringSubmatch(key) + if len(matches) == 2 { + if value, ok := vars[key]; ok { + return value + } + } + return "" + }), nil +} + // CreateNamedPolicy stubs a response from the apiserver to fetch a EnterpriseContractPolicy // custom resource from the `acceptance` namespace with the given name and specification // the specification part can be templated using ${...} notation and supports // `GITHOST` and `REGISTRY` variable substitution func (s stubCluster) CreateNamedPolicy(ctx context.Context, name string, specification string) error { ns := "acceptance" // TODO: namespace support + + specification, err := expandSpecification(ctx, specification) + if err != nil { + return err + } + return wiremock.StubFor(ctx, wiremock.Get(wiremock.URLPathEqualTo(fmt.Sprintf("/apis/appstudio.redhat.com/v1alpha1/namespaces/%s/enterprisecontractpolicies/%s", ns, name))). WillReturnResponse(wiremock.NewResponse().WithBody(fmt.Sprintf(`{ "apiVersion": "appstudio.redhat.com/v1alpha1", @@ -61,20 +106,7 @@ func (s stubCluster) CreateNamedPolicy(ctx context.Context, name string, specifi "namespace": "%s" }, "spec": %s - }`, name, ns, os.Expand(specification, func(key string) string { - switch key { - case "GITHOST": - return git.Host(ctx) - case "REGISTRY": - uri, err := registry.Url(ctx) - if err != nil { - panic(err) - } - return uri - } - - return "" - }))).WithHeaders(map[string]string{"Content-Type": "application/json"}).WithStatus(200))) + }`, name, ns, specification)).WithHeaders(map[string]string{"Content-Type": "application/json"}).WithStatus(200))) } // CreateNamedSnapshot stubs a response from the apiserver to fetch a Snapshot diff --git a/cmd/validate/common_test.go b/cmd/validate/common_test.go index 2e8679579..322c64a57 100644 --- a/cmd/validate/common_test.go +++ b/cmd/validate/common_test.go @@ -40,8 +40,8 @@ type mockEvaluator struct { mock.Mock } -func (e *mockEvaluator) Evaluate(ctx context.Context, inputs []string) ([]evaluator.Outcome, evaluator.Data, error) { - args := e.Called(ctx, inputs) +func (e *mockEvaluator) Evaluate(ctx context.Context, target evaluator.EvaluationTarget) ([]evaluator.Outcome, evaluator.Data, error) { + args := e.Called(ctx, target.Inputs) return args.Get(0).([]evaluator.Outcome), args.Get(1).(evaluator.Data), args.Error(2) } diff --git a/cmd/validate/image_integration_test.go b/cmd/validate/image_integration_test.go index 71a318847..6cc6dd3cd 100644 --- a/cmd/validate/image_integration_test.go +++ b/cmd/validate/image_integration_test.go @@ -78,7 +78,7 @@ func TestEvaluatorLifecycle(t *testing.T) { validate := func(_ context.Context, component app.SnapshotComponent, _ policy.Policy, evaluators []evaluator.Evaluator, _ bool) (*output.Output, error) { for _, e := range evaluators { - _, _, err := e.Evaluate(ctx, []string{}) + _, _, err := e.Evaluate(ctx, evaluator.EvaluationTarget{Inputs: []string{}}) require.NoError(t, err) } diff --git a/features/__snapshots__/validate_image.snap b/features/__snapshots__/validate_image.snap index b97b5ded3..a83e80d3d 100755 --- a/features/__snapshots__/validate_image.snap +++ b/features/__snapshots__/validate_image.snap @@ -808,6 +808,107 @@ Error: success criteria not met --- +[policy rule filtering on imageRef:stdout - 1] +{ + "success": true, + "components": [ + { + "name": "Unnamed", + "containerImage": "${REGISTRY}/acceptance/ec-happy-day@sha256:${REGISTRY_acceptance/ec-happy-day:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "filtering.always_pass" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "filtering.always_pass_with_collection" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_acceptance/ec-happy-day}" + } + ], + "attestations": [ + { + "type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v0.2", + "predicateBuildType": "https://tekton.dev/attestations/chains/pipelinerun@v2", + "signatures": [ + { + "keyid": "", + "sig": "${ATTESTATION_SIGNATURE_acceptance/ec-happy-day}" + } + ] + } + ] + } + ], + "key": "${known_PUBLIC_KEY_JSON}", + "policy": { + "sources": [ + { + "policy": [ + "git::https://${GITHOST}/git/happy-day-policy.git" + ], + "volatileConfig": { + "exclude": [ + { + "value": "filtering.always_fail", + "imageRef": "sha256:${REGISTRY_acceptance/ec-happy-day:latest_DIGEST}" + }, + { + "value": "filtering.always_fail_with_collection", + "imageRef": "sha256:${REGISTRY_acceptance/ec-happy-day:latest_DIGEST}" + } + ] + } + } + ], + "configuration": { + "include": [ + "@stamps", + "filtering.always_pass" + ] + }, + "rekorUrl": "${REKOR}", + "publicKey": "${known_PUBLIC_KEY}" + }, + "ec-version": "${EC_VERSION}", + "effective-time": "${TIMESTAMP}" +} +--- + +[policy rule filtering on imageRef:stderr - 1] + +--- + [application snapshot reference:stdout - 1] { "success": true, diff --git a/features/validate_image.feature b/features/validate_image.feature index 7eee8b87d..a9a3168e2 100644 --- a/features/validate_image.feature +++ b/features/validate_image.feature @@ -411,6 +411,46 @@ Feature: evaluate enterprise contract When ec command is run with "validate image --image ${REGISTRY}/acceptance/ec-happy-day --policy acceptance/ec-policy --public-key ${known_PUBLIC_KEY} --rekor-url ${REKOR} --show-successes" Then the exit status should be 0 Then the output should match the snapshot + + Scenario: policy rule filtering on imageRef + Given a key pair named "known" + Given an image named "acceptance/ec-happy-day" + Given a valid image signature of "acceptance/ec-happy-day" image signed by the "known" key + Given a valid Rekor entry for image signature of "acceptance/ec-happy-day" + Given a valid attestation of "acceptance/ec-happy-day" signed by the "known" key + Given a valid Rekor entry for attestation of "acceptance/ec-happy-day" + Given a git repository named "happy-day-policy" with + | filtering.rego | examples/filtering.rego | + Given policy configuration named "ec-policy" with specification + """ + { + "configuration": { + "include": ["@stamps", "filtering.always_pass"] + }, + "sources": [ + { + "volatileConfig": { + "exclude": [ + { + "value": "filtering.always_fail", + "imageRef": "sha256:${REGISTRY_acceptance/ec-happy-day:latest_DIGEST}" + }, + { + "value": "filtering.always_fail_with_collection", + "imageRef": "sha256:${REGISTRY_acceptance/ec-happy-day:latest_DIGEST}" + } + ] + }, + "policy": [ + "git::https://${GITHOST}/git/happy-day-policy.git" + ] + } + ] + } + """ + When ec command is run with "validate image --image ${REGISTRY}/acceptance/ec-happy-day --policy acceptance/ec-policy --public-key ${known_PUBLIC_KEY} --rekor-url ${REKOR} --show-successes" + Then the exit status should be 0 + Then the output should match the snapshot Scenario: policy rule filtering for successes Given a key pair named "known" diff --git a/go.mod b/go.mod index 51cc8b788..dd9f47553 100644 --- a/go.mod +++ b/go.mod @@ -150,6 +150,7 @@ require ( github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v25.0.3+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker v25.0.5+incompatible // indirect diff --git a/go.sum b/go.sum index c0c80498d..6df3165fa 100644 --- a/go.sum +++ b/go.sum @@ -510,6 +510,8 @@ github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1G github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v25.0.3+incompatible h1:KLeNs7zws74oFuVhgZQ5ONGZiXUUdgsdy6/EsX/6284= github.com/docker/cli v25.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= diff --git a/internal/definition/validate.go b/internal/definition/validate.go index 1e5de27f9..85c6c25e2 100644 --- a/internal/definition/validate.go +++ b/internal/definition/validate.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/afero" "github.com/enterprise-contract/ec-cli/internal/evaluation_target/definition" + "github.com/enterprise-contract/ec-cli/internal/evaluator" "github.com/enterprise-contract/ec-cli/internal/output" "github.com/enterprise-contract/ec-cli/internal/policy/source" "github.com/enterprise-contract/ec-cli/internal/utils" @@ -45,7 +46,7 @@ func ValidateDefinition(ctx context.Context, fpath string, sources []source.Poli return nil, err } - results, _, err := p.Evaluator.Evaluate(ctx, defFiles) + results, _, err := p.Evaluator.Evaluate(ctx, evaluator.EvaluationTarget{Inputs: defFiles}) if err != nil { log.Debug("Problem running conftest policy check!") return nil, err diff --git a/internal/definition/validate_test.go b/internal/definition/validate_test.go index d93832b8d..c0e871000 100644 --- a/internal/definition/validate_test.go +++ b/internal/definition/validate_test.go @@ -40,7 +40,7 @@ type ( badMockEvaluator struct{} ) -func (e mockEvaluator) Evaluate(ctx context.Context, inputs []string) ([]evaluator.Outcome, evaluator.Data, error) { +func (e mockEvaluator) Evaluate(ctx context.Context, target evaluator.EvaluationTarget) ([]evaluator.Outcome, evaluator.Data, error) { return []evaluator.Outcome{}, nil, nil } @@ -51,7 +51,7 @@ func (e mockEvaluator) CapabilitiesPath() string { return "" } -func (b badMockEvaluator) Evaluate(ctx context.Context, inputs []string) ([]evaluator.Outcome, evaluator.Data, error) { +func (b badMockEvaluator) Evaluate(ctx context.Context, target evaluator.EvaluationTarget) ([]evaluator.Outcome, evaluator.Data, error) { return nil, nil, errors.New("Evaluator error") } diff --git a/internal/evaluator/conftest_evaluator.go b/internal/evaluator/conftest_evaluator.go index a3eca7e6f..c5728f9ac 100644 --- a/internal/evaluator/conftest_evaluator.go +++ b/internal/evaluator/conftest_evaluator.go @@ -193,8 +193,8 @@ type conftestEvaluator struct { dataDir string policyDir string policy ConfigProvider - include []string - exclude []string + include *Criteria + exclude *Criteria fs afero.Fs namespace []string } @@ -355,7 +355,7 @@ func (r *policyRules) collect(a *ast.AnnotationsRef) error { return nil } -func (c conftestEvaluator) Evaluate(ctx context.Context, inputs []string) ([]Outcome, Data, error) { +func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget) ([]Outcome, Data, error) { var results []Outcome // hold all rule annotations from all policy sources @@ -435,9 +435,9 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, inputs []string) ([]Out } log.Debugf("runner: %#v", r) - log.Debugf("inputs: %#v", inputs) + log.Debugf("inputs: %#v", target.Inputs) - runResults, data, err := r.Run(ctx, inputs) + runResults, data, err := r.Run(ctx, target.Inputs) if err != nil { // TODO do we want to evaluate further policies instead of erroring out? return nil, nil, err @@ -463,7 +463,7 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, inputs []string) ([]Out warning := result.Warnings[i] addRuleMetadata(ctx, &warning, rules) - if !c.isResultIncluded(warning) { + if !c.isResultIncluded(warning, target.Target) { log.Debugf("Skipping result warning: %#v", warning) continue } @@ -474,7 +474,7 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, inputs []string) ([]Out failure := result.Failures[i] addRuleMetadata(ctx, &failure, rules) - if !c.isResultIncluded(failure) { + if !c.isResultIncluded(failure, target.Target) { log.Debugf("Skipping result failure: %#v", failure) continue } @@ -505,7 +505,7 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, inputs []string) ([]Out result.Skipped = skipped // Replace the placeholder successes slice with the actual successes. - result.Successes = c.computeSuccesses(result, rules, effectiveTime) + result.Successes = c.computeSuccesses(result, rules, effectiveTime, target.Target) totalRules += len(result.Warnings) + len(result.Failures) + len(result.Successes) @@ -540,7 +540,7 @@ func toRules(results []output.Result) []Result { // computeSuccesses generates success results, these are not provided in the // Conftest results, so we reconstruct these from the parsed rules, any rule // that hasn't been touched by adding metadata must have succeeded -func (c conftestEvaluator) computeSuccesses(result Outcome, rules policyRules, effectiveTime time.Time) []Result { +func (c conftestEvaluator) computeSuccesses(result Outcome, rules policyRules, effectiveTime time.Time, target string) []Result { // what rules, by code, have we seen in the Conftest results, use map to // take advantage of hashing for quicker lookup seenRules := map[string]bool{} @@ -592,7 +592,7 @@ func (c conftestEvaluator) computeSuccesses(result Outcome, rules policyRules, e success.Metadata[metadataDependsOn] = rule.DependsOn } - if !c.isResultIncluded(success) { + if !c.isResultIncluded(success, target) { log.Debugf("Skipping result success: %#v", success) continue } @@ -804,10 +804,10 @@ func isResultEffective(failure Result, now time.Time) bool { // isResultIncluded returns whether or not the result should be included or // discarded based on the policy configuration. -func (c conftestEvaluator) isResultIncluded(result Result) bool { +func (c conftestEvaluator) isResultIncluded(result Result, target string) bool { ruleMatchers := makeMatchers(result) - includeScore := scoreMatches(ruleMatchers, c.include) - excludeScore := scoreMatches(ruleMatchers, c.exclude) + includeScore := scoreMatches(ruleMatchers, c.include.get(target)) + excludeScore := scoreMatches(ruleMatchers, c.exclude.get(target)) return includeScore > excludeScore } @@ -979,62 +979,3 @@ func strictCapabilities(ctx context.Context) (string, error) { } return string(blob), nil } - -func computeIncludeExclude(src ecc.Source, p ConfigProvider) ([]string, []string) { - var include, exclude []string - - sc := src.Config - - // The lines below take care to make a copy of the includes/excludes slices in order - // to ensure mutations are not unexpectedly propagated. - if sc != nil && (len(sc.Include) != 0 || len(sc.Exclude) != 0) { - include = append(include, sc.Include...) - exclude = append(exclude, sc.Exclude...) - } - - vc := src.VolatileConfig - if vc != nil { - at := p.EffectiveTime() - filter := func(items []string, criteria []ecc.VolatileCriteria) []string { - for _, c := range criteria { - from, err := time.Parse(time.RFC3339, c.EffectiveOn) - if err != nil { - if c.EffectiveOn != "" { - log.Warnf("unable to parse time for criteria %q, was given %q: %v", c.Value, c.EffectiveOn, err) - } - from = at - } - until, err := time.Parse(time.RFC3339, c.EffectiveUntil) - if err != nil { - if c.EffectiveUntil != "" { - log.Warnf("unable to parse time for criteria %q, was given %q: %v", c.Value, c.EffectiveUntil, err) - } - until = at - } - if until.Compare(at) >= 0 && from.Compare(at) <= 0 { - items = append(items, c.Value) - } - } - - return items - } - - include = filter(include, vc.Include) - exclude = filter(exclude, vc.Exclude) - } - - if policyConfig := p.Spec().Configuration; len(include) == 0 && len(exclude) == 0 && policyConfig != nil { - include = append(include, policyConfig.Include...) - exclude = append(exclude, policyConfig.Exclude...) - // If the old way of specifying collections are used, convert them. - for _, collection := range policyConfig.Collections { - include = append(include, fmt.Sprintf("@%s", collection)) - } - } - - if len(include) == 0 { - include = []string{"*"} - } - - return include, exclude -} diff --git a/internal/evaluator/conftest_evaluator_test.go b/internal/evaluator/conftest_evaluator_test.go index 722507950..be061eca7 100644 --- a/internal/evaluator/conftest_evaluator_test.go +++ b/internal/evaluator/conftest_evaluator_test.go @@ -182,7 +182,7 @@ func TestConftestEvaluatorEvaluateTimeBased(t *testing.T) { dl := mockDownloader{} - inputs := []string{"inputs"} + inputs := EvaluationTarget{Inputs: []string{"inputs"}} expectedData := Data(map[string]any{ "a": 1, @@ -190,7 +190,7 @@ func TestConftestEvaluatorEvaluateTimeBased(t *testing.T) { ctx := setupTestContext(&r, &dl) - r.On("Run", ctx, inputs).Return(results, expectedData, nil) + r.On("Run", ctx, inputs.Inputs).Return(results, expectedData, nil) pol, err := policy.NewOfflinePolicy(ctx, policy.Now) assert.NoError(t, err) @@ -299,10 +299,10 @@ func TestConftestEvaluatorEvaluateNoSuccessWarningsOrFailures(t *testing.T) { t.Run(tt.name, func(t *testing.T) { r := mockTestRunner{} dl := mockDownloader{} - inputs := []string{"inputs"} + inputs := EvaluationTarget{Inputs: []string{"inputs"}} ctx := setupTestContext(&r, &dl) - r.On("Run", ctx, inputs).Return(tt.results, Data(nil), nil) + r.On("Run", ctx, inputs.Inputs).Return(tt.results, Data(nil), nil) p, err := policy.NewOfflinePolicy(ctx, policy.Now) assert.NoError(t, err) @@ -1188,9 +1188,9 @@ func TestConftestEvaluatorIncludeExclude(t *testing.T) { t.Run(tt.name, func(t *testing.T) { r := mockTestRunner{} dl := mockDownloader{} - inputs := []string{"inputs"} + inputs := EvaluationTarget{Inputs: []string{"inputs"}} ctx := setupTestContext(&r, &dl) - r.On("Run", ctx, inputs).Return(tt.results, Data(nil), nil) + r.On("Run", ctx, inputs.Inputs).Return(tt.results, Data(nil), nil) p, err := policy.NewOfflinePolicy(ctx, policy.Now) assert.NoError(t, err) @@ -1724,7 +1724,7 @@ func TestConftestEvaluatorEvaluate(t *testing.T) { }, config, ecc.Source{}) require.NoError(t, err) - results, data, err := evaluator.Evaluate(ctx, []string{path.Join(dir, "inputs")}) + results, data, err := evaluator.Evaluate(ctx, EvaluationTarget{Inputs: []string{path.Join(dir, "inputs")}}) require.NoError(t, err) // sort the slice by code for test stability @@ -1787,7 +1787,7 @@ func TestUnconformingRule(t *testing.T) { }, p, ecc.Source{}) require.NoError(t, err) - _, _, err = evaluator.Evaluate(ctx, []string{path.Join(dir, "inputs")}) + _, _, err = evaluator.Evaluate(ctx, EvaluationTarget{Inputs: []string{path.Join(dir, "inputs")}}) assert.EqualError(t, err, `the rule "deny = true { true }" returns an unsupported value, at no_msg.rego:3`) } @@ -1796,14 +1796,15 @@ func TestNewConftestEvaluatorComputeIncludeExclude(t *testing.T) { name string globalConfig *ecc.EnterpriseContractPolicyConfiguration source ecc.Source - expectedInclude []string - expectedExclude []string + expectedInclude *Criteria + expectedExclude *Criteria }{ - {name: "no config", expectedInclude: []string{"*"}}, + {name: "no config", expectedInclude: &Criteria{defaultItems: []string{"*"}}, expectedExclude: &Criteria{}}, { name: "empty global config", globalConfig: &ecc.EnterpriseContractPolicyConfiguration{}, - expectedInclude: []string{"*"}, + expectedInclude: &Criteria{defaultItems: []string{"*"}}, + expectedExclude: &Criteria{}, }, { name: "global config", @@ -1812,15 +1813,15 @@ func TestNewConftestEvaluatorComputeIncludeExclude(t *testing.T) { Exclude: []string{"exclude-me"}, Collections: []string{"collect-me"}, }, - expectedInclude: []string{"include-me", "@collect-me"}, - expectedExclude: []string{"exclude-me"}, + expectedInclude: &Criteria{defaultItems: []string{"include-me", "@collect-me"}}, + expectedExclude: &Criteria{defaultItems: []string{"exclude-me"}}, }, { name: "empty source config", source: ecc.Source{ Config: &ecc.SourceConfig{}, }, - expectedInclude: []string{"*"}, + expectedInclude: &Criteria{defaultItems: []string{"*"}}, expectedExclude: &Criteria{}, }, { name: "source config", @@ -1830,8 +1831,8 @@ func TestNewConftestEvaluatorComputeIncludeExclude(t *testing.T) { Exclude: []string{"exclude-me"}, }, }, - expectedInclude: []string{"include-me"}, - expectedExclude: []string{"exclude-me"}, + expectedInclude: &Criteria{defaultItems: []string{"include-me"}}, + expectedExclude: &Criteria{defaultItems: []string{"exclude-me"}}, }, { name: "source config over global config", @@ -1846,8 +1847,8 @@ func TestNewConftestEvaluatorComputeIncludeExclude(t *testing.T) { Exclude: []string{"exclude-me"}, }, }, - expectedInclude: []string{"include-me"}, - expectedExclude: []string{"exclude-me"}, + expectedInclude: &Criteria{defaultItems: []string{"include-me"}}, + expectedExclude: &Criteria{defaultItems: []string{"exclude-me"}}, }, { name: "volatile source config", @@ -1865,8 +1866,32 @@ func TestNewConftestEvaluatorComputeIncludeExclude(t *testing.T) { }, }, }, - expectedInclude: []string{"include-me"}, - expectedExclude: []string{"exclude-me"}, + expectedInclude: &Criteria{defaultItems: []string{"include-me"}}, + expectedExclude: &Criteria{defaultItems: []string{"exclude-me"}}, + }, + { + name: "imageRef used in volatile source config", + source: ecc.Source{ + VolatileConfig: &ecc.VolatileSourceConfig{ + Include: []ecc.VolatileCriteria{ + { + Value: "include-me", + ImageRef: "included-image-ref", + }, + { + Value: "include-me2", + }, + }, + Exclude: []ecc.VolatileCriteria{ + { + Value: "exclude-me", + ImageRef: "excluded-image-ref", + }, + }, + }, + }, + expectedInclude: &Criteria{digestItems: map[string][]string{"included-image-ref": {"include-me"}}, defaultItems: []string{"include-me2"}}, + expectedExclude: &Criteria{digestItems: map[string][]string{"excluded-image-ref": {"exclude-me"}}}, }, { name: "volatile source config not applicable", @@ -1914,7 +1939,8 @@ func TestNewConftestEvaluatorComputeIncludeExclude(t *testing.T) { }, }, }, - expectedInclude: []string{"*"}, + expectedInclude: &Criteria{defaultItems: []string{"*"}}, + expectedExclude: &Criteria{}, }, { name: "volatile source config applicable", @@ -1952,8 +1978,8 @@ func TestNewConftestEvaluatorComputeIncludeExclude(t *testing.T) { }, }, }, - expectedInclude: []string{"include-open-ended", "include-un-expired", "include-in-range"}, - expectedExclude: []string{"exclude-open-ended", "exclude-un-expired", "exclude-in-range"}, + expectedInclude: &Criteria{defaultItems: []string{"include-open-ended", "include-un-expired", "include-in-range"}}, + expectedExclude: &Criteria{defaultItems: []string{"exclude-open-ended", "exclude-un-expired", "exclude-in-range"}}, }, } diff --git a/internal/evaluator/criteria.go b/internal/evaluator/criteria.go new file mode 100644 index 000000000..91d04d6cf --- /dev/null +++ b/internal/evaluator/criteria.go @@ -0,0 +1,132 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package evaluator + +import ( + "fmt" + "time" + + ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" + log "github.com/sirupsen/logrus" +) + +// contains include/exclude items +// digestItems stores include/exclude items that are specific with an imageRef +// - the imageRef is the key, value is the policy to include/exclude. +// defaultItems are include/exclude items without an imageRef +type Criteria struct { + digestItems map[string][]string + defaultItems []string +} + +func (c *Criteria) len() int { + totalLength := len(c.defaultItems) + for _, items := range c.digestItems { + totalLength += len(items) + } + return totalLength +} + +func (c *Criteria) addItem(key, value string) { + if key == "" { + c.defaultItems = append(c.defaultItems, value) + } else { + if c.digestItems == nil { + c.digestItems = make(map[string][]string) + } + c.digestItems[key] = append(c.digestItems[key], value) + } +} + +func (c *Criteria) addArray(key string, values []string) { + if key == "" { + c.defaultItems = append(c.defaultItems, values...) + } else { + if c.digestItems == nil { + c.digestItems = make(map[string][]string) + } + c.digestItems[key] = append(c.digestItems[key], values...) + } +} + +func (c *Criteria) get(key string) []string { + if items, ok := c.digestItems[key]; ok { + items = append(items, c.defaultItems...) + return items + } + return c.defaultItems +} + +func computeIncludeExclude(src ecc.Source, p ConfigProvider) (*Criteria, *Criteria) { + include := &Criteria{} + exclude := &Criteria{} + + sc := src.Config + + // The lines below take care to make a copy of the includes/excludes slices in order + // to ensure mutations are not unexpectedly propagated. + if sc != nil && (len(sc.Include) != 0 || len(sc.Exclude) != 0) { + include.addArray("", sc.Include) + exclude.addArray("", sc.Exclude) + } + + vc := src.VolatileConfig + if vc != nil { + at := p.EffectiveTime() + filter := func(items *Criteria, volatileCriteria []ecc.VolatileCriteria) *Criteria { + for _, c := range volatileCriteria { + from, err := time.Parse(time.RFC3339, c.EffectiveOn) + if err != nil { + if c.EffectiveOn != "" { + log.Warnf("unable to parse time for criteria %q, was given %q: %v", c.Value, c.EffectiveOn, err) + } + from = at + } + until, err := time.Parse(time.RFC3339, c.EffectiveUntil) + if err != nil { + if c.EffectiveUntil != "" { + log.Warnf("unable to parse time for criteria %q, was given %q: %v", c.Value, c.EffectiveUntil, err) + } + until = at + } + if until.Compare(at) >= 0 && from.Compare(at) <= 0 { + items.addItem(c.ImageRef, c.Value) + } + } + + return items + } + + include = filter(include, vc.Include) + exclude = filter(exclude, vc.Exclude) + } + + if policyConfig := p.Spec().Configuration; include.len() == 0 && exclude.len() == 0 && policyConfig != nil { + include.addArray("", policyConfig.Include) + exclude.addArray("", policyConfig.Exclude) + // If the old way of specifying collections are used, convert them. + for _, collection := range policyConfig.Collections { + include.addItem("", fmt.Sprintf("@%s", collection)) + } + } + + if include.len() == 0 { + include.addItem("", "*") + } + + return include, exclude +} diff --git a/internal/evaluator/criteria_test.go b/internal/evaluator/criteria_test.go new file mode 100644 index 000000000..254ffb645 --- /dev/null +++ b/internal/evaluator/criteria_test.go @@ -0,0 +1,225 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build unit + +package evaluator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLen(t *testing.T) { + tests := []struct { + name string + criteria *Criteria + expectedLen int + }{ + { + name: "Empty Criteria", + criteria: &Criteria{ + digestItems: map[string][]string{}, + defaultItems: []string{}, + }, + expectedLen: 0, + }, + { + name: "Only Default Items", + criteria: &Criteria{ + digestItems: map[string][]string{}, + defaultItems: []string{"default1", "default2"}, + }, + expectedLen: 2, + }, + { + name: "Only Digest Items", + criteria: &Criteria{ + digestItems: map[string][]string{ + "key1": {"value1", "value2"}, + "key2": {"value3"}, + }, + defaultItems: []string{}, + }, + expectedLen: 3, + }, + { + name: "Both Default and Digest Items", + criteria: &Criteria{ + digestItems: map[string][]string{ + "key1": {"value1", "value2"}, + "key2": {"value3"}, + }, + defaultItems: []string{"default1", "default2"}, + }, + expectedLen: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.criteria.len(); got != tt.expectedLen { + t.Errorf("Criteria.len() = %d, want %d", got, tt.expectedLen) + } + }) + } +} + +func TestAddItem(t *testing.T) { + tests := []struct { + name string + key string + value string + initial *Criteria + expected *Criteria + }{ + { + name: "Add to defaultItems", + key: "", + value: "defaultValue", + initial: &Criteria{ + defaultItems: []string{}, + digestItems: make(map[string][]string), + }, + expected: &Criteria{ + defaultItems: []string{"defaultValue"}, + digestItems: make(map[string][]string), + }, + }, + { + name: "Add to digestItems", + key: "key1", + value: "digestValue1", + initial: &Criteria{ + defaultItems: []string{}, + digestItems: make(map[string][]string), + }, + expected: &Criteria{ + defaultItems: []string{}, + digestItems: map[string][]string{ + "key1": {"digestValue1"}, + }, + }, + }, + { + name: "Add to existing digestItems", + key: "key1", + value: "digestValue2", + initial: &Criteria{ + defaultItems: []string{}, + digestItems: map[string][]string{ + "key1": {"digestValue1"}, + }, + }, + expected: &Criteria{ + defaultItems: []string{}, + digestItems: map[string][]string{ + "key1": {"digestValue1", "digestValue2"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.initial.addItem(tt.key, tt.value) + require.Equal(t, tt.initial, tt.expected) + }) + } +} + +func TestAddArray(t *testing.T) { + tests := []struct { + name string + key string + values []string + initial *Criteria + expected *Criteria + }{ + { + name: "Add to defaultItems", + key: "", + values: []string{"defaultValue1", "defaultValue2"}, + initial: &Criteria{ + defaultItems: []string{}, + digestItems: make(map[string][]string), + }, + expected: &Criteria{ + defaultItems: []string{"defaultValue1", "defaultValue2"}, + digestItems: make(map[string][]string), + }, + }, + { + name: "Add to digestItems", + key: "key1", + values: []string{"digestValue1", "digestValue2"}, + initial: &Criteria{ + defaultItems: []string{}, + digestItems: make(map[string][]string), + }, + expected: &Criteria{ + defaultItems: []string{}, + digestItems: map[string][]string{ + "key1": {"digestValue1", "digestValue2"}, + }, + }, + }, + { + name: "Add to existing digestItems", + key: "key1", + values: []string{"digestValue2", "digestValue3"}, + initial: &Criteria{ + defaultItems: []string{}, + digestItems: map[string][]string{ + "key1": {"digestValue1"}, + }, + }, + expected: &Criteria{ + defaultItems: []string{}, + digestItems: map[string][]string{ + "key1": {"digestValue1", "digestValue2", "digestValue3"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.initial.addArray(tt.key, tt.values) + require.Equal(t, tt.initial, tt.expected) + }) + } +} + +func TestGet(t *testing.T) { + c := &Criteria{ + digestItems: map[string][]string{ + "key1": {"item1", "item2"}, + }, + defaultItems: []string{"default1", "default2"}, + } + + // Test getting items for a key that exists + expectedItems := []string{"item1", "item2", "default1", "default2"} + assert.ElementsMatch(t, expectedItems, c.get("key1")) + + // Test getting items for a key that does not exist + expectedDefaultItems := []string{"default1", "default2"} + assert.ElementsMatch(t, expectedDefaultItems, c.get("key2")) + +} diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go index a088bbdc6..3b03c7ac6 100644 --- a/internal/evaluator/evaluator.go +++ b/internal/evaluator/evaluator.go @@ -20,8 +20,13 @@ import ( "context" ) +type EvaluationTarget struct { + Inputs []string + Target string +} + type Evaluator interface { - Evaluate(ctx context.Context, inputs []string) ([]Outcome, Data, error) + Evaluate(ctx context.Context, target EvaluationTarget) ([]Outcome, Data, error) // Destroy performs any cleanup needed Destroy() diff --git a/internal/image/validate.go b/internal/image/validate.go index 3d77589a9..48c95592c 100644 --- a/internal/image/validate.go +++ b/internal/image/validate.go @@ -109,7 +109,13 @@ func ValidateImage(ctx context.Context, comp app.SnapshotComponent, p policy.Pol for _, e := range evaluators { // Todo maybe: Handle each one concurrently - results, data, err := e.Evaluate(ctx, []string{inputPath}) + target := evaluator.EvaluationTarget{Inputs: []string{inputPath}} + if digest, err := a.ResolveDigest(ctx); err != nil { + log.Debugf("Problem parsing digest from image") + } else { + target.Target = digest + } + results, data, err := e.Evaluate(ctx, target) log.Debug("\n\nRunning conftest policy check\n\n") if err != nil { diff --git a/internal/image/validate_test.go b/internal/image/validate_test.go index 9bce55356..4acaa4e44 100644 --- a/internal/image/validate_test.go +++ b/internal/image/validate_test.go @@ -275,8 +275,8 @@ type mockEvaluator struct { mock.Mock } -func (e *mockEvaluator) Evaluate(ctx context.Context, inputs []string) ([]evaluator.Outcome, evaluator.Data, error) { - args := e.Called(ctx, inputs) +func (e *mockEvaluator) Evaluate(ctx context.Context, target evaluator.EvaluationTarget) ([]evaluator.Outcome, evaluator.Data, error) { + args := e.Called(ctx, target.Inputs) return args.Get(0).([]evaluator.Outcome), args.Get(1).(evaluator.Data), args.Error(2) } @@ -300,6 +300,7 @@ func TestEvaluatorLifecycle(t *testing.T) { client.On("Head", ref).Return(&gcr.Descriptor{MediaType: types.OCIManifestSchema1}, nil) client.On("VerifyImageSignatures", refNoTag, mock.Anything).Return([]oci.Signature{validSignature}, true, nil) client.On("VerifyImageAttestations", refNoTag, mock.Anything).Return([]oci.Signature{validAttestation}, true, nil) + client.On("ResolveDigest", refNoTag).Return("@sha256:"+imageDigest, nil) ctx = ecoci.WithClient(ctx, &client) component := app.SnapshotComponent{ diff --git a/internal/input/validate.go b/internal/input/validate.go index dfbf18f44..94b93b981 100644 --- a/internal/input/validate.go +++ b/internal/input/validate.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/afero" "github.com/enterprise-contract/ec-cli/internal/evaluation_target/input" + "github.com/enterprise-contract/ec-cli/internal/evaluator" "github.com/enterprise-contract/ec-cli/internal/output" "github.com/enterprise-contract/ec-cli/internal/policy" "github.com/enterprise-contract/ec-cli/internal/utils" @@ -45,7 +46,7 @@ func ValidateInput(ctx context.Context, fpath string, policy policy.Policy, deta return nil, err } - results, _, err := p.Evaluator.Evaluate(ctx, inputFiles) + results, _, err := p.Evaluator.Evaluate(ctx, evaluator.EvaluationTarget{Inputs: inputFiles}) if err != nil { log.Debug("Problem running conftest policy check!") return nil, err diff --git a/internal/input/validate_test.go b/internal/input/validate_test.go index 9f4dd08b4..191f454a2 100644 --- a/internal/input/validate_test.go +++ b/internal/input/validate_test.go @@ -40,7 +40,7 @@ type ( badMockEvaluator struct{} ) -func (e mockEvaluator) Evaluate(ctx context.Context, inputs []string) ([]evaluator.Outcome, evaluator.Data, error) { +func (e mockEvaluator) Evaluate(ctx context.Context, target evaluator.EvaluationTarget) ([]evaluator.Outcome, evaluator.Data, error) { return []evaluator.Outcome{}, nil, nil } @@ -51,7 +51,7 @@ func (e mockEvaluator) CapabilitiesPath() string { return "" } -func (b badMockEvaluator) Evaluate(ctx context.Context, inputs []string) ([]evaluator.Outcome, evaluator.Data, error) { +func (b badMockEvaluator) Evaluate(ctx context.Context, target evaluator.EvaluationTarget) ([]evaluator.Outcome, evaluator.Data, error) { return nil, nil, errors.New("Evaluator error") } diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index b46d152fc..e758860b2 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -722,7 +722,6 @@ func TestJsonSchemaFromPolicySpec(t *testing.T) { PublicKey: "testPublicKey", RekorUrl: "testRekorUrl", } - schemaJson, err := jsonSchemaFromPolicySpec(ecp) assert.NoError(t, err)