Skip to content

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:

default decision := "defer"

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

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-info
  • triage-payments-accept-pci
  • priority-org-weighted-scoring
  • ownership-org-by-repository
  • pr-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:

  1. Save it as inactive.
  2. Use the playground with real finding data from recent scans.
  3. Review the decisions it would have made.
  4. 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:

  1. Deactivate it (don't delete).
  2. Add a note in the description: "Deprecated: replaced by X".
  3. Monitor for 30 days to confirm no impact.
  4. 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() and startswith() when possible; fall back to regex.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