diff --git a/cmd/kosli/evaluate.go b/cmd/kosli/evaluate.go index 40cfa2140..62874b6c0 100644 --- a/cmd/kosli/evaluate.go +++ b/cmd/kosli/evaluate.go @@ -22,6 +22,7 @@ Use ` + "`evaluate input`" + ` to evaluate a local JSON file or stdin without an The policy must use ` + "`package policy`" + ` and define an ` + "`allow`" + ` rule. An optional ` + "`violations`" + ` rule (a set of strings) can provide human-readable denial reasons. +Use ` + "`--output-rule`" + ` to add other rules of the policy, like a report, to the JSON output. By default a deny exits with code 1 so the command can gate a pipeline. Pass ` + "`--no-assert`" + ` to use the command as a policy decision point: it prints diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index f1b332c2f..3c0bf7e6c 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -12,6 +12,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "time" @@ -63,6 +64,7 @@ type commonEvaluateOptions struct { assert bool noAssert bool serverSide bool + outputRules []string } func (o *commonEvaluateOptions) addFlags(cmd *cobra.Command, policyDesc string) { @@ -74,6 +76,7 @@ func (o *commonEvaluateOptions) addFlags(cmd *cobra.Command, policyDesc string) cmd.Flags().StringVar(&o.params, "params", "", policyParamsFlag) cmd.Flags().BoolVar(&o.assert, "assert", false, "[optional] Exit with a non-zero status when the policy denies. This is the current default; pass --assert to lock it in across future releases.") cmd.Flags().BoolVar(&o.noAssert, "no-assert", false, "[optional] Print the result and always exit 0, even when the policy denies. Use when this command feeds another tool as a policy decision point.") + cmd.Flags().StringSliceVar(&o.outputRules, "output-rule", nil, policyOutputRuleFlag) cmd.MarkFlagsMutuallyExclusive("assert", "no-assert") } @@ -254,16 +257,23 @@ func parseParams(raw string) (map[string]any, error) { return params, nil } -func evaluateAndPrintResult(out io.Writer, policyRef string, input map[string]any, outputFormat string, showInput bool, params map[string]any, assertOnDeny bool) error { +func evaluateAndPrintResult(out io.Writer, policyRef string, input map[string]any, outputFormat string, showInput bool, params map[string]any, assertOnDeny bool, outputRules []string) error { + if err := validateOutputRules(outputRules); err != nil { + return err + } + policySource, err := loadPolicy(policyRef) if err != nil { return err } - result, err := evaluate.Evaluate(string(policySource), input, params) + result, err := evaluate.Evaluate(string(policySource), input, params, outputRules...) if err != nil { return err } + if len(outputRules) > 0 && outputFormat == "table" { + logger.Warn("--output-rule values are only shown with --output json") + } return printEvaluateResult(out, result, input, outputFormat, showInput, params, assertOnDeny, "") } @@ -371,6 +381,11 @@ func (o *commonEvaluateOptions) refuseWhatTheServerCannotDo() error { "--show-input is not supported with --server-side; " + "the server does not return the input it evaluated") } + if len(o.outputRules) > 0 { + return fmt.Errorf( + "--output-rule is not supported with --server-side; " + + "the server only returns allow and violations") + } return nil } @@ -531,6 +546,17 @@ func policyBundleKey(ref string) string { return base } +var evaluateResultKeys = []string{"allow", "violations", "input", "params", "decision_attestation_id"} + +func validateOutputRules(rules []string) error { + for _, rule := range rules { + if slices.Contains(evaluateResultKeys, rule) { + return fmt.Errorf("--output-rule cannot be '%s', it is already part of the output", rule) + } + } + return nil +} + // printEvaluateResult renders a verdict, whatever produced it, so that every // evaluation path prints the same bytes for the same verdict. func printEvaluateResult(out io.Writer, result *evaluate.Result, input map[string]any, outputFormat string, showInput bool, params map[string]any, assertOnDeny bool, decisionID string) error { @@ -538,6 +564,9 @@ func printEvaluateResult(out io.Writer, result *evaluate.Result, input map[strin "allow": result.Allow, "violations": result.Violations, } + for rule, value := range result.Outputs { + auditResult[rule] = value + } // Absent everywhere else, so a caller reading a verdict alone parses the // same page as before. if decisionID != "" { diff --git a/cmd/kosli/evaluateHelpers_test.go b/cmd/kosli/evaluateHelpers_test.go new file mode 100644 index 000000000..5e331421d --- /dev/null +++ b/cmd/kosli/evaluateHelpers_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/kosli-dev/cli/internal/evaluate" + "github.com/stretchr/testify/require" +) + +func TestEvaluateResultKeysListsEveryKeyTheOutputCanHave(t *testing.T) { + var out bytes.Buffer + result := &evaluate.Result{Allow: true} + err := printEvaluateResult(&out, result, map[string]any{}, "json", true, map[string]any{}, false, "decision-id") + require.NoError(t, err) + + var printed map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &printed)) + for key := range printed { + require.Contains(t, evaluateResultKeys, key) + } +} diff --git a/cmd/kosli/evaluateInput.go b/cmd/kosli/evaluateInput.go index ca1c96deb..d7f68bebf 100644 --- a/cmd/kosli/evaluateInput.go +++ b/cmd/kosli/evaluateInput.go @@ -25,6 +25,7 @@ the policy input from a ` + "`--show-input --output json`" + ` capture. The policy must use ` + "`package policy`" + ` and define an ` + "`allow`" + ` rule. An optional ` + "`violations`" + ` rule (a set of strings) can provide human-readable denial reasons. +Use ` + "`--output-rule`" + ` to add other rules of the policy, like a report, to the JSON output. By default a deny exits with code 1. Pass ` + "`--no-assert`" + ` to print the verdict and exit 0 even on deny, when this command is feeding another tool as a @@ -78,7 +79,14 @@ kosli evaluate input \ kosli evaluate input \ --input-file trail-data.json \ --policy policy.rego \ - --no-assert` + --no-assert + +# add the policy's report rule to the JSON output: +kosli evaluate input \ + --input-file trail-data.json \ + --policy policy.rego \ + --output-rule report \ + --output json` func newEvaluateInputCmd(out io.Writer) *cobra.Command { o := new(evaluateInputOptions) @@ -128,7 +136,7 @@ func (o *evaluateInputOptions) run(out io.Writer, in io.Reader) error { return err } - return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny()) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny(), o.outputRules) } func loadInputFromFile(filePath string) (result map[string]any, err error) { diff --git a/cmd/kosli/evaluateInput_test.go b/cmd/kosli/evaluateInput_test.go index 077fe636a..d0e3d0d97 100644 --- a/cmd/kosli/evaluateInput_test.go +++ b/cmd/kosli/evaluateInput_test.go @@ -144,6 +144,57 @@ func (suite *EvaluateInputCommandTestSuite) TestEvaluateInputCmd() { {"allow", false}, }, }, + { + name: "--output-rule adds the rule to the JSON output", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-with-report.rego --output-rule report --output json", + goldenJson: []jsonCheck{ + {"allow", true}, + {"report.compliant", true}, + }, + }, + { + name: "--output-rule adds the rule to the JSON output when the policy denies", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/deny-with-report.rego --output-rule report --output json --no-assert", + goldenJson: []jsonCheck{ + {"allow", false}, + {"report.compliant", false}, + }, + }, + { + name: "--output-rule can be repeated", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-with-report.rego --output-rule report --output-rule summary --output json", + goldenJson: []jsonCheck{ + {"report.compliant", true}, + {"summary", "all good"}, + }, + }, + { + name: "--output-rule takes a comma-separated list", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-with-report.rego --output-rule report,summary --output json", + goldenJson: []jsonCheck{ + {"report.compliant", true}, + {"summary", "all good"}, + }, + }, + { + wantError: true, + name: "--output-rule naming a rule the policy does not declare fails", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-all.rego --output-rule report --output json", + goldenRegex: `policy does not declare a 'report' rule`, + }, + { + name: "--output-rule prints null for a rule with no value", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/undefined-report.rego --output-rule report --output json", + goldenJson: []jsonCheck{ + {"report", nil}, + }, + }, + { + name: "--output-rule is left out of table output, with a hint", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-with-report.rego --output-rule report", + goldenStdout: "RESULT: ALLOWED\n", + goldenStderr: "[warning] --output-rule values are only shown with --output json\n", + }, } runTestCmd(suite.T(), tests) } @@ -377,6 +428,19 @@ func TestLoadPolicyHonorsHTTPProxy(t *testing.T) { require.True(t, sawProxyStyleRequest, "expected proxy to receive an absolute-URL request") } +func (suite *EvaluateInputCommandTestSuite) TestEvaluateInputCmdRefusesOutputRulesClashingWithOutputKeys() { + tests := []cmdTestCase{} + for _, name := range []string{"allow", "violations", "input", "params", "decision_attestation_id"} { + tests = append(tests, cmdTestCase{ + wantError: true, + name: "--output-rule " + name + " is refused", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-all.rego --output-rule " + name, + goldenRegex: `--output-rule cannot be '` + name + `', it is already part of the output`, + }) + } + runTestCmd(suite.T(), tests) +} + func TestEvaluateInputCommandTestSuite(t *testing.T) { suite.Run(t, new(EvaluateInputCommandTestSuite)) } diff --git a/cmd/kosli/evaluateServerSide_test.go b/cmd/kosli/evaluateServerSide_test.go index b8c8d4698..511cc2f5b 100644 --- a/cmd/kosli/evaluateServerSide_test.go +++ b/cmd/kosli/evaluateServerSide_test.go @@ -370,6 +370,11 @@ func (suite *EvaluateServerSideTestSuite) TestItRefusesWhatTheServerCannotDo() { extra: "--show-input", message: "--show-input is not supported with --server-side", }, + { + name: "adding policy rules to the output", + extra: "--output-rule report", + message: "--output-rule is not supported with --server-side", + }, } { suite.Run(test.name, func() { server, fake := newFakeEvaluations(suite.T(), verdictAllowed) diff --git a/cmd/kosli/evaluateTrail.go b/cmd/kosli/evaluateTrail.go index d84bcb19a..fcaf9a66d 100644 --- a/cmd/kosli/evaluateTrail.go +++ b/cmd/kosli/evaluateTrail.go @@ -15,7 +15,8 @@ The trail data is passed to the policy as ` + "`input.trail`" + `. Use ` + "`--attestations`" + ` to enrich the input with detailed attestation data (e.g. pull request approvers, scan results). Use ` + "`--show-input`" + ` to inspect the -full data structure available to the policy. Use ` + "`--output json`" + ` for structured output.` +full data structure available to the policy. Use ` + "`--output json`" + ` for structured output, +and ` + "`--output-rule`" + ` to add other rules of the policy, like a report, to it.` const evaluateTrailExample = ` # evaluate a trail against a policy: @@ -71,6 +72,15 @@ kosli evaluate trail yourTrailName \ --flow yourFlowName \ --no-assert \ --api-token yourAPIToken \ + --org yourOrgName + +# add the policy's report rule to the JSON output: +kosli evaluate trail yourTrailName \ + --policy yourPolicyFile.rego \ + --flow yourFlowName \ + --output-rule report \ + --output json \ + --api-token yourAPIToken \ --org yourOrgName` type evaluateTrailOptions struct { @@ -128,5 +138,5 @@ func (o *evaluateTrailOptions) run(out io.Writer, args []string) error { "trail": trailData, } - return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny()) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny(), o.outputRules) } diff --git a/cmd/kosli/evaluateTrail_test.go b/cmd/kosli/evaluateTrail_test.go index d60a5bc83..4b2bcfe78 100644 --- a/cmd/kosli/evaluateTrail_test.go +++ b/cmd/kosli/evaluateTrail_test.go @@ -150,6 +150,19 @@ func (suite *EvaluateTrailCommandTestSuite) TestEvaluateTrailCmd() { cmd: fmt.Sprintf(`evaluate trail %s --flow %s --policy testdata/policies/allow-all.rego --assert --no-assert %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), goldenRegex: `none of the others can be.*\[assert no-assert\] were all set`, }, + { + name: "--output-rule adds the rule to the JSON output", + cmd: fmt.Sprintf(`evaluate trail %s --flow %s --policy testdata/policies/trail-report.rego --output-rule report --output json %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"report.trail", suite.trailName}, + }, + }, + { + wantError: true, + name: "--output-rule clashing with an output key is refused", + cmd: fmt.Sprintf(`evaluate trail %s --flow %s --policy testdata/policies/allow-all.rego --output-rule allow %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), + goldenRegex: `--output-rule cannot be 'allow', it is already part of the output`, + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/evaluateTrails.go b/cmd/kosli/evaluateTrails.go index 88e910ce9..11e6eb907 100644 --- a/cmd/kosli/evaluateTrails.go +++ b/cmd/kosli/evaluateTrails.go @@ -16,7 +16,8 @@ The trail data is passed to the policy as ` + "`input.trails`" + ` (an array), u Use ` + "`--attestations`" + ` to enrich the input with detailed attestation data (e.g. pull request approvers, scan results). Use ` + "`--show-input`" + ` to inspect the -full data structure available to the policy. Use ` + "`--output json`" + ` for structured output.` +full data structure available to the policy. Use ` + "`--output json`" + ` for structured output, +and ` + "`--output-rule`" + ` to add other rules of the policy, like a report, to it.` const evaluateTrailsExample = ` # evaluate multiple trails against a policy: @@ -64,6 +65,15 @@ kosli evaluate trails yourTrailName1 yourTrailName2 \ --flow yourFlowName \ --no-assert \ --api-token yourAPIToken \ + --org yourOrgName + +# add the policy's report rule to the JSON output: +kosli evaluate trails yourTrailName1 yourTrailName2 \ + --policy yourPolicyFile.rego \ + --flow yourFlowName \ + --output-rule report \ + --output json \ + --api-token yourAPIToken \ --org yourOrgName` type evaluateTrailsOptions struct { @@ -128,5 +138,5 @@ func (o *evaluateTrailsOptions) run(out io.Writer, args []string) error { "trails": trails, } - return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny()) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny(), o.outputRules) } diff --git a/cmd/kosli/evaluateTrails_test.go b/cmd/kosli/evaluateTrails_test.go index d26c14742..ec86c6fe1 100644 --- a/cmd/kosli/evaluateTrails_test.go +++ b/cmd/kosli/evaluateTrails_test.go @@ -109,6 +109,19 @@ func (suite *EvaluateTrailsCommandTestSuite) TestEvaluateTrailsCmd() { cmd: fmt.Sprintf(`evaluate trails %s --flow %s --policy testdata/policies/allow-all.rego --assert --no-assert %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), goldenRegex: `none of the others can be.*\[assert no-assert\] were all set`, }, + { + name: "--output-rule adds one rule value covering all the trails", + cmd: fmt.Sprintf(`evaluate trails %s %s --flow %s --policy testdata/policies/trails-report.rego --output-rule report --output json %s`, suite.trailName, suite.trailName2, suite.flowName, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"report.trails", 2.0}, + }, + }, + { + wantError: true, + name: "--output-rule clashing with an output key is refused", + cmd: fmt.Sprintf(`evaluate trails %s --flow %s --policy testdata/policies/allow-all.rego --output-rule allow %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), + goldenRegex: `--output-rule cannot be 'allow', it is already part of the output`, + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 2f67671c9..1f260478c 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -148,6 +148,7 @@ Paths the list already matches stay excluded whatever is later added there, so k outputFlag = "[defaulted] The format of the output. Valid formats are: [table, json]." serverSideFlag = "[hidden] Evaluate the policy on the Kosli server rather than on this machine. Unsupported and subject to change." policyParamsFlag = "[optional] Policy parameters as inline JSON or @file.json. Available in policies as data.params." + policyOutputRuleFlag = "[optional] Name of a policy rule to add to the JSON output, next to allow and violations. Can be repeated, or given as a comma-separated list." policyAssertFlag = "[optional] Exit with a non-zero status when the policy denies. Without it the verdict is printed and the command exits 0." policyContextFlag = "What to evaluate, as trail=/. Repeat it to evaluate several trails at one instant." policyControlFlag = "[optional] Record the outcome as a decision against this control. Without it nothing is recorded." diff --git a/cmd/kosli/testdata/empty-flag-audit-coverage.json b/cmd/kosli/testdata/empty-flag-audit-coverage.json index 01381dd12..fcf6e3017 100644 --- a/cmd/kosli/testdata/empty-flag-audit-coverage.json +++ b/cmd/kosli/testdata/empty-flag-audit-coverage.json @@ -594,6 +594,7 @@ "input-file": "string", "no-assert": "bool", "output": "string", + "output-rule": "stringSlice", "params": "string", "policy": "string", "show-input": "bool" @@ -616,6 +617,7 @@ "flow": "string", "no-assert": "bool", "output": "string", + "output-rule": "stringSlice", "params": "string", "policy": "string", "server-side": "bool", @@ -627,6 +629,7 @@ "flow": "string", "no-assert": "bool", "output": "string", + "output-rule": "stringSlice", "params": "string", "policy": "string", "server-side": "bool", diff --git a/cmd/kosli/testdata/policies/allow-with-report.rego b/cmd/kosli/testdata/policies/allow-with-report.rego new file mode 100644 index 000000000..3804ec458 --- /dev/null +++ b/cmd/kosli/testdata/policies/allow-with-report.rego @@ -0,0 +1,7 @@ +package policy + +allow = true + +report := {"compliant": true} + +summary := "all good" diff --git a/cmd/kosli/testdata/policies/deny-with-report.rego b/cmd/kosli/testdata/policies/deny-with-report.rego new file mode 100644 index 000000000..00bf63301 --- /dev/null +++ b/cmd/kosli/testdata/policies/deny-with-report.rego @@ -0,0 +1,5 @@ +package policy + +allow = false + +report := {"compliant": false} diff --git a/cmd/kosli/testdata/policies/trail-report.rego b/cmd/kosli/testdata/policies/trail-report.rego new file mode 100644 index 000000000..e4a0e480d --- /dev/null +++ b/cmd/kosli/testdata/policies/trail-report.rego @@ -0,0 +1,5 @@ +package policy + +allow = true + +report := {"trail": input.trail.name} diff --git a/cmd/kosli/testdata/policies/trails-report.rego b/cmd/kosli/testdata/policies/trails-report.rego new file mode 100644 index 000000000..2250763cb --- /dev/null +++ b/cmd/kosli/testdata/policies/trails-report.rego @@ -0,0 +1,5 @@ +package policy + +allow = true + +report := {"trails": count(input.trails)} diff --git a/cmd/kosli/testdata/policies/undefined-report.rego b/cmd/kosli/testdata/policies/undefined-report.rego new file mode 100644 index 000000000..8803a37e0 --- /dev/null +++ b/cmd/kosli/testdata/policies/undefined-report.rego @@ -0,0 +1,7 @@ +package policy + +allow = true + +report := {"compliant": true} if { + input.never_there +} diff --git a/hack/empty-flag-audit/spec.json b/hack/empty-flag-audit/spec.json index 1ec2b7c7a..6deee67de 100644 --- a/hack/empty-flag-audit/spec.json +++ b/hack/empty-flag-audit/spec.json @@ -1669,7 +1669,7 @@ "args": [], "flags": { "input-file": "cmd/kosli/testdata/person-schema.json", - "policy": "cmd/kosli/testdata/policies/allow-all.rego" + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego" }, "baseline_ok": true, "baseline_exit": 0, @@ -1681,6 +1681,7 @@ "input-file", "no-assert", "output", + "output-rule", "params", "policy", "show-input" @@ -1692,8 +1693,9 @@ "input-file": "cmd/kosli/testdata/person-schema.json", "no-assert": "true", "output": "json", + "output-rule": "report", "params": "{}", - "policy": "cmd/kosli/testdata/policies/allow-all.rego", + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego", "show-input": "true" }, "setup": [], @@ -1705,7 +1707,7 @@ ], "flags": { "flow": "{flow}", - "policy": "cmd/kosli/testdata/policies/allow-all.rego" + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego" }, "baseline_ok": true, "baseline_exit": 0, @@ -1716,6 +1718,7 @@ "flow", "no-assert", "output", + "output-rule", "params", "policy", "server-side", @@ -1727,8 +1730,9 @@ "flow": "{flow}", "no-assert": "true", "output": "json", + "output-rule": "report", "params": "{}", - "policy": "cmd/kosli/testdata/policies/allow-all.rego", + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego", "server-side": "false", "show-input": "true" }, @@ -1759,7 +1763,7 @@ ], "flags": { "flow": "{flow}", - "policy": "cmd/kosli/testdata/policies/allow-all.rego" + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego" }, "baseline_ok": true, "baseline_exit": 0, @@ -1770,6 +1774,7 @@ "flow", "no-assert", "output", + "output-rule", "params", "policy", "server-side", @@ -1781,8 +1786,9 @@ "flow": "{flow}", "no-assert": "true", "output": "json", + "output-rule": "report", "params": "{}", - "policy": "cmd/kosli/testdata/policies/allow-all.rego", + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego", "server-side": "false", "show-input": "true" }, diff --git a/internal/evaluate/rego.go b/internal/evaluate/rego.go index db50a9230..bfcb1b998 100644 --- a/internal/evaluate/rego.go +++ b/internal/evaluate/rego.go @@ -3,41 +3,34 @@ package evaluate import ( "context" "fmt" + "regexp" "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/rego" "github.com/open-policy-agent/opa/v1/storage/inmem" ) +var ruleName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + // Result holds the outcome of a policy evaluation. type Result struct { Allow bool Violations []string + Outputs map[string]any } // Evaluate evaluates a Rego policy against the given input. // The policy must use `package policy` and declare an `allow` rule. // An optional params map can be provided to populate data.params in the policy. -func Evaluate(policySource string, input any, params map[string]any) (*Result, error) { - if err := validatePolicy(policySource); err != nil { +// The values of outputRules, rules of the policy package, are returned in Result.Outputs. +func Evaluate(policySource string, input any, params map[string]any, outputRules ...string) (*Result, error) { + if err := validatePolicy(policySource, outputRules); err != nil { return nil, err } ctx := context.Background() - opts := []func(*rego.Rego){ - rego.Query("data.policy.allow"), - rego.Module("policy.rego", policySource), - rego.Input(input), - } - if params != nil { - store := inmem.NewFromObject(map[string]any{"params": params}) - opts = append(opts, rego.Store(store)) - } - - r := rego.New(opts...) - - rs, err := r.Eval(ctx) + rs, err := evalQuery(ctx, "data.policy.allow", policySource, input, params) if err != nil { return nil, fmt.Errorf("policy evaluation failed: %w", err) } @@ -61,10 +54,32 @@ func Evaluate(policySource string, input any, params map[string]any) (*Result, e result.Violations = violations } + for _, rule := range outputRules { + value, err := evaluateRule(ctx, policySource, input, params, rule) + if err != nil { + return nil, err + } + if result.Outputs == nil { + result.Outputs = map[string]any{} + } + result.Outputs[rule] = value + } + return result, nil } -func validatePolicy(policySource string) error { +func evaluateRule(ctx context.Context, policySource string, input any, params map[string]any, rule string) (any, error) { + rs, err := evalQuery(ctx, "data.policy."+rule, policySource, input, params) + if err != nil { + return nil, fmt.Errorf("%s evaluation failed: %w", rule, err) + } + if len(rs) == 0 || len(rs[0].Expressions) == 0 { + return nil, nil + } + return rs[0].Expressions[0].Value, nil +} + +func validatePolicy(policySource string, outputRules []string) error { module, err := ast.ParseModuleWithOpts("policy.rego", policySource, ast.ParserOptions{}) if err != nil { return fmt.Errorf("failed to parse policy: %w", err) @@ -75,34 +90,27 @@ func validatePolicy(policySource string) error { module.Package.Path[1:].String()) } - hasAllow := false + declared := map[string]bool{} for _, rule := range module.Rules { - if rule.Head.Name.String() == "allow" { - hasAllow = true - break - } + declared[rule.Head.Name.String()] = true } - if !hasAllow { + if !declared["allow"] { return fmt.Errorf("policy must declare an 'allow' rule") } + for _, rule := range outputRules { + if !ruleName.MatchString(rule) { + return fmt.Errorf("'%s' is not a valid rule name", rule) + } + if !declared[rule] { + return fmt.Errorf("policy does not declare a '%s' rule", rule) + } + } return nil } func collectViolations(ctx context.Context, policySource string, input any, params map[string]any) ([]string, error) { - opts := []func(*rego.Rego){ - rego.Query("data.policy.violations"), - rego.Module("policy.rego", policySource), - rego.Input(input), - } - if params != nil { - store := inmem.NewFromObject(map[string]any{"params": params}) - opts = append(opts, rego.Store(store)) - } - - r := rego.New(opts...) - - rs, err := r.Eval(ctx) + rs, err := evalQuery(ctx, "data.policy.violations", policySource, input, params) if err != nil { return nil, fmt.Errorf("violations evaluation failed: %w", err) } @@ -120,3 +128,16 @@ func collectViolations(ctx context.Context, policySource string, input any, para return violations, nil } + +func evalQuery(ctx context.Context, query string, policySource string, input any, params map[string]any) (rego.ResultSet, error) { + opts := []func(*rego.Rego){ + rego.Query(query), + rego.Module("policy.rego", policySource), + rego.Input(input), + } + if params != nil { + store := inmem.NewFromObject(map[string]any{"params": params}) + opts = append(opts, rego.Store(store)) + } + return rego.New(opts...).Eval(ctx) +} diff --git a/internal/evaluate/rego_test.go b/internal/evaluate/rego_test.go index d5d047812..70d2c8541 100644 --- a/internal/evaluate/rego_test.go +++ b/internal/evaluate/rego_test.go @@ -181,3 +181,67 @@ allow = true require.NoError(t, err) require.True(t, result.Allow, "params not referenced by policy should have no effect") } + +func TestEvaluate_OutputRules(t *testing.T) { + for _, allow := range []string{"true", "false"} { + t.Run("allow = "+allow, func(t *testing.T) { + policy := `package policy + +allow = ` + allow + ` + +report := {"compliant": ` + allow + `} +` + result, err := Evaluate(policy, map[string]any{}, nil, "report") + require.NoError(t, err) + require.Equal(t, map[string]any{"report": map[string]any{"compliant": allow == "true"}}, result.Outputs) + }) + } +} + +func TestEvaluate_OutputRuleNotDeclared(t *testing.T) { + policy := `package policy + +allow = true +` + _, err := Evaluate(policy, map[string]any{}, nil, "report") + require.EqualError(t, err, "policy does not declare a 'report' rule") +} + +func TestEvaluate_OutputRuleUndefined(t *testing.T) { + policy := `package policy + +allow = true + +report := "never" if { + input.never_there +} +` + result, err := Evaluate(policy, map[string]any{}, nil, "report") + require.NoError(t, err) + require.Equal(t, map[string]any{"report": nil}, result.Outputs) +} + +func TestEvaluate_OutputRuleDeclaredWithRefHead(t *testing.T) { + policy := `package policy + +allow = true + +report.summary := "all good" +` + result, err := Evaluate(policy, map[string]any{}, nil, "report") + require.NoError(t, err) + require.Equal(t, map[string]any{"report": map[string]any{"summary": "all good"}}, result.Outputs) +} + +func TestEvaluate_OutputRuleWithInvalidName(t *testing.T) { + policy := `package policy + +allow = true +` + for _, name := range []string{"", "report.summary", "report[0]", "1report"} { + t.Run(name, func(t *testing.T) { + _, err := Evaluate(policy, map[string]any{}, nil, name) + require.EqualError(t, err, "'"+name+"' is not a valid rule name") + }) + } +}