1use anyhow::{Context, Result, bail};
2
3use libverify_core::control::ControlFinding;
4use libverify_core::profile::{
5 ControlProfile, FindingSeverity, GateDecision, ProfileOutcome, SeverityLabels,
6};
7
8const DEFAULT_POLICY: &str = include_str!("default.rego");
9const OSS_POLICY: &str = include_str!("oss.rego");
10const AIOPS_POLICY: &str = include_str!("aiops.rego");
11const SOC1_POLICY: &str = include_str!("soc1.rego");
12const SOC2_POLICY: &str = include_str!("soc2.rego");
13const SLSA_L1_POLICY: &str = include_str!("slsa-l1.rego");
14const SLSA_L2_POLICY: &str = include_str!("slsa-l2.rego");
15const SLSA_L3_POLICY: &str = include_str!("slsa-l3.rego");
16const SLSA_L4_POLICY: &str = include_str!("slsa-l4.rego");
17const RULE_PATH: &str = "data.verify.profile.map";
18
19pub struct OpaProfile {
22 engine: regorus::Engine,
23 profile_name: String,
24}
25
26impl OpaProfile {
27 pub fn from_file(path: &str) -> Result<Self> {
29 let policy = std::fs::read_to_string(path).with_context(|| {
30 format!(
31 "reading policy '{path}'. Use a built-in preset (default, oss, aiops, soc1, soc2) or a path to a .rego file"
32 )
33 })?;
34 Self::from_rego_with_name(path, &policy, "opa-custom")
35 }
36
37 pub fn from_rego(name: &str, rego: &str) -> Result<Self> {
39 Self::from_rego_with_name(name, rego, "opa-custom")
40 }
41
42 pub fn default_policy() -> Result<Self> {
43 Self::from_rego_with_name("default.rego", DEFAULT_POLICY, "opa-default")
44 }
45
46 pub fn oss_preset() -> Result<Self> {
47 Self::from_rego_with_name("oss.rego", OSS_POLICY, "oss")
48 }
49
50 pub fn aiops_preset() -> Result<Self> {
51 Self::from_rego_with_name("aiops.rego", AIOPS_POLICY, "aiops")
52 }
53
54 pub fn soc1_preset() -> Result<Self> {
55 Self::from_rego_with_name("soc1.rego", SOC1_POLICY, "soc1")
56 }
57
58 pub fn soc2_preset() -> Result<Self> {
59 Self::from_rego_with_name("soc2.rego", SOC2_POLICY, "soc2")
60 }
61
62 pub fn slsa_l1_preset() -> Result<Self> {
63 Self::from_rego_with_name("slsa-l1.rego", SLSA_L1_POLICY, "slsa-l1")
64 }
65
66 pub fn slsa_l2_preset() -> Result<Self> {
67 Self::from_rego_with_name("slsa-l2.rego", SLSA_L2_POLICY, "slsa-l2")
68 }
69
70 pub fn slsa_l3_preset() -> Result<Self> {
71 Self::from_rego_with_name("slsa-l3.rego", SLSA_L3_POLICY, "slsa-l3")
72 }
73
74 pub fn slsa_l4_preset() -> Result<Self> {
75 Self::from_rego_with_name("slsa-l4.rego", SLSA_L4_POLICY, "slsa-l4")
76 }
77
78 pub fn from_preset_or_file(name: &str) -> Result<Self> {
80 match name {
81 "default" => Self::default_policy(),
82 "oss" => Self::oss_preset(),
83 "aiops" => Self::aiops_preset(),
84 "soc1" => Self::soc1_preset(),
85 "soc2" => Self::soc2_preset(),
86 "slsa-l1" => Self::slsa_l1_preset(),
87 "slsa-l2" => Self::slsa_l2_preset(),
88 "slsa-l3" => Self::slsa_l3_preset(),
89 "slsa-l4" => Self::slsa_l4_preset(),
90 path => Self::from_file(path),
91 }
92 }
93
94 fn from_rego_with_name(name: &str, rego: &str, profile_name: &str) -> Result<Self> {
95 let mut engine = regorus::Engine::new();
96 engine
97 .add_policy(name.to_string(), rego.to_string())
98 .with_context(|| format!("parsing policy {name}"))?;
99 Ok(Self {
100 engine,
101 profile_name: profile_name.to_string(),
102 })
103 }
104
105 fn eval_finding(&self, finding: &ControlFinding) -> Result<(FindingSeverity, GateDecision)> {
106 let input_json = serde_json::to_string(finding).context("serializing finding to JSON")?;
107
108 let mut engine = self.engine.clone();
109 engine.set_input(regorus::Value::from_json_str(&input_json).context("parsing input")?);
110
111 let result = engine
112 .eval_rule(RULE_PATH.to_string())
113 .context("evaluating OPA rule")?;
114
115 let severity = result["severity"]
116 .as_string()
117 .context("policy output missing 'severity' string field")?;
118 let decision = result["decision"]
119 .as_string()
120 .context("policy output missing 'decision' string field")?;
121
122 let severity = parse_severity(severity.as_ref())?;
123 let decision = parse_decision(decision.as_ref())?;
124 Ok((severity, decision))
125 }
126}
127
128impl ControlProfile for OpaProfile {
129 fn name(&self) -> &str {
130 &self.profile_name
131 }
132
133 fn map(&self, finding: &ControlFinding) -> ProfileOutcome {
134 let (severity, decision) = self.eval_finding(finding).unwrap_or_else(|err| {
135 eprintln!(
136 "Warning: OPA evaluation failed for {}: {err:#}. Defaulting to Fail.",
137 finding.control_id
138 );
139 (FindingSeverity::Error, GateDecision::Fail)
140 });
141
142 ProfileOutcome {
143 control_id: finding.control_id.clone(),
144 severity,
145 decision,
146 rationale: finding.rationale.clone(),
147 }
148 }
149
150 fn severity_labels(&self) -> SeverityLabels {
151 match self.profile_name.as_str() {
152 "soc1" => SeverityLabels {
153 info: "effective".to_string(),
154 warning: "deficiency".to_string(),
155 error: "material_weakness".to_string(),
156 },
157 _ => SeverityLabels::default(),
158 }
159 }
160}
161
162fn parse_severity(s: &str) -> Result<FindingSeverity> {
163 match s {
164 "info" => Ok(FindingSeverity::Info),
165 "warning" => Ok(FindingSeverity::Warning),
166 "error" => Ok(FindingSeverity::Error),
167 _ => bail!("invalid severity '{s}': expected info, warning, or error"),
168 }
169}
170
171fn parse_decision(s: &str) -> Result<GateDecision> {
172 match s {
173 "pass" => Ok(GateDecision::Pass),
174 "review" => Ok(GateDecision::Review),
175 "fail" => Ok(GateDecision::Fail),
176 _ => bail!("invalid decision '{s}': expected pass, review, or fail"),
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use libverify_core::control::{ControlStatus, builtin};
184
185 fn make_finding(control_id: &str, status: ControlStatus) -> ControlFinding {
186 let id = builtin::id(control_id);
187 match status {
188 ControlStatus::Satisfied => {
189 ControlFinding::satisfied(id, "test rationale", vec!["subject".into()])
190 }
191 ControlStatus::Violated => {
192 ControlFinding::violated(id, "test rationale", vec!["subject".into()])
193 }
194 ControlStatus::Indeterminate => {
195 ControlFinding::indeterminate(id, "test rationale", vec!["subject".into()], vec![])
196 }
197 ControlStatus::NotApplicable => ControlFinding::not_applicable(id, "test rationale"),
198 }
199 }
200
201 #[test]
202 fn default_policy_loads() {
203 assert!(OpaProfile::default_policy().is_ok());
204 }
205
206 #[test]
207 fn all_presets_load() {
208 assert!(OpaProfile::from_preset_or_file("default").is_ok());
209 assert!(OpaProfile::from_preset_or_file("oss").is_ok());
210 assert!(OpaProfile::from_preset_or_file("aiops").is_ok());
211 assert!(OpaProfile::from_preset_or_file("soc1").is_ok());
212 assert!(OpaProfile::from_preset_or_file("soc2").is_ok());
213 assert!(OpaProfile::from_preset_or_file("slsa-l1").is_ok());
214 assert!(OpaProfile::from_preset_or_file("slsa-l2").is_ok());
215 assert!(OpaProfile::from_preset_or_file("slsa-l3").is_ok());
216 assert!(OpaProfile::from_preset_or_file("slsa-l4").is_ok());
217 }
218
219 #[test]
220 fn default_policy_violated_fails() {
221 let profile = OpaProfile::default_policy().unwrap();
222 let finding = make_finding(builtin::REVIEW_INDEPENDENCE, ControlStatus::Violated);
223 let outcome = profile.map(&finding);
224 assert_eq!(outcome.decision, GateDecision::Fail);
225 }
226
227 #[test]
228 fn default_policy_satisfied_passes() {
229 let profile = OpaProfile::default_policy().unwrap();
230 let finding = make_finding(builtin::REVIEW_INDEPENDENCE, ControlStatus::Satisfied);
231 let outcome = profile.map(&finding);
232 assert_eq!(outcome.decision, GateDecision::Pass);
233 }
234
235 #[test]
236 fn oss_preset_source_authenticity_violated_is_review() {
237 let profile = OpaProfile::oss_preset().unwrap();
238 let finding = make_finding(builtin::SOURCE_AUTHENTICITY, ControlStatus::Violated);
239 let outcome = profile.map(&finding);
240 assert_eq!(outcome.decision, GateDecision::Review);
241 }
242
243 #[test]
244 fn soc1_preset_returns_soc1_severity_labels() {
245 let profile = OpaProfile::soc1_preset().unwrap();
246 let labels = profile.severity_labels();
247 assert_eq!(labels.error, "material_weakness");
248 }
249
250 #[test]
251 fn slsa_l1_required_indeterminate_fails() {
252 let profile = OpaProfile::slsa_l1_preset().unwrap();
253 let finding = make_finding(builtin::REVIEW_INDEPENDENCE, ControlStatus::Indeterminate);
254 let outcome = profile.map(&finding);
255 assert_eq!(outcome.decision, GateDecision::Fail);
256 }
257
258 #[test]
259 fn slsa_l1_optional_indeterminate_reviews() {
260 let profile = OpaProfile::slsa_l1_preset().unwrap();
261 let finding = make_finding(
263 builtin::BRANCH_HISTORY_INTEGRITY,
264 ControlStatus::Indeterminate,
265 );
266 let outcome = profile.map(&finding);
267 assert_eq!(outcome.decision, GateDecision::Review);
268 }
269
270 #[test]
271 fn slsa_l2_branch_history_required() {
272 let profile = OpaProfile::slsa_l2_preset().unwrap();
273 let finding = make_finding(
274 builtin::BRANCH_HISTORY_INTEGRITY,
275 ControlStatus::Indeterminate,
276 );
277 let outcome = profile.map(&finding);
278 assert_eq!(outcome.decision, GateDecision::Fail);
279 }
280
281 #[test]
282 fn slsa_l1_non_slsa_control_indeterminate_reviews() {
283 let profile = OpaProfile::slsa_l1_preset().unwrap();
284 let finding = make_finding(builtin::CHANGE_REQUEST_SIZE, ControlStatus::Indeterminate);
285 let outcome = profile.map(&finding);
286 assert_eq!(outcome.decision, GateDecision::Review);
287 }
288
289 #[test]
290 fn slsa_l1_dependency_signature_required() {
291 let profile = OpaProfile::slsa_l1_preset().unwrap();
292 let finding = make_finding(builtin::DEPENDENCY_SIGNATURE, ControlStatus::Indeterminate);
293 let outcome = profile.map(&finding);
294 assert_eq!(outcome.decision, GateDecision::Fail);
295 }
296
297 #[test]
298 fn slsa_l2_dependency_provenance_required() {
299 let profile = OpaProfile::slsa_l2_preset().unwrap();
300 let finding = make_finding(
301 builtin::DEPENDENCY_PROVENANCE_CHECK,
302 ControlStatus::Indeterminate,
303 );
304 let outcome = profile.map(&finding);
305 assert_eq!(outcome.decision, GateDecision::Fail);
306 }
307
308 #[test]
309 fn slsa_l3_dependency_signer_verified_required() {
310 let profile = OpaProfile::slsa_l3_preset().unwrap();
311 let finding = make_finding(
312 builtin::DEPENDENCY_SIGNER_VERIFIED,
313 ControlStatus::Indeterminate,
314 );
315 let outcome = profile.map(&finding);
316 assert_eq!(outcome.decision, GateDecision::Fail);
317 }
318
319 #[test]
320 fn slsa_l4_dependency_completeness_required() {
321 let profile = OpaProfile::slsa_l4_preset().unwrap();
322 let finding = make_finding(
323 builtin::DEPENDENCY_COMPLETENESS,
324 ControlStatus::Indeterminate,
325 );
326 let outcome = profile.map(&finding);
327 assert_eq!(outcome.decision, GateDecision::Fail);
328 }
329
330 #[test]
331 fn slsa_l1_dependency_provenance_optional() {
332 let profile = OpaProfile::slsa_l1_preset().unwrap();
333 let finding = make_finding(
335 builtin::DEPENDENCY_PROVENANCE_CHECK,
336 ControlStatus::Indeterminate,
337 );
338 let outcome = profile.map(&finding);
339 assert_eq!(outcome.decision, GateDecision::Review);
340 }
341
342 #[test]
343 fn soc1_change_request_size_advisory() {
344 let profile = OpaProfile::soc1_preset().unwrap();
345 let finding = make_finding(builtin::CHANGE_REQUEST_SIZE, ControlStatus::Violated);
346 let outcome = profile.map(&finding);
347 assert_eq!(outcome.decision, GateDecision::Review);
348 }
349
350 #[test]
351 fn custom_policy_from_string() {
352 let custom_rego = r#"
353package verify.profile
354import rego.v1
355default map := {"severity": "error", "decision": "fail"}
356map := {"severity": "info", "decision": "pass"} if { input.status == "satisfied" }
357map := {"severity": "warning", "decision": "review"} if { input.status == "indeterminate" }
358"#;
359 let profile = OpaProfile::from_rego("custom.rego", custom_rego).unwrap();
360 let finding = make_finding(builtin::REVIEW_INDEPENDENCE, ControlStatus::Indeterminate);
361 let outcome = profile.map(&finding);
362 assert_eq!(outcome.decision, GateDecision::Review);
363 }
364}