Files
git.stella-ops.org/docs/policy/dsl.md
root 68da90a11a
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Restructure solution layout by module
2025-10-28 15:10:40 +02:00

295 lines
14 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Stella Policy DSL (`stella-dsl@1`)
> **Audience:** Policy authors, reviewers, and tooling engineers building lint/compile flows for the Policy Engine v2 rollout (Sprint20).
This document specifies the `stella-dsl@1` grammar, semantics, and guardrails used by StellaOps to transform SBOM facts, Concelier advisories, and Excititor VEX statements into effective findings. Use it with the [Policy Engine Overview](overview.md) for architectural context and the upcoming lifecycle/run guides for operational workflows.
---
## 1·Design Goals
- **Deterministic:** Same policy + same inputs ⇒ identical findings on every machine.
- **Declarative:** No arbitrary loops, network calls, or clock access.
- **Explainable:** Every decision records the rule, inputs, and rationale in the explain trace.
- **Lean authoring:** Common precedence, severity, and suppression patterns are first-class.
- **Offline-friendly:** Grammar and built-ins avoid cloud dependencies, run the same in sealed deployments.
---
## 2·Document Structure
Policy packs ship one or more `.stella` files. Each file contains exactly one `policy` block:
```dsl
policy "Default Org Policy" syntax "stella-dsl@1" {
metadata {
description = "Baseline severity + VEX precedence"
tags = ["baseline","vex"]
}
profile severity {
map vendor_weight {
source "GHSA" => +0.5
source "OSV" => +0.0
source "VendorX" => -0.2
}
env exposure_adjustments {
if env.runtime == "serverless" then -0.5
if env.exposure == "internal-only" then -1.0
}
}
rule vex_precedence priority 10 {
when vex.any(status in ["not_affected","fixed"])
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
then status := vex.status
because "Strong vendor justification prevails";
}
}
```
High-level layout:
| Section | Purpose |
|---------|---------|
| `metadata` | Optional descriptive fields surfaced in Console/CLI. |
| `imports` | Reserved for future reuse (not yet implemented in `@1`). |
| `profile` blocks | Declarative scoring modifiers (`severity`, `trust`, `reachability`). |
| `rule` blocks | When/then logic applied to each `(component, advisory, vex[])` tuple. |
| `settings` | Optional evaluation toggles (sampling, default status overrides). |
---
## 3·Lexical Rules
- **Case sensitivity:** Keywords are lowercase; identifiers are case-sensitive.
- **Whitespace:** Space, tab, newline act as separators. Indentation is cosmetic.
- **Comments:** `// inline` and `/* block */` are ignored.
- **Literals:**
- Strings use double quotes (`"text"`); escape with `\"`, `\n`, `\t`.
- Numbers are decimal; suffix `%` allowed for percentage weights (`-2.5%` becomes `-0.025`).
- Booleans: `true`, `false`.
- Lists: `[1, 2, 3]`, `["a","b"]`.
- **Identifiers:** Start with letter or underscore, continue with letters, digits, `_`.
- **Operators:** `=`, `==`, `!=`, `<`, `<=`, `>`, `>=`, `in`, `not in`, `and`, `or`, `not`, `:=`.
---
## 4·Grammar (EBNF)
```ebnf
policy = "policy", string, "syntax", string, "{", policy-body, "}" ;
policy-body = { metadata | profile | settings | rule | helper } ;
metadata = "metadata", "{", { meta-entry }, "}" ;
meta-entry = identifier, "=", (string | list) ;
profile = "profile", identifier, "{", { profile-item }, "}" ;
profile-item= map | env-map | scalar ;
map = "map", identifier, "{", { "source", string, "=>", number, ";" }, "}" ;
env-map = "env", identifier, "{", { "if", expression, "then", number, ";" }, "}" ;
scalar = identifier, "=", (number | string | list), ";" ;
settings = "settings", "{", { setting-entry }, "}" ;
setting-entry = identifier, "=", (number | string | boolean), ";" ;
rule = "rule", identifier, [ "priority", integer ], "{",
"when", predicate,
{ "and", predicate },
"then", { action },
[ "else", { action } ],
[ "because", string ],
"}" ;
predicate = expression ;
expression = term, { ("and" | "or"), term } ;
term = ["not"], factor ;
factor = comparison | membership | function-call | literal | identifier | "(" expression ")" ;
comparison = value, comparator, value ;
membership = value, ("in" | "not in"), list ;
value = identifier | literal | function-call | field-access ;
field-access= identifier, { ".", identifier | "[" literal "]" } ;
function-call = identifier, "(", [ arg-list ], ")" ;
arg-list = expression, { ",", expression } ;
literal = string | number | boolean | list ;
action = assignment | ignore | escalate | require | warn | defer | annotate ;
assignment = target, ":=", expression, ";" ;
target = identifier, { ".", identifier } ;
ignore = "ignore", [ "until", expression ], [ "because", string ], ";" ;
escalate = "escalate", [ "to", expression ], [ "when", expression ], ";" ;
require = "requireVex", "{", require-fields, "}", ";" ;
warn = "warn", [ "message", string ], ";" ;
defer = "defer", [ "until", expression ], ";" ;
annotate = "annotate", identifier, ":=", expression, ";" ;
```
Notes:
- `helper` is reserved for shared calculcations (not yet implemented in `@1`).
- `else` branch executes only if `when` predicates evaluate truthy **and** no prior rule earlier in priority handled the tuple.
- Semicolons inside rule bodies are optional when each clause is on its own line; the compiler emits canonical semicolons in IR.
---
## 5·Evaluation Context
Within predicates and actions you may reference the following namespaces:
| Namespace | Fields | Description |
|-----------|--------|-------------|
| `sbom` | `purl`, `name`, `version`, `licenses`, `layerDigest`, `tags`, `usedByEntrypoint` | Component metadata from Scanner. |
| `advisory` | `id`, `source`, `aliases`, `severity`, `cvss`, `publishedAt`, `modifiedAt`, `content.raw` | Canonical Concelier advisory view. |
| `vex` | `status`, `justification`, `statementId`, `timestamp`, `scope` | Current VEX statement when iterating; aggregator helpers available. |
| `vex.any(...)`, `vex.all(...)`, `vex.count(...)` | Functions operating over all matching statements. |
| `run` | `policyId`, `policyVersion`, `tenant`, `timestamp` | Metadata for explain annotations. |
| `env` | Arbitrary key/value pairs injected per run (e.g., `environment`, `runtime`). |
| `telemetry` | Optional reachability signals; missing fields evaluate to `unknown`. |
| `profile.<name>` | Values computed inside profile blocks (maps, scalars). |
Missing fields evaluate to `null`, which is falsey in boolean context and propagates through comparisons unless explicitly checked.
---
## 6·Built-ins (v1)
| Function / Property | Signature | Description |
|---------------------|-----------|-------------|
| `normalize_cvss(advisory)` | `Advisory → SeverityScalar` | Parses `advisory.content.raw` for CVSS data; falls back to policy maps. |
| `cvss(score, vector)` | `double × string → SeverityScalar` | Constructs a severity object manually. |
| `severity_band(value)` | `string → SeverityBand` | Normalises strings like `"critical"`, `"medium"`. |
| `risk_score(base, modifiers...)` | Variadic | Multiplies numeric modifiers (severity × trust × reachability). |
| `vex.any(predicate)` | `(Statement → bool) → bool` | `true` if any statement satisfies predicate. |
| `vex.all(predicate)` | `(Statement → bool) → bool` | `true` if all statements satisfy predicate. |
| `vex.latest()` | `→ Statement` | Lexicographically newest statement. |
| `advisory.has_tag(tag)` | `string → bool` | Checks advisory metadata tags. |
| `advisory.matches(pattern)` | `string → bool` | Glob match against advisory identifiers. |
| `sbom.has_tag(tag)` | `string → bool` | Uses SBOM inventory tags (usage vs inventory). |
| `exists(expression)` | `→ bool` | `true` when value is non-null/empty. |
| `coalesce(a, b, ...)` | `→ value` | First non-null argument. |
| `days_between(dateA, dateB)` | `→ int` | Absolute day difference (UTC). |
| `percent_of(part, whole)` | `→ double` | Fractions for scoring adjustments. |
| `lowercase(text)` | `string → string` | Normalises casing deterministically (InvariantCulture). |
All built-ins are pure; if inputs are null the result is null unless otherwise noted.
---
## 7·Rule Semantics
1. **Ordering:** Rules execute in ascending `priority`. When priorities tie, lexical order defines precedence.
2. **Short-circuit:** Once a rule sets `status`, subsequent rules only execute if they use `combine`. Use this sparingly to avoid ambiguity.
3. **Actions:**
- `status := <string>` Allowed values: `affected`, `not_affected`, `fixed`, `suppressed`, `under_investigation`, `escalated`.
- `severity := <SeverityScalar>` Either from `normalize_cvss`, `cvss`, or numeric map; ensures `normalized` and `score`.
- `ignore until <ISO-8601>` Temporarily treats finding as suppressed until timestamp; recorded in explain trace.
- `warn message "<text>"` Adds warn verdict and deducts `warnPenalty`.
- `escalate to severity_band("critical") when condition` Forces verdict severity upward when condition true.
- `requireVex { vendors = ["VendorX"], justifications = ["component_not_present"] }` Fails evaluation if matching VEX evidence absent.
- `annotate reason := "text"` Adds free-form key/value pairs to explain payload.
4. **Because clause:** Mandatory for actions changing status or severity; captured verbatim in explain traces.
---
## 8·Scoping Helpers
- **Maps:** Use `profile severity { map vendor_weight { ... } }` to declare additive factors. Retrieve with `profile.severity.vendor_weight["GHSA"]`.
- **Environment overrides:** `env` profiles allow conditional adjustments based on runtime metadata.
- **Tenancy:** `run.tenant` ensures policies remain tenant-aware; avoid hardcoding single-tenant IDs.
- **Default values:** Use `settings { default_status = "affected"; }` to override built-in defaults.
---
## 9·Examples
### 9.1 Baseline Severity Normalisation
```dsl
rule advisory_normalization {
when advisory.source in ["GHSA","OSV"]
then severity := normalize_cvss(advisory)
because "Align vendor severity to CVSS baseline";
}
```
### 9.2 VEX Override with Quiet Mode
```dsl
rule vex_strong_claim priority 5 {
when vex.any(status == "not_affected")
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
then status := vex.status
annotate winning_statement := vex.latest().statementId
warn message "VEX override applied"
because "Strong VEX justification";
}
```
### 9.3 Environment-Specific Escalation
```dsl
rule internet_exposed_guard {
when env.exposure == "internet"
and severity.normalized >= "High"
then escalate to severity_band("Critical")
because "Internet-exposed assets require critical posture";
}
```
### 9.4 Anti-pattern (flagged by linter)
```dsl
rule catch_all {
when true
then status := "suppressed"
because "Suppress everything" // ❌ Fails lint: unbounded suppression
}
```
---
## 10·Validation & Tooling
- `stella policy lint` ensures:
- Grammar compliance and canonical formatting.
- Static determinism guard (no forbidden namespaces).
- Anti-pattern detection (e.g., unconditional suppression, missing `because`).
- `stella policy compile` emits IR (`.stella.ir.json`) and SHA-256 digest used in `policy_runs`.
- CI pipelines (see `DEVOPS-POLICY-20-001`) compile sample packs and fail on lint violations.
- Simulation harnesses (`stella policy simulate`) highlight provided/queried fields so policy authors affirm assumptions before promotion.
---
## 11·Anti-patterns & Mitigations
| Anti-pattern | Risk | Mitigation |
|--------------|------|------------|
| Catch-all suppress/ignore without scope | Masks all findings | Linter blocks rules with `when true` unless `priority` > 1000 and justification includes remediation plan. |
| Comparing strings with inconsistent casing | Missed matches | Wrap comparisons in `lowercase(value)` to align casing or normalise metadata during ingest. |
| Referencing `telemetry` without fallback | Null propagation | Wrap access in `exists(telemetry.reachability)`. |
| Hardcoding tenant IDs | Breaks multi-tenant | Prefer `env.tenantTag` or metadata-sourced predicates. |
| Duplicated rule names | Explain trace ambiguity | Compiler enforces unique `rule` identifiers within a policy. |
---
## 12·Versioning & Compatibility
- `syntax "stella-dsl@1"` is mandatory.
- Future revisions (`@2`, …) will be additive; existing packs continue to compile with their declared version.
- The compiler canonicalises documents (sorted keys, normalised whitespace) before hashing to ensure reproducibility.
---
## 13·Compliance Checklist
- [ ] **Grammar validated:** Policy compiles with `stella policy lint` and matches `syntax "stella-dsl@1"`.
- [ ] **Deterministic constructs only:** No use of forbidden namespaces (`DateTime.Now`, `Guid.NewGuid`, external services).
- [ ] **Rationales present:** Every status/severity change includes a `because` clause or `annotate` entry.
- [ ] **Scoped suppressions:** Rules that ignore/suppress findings reference explicit components, vendors, or VEX justifications.
- [ ] **Explain fields verified:** `annotate` keys align with Console/CLI expectations (documented in upcoming lifecycle guide).
- [ ] **Offline parity tested:** Policy pack simulated in sealed mode (`--sealed`) to confirm absence of network dependencies.
---
*Last updated: 2025-10-26 (Sprint 20).*