|
| 1 | +package policy |
| 2 | + |
| 3 | +import rego.v1 |
| 4 | + |
| 5 | +# Four-eyes principle enforcement: every commit must have independent review. |
| 6 | +# This policy evaluates per-commit attestation data from Kosli and passes only |
| 7 | +# when all violations are resolved. |
| 8 | +default allow = false |
| 9 | + |
| 10 | +allow if count(violation_reasons) == 0 |
| 11 | + |
| 12 | +# Set PR attestation name |
| 13 | +attestation_name := name if { |
| 14 | + name := data.params.attestation_name |
| 15 | + is_string(name) |
| 16 | +} else := "pr-review" |
| 17 | + |
| 18 | +# --------------------------------------------------------------------------- |
| 19 | +# Attestation data |
| 20 | +# |
| 21 | +# Used with `kosli evaluate trails` (plural). Each trail in input.trails |
| 22 | +# represents one commit. The PR attestation payload is at: |
| 23 | +# trail.compliance_status.attestations_statuses["pr-review"] |
| 24 | +# |
| 25 | +# Attested via: kosli attest pullrequest github --name pr-review --commit <sha> |
| 26 | +# --------------------------------------------------------------------------- |
| 27 | + |
| 28 | +# Extract PR attestation payload from a trail. |
| 29 | +pr_attest(trail) := trail.compliance_status.attestations_statuses[attestation_name] |
| 30 | + |
| 31 | +# --------------------------------------------------------------------------- |
| 32 | +# Helpers |
| 33 | +# --------------------------------------------------------------------------- |
| 34 | + |
| 35 | +# GitHub usernames of all PR branch commit authors whose identity was resolved. |
| 36 | +pr_commit_authors(pr) := {u | |
| 37 | + some c in pr.commits |
| 38 | + u := c.author_username |
| 39 | + u != null |
| 40 | +} |
| 41 | + |
| 42 | +# Latest Unix timestamp among PR branch commits. |
| 43 | +latest_commit_ts(pr) := max({c.timestamp | some c in pr.commits}) |
| 44 | + |
| 45 | +# A commit is the merge commit when the PR's merge_commit field matches the |
| 46 | +# trail name (which is the commit SHA). Covers squash, regular, and rebase merges. |
| 47 | +is_merge_commit(trail, pr) if { |
| 48 | + trail.name == pr.merge_commit |
| 49 | +} |
| 50 | + |
| 51 | +# Regular commit: PR branch authors + PR author all need independent approval after last code commit. |
| 52 | +has_independent_approval(trail, pr) if { |
| 53 | + not is_merge_commit(trail, pr) |
| 54 | + cutoff := latest_commit_ts(pr) |
| 55 | + all_authors := pr_commit_authors(pr) | {pr.author} |
| 56 | + count(all_authors) > 0 |
| 57 | + every author in all_authors { |
| 58 | + some approver in pr.approvers |
| 59 | + approver.state == "APPROVED" |
| 60 | + is_string(approver.username) |
| 61 | + approver.username != author |
| 62 | + approver.timestamp > cutoff |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +# Merge commit: only PR branch commit authors need independent approval. |
| 67 | +# The merge button clicker did not write code and requires no separate review. |
| 68 | +has_independent_approval(trail, pr) if { |
| 69 | + is_merge_commit(trail, pr) |
| 70 | + cutoff := latest_commit_ts(pr) |
| 71 | + all_authors := pr_commit_authors(pr) |
| 72 | + count(all_authors) > 0 |
| 73 | + every author in all_authors { |
| 74 | + some approver in pr.approvers |
| 75 | + approver.state == "APPROVED" |
| 76 | + is_string(approver.username) |
| 77 | + approver.username != author |
| 78 | + approver.timestamp > cutoff |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +# --------------------------------------------------------------------------- |
| 83 | +# Service account exemption |
| 84 | +# |
| 85 | +# Matched against trail.git_commit_info.author, which is "Name <email>" format. |
| 86 | +# Patterns work against the full string, e.g.: |
| 87 | +# "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" |
| 88 | +# --------------------------------------------------------------------------- |
| 89 | + |
| 90 | +service_account_patterns := { |
| 91 | + "svc_.*", |
| 92 | + ".*\\[bot\\]", |
| 93 | + "noreply@github.com" |
| 94 | +} |
| 95 | + |
| 96 | +# Commit author is a service account (CI, GitHub Actions, dependabot, etc). |
| 97 | +is_service_account(trail) if { |
| 98 | + some pattern in service_account_patterns |
| 99 | + regex.match(pattern, trail.git_commit_info.author) |
| 100 | +} |
| 101 | + |
| 102 | +# PR commit author is unresolvable (web-flow edits, Copilot co-auth). |
| 103 | +is_web_flow_commit(c) if { |
| 104 | + some pattern in service_account_patterns |
| 105 | + regex.match(pattern, object.get(c, "author", "")) |
| 106 | +} |
| 107 | + |
| 108 | +# --------------------------------------------------------------------------- |
| 109 | +# Helpers — multi-PR support |
| 110 | +# --------------------------------------------------------------------------- |
| 111 | + |
| 112 | +# Check if any associated PR has independent approval for the commit. |
| 113 | +has_any_pr_approval(trail, attest) if { |
| 114 | + some pr in attest.pull_requests |
| 115 | + has_independent_approval(trail, pr) |
| 116 | +} |
| 117 | + |
| 118 | +# --------------------------------------------------------------------------- |
| 119 | +# Violation reasons — detection only, no sprintf |
| 120 | +# |
| 121 | +# allow is derived from this set. Keeping detection logic here (no sprintf) |
| 122 | +# means a formatting failure in the violations rules below cannot silently |
| 123 | +# empty this set and flip allow to true. |
| 124 | +# --------------------------------------------------------------------------- |
| 125 | + |
| 126 | +# Guard: if input.trails is absent or not an array, every other rule silently |
| 127 | +# skips iteration and violation_reasons stays empty, making allow=true. |
| 128 | +# object.get ensures the argument to is_array is always defined |
| 129 | +# (avoids undefined-arg propagation that would make `not is_array(undefined)` |
| 130 | +# silently skip the rule). |
| 131 | +violation_reasons contains "missing_trails_input" if { |
| 132 | + trails := object.get(input, "trails", null) |
| 133 | + not is_array(trails) |
| 134 | +} |
| 135 | + |
| 136 | +# Missing attestation: no PR review data collected for this commit. |
| 137 | +violation_reasons contains {"type": "missing_attestation", "trail": trail.name} if { |
| 138 | + some trail in input.trails |
| 139 | + not trail.compliance_status.attestations_statuses[attestation_name] |
| 140 | +} |
| 141 | + |
| 142 | +# Unverifiable identity: commit author has no resolvable GitHub account and is not a known service account. |
| 143 | +violation_reasons contains {"type": "unverifiable_identity", "pr_url": pr.url, "sha": c.sha1} if { |
| 144 | + some trail in input.trails |
| 145 | + attest := pr_attest(trail) |
| 146 | + some pr in attest.pull_requests |
| 147 | + some c in pr.commits |
| 148 | + object.get(c, "author_username", null) == null |
| 149 | + not is_service_account(trail) |
| 150 | + not is_web_flow_commit(c) |
| 151 | +} |
| 152 | + |
| 153 | +# Missing PR: commit has no associated merged pull request (non-service-account commits must come through a PR). |
| 154 | +violation_reasons contains {"type": "missing_pr", "trail": trail.name} if { |
| 155 | + some trail in input.trails |
| 156 | + not is_service_account(trail) |
| 157 | + attest := pr_attest(trail) |
| 158 | + count(attest.pull_requests) == 0 |
| 159 | +} |
| 160 | + |
| 161 | +# Missing approval: commit has an associated PR but no independent approval from someone other than the authors. |
| 162 | +violation_reasons contains {"type": "missing_approval", "trail": trail.name} if { |
| 163 | + some trail in input.trails |
| 164 | + not is_service_account(trail) |
| 165 | + attest := pr_attest(trail) |
| 166 | + count(attest.pull_requests) > 0 |
| 167 | + not has_any_pr_approval(trail, attest) |
| 168 | +} |
| 169 | + |
| 170 | +# --------------------------------------------------------------------------- |
| 171 | +# Violations — message formatting only |
| 172 | +# |
| 173 | +# allow does NOT depend on this set. A sprintf failure here cannot affect |
| 174 | +# the compliance decision; it only affects the human-readable output. |
| 175 | +# --------------------------------------------------------------------------- |
| 176 | + |
| 177 | +violations contains "Policy error: input.trails is missing or not an array — cannot evaluate" if { |
| 178 | + "missing_trails_input" in violation_reasons |
| 179 | +} |
| 180 | + |
| 181 | +violations contains msg if { |
| 182 | + some r in violation_reasons |
| 183 | + r.type == "missing_attestation" |
| 184 | + msg := sprintf("Trail %v: %v attestation is missing", [r.trail, attestation_name]) |
| 185 | +} |
| 186 | + |
| 187 | +violations contains msg if { |
| 188 | + some r in violation_reasons |
| 189 | + r.type == "unverifiable_identity" |
| 190 | + msg := sprintf( |
| 191 | + "PR %v: commit %v has no linked GitHub account — identity unverifiable", |
| 192 | + [r.pr_url, substring(r.sha, 0, 7)], |
| 193 | + ) |
| 194 | +} |
| 195 | + |
| 196 | +violations contains msg if { |
| 197 | + some r in violation_reasons |
| 198 | + r.type == "missing_pr" |
| 199 | + msg := sprintf("Commit %v: no associated PR found", [substring(r.trail, 0, 7)]) |
| 200 | +} |
| 201 | + |
| 202 | +violations contains msg if { |
| 203 | + some r in violation_reasons |
| 204 | + r.type == "missing_approval" |
| 205 | + msg := sprintf( |
| 206 | + "Commit %v: no independent approval after latest code commit", |
| 207 | + [substring(r.trail, 0, 7)], |
| 208 | + ) |
| 209 | +} |
0 commit comments