diff --git a/docs.json b/docs.json index 759e19b..4de7e53 100644 --- a/docs.json +++ b/docs.json @@ -21,7 +21,12 @@ "styling": { "eyebrows": "breadcrumbs", "codeblocks": { - "theme": "dracula" + "theme": "dracula", + "languages": { + "custom": [ + "/languages/rego.json" + ] + } } }, "background": { @@ -135,6 +140,12 @@ "pages": [ "tutorials/unauthorized_iac_changes" ] + }, + { + "group": "Evaluation", + "pages": [ + "tutorials/evaluate_trails_with_opa" + ] } ] }, @@ -410,6 +421,18 @@ } ] }, + { + "item": "Policy Reference", + "icon": "scroll", + "groups": [ + { + "group": "Policies", + "pages": [ + "policy-reference/rego_policy" + ] + } + ] + }, { "item": "API Reference", "icon": "code", diff --git a/languages/rego.json b/languages/rego.json new file mode 100644 index 0000000..01d5824 --- /dev/null +++ b/languages/rego.json @@ -0,0 +1,286 @@ +{ + "fileTypes": [ + "rego" + ], + "name": "rego", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#keyword" + }, + { + "include": "#comparison-operators" + }, + { + "include": "#assignment-operators" + }, + { + "include": "#term" + } + ], + "repository": { + "call": { + "captures": { + "1": { + "name": "support.function.any-method.rego" + } + }, + "match": "([a-zA-Z_][a-zA-Z0-9_]*)\\(", + "name": "meta.function-call.rego" + }, + "comment": { + "patterns": [ + { + "match": "(#)\\s*(METADATA)\\s*$\\n?", + "captures": { + "1": { + "name": "punctuation.definition.comment.rego" + }, + "2": { + "name": "strong" + } + }, + "name": "comment.line.number-sign.rego" + }, + { + "match": "(#)\\s*(scope|title|description|related_resources|authors|organizations|schemas|entrypoint|custom):.*$\\n?", + "captures": { + "1": { + "name": "punctuation.definition.comment.rego" + }, + "2": { + "name": "strong" + } + }, + "name": "comment.line.number-sign.rego" + }, + { + "captures": { + "1": { + "name": "punctuation.definition.comment.rego" + } + }, + "match": "(#).*$\\n?", + "name": "comment.line.number-sign.rego" + } + ] + }, + "constant": { + "match": "\\b(?:true|false|null)\\b", + "name": "constant.language.rego" + }, + "root-document": { + "match": "(?|<|<\\=|>\\=|\\+|-|\\*|%|/|\\||&", + "name": "keyword.operator.comparison.rego" + }, + "assignment-operators": { + "match": ":\\=|\\=", + "name": "keyword.operator.assignment.rego" + }, + "interpolated-string-double": { + "begin": "(\\$)(\")", + "beginCaptures": { + "1": { + "name": "punctuation.definition.template-expression.begin.rego" + }, + "2": { + "name": "punctuation.definition.string.begin.rego" + } + }, + "end": "\"", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.rego" + } + }, + "name": "string.template.rego", + "patterns": [ + { + "include": "#interpolation-expression" + }, + { + "include": "#string-escape" + }, + { + "include": "#interpolation-escape" + }, + { + "include": "#string-escape-invalid" + } + ] + }, + "interpolated-string-raw": { + "begin": "(\\$)(`)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.template-expression.begin.rego" + }, + "2": { + "name": "punctuation.definition.string.begin.rego" + } + }, + "end": "`", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.rego" + } + }, + "name": "string.template.rego", + "patterns": [ + { + "include": "#interpolation-expression" + } + ] + }, + "interpolation-expression": { + "begin": "(?Rego evaluator — no OPA installation required. + +## Policy contract + +These rules are Kosli-specific conventions, not OPA built-ins. Kosli queries `data.policy.*` to find them. + + + Every policy must declare `package policy`. Kosli queries `data.policy.allow` and `data.policy.violations` to read the result. + + + + Must evaluate to a boolean. Kosli exits with code `0` when `true`, code `1` when `false`. Typically defined as: + + ```rego + default allow = false + + allow if { + count(violations) == 0 + } + ``` + + + + Optional but recommended. A set of human-readable strings describing why the policy failed. Kosli displays these when `allow` is `false`. Each message should identify the offending resource and the reason. + + ```rego + violations contains msg if { + # ... rule body ... + msg := sprintf("descriptive message about %v", [resource]) + } + ``` + + +## Input data + +The data structure passed to the policy as `input` depends on which command you use. + +### `kosli evaluate trail` — single trail + +The policy receives `input.trail`, a single trail object. + + + The trail being evaluated. + + + + The trail name (git commit SHA or custom name). + + + + Compliance data for the trail. + + + + Whether the trail is compliant against its flow template. + + + + Compliance status string, e.g. `"COMPLIANT"` or `"INCOMPLIANT"`. + + + + Map of attestation name → attestation status object. Each object contains the attestation's data, including type-specific fields enriched via `--attestations`. For example, a `pull-request` attestation includes a `pull_requests` array, each with an `approvers` array and a `url` string. + + + + Map of artifact name → artifact status object. Each artifact has its own `attestations_statuses` map with the same structure as above. + + + + + + +### `kosli evaluate trails` — multiple trails + +The policy receives `input.trails`, an array of trail objects with the same structure as `input.trail` above. + + + Array of trail objects. Each element has the same structure as `input.trail` described above. + + + +Use `--show-input` with `--output json` to print the full input structure for a given trail. Pipe through `jq` to explore specific fields: + +```shell +kosli evaluate trail "$TRAIL_NAME" \ + --policy my-policy.rego \ + --org "$ORG" \ + --flow "$FLOW" \ + --show-input \ + --output json 2>/dev/null | jq '.input' +``` + + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Policy allowed (`allow = true`) | +| `1` | Policy denied (`allow = false`) **or** command error (network failure, invalid Rego, policy file not found) | + +Exit code `1` is used for both denial and failure. To distinguish between them in CI, use `--output json` and read the `allow` field directly from the output rather than relying on the exit code. + +## Examples + +### Check pull request approvals across multiple trails + +```rego +package policy + +import rego.v1 + +default allow = false + +violations contains msg if { + some trail in input.trails + some pr in trail.compliance_status.attestations_statuses["pull-request"].pull_requests + count(pr.approvers) == 0 + msg := sprintf("trail '%v': pull-request %v has no approvers", [trail.name, pr.url]) +} + +allow if { + count(violations) == 0 +} +``` + +### Check Snyk scan results on a single trail + +```rego +package policy + +import rego.v1 + +default allow = false + +violations contains msg if { + some name, artifact in input.trail.compliance_status.artifacts_statuses + snyk := artifact.attestations_statuses["snyk-container-scan"] + some result in snyk.processed_snyk_results.results + result.high_count > 0 + msg := sprintf("artifact '%v': snyk scan found %d high severity vulnerabilities", [name, result.high_count]) +} + +allow if { + count(violations) == 0 +} +``` + +## Further reading + +- [Rego Style Guide](https://docs.styra.com/opa/rego-style-guide) — naming, rule structure, and test conventions +- [OPA Annotations](https://www.openpolicyagent.org/docs/latest/annotations/) — including `entrypoint: true` for use with `opa build` +- [OPA Best Practices](https://www.openpolicyagent.org/docs/latest/best-practices/) +- [Tutorial: Evaluate trails with OPA policies](/tutorials/evaluate_trails_with_opa) diff --git a/tutorials/evaluate_trails_with_opa.mdx b/tutorials/evaluate_trails_with_opa.mdx new file mode 100644 index 0000000..5007165 --- /dev/null +++ b/tutorials/evaluate_trails_with_opa.mdx @@ -0,0 +1,286 @@ +--- +title: "Evaluate trails with OPA policies" +description: "Learn how to use kosli evaluate trail and kosli evaluate trails to check your Kosli trails against custom OPA/Rego policies. This tutorial walks through writing a policy that verifies pull requests have been approved." +--- + +The `kosli evaluate` commands let you evaluate Kosli trails against custom policies written in [Rego](https://www.openpolicyagent.org/docs/latest/policy-language/). This is useful for enforcing rules like "every artifact must have an approved pull request" or "all security scans must pass", and for gating deployments in CI/CD pipelines based on those rules. + +In this tutorial, we'll write a policy that checks whether pull requests on a trail have been approved, then evaluate it against real trails in public Kosli orgs. + + + + + +To follow this tutorial, you need to: + +* [Install Kosli CLI](/getting_started/install). +* [Get a Kosli API token](/getting_started/service-accounts). +* Set the `KOSLI_API_TOKEN` environment variable to your token: + + ```shell + export KOSLI_API_TOKEN= + ``` + + +You don't need OPA installed — the Kosli CLI has a built-in Rego evaluator. You just need to write a `.rego` policy file. + + + + + + +Create a file called `pr-approved.rego` with the following content: + +```rego pr-approved.rego +package policy + +import rego.v1 + +default allow = false + +violations contains msg if { + some trail in input.trails + some pr in trail.compliance_status.attestations_statuses["pull-request"].pull_requests + count(pr.approvers) == 0 + msg := sprintf("trail '%v': pull-request %v has no approvers", [trail.name, pr.url]) +} + +allow if { + count(violations) == 0 +} +``` + +Let's break down what this policy does: + +* **`package policy`** — every evaluate policy must use the `policy` package. +* **`import rego.v1`** — use Rego v1 syntax (the `if`/`contains` keywords). +* **`default allow = false`** — trails are denied unless explicitly allowed. +* **`violations`** — a set of messages describing why the policy failed. The rule iterates over trails, then over pull requests within the `pull-request` attestation, looking for PRs where `approvers` is empty. +* **`allow`** — trails are allowed only when there are no violations. + + +See the [Rego Policy reference](/policy-reference/rego_policy) for the full policy contract, input data shape, and exit code behaviour. + + + + + + +Let's evaluate several trails from the public `cyber-dojo` org against our policy. The `kosli evaluate trails` command fetches trail data from Kosli and passes it to the policy as `input.trails`: + +```shell +kosli evaluate trails \ + --policy pr-approved.rego \ + --org cyber-dojo \ + --flow dashboard-ci \ + 9978a1ca82c273a68afaa85fc37dd60d1e394f84 \ + b334d371eb85c9a5c811776de1b65fb80b52d952 \ + 5abd63aa1d64af7be5b5900af974dc73ae425bd6 \ + cb3ec71f5ce1103779009abaf4e8f8a3ed97d813 +``` + +The cyber-dojo project doesn't require PR approvals, so you'll see violations: + +```plaintext +RESULT: DENIED +VIOLATIONS: trail '5abd63aa1d64af7be5b5900af974dc73ae425bd6': pull-request https://github.com/cyber-dojo/dashboard/pull/342 has no approvers + trail '9978a1ca82c273a68afaa85fc37dd60d1e394f84': pull-request https://github.com/cyber-dojo/dashboard/pull/344 has no approvers + trail 'b334d371eb85c9a5c811776de1b65fb80b52d952': pull-request https://github.com/cyber-dojo/dashboard/pull/343 has no approvers + trail 'cb3ec71f5ce1103779009abaf4e8f8a3ed97d813': pull-request https://github.com/cyber-dojo/dashboard/pull/341 has no approvers +``` + +Now try the `kosli-public` org, where PRs do have approvers: + +```shell +kosli evaluate trails \ + --policy pr-approved.rego \ + --org kosli-public \ + --flow cli \ + 5a0f3c0 \ + 167ed93 \ + 030cc31 +``` + +```plaintext +RESULT: ALLOWED +``` + + + + + +The `kosli evaluate trail` (singular) command evaluates facts within a single trail, which is a different use case from comparing across multiple trails. For example, you might check that a snyk container scan found no high-severity vulnerabilities. + +Save this as `snyk-no-high-vulns.rego`: + +```rego +package policy + +import rego.v1 + +default allow = false + +violations contains msg if { + some name, artifact in input.trail.compliance_status.artifacts_statuses + snyk := artifact.attestations_statuses["snyk-container-scan"] + some result in snyk.processed_snyk_results.results + result.high_count > 0 + msg := sprintf("artifact '%v': snyk container scan found %d high severity vulnerabilities", [name, result.high_count]) +} + +allow if { + count(violations) == 0 +} +``` + +This policy iterates over every artifact in the trail, looks up its `snyk-container-scan` attestation, and checks whether any result has a non-zero `high_count`. + +Use `--attestations` to enrich only the snyk data (faster than fetching all attestation details). +The value uses the format `artifact-name.attestation-type`. Here, `dashboard` is the artifact name and `snyk-container-scan` is the attestation name: + +```shell +kosli evaluate trail \ + --policy snyk-no-high-vulns.rego \ + --org cyber-dojo \ + --flow dashboard-ci \ + --attestations dashboard.snyk-container-scan \ + 44ca5fa2630947cf375fdbda10972a4bedaaaba3 +``` + +```plaintext +RESULT: ALLOWED +``` + +The trail has zero high-severity vulnerabilities, so the policy allows it. + + +The `input.trail` / `input.trails` distinction and the full input data shape are documented in the [Rego Policy reference](/policy-reference/rego_policy#input-data). + + + + + + +When writing policies, it helps to see exactly what data is available. Use `--show-input` combined with `--output json` to see the full input that gets passed to the policy: + +```shell +kosli evaluate trail \ + --policy snyk-no-high-vulns.rego \ + --org cyber-dojo \ + --flow dashboard-ci \ + --attestations dashboard.snyk-container-scan \ + --show-input \ + --output json \ + 44ca5fa2630947cf375fdbda10972a4bedaaaba3 +``` + +This outputs the evaluation result along with the complete `input` object. You can pipe it through `jq` to explore the structure: + +```shell +kosli evaluate trail \ + --policy snyk-no-high-vulns.rego \ + --org cyber-dojo \ + --flow dashboard-ci \ + --attestations dashboard.snyk-container-scan \ + --show-input \ + --output json \ + 44ca5fa2630947cf375fdbda10972a4bedaaaba3 2>/dev/null | jq '.input.trail.compliance_status | keys' +``` + +```plaintext +[ + "artifacts_statuses", + "attestations_statuses", + "evaluated_at", + "flow_template_id", + "is_compliant", + "status" +] +``` + + +Use the `--attestations` flag to limit which attestations are enriched with full detail. The flag filters by **attestation name** (not type). For example, `--attestations pull-request` fetches only details for attestations named `pull-request`, which speeds up evaluation and reduces noise when exploring the input. + + + + + + +The `kosli evaluate` commands exit with `0` on allow and `1` on deny or error — making them straightforward to use as pipeline gates. See the [Rego Policy reference](/policy-reference/rego_policy#exit-codes) for details on distinguishing denial from command failure. + + + +```shell +# Example: gate a deployment on policy evaluation +if kosli evaluate trail \ + --policy policies/pr-approved.rego \ + --org "$KOSLI_ORG" \ + --flow "$FLOW_NAME" \ + "$GIT_COMMIT"; then + echo "Policy passed — proceeding with deployment" + # ... deploy commands ... +else + echo "Policy denied — blocking deployment" + exit 1 +fi +``` + +This pattern lets you enforce custom compliance rules as part of your delivery pipeline, using the same trail data that Kosli already collects. + + + + + +After evaluating a trail, you can record the result as an attestation. This creates an audit record in Kosli that captures the policy, the full evaluation report, and any violations. + +This step requires write access to your Kosli org. The examples below use variables you'd set in your CI/CD pipeline. In your own pipeline you'd use your own policy file — here we use `my-policy.rego` as a placeholder: + +```shell +# Run the evaluation and save the full JSON report to a file +# (|| true prevents the step from failing when the policy denies) +kosli evaluate trail "$TRAIL_NAME" \ + --policy my-policy.rego \ + --org "$KOSLI_ORG" \ + --flow "$FLOW_NAME" \ + --show-input \ + --output json > eval-report.json 2>/dev/null || true + +# Read the allow/deny result from the report +is_compliant=$(jq -r '.allow' eval-report.json) + +# Extract violations as structured user-data +jq '{violations: .violations}' eval-report.json > eval-violations.json + +# Attest the result +kosli attest generic \ + --name opa-evaluation \ + --flow "$FLOW_NAME" \ + --trail "$TRAIL_NAME" \ + --org "$KOSLI_ORG" \ + --compliant="$is_compliant" \ + --attachments my-policy.rego,eval-report.json \ + --user-data eval-violations.json +``` + +This creates a generic attestation on the trail with: + +* **`--compliant`** set based on whether the policy allowed or denied — read directly from the JSON report rather than relying on the exit code, which avoids issues with `set -e` in CI environments like GitHub Actions +* **`--attachments`** containing the Rego policy (for reproducibility) and the full JSON evaluation report (including the input data the policy evaluated) +* **`--user-data`** containing the violations, which appear in the Kosli UI as structured metadata on the attestation + + +Use `--compliant=value` (with `=`) not `--compliant value` (with a space). Boolean flags in Kosli CLI require the `=` syntax when passing `false` — otherwise `false` is interpreted as a positional argument. + + + + + + +## What you've accomplished + +You have written OPA/Rego policies and evaluated Kosli trails against them, both across multiple trails and within a single trail. You've also recorded evaluation results as attestations, creating a tamper-proof audit record of every policy decision linked to a specific trail. + +From here you can: +* Explore evaluated trails in the [Kosli app](https://app.kosli.com) +* Gate deployments in CI/CD pipelines using `kosli evaluate trail` exit codes +* Extend your policies to check other attestation types. See [`kosli evaluate trail`](/client_reference/kosli_evaluate_trail) and [`kosli evaluate trails`](/client_reference/kosli_evaluate_trails) for the full flag reference