Designing Effective Policies¶
This guide covers how to design, structure, and maintain OPA policies in Mayo ASPM for long-term success.
Design principles¶
1. One policy, one purpose¶
Each policy should do one thing well:
- Good: "Suppress findings in test directories"
- Bad: "Suppress test findings, score priority, and assign ownership"
2. Be explicit, not clever¶
Rego supports complex logic, but simpler is better:
# Good: clear and readable
decision := "accept" if {
input.finding.severity == "critical"
input.finding.in_kev == true
}
# Bad: unnecessarily compact
decision := d if {
s := {"critical": true}
s[input.finding.severity]
d := ["reject", "accept"][input.finding.in_kev]
}
3. Always have a default¶
Every policy should handle the "none of my rules matched" case:
4. Make rules mutually exclusive¶
Avoid rules that could produce conflicting outputs:
# BAD: A critical finding in a test file matches both
decision := "accept" if { input.finding.severity == "critical" }
decision := "reject" if { contains(input.finding.file_path, "/test/") }
# GOOD: Order matters, critical takes precedence
decision := "accept" if {
input.finding.severity == "critical"
not contains(input.finding.file_path, "/test/")
}
decision := "reject" if {
contains(input.finding.file_path, "/test/")
}
Structuring your policy set¶
Recommended structure¶
| Policy | Kind | Scope | Purpose |
|---|---|---|---|
triage-severity-routing |
Triage | Org | Base severity decisions |
triage-test-suppression |
Triage | Org | Suppress test/vendor files |
triage-noisy-rules |
Triage | Org | Suppress known noisy rules |
triage-{project}-overrides |
Triage | Project | Project-specific exceptions |
priority-weighted-scoring |
Priority | Org | Score with severity + EPSS + KEV |
ownership-by-repo |
Ownership | Org | Assign by repository |
ownership-{project}-paths |
Ownership | Project | Assign by file path in monorepos |
project-mapping |
Project | Org | Map assets to projects |
pr-gate-standard |
PR Scan | Org | Standard PR gate |
pr-gate-{project}-strict |
PR Scan | Project | Strict gate for critical services |
Naming convention¶
Use a consistent pattern: {kind}-{scope}-{purpose}
Examples:
triage-org-suppress-infotriage-payments-accept-pcipriority-org-weighted-scoringownership-org-by-repositorypr-scan-main-block-critical
Testing strategy¶
Unit tests in the playground¶
For every policy, maintain test cases:
| Test case | Input | Expected output |
|---|---|---|
| Critical CVE | severity=critical, cve_id=CVE-... | decision=accept |
| Info finding | severity=info | decision=reject |
| Test file | file_path=/test/foo.js | decision=reject |
| High in prod | severity=high, file_path=src/app.js | decision=defer |
Batch testing¶
Save test cases in the playground and run them as a suite whenever you edit the policy.
Shadow mode¶
Before activating a new policy:
- Save it as inactive.
- Use the playground with real finding data from recent scans.
- Review the decisions it would have made.
- Activate only when satisfied.
Maintenance patterns¶
Regular review cycle¶
| Cadence | Action |
|---|---|
| Weekly | Review triage queue for new automatable patterns |
| Monthly | Check automation rate and false positive rate |
| Quarterly | Audit all policies for relevance and accuracy |
| On incident | Update policies to catch similar issues |
Deprecating policies¶
When a policy is no longer needed:
- Deactivate it (don't delete).
- Add a note in the description: "Deprecated: replaced by X".
- Monitor for 30 days to confirm no impact.
- Delete after the monitoring period.
Policy documentation¶
Use the policy description field to document:
- Purpose: What does this policy do?
- Rationale: Why does this rule exist?
- Author: Who wrote it and when?
- Review date: When should it be reviewed next?
Performance considerations¶
Keep rule sets small¶
Each additional rule adds evaluation time. Group related conditions:
# Good: single rule with OR pattern
decision := "reject" if {
some pattern in ["/test/", "/spec/", "/vendor/"]
contains(input.finding.file_path, pattern)
}
# Bad: separate rule for each path
decision := "reject" if { contains(input.finding.file_path, "/test/") }
decision := "reject" if { contains(input.finding.file_path, "/spec/") }
decision := "reject" if { contains(input.finding.file_path, "/vendor/") }
Avoid expensive operations¶
- Regex: Use
contains()andstartswith()when possible; fall back toregex.match()only when needed. - Data lookups: Keep lookup tables small (< 10,000 entries).
Common pitfalls¶
| Pitfall | Impact | Fix |
|---|---|---|
| No default value | Findings with no matching rule get no decision | Add default decision := "defer" |
| Conflicting rules | OPA error, evaluation fails | Make rules mutually exclusive |
| Overly broad rejection | Real issues suppressed | Use defer for uncertain findings |
| Hardcoded finding IDs | Brittle, breaks on re-scan | Use patterns (rule_id, file_path) |
| No test cases | Regressions go unnoticed | Maintain batch tests for every policy |
Next steps¶
- Rego guide — syntax reference
- Policy best practices — patterns and anti-patterns
- Policy Playground — interactive testing