Skip to content

Commit f95da2e

Browse files
authored
feat(rego): extend engine with custom matchers (#2396)
Signed-off-by: Sylwester Piskozub <sylwesterpiskozub@gmail.com>
1 parent 65ad51d commit f95da2e

5 files changed

Lines changed: 259 additions & 8 deletions

File tree

pkg/policies/engine/engine.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,29 @@ import (
2424
type PolicyEngine interface {
2525
// Verify verifies an input against a policy
2626
Verify(ctx context.Context, policy *Policy, input []byte, args map[string]any) (*EvaluationResult, error)
27+
// MatchesParameters evaluates the matches_parameters rule to determine if evaluation parameters match expected parameters
28+
MatchesParameters(ctx context.Context, policy *Policy, evaluationParams, expectedParams map[string]string) (bool, error)
29+
// MatchesEvaluation evaluates the matches_evaluation rule using a PolicyEvaluation result and evaluation parameters
30+
MatchesEvaluation(ctx context.Context, policy *Policy, evaluation *EvaluationResult, evaluationParams map[string]string) (bool, error)
2731
}
2832

2933
type EvaluationResult struct {
30-
Violations []*PolicyViolation
31-
Skipped bool
32-
SkipReason string
33-
Ignore bool
34-
RawData *RawData
34+
Violations []*PolicyViolation `json:"violations"`
35+
Skipped bool `json:"skipped"`
36+
SkipReason string `json:"skipReason"`
37+
Ignore bool `json:"ignore"`
38+
RawData *RawData `json:"rawData"`
3539
}
40+
3641
type RawData struct {
37-
Input json.RawMessage
38-
Output json.RawMessage
42+
Input json.RawMessage `json:"input"`
43+
Output json.RawMessage `json:"output"`
3944
}
4045

4146
// PolicyViolation represents a policy failure
4247
type PolicyViolation struct {
43-
Subject, Violation string
48+
Subject string `json:"subject"`
49+
Violation string `json:"violation"`
4450
}
4551

4652
// Policy represents a loaded policy in any of the supported engines.

pkg/policies/engine/rego/rego.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,13 @@ const (
111111
// EnvironmentModePermissive allows all operations on the compiler
112112
EnvironmentModePermissive EnvironmentMode = 1
113113
inputArgs = "args"
114+
expectedArgs = "expected_args"
115+
evalResult = "evaluation_result"
114116
inputElements = "elements"
115117
deprecatedRule = "violations"
116118
mainRule = "result"
119+
matchesParametersRule = "matches_parameters"
120+
matchesEvaluationRule = "matches_evaluation"
117121
)
118122

119123
// builtinFuncNotAllowed is a list of builtin functions that are not allowed in the compiler
@@ -383,3 +387,85 @@ func getRuleName(packagePath ast.Ref, rule string) string {
383387
}
384388
return fmt.Sprintf("%v.%s\n", packagePath, rule)
385389
}
390+
391+
// MatchesParameters evaluates the matches_parameters rule in a rego policy.
392+
// The function creates an input object with policy parameters and expected parameters.
393+
// Returns true if the policy's matches_parameters rule evaluates to true, false otherwise.
394+
func (r *Engine) MatchesParameters(ctx context.Context, policy *engine.Policy, evaluationParams, expectedParams map[string]string) (bool, error) {
395+
policyString := string(policy.Source)
396+
parsedModule, err := ast.ParseModule(policy.Name, policyString)
397+
if err != nil {
398+
return false, fmt.Errorf("failed to parse rego policy: %w", err)
399+
}
400+
401+
// Create input with policy and expected parameters
402+
inputMap := make(map[string]interface{})
403+
inputMap[inputArgs] = evaluationParams
404+
inputMap[expectedArgs] = expectedParams
405+
406+
// Evaluate matches_parameters rule
407+
matchesParameters, err := r.evaluateMatchingRule(ctx, getRuleName(parsedModule.Package.Path, matchesParametersRule), parsedModule, inputMap)
408+
if err != nil {
409+
// Defaults to false
410+
return false, err
411+
}
412+
413+
return matchesParameters, nil
414+
}
415+
416+
// MatchesEvaluation evaluates the matches_evaluation rule in a rego policy.
417+
// The function creates an input object with policy parameters and evaluation result.
418+
// Returns true if the policy's matches_evaluation rule evaluates to true, false otherwise.
419+
// If the rule is not found or evaluation fails, it defaults to false.
420+
func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, ev *engine.EvaluationResult, evaluationParams map[string]string) (bool, error) {
421+
policyString := string(policy.Source)
422+
parsedModule, err := ast.ParseModule(policy.Name, policyString)
423+
if err != nil {
424+
return false, fmt.Errorf("failed to parse rego policy: %w", err)
425+
}
426+
427+
// Create input with the policy evaluation data
428+
inputMap := make(map[string]interface{})
429+
inputMap[inputArgs] = evaluationParams
430+
inputMap[evalResult] = ev
431+
432+
// Evaluate matches_parameters rule
433+
matchesEvaluation, err := r.evaluateMatchingRule(ctx, getRuleName(parsedModule.Package.Path, matchesEvaluationRule), parsedModule, inputMap)
434+
if err != nil {
435+
// Defaults to false
436+
return false, err
437+
}
438+
439+
return matchesEvaluation, nil
440+
}
441+
442+
// Evaluates a single rule and returns its boolean result
443+
func (r *Engine) evaluateMatchingRule(ctx context.Context, ruleName string, parsedModule *ast.Module, decodedInput interface{}) (bool, error) {
444+
// Add input
445+
regoInput := rego.Input(decodedInput)
446+
447+
// Add module
448+
regoFunc := rego.ParsedModule(parsedModule)
449+
options := []func(r *rego.Rego){regoInput, regoFunc, rego.Capabilities(r.Capabilities())}
450+
451+
if r.operatingMode == EnvironmentModeRestrictive {
452+
options = append(options, rego.StrictBuiltinErrors(true))
453+
}
454+
455+
res, err := queryRego(ctx, ruleName, options...)
456+
if err != nil {
457+
return false, err
458+
}
459+
460+
// Parse the boolean result
461+
for _, exp := range res {
462+
for _, val := range exp.Expressions {
463+
if boolResult, ok := val.Value.(bool); ok {
464+
return boolResult, nil
465+
}
466+
}
467+
}
468+
469+
// No valid boolean result found
470+
return false, nil
471+
}

pkg/policies/engine/rego/rego_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,130 @@ func TestRego_WithPermissiveMode(t *testing.T) {
330330
assert.NotContains(t, err.Error(), "rego_type_error: undefined function rego.parse_module")
331331
})
332332
}
333+
334+
func TestRego_MatchesParameters(t *testing.T) {
335+
regoContent, err := os.ReadFile("testfiles/matches_parameters.rego")
336+
require.NoError(t, err)
337+
338+
r := NewEngine()
339+
policy := &engine.Policy{
340+
Name: "matches-parameters-test",
341+
Source: regoContent,
342+
}
343+
344+
t.Run("high severity matches medium expectation", func(t *testing.T) {
345+
matches, err := r.MatchesParameters(context.TODO(), policy,
346+
map[string]string{"severity": "high"},
347+
map[string]string{"severity": "medium"})
348+
require.NoError(t, err)
349+
assert.True(t, matches)
350+
})
351+
352+
t.Run("low severity does not match high expectation", func(t *testing.T) {
353+
matches, err := r.MatchesParameters(context.TODO(), policy,
354+
map[string]string{"severity": "low"},
355+
map[string]string{"severity": "high"})
356+
require.NoError(t, err)
357+
assert.False(t, matches)
358+
})
359+
360+
t.Run("critical severity matches critical expectation", func(t *testing.T) {
361+
matches, err := r.MatchesParameters(context.TODO(), policy,
362+
map[string]string{"severity": "critical"},
363+
map[string]string{"severity": "critical"})
364+
require.NoError(t, err)
365+
assert.True(t, matches)
366+
})
367+
368+
t.Run("unknown severity parameter", func(t *testing.T) {
369+
matches, err := r.MatchesParameters(context.TODO(), policy,
370+
map[string]string{"severity": "unknown"},
371+
map[string]string{"severity": "medium"})
372+
require.NoError(t, err)
373+
assert.False(t, matches)
374+
})
375+
376+
t.Run("empty parameters", func(t *testing.T) {
377+
matches, err := r.MatchesParameters(context.TODO(), policy,
378+
map[string]string{},
379+
map[string]string{})
380+
require.NoError(t, err)
381+
assert.False(t, matches)
382+
})
383+
}
384+
385+
func TestRego_MatchesEvaluation(t *testing.T) {
386+
regoContent, err := os.ReadFile("testfiles/matches_evaluation.rego")
387+
require.NoError(t, err)
388+
389+
r := NewEngine()
390+
policy := &engine.Policy{
391+
Name: "matches-evaluation-test",
392+
Source: regoContent,
393+
}
394+
395+
t.Run("evaluation with violations and high severity matches", func(t *testing.T) {
396+
evaluation := &engine.EvaluationResult{
397+
Violations: []*engine.PolicyViolation{
398+
{Subject: "test", Violation: "test violation"},
399+
},
400+
Skipped: false,
401+
SkipReason: "",
402+
Ignore: false,
403+
}
404+
evaluationParams := map[string]string{"severity": "high"}
405+
matches, err := r.MatchesEvaluation(context.TODO(), policy, evaluation, evaluationParams)
406+
require.NoError(t, err)
407+
assert.True(t, matches)
408+
})
409+
410+
t.Run("evaluation without violations does not match", func(t *testing.T) {
411+
evaluation := &engine.EvaluationResult{
412+
Violations: []*engine.PolicyViolation{},
413+
Skipped: false,
414+
SkipReason: "",
415+
Ignore: false,
416+
}
417+
evaluationParams := map[string]string{"severity": "high"}
418+
matches, err := r.MatchesEvaluation(context.TODO(), policy, evaluation, evaluationParams)
419+
require.NoError(t, err)
420+
assert.False(t, matches)
421+
})
422+
423+
t.Run("evaluation with violations but wrong severity does not match", func(t *testing.T) {
424+
evaluation := &engine.EvaluationResult{
425+
Violations: []*engine.PolicyViolation{
426+
{Subject: "test", Violation: "test violation"},
427+
},
428+
Skipped: false,
429+
SkipReason: "",
430+
Ignore: false,
431+
}
432+
evaluationParams := map[string]string{"severity": "low"}
433+
matches, err := r.MatchesEvaluation(context.TODO(), policy, evaluation, evaluationParams)
434+
require.NoError(t, err)
435+
assert.False(t, matches)
436+
})
437+
438+
t.Run("nil evaluation does not match", func(t *testing.T) {
439+
evaluationParams := map[string]string{"severity": "high"}
440+
matches, err := r.MatchesEvaluation(context.TODO(), policy, nil, evaluationParams)
441+
require.NoError(t, err)
442+
assert.False(t, matches)
443+
})
444+
445+
t.Run("empty evaluation params", func(t *testing.T) {
446+
evaluation := &engine.EvaluationResult{
447+
Violations: []*engine.PolicyViolation{
448+
{Subject: "test", Violation: "test violation"},
449+
},
450+
Skipped: false,
451+
SkipReason: "",
452+
Ignore: false,
453+
}
454+
evaluationParams := map[string]string{}
455+
matches, err := r.MatchesEvaluation(context.TODO(), policy, evaluation, evaluationParams)
456+
require.NoError(t, err)
457+
assert.False(t, matches)
458+
})
459+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package test_matches_evaluation
2+
3+
matches_evaluation := result {
4+
# Check if the evaluation contains violations
5+
count(input.evaluation_result.violations) > 0
6+
7+
# Check if we have the expected parameter
8+
input.args.severity == "high"
9+
10+
# If both conditions are met, return true
11+
result := true
12+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package test_matches_parameters
2+
3+
severity_levels := ["low", "medium", "high", "critical"]
4+
5+
severity_index(level) := index {
6+
some i
7+
severity_levels[i] == level
8+
index := i
9+
}
10+
11+
matches_parameters := result {
12+
eval_severity := input.args.severity
13+
expected_severity := input.expected_args.severity
14+
15+
eval_idx := severity_index(eval_severity)
16+
expected_idx := severity_index(expected_severity)
17+
18+
# Evaluation severity must be >= expected severity
19+
result := eval_idx >= expected_idx
20+
}

0 commit comments

Comments
 (0)