diff --git a/pkg/policies/engine/engine.go b/pkg/policies/engine/engine.go index 8579f1613..785648c4b 100644 --- a/pkg/policies/engine/engine.go +++ b/pkg/policies/engine/engine.go @@ -179,8 +179,10 @@ type PolicyEngine interface { Verify(ctx context.Context, policy *Policy, input []byte, args map[string]any) (*EvaluationResult, error) // MatchesParameters evaluates the matches_parameters rule to determine if evaluation parameters match expected parameters MatchesParameters(ctx context.Context, policy *Policy, evaluationParams, expectedParams map[string]string) (bool, error) - // MatchesEvaluation evaluates the matches_evaluation rule using policy violations and expected parameters - MatchesEvaluation(ctx context.Context, policy *Policy, violations []string, expectedParams map[string]string) (bool, error) + // MatchesEvaluation evaluates the matches_evaluation rule using the policy violation messages, + // the structured findings of those violations and the expected parameters. + // findings is nil when the violations carry no structured findings. + MatchesEvaluation(ctx context.Context, policy *Policy, violations []string, findings []map[string]any, expectedParams map[string]string) (bool, error) } type EvaluationResult struct { diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index f0352bc5c..676c83371 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -79,6 +79,7 @@ const ( inputArgs = "args" expectedArgs = "expected_args" violationsResult = "violations" + findingsResult = "findings" inputElements = "elements" mainRule = "result" matchesParametersRule = "matches_parameters" @@ -418,15 +419,17 @@ func (r *Engine) MatchesParameters(ctx context.Context, policy *engine.Policy, e } // MatchesEvaluation evaluates the matches_evaluation rule in a rego policy. -// Creates an input object with expected parameters and policy violations. -func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, violations []string, expectedParams map[string]string) (bool, error) { +// Creates an input object with expected parameters, policy violations and the +// structured findings of those violations. A nil list reaches the rule as an empty +// array, never as null, so that a rule that counts it raises no strict builtin error. +func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, violations []string, findings []map[string]any, expectedParams map[string]string) (bool, error) { policyString := string(policy.Source) parsedModule, err := ast.ParseModule(policy.Name, policyString) if err != nil { return false, fmt.Errorf("failed to parse rego policy: %w", sanitizeParseError(err)) } - // Create input expected parameters and policy violations + // Create input expected parameters, policy violations and findings inputMap := make(map[string]interface{}) if expectedParams == nil { inputMap[expectedArgs] = map[string]string{} @@ -438,6 +441,11 @@ func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, v } else { inputMap[violationsResult] = violations } + if findings == nil { + inputMap[findingsResult] = []map[string]any{} + } else { + inputMap[findingsResult] = findings + } decodedInput := r.injectProjectMetadata(inputMap) // Evaluate matches_evaluation rule diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index 331cdeece..9c0a787eb 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -61,7 +61,7 @@ func TestRego_ParseErrorOmitsSource(t *testing.T) { { name: "matches evaluation", run: func() error { - _, err := r.MatchesEvaluation(context.TODO(), policy, nil, nil) + _, err := r.MatchesEvaluation(context.TODO(), policy, nil, nil, nil) return err }, }, @@ -448,7 +448,7 @@ func TestRego_MatchesEvaluation(t *testing.T) { t.Run("evaluation with violations and high severity does not match", func(t *testing.T) { violations := []string{"test violation"} evaluationParams := map[string]string{"severity": "high"} - matches, err := r.MatchesEvaluation(context.TODO(), policy, violations, evaluationParams) + matches, err := r.MatchesEvaluation(context.TODO(), policy, violations, nil, evaluationParams) require.NoError(t, err) assert.False(t, matches) }) @@ -456,7 +456,7 @@ func TestRego_MatchesEvaluation(t *testing.T) { t.Run("evaluation without violations does matches", func(t *testing.T) { violations := []string{} evaluationParams := map[string]string{"severity": "high"} - matches, err := r.MatchesEvaluation(context.TODO(), policy, violations, evaluationParams) + matches, err := r.MatchesEvaluation(context.TODO(), policy, violations, nil, evaluationParams) require.NoError(t, err) assert.False(t, matches) }) @@ -464,14 +464,14 @@ func TestRego_MatchesEvaluation(t *testing.T) { t.Run("evaluation with violations but wrong severity does match", func(t *testing.T) { violations := []string{"test violation"} evaluationParams := map[string]string{"severity": "low"} - matches, err := r.MatchesEvaluation(context.TODO(), policy, violations, evaluationParams) + matches, err := r.MatchesEvaluation(context.TODO(), policy, violations, nil, evaluationParams) require.NoError(t, err) assert.False(t, matches) }) t.Run("nil evaluation matches", func(t *testing.T) { evaluationParams := map[string]string{"severity": "high"} - matches, err := r.MatchesEvaluation(context.TODO(), policy, nil, evaluationParams) + matches, err := r.MatchesEvaluation(context.TODO(), policy, nil, nil, evaluationParams) require.NoError(t, err) assert.False(t, matches) }) @@ -479,12 +479,130 @@ func TestRego_MatchesEvaluation(t *testing.T) { t.Run("empty evaluation params", func(t *testing.T) { violations := []string{"test violation"} evaluationParams := map[string]string{} - matches, err := r.MatchesEvaluation(context.TODO(), policy, violations, evaluationParams) + matches, err := r.MatchesEvaluation(context.TODO(), policy, violations, nil, evaluationParams) require.NoError(t, err) assert.False(t, matches) }) } +func TestRego_MatchesEvaluationFindings(t *testing.T) { + findingsRule, err := os.ReadFile("testfiles/matches_evaluation_findings.rego") + require.NoError(t, err) + + // Matches only when input.findings is an empty array. A null value does not match. + emptyArrayRule := []byte(`package test_findings_shape + +import rego.v1 + +default matches_evaluation := false + +matches_evaluation if { + is_array(input.findings) + count(input.findings) == 0 +} +`) + + // Reads only the messages, as every rule written before findings reached the input. + violationsOnlyRule := []byte(`package test_violations_only + +import rego.v1 + +default matches_evaluation := true + +matches_evaluation := false if { + count(input.violations) > 0 + every v in input.violations { + not contains(v, "HIGH") + } +} +`) + + const score = "score" + scoreThreshold := map[string]string{score: "7.0"} + oneViolation := []string{"first"} + twoViolations := []string{"first", "second"} + + testCases := []struct { + name string + source []byte + violations []string + findings []map[string]any + expected map[string]string + want bool + }{ + { + name: "a finding at the expected score matches", + source: findingsRule, + violations: twoViolations, + findings: []map[string]any{ + {score: 3.0}, + {score: 9.8, "location": map[string]any{"file": "main.go", "line": 7}}, + }, + expected: scoreThreshold, + want: true, + }, + { + name: "findings below the expected score do not match", + source: findingsRule, + violations: twoViolations, + findings: []map[string]any{{score: 3.0}, {score: 6.5}}, + expected: scoreThreshold, + want: false, + }, + { + name: "nil findings raise no strict builtin error", + source: findingsRule, + violations: oneViolation, + expected: scoreThreshold, + want: true, + }, + { + name: "empty findings raise no strict builtin error", + source: findingsRule, + violations: oneViolation, + findings: []map[string]any{}, + expected: scoreThreshold, + want: true, + }, + { + name: "nil findings reach the rule as an empty array", + source: emptyArrayRule, + violations: oneViolation, + want: true, + }, + { + name: "findings reach the rule as an array", + source: emptyArrayRule, + violations: oneViolation, + findings: []map[string]any{{score: 9.8}}, + want: false, + }, + { + name: "a rule that reads only the messages ignores the findings", + source: violationsOnlyRule, + violations: []string{"LOW issue"}, + findings: []map[string]any{{score: 9.8}}, + want: false, + }, + { + name: "a rule that reads only the messages still matches on them", + source: violationsOnlyRule, + violations: []string{"HIGH issue"}, + want: true, + }, + } + + r := NewEngine() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + policy := &engine.Policy{Name: "matches-evaluation-findings", Source: tc.source} + matches, err := r.MatchesEvaluation(context.TODO(), policy, tc.violations, tc.findings, tc.expected) + require.NoError(t, err) + assert.Equal(t, tc.want, matches) + }) + } +} + func TestRego_CustomBuiltinsPermissiveMode(t *testing.T) { regoContent, err := os.ReadFile("testfiles/custom_builtin_permissive.rego") require.NoError(t, err) diff --git a/pkg/policies/engine/rego/testfiles/matches_evaluation_findings.rego b/pkg/policies/engine/rego/testfiles/matches_evaluation_findings.rego new file mode 100644 index 000000000..997ac003a --- /dev/null +++ b/pkg/policies/engine/rego/testfiles/matches_evaluation_findings.rego @@ -0,0 +1,16 @@ +package test_matches_evaluation_findings + +import rego.v1 + +default matches_evaluation := true + +# count() raises a strict builtin error when input.findings is null. +matches_evaluation := false if { + count(input.findings) > 0 + not any_finding_reaches(to_number(input.expected_args.score)) +} + +any_finding_reaches(expected) if { + some f in input.findings + f.score >= expected +} diff --git a/pkg/policies/engine/wasm/engine.go b/pkg/policies/engine/wasm/engine.go index 9e46fc33b..8c91d2d3b 100644 --- a/pkg/policies/engine/wasm/engine.go +++ b/pkg/policies/engine/wasm/engine.go @@ -284,7 +284,7 @@ func (e *Engine) MatchesParameters(_ context.Context, _ *engine.Policy, // MatchesEvaluation is a stub implementation for WASM policies // WASM policies don't currently support evaluation matching func (e *Engine) MatchesEvaluation(_ context.Context, _ *engine.Policy, - _ []string, _ map[string]string) (bool, error) { + _ []string, _ []map[string]any, _ map[string]string) (bool, error) { // Default to true - WASM policies handle their own evaluation logic return true, nil } diff --git a/pkg/policies/engine/wasm/engine_test.go b/pkg/policies/engine/wasm/engine_test.go index 068482a01..64029881f 100644 --- a/pkg/policies/engine/wasm/engine_test.go +++ b/pkg/policies/engine/wasm/engine_test.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -108,7 +108,7 @@ func TestMatchesEvaluation(t *testing.T) { } // Should always return true for WASM policies - matches, err := eng.MatchesEvaluation(ctx, policy, []string{"violation"}, map[string]string{"key": "value"}) + matches, err := eng.MatchesEvaluation(ctx, policy, []string{"violation"}, []map[string]any{{"severity": "HIGH"}}, map[string]string{"key": "value"}) assert.NoError(t, err) assert.True(t, matches) }