Skip to main content

libverify_policy/
lib.rs

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
19/// OPA-based profile that evaluates Rego policies to map control findings
20/// to gate decisions, enabling per-organization customization.
21pub struct OpaProfile {
22    engine: regorus::Engine,
23    profile_name: String,
24}
25
26impl OpaProfile {
27    /// Loads a custom Rego policy from the given file path.
28    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    /// Loads a Rego policy from a string.
38    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    /// Loads a built-in preset by name, or falls back to file path.
79    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        // branch-history-integrity is L2, so optional in L1
262        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        // dependency-provenance is L2, so optional in L1
334        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}