Skip to main content

libverify_core/
control.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::evidence::{EvidenceBundle, EvidenceGap, EvidenceState, RepositoryPosture};
6
7/// A string-based control identifier, enabling open extensibility.
8///
9/// Built-in controls use kebab-case IDs (e.g. "review-independence").
10/// Platform-specific verifiers can register controls with their own IDs
11/// (e.g. "jira-linkage", "bitbucket-pipeline-status").
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(transparent)]
14pub struct ControlId(String);
15
16impl ControlId {
17    pub fn new(id: impl Into<String>) -> Self {
18        Self(id.into())
19    }
20
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24}
25
26impl fmt::Display for ControlId {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        f.write_str(&self.0)
29    }
30}
31
32impl From<&str> for ControlId {
33    fn from(s: &str) -> Self {
34        Self(s.to_string())
35    }
36}
37
38impl From<String> for ControlId {
39    fn from(s: String) -> Self {
40        Self(s)
41    }
42}
43
44// --- Built-in control IDs (constants for compile-time safety) ---
45
46pub mod builtin {
47    use super::ControlId;
48
49    // Source Track
50    pub const SOURCE_AUTHENTICITY: &str = "source-authenticity";
51    pub const REVIEW_INDEPENDENCE: &str = "review-independence";
52    pub const BRANCH_HISTORY_INTEGRITY: &str = "branch-history-integrity";
53    pub const BRANCH_PROTECTION_ENFORCEMENT: &str = "branch-protection-enforcement";
54    pub const TWO_PARTY_REVIEW: &str = "two-party-review";
55
56    // Build Track
57    pub const BUILD_PROVENANCE: &str = "build-provenance";
58    pub const REQUIRED_STATUS_CHECKS: &str = "required-status-checks";
59    pub const HOSTED_BUILD_PLATFORM: &str = "hosted-build-platform";
60    pub const PROVENANCE_AUTHENTICITY: &str = "provenance-authenticity";
61    pub const BUILD_ISOLATION: &str = "build-isolation";
62
63    // Dependencies Track
64    pub const DEPENDENCY_SIGNATURE: &str = "dependency-signature";
65    pub const DEPENDENCY_PROVENANCE_CHECK: &str = "dependency-provenance";
66    pub const DEPENDENCY_SIGNER_VERIFIED: &str = "dependency-signer-verified";
67    pub const DEPENDENCY_COMPLETENESS: &str = "dependency-completeness";
68
69    // Compliance (platform-neutral naming)
70    pub const CHANGE_REQUEST_SIZE: &str = "change-request-size";
71    pub const TEST_COVERAGE: &str = "test-coverage";
72    pub const SCOPED_CHANGE: &str = "scoped-change";
73    pub const ISSUE_LINKAGE: &str = "issue-linkage";
74    pub const STALE_REVIEW: &str = "stale-review";
75    pub const DESCRIPTION_QUALITY: &str = "description-quality";
76    pub const MERGE_COMMIT_POLICY: &str = "merge-commit-policy";
77    pub const CONVENTIONAL_TITLE: &str = "conventional-title";
78    pub const SECURITY_FILE_CHANGE: &str = "security-file-change";
79    pub const RELEASE_TRACEABILITY: &str = "release-traceability";
80
81    // ASPM / Repository Posture
82    pub const CODEOWNERS_COVERAGE: &str = "codeowners-coverage";
83    pub const SECRET_SCANNING: &str = "secret-scanning";
84    pub const VULNERABILITY_SCANNING: &str = "vulnerability-scanning";
85    pub const SECURITY_POLICY: &str = "security-policy";
86
87    /// All 28 built-in control IDs.
88    pub const ALL: &[&str] = &[
89        SOURCE_AUTHENTICITY,
90        REVIEW_INDEPENDENCE,
91        BRANCH_HISTORY_INTEGRITY,
92        BRANCH_PROTECTION_ENFORCEMENT,
93        TWO_PARTY_REVIEW,
94        BUILD_PROVENANCE,
95        REQUIRED_STATUS_CHECKS,
96        HOSTED_BUILD_PLATFORM,
97        PROVENANCE_AUTHENTICITY,
98        BUILD_ISOLATION,
99        DEPENDENCY_SIGNATURE,
100        DEPENDENCY_PROVENANCE_CHECK,
101        DEPENDENCY_SIGNER_VERIFIED,
102        DEPENDENCY_COMPLETENESS,
103        CHANGE_REQUEST_SIZE,
104        TEST_COVERAGE,
105        SCOPED_CHANGE,
106        ISSUE_LINKAGE,
107        STALE_REVIEW,
108        DESCRIPTION_QUALITY,
109        MERGE_COMMIT_POLICY,
110        CONVENTIONAL_TITLE,
111        SECURITY_FILE_CHANGE,
112        RELEASE_TRACEABILITY,
113        CODEOWNERS_COVERAGE,
114        SECRET_SCANNING,
115        VULNERABILITY_SCANNING,
116        SECURITY_POLICY,
117    ];
118
119    /// Returns a ControlId for a built-in constant.
120    pub fn id(s: &str) -> ControlId {
121        ControlId::new(s)
122    }
123}
124
125/// Outcome of evaluating a single control against evidence.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum ControlStatus {
129    Satisfied,
130    Violated,
131    Indeterminate,
132    NotApplicable,
133}
134
135impl ControlStatus {
136    pub fn as_str(&self) -> &'static str {
137        match self {
138            Self::Satisfied => "satisfied",
139            Self::Violated => "violated",
140            Self::Indeterminate => "indeterminate",
141            Self::NotApplicable => "not_applicable",
142        }
143    }
144}
145
146impl fmt::Display for ControlStatus {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        f.write_str(self.as_str())
149    }
150}
151
152/// Result of a single control evaluation.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct ControlFinding {
155    pub control_id: ControlId,
156    pub status: ControlStatus,
157    pub rationale: String,
158    pub subjects: Vec<String>,
159    pub evidence_gaps: Vec<EvidenceGap>,
160}
161
162impl ControlFinding {
163    pub fn satisfied(
164        control_id: ControlId,
165        rationale: impl Into<String>,
166        subjects: Vec<String>,
167    ) -> Self {
168        Self {
169            control_id,
170            status: ControlStatus::Satisfied,
171            rationale: rationale.into(),
172            subjects,
173            evidence_gaps: Vec::new(),
174        }
175    }
176
177    pub fn violated(
178        control_id: ControlId,
179        rationale: impl Into<String>,
180        subjects: Vec<String>,
181    ) -> Self {
182        Self {
183            control_id,
184            status: ControlStatus::Violated,
185            rationale: rationale.into(),
186            subjects,
187            evidence_gaps: Vec::new(),
188        }
189    }
190
191    pub fn indeterminate(
192        control_id: ControlId,
193        rationale: impl Into<String>,
194        subjects: Vec<String>,
195        evidence_gaps: Vec<EvidenceGap>,
196    ) -> Self {
197        Self {
198            control_id,
199            status: ControlStatus::Indeterminate,
200            rationale: rationale.into(),
201            subjects,
202            evidence_gaps,
203        }
204    }
205
206    pub fn not_applicable(control_id: ControlId, rationale: impl Into<String>) -> Self {
207        Self {
208            control_id,
209            status: ControlStatus::NotApplicable,
210            rationale: rationale.into(),
211            subjects: Vec::new(),
212            evidence_gaps: Vec::new(),
213        }
214    }
215
216    /// Extracts `RepositoryPosture` from evidence, returning appropriate
217    /// `Indeterminate` or `NotApplicable` findings for non-complete states.
218    ///
219    /// Use in posture controls to eliminate repeated `match` boilerplate:
220    /// ```ignore
221    /// let posture = match ControlFinding::extract_posture(self.id(), evidence) {
222    ///     Ok(p) => p,
223    ///     Err(findings) => return findings,
224    /// };
225    /// ```
226    pub fn extract_posture(
227        id: ControlId,
228        evidence: &EvidenceBundle,
229    ) -> Result<&RepositoryPosture, Vec<ControlFinding>> {
230        match &evidence.repository_posture {
231            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => Ok(value),
232            EvidenceState::Missing { gaps } => Err(vec![ControlFinding::indeterminate(
233                id,
234                "Repository posture evidence could not be collected",
235                vec![],
236                gaps.clone(),
237            )]),
238            EvidenceState::NotApplicable => Err(vec![ControlFinding::not_applicable(
239                id,
240                "Repository posture not applicable",
241            )]),
242        }
243    }
244}
245
246/// A verifiable SDLC control that produces findings from evidence.
247pub trait Control: Send + Sync {
248    /// Returns the unique identifier for this control.
249    fn id(&self) -> ControlId;
250
251    /// Human-readable description for SARIF rule output.
252    fn description(&self) -> &'static str {
253        "Custom control"
254    }
255
256    /// SOC2 Trust Services Criteria this control maps to (e.g., &["CC6.1", "CC8.1"]).
257    /// Returns empty slice for controls not mapped to SOC2.
258    fn tsc_criteria(&self) -> &'static [&'static str] {
259        builtin_tsc_mapping(self.id().as_str())
260    }
261
262    /// Evaluates the evidence bundle and returns one finding per subject.
263    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding>;
264}
265
266/// Returns SOC2 Trust Services Criteria for a built-in control ID.
267pub fn builtin_tsc_mapping(id: &str) -> &'static [&'static str] {
268    match id {
269        // CC6: Logical and Physical Access Controls
270        builtin::SOURCE_AUTHENTICITY => &["CC6.1"],
271        builtin::BRANCH_PROTECTION_ENFORCEMENT => &["CC6.1", "CC8.1"],
272        builtin::CODEOWNERS_COVERAGE => &["CC6.1"],
273        builtin::SECRET_SCANNING => &["CC6.1", "CC6.6"],
274        // CC7: System Operations
275        builtin::ISSUE_LINKAGE => &["CC7.2"],
276        builtin::STALE_REVIEW => &["CC7.2"],
277        builtin::SECURITY_FILE_CHANGE => &["CC7.2"],
278        builtin::RELEASE_TRACEABILITY => &["CC7.2"],
279        builtin::REQUIRED_STATUS_CHECKS => &["CC7.1"],
280        builtin::VULNERABILITY_SCANNING => &["CC7.1"],
281        builtin::SECURITY_POLICY => &["CC7.3", "CC7.4"],
282        // CC8: Change Management
283        builtin::REVIEW_INDEPENDENCE => &["CC8.1"],
284        builtin::TWO_PARTY_REVIEW => &["CC8.1"],
285        builtin::CHANGE_REQUEST_SIZE => &["CC8.1"],
286        builtin::TEST_COVERAGE => &["CC8.1"],
287        builtin::SCOPED_CHANGE => &["CC8.1"],
288        builtin::DESCRIPTION_QUALITY => &["CC8.1"],
289        builtin::MERGE_COMMIT_POLICY => &["CC8.1"],
290        builtin::CONVENTIONAL_TITLE => &["CC8.1"],
291        builtin::BRANCH_HISTORY_INTEGRITY => &["CC8.1"],
292        // PI: Processing Integrity
293        builtin::BUILD_PROVENANCE => &["PI1.4"],
294        builtin::HOSTED_BUILD_PLATFORM => &["PI1.4"],
295        builtin::PROVENANCE_AUTHENTICITY => &["PI1.4"],
296        builtin::BUILD_ISOLATION => &["PI1.4"],
297        // Dependencies (CC7.1 + PI)
298        builtin::DEPENDENCY_SIGNATURE => &["CC7.1", "PI1.4"],
299        builtin::DEPENDENCY_PROVENANCE_CHECK => &["CC7.1", "PI1.4"],
300        builtin::DEPENDENCY_SIGNER_VERIFIED => &["CC7.1", "PI1.4"],
301        builtin::DEPENDENCY_COMPLETENESS => &["CC7.1", "PI1.4"],
302        _ => &[],
303    }
304}
305
306/// Runs every control against the evidence bundle and collects all findings.
307pub fn evaluate_all(
308    controls: &[Box<dyn Control>],
309    evidence: &EvidenceBundle,
310) -> Vec<ControlFinding> {
311    let mut findings = Vec::new();
312    for control in controls {
313        findings.extend(control.evaluate(evidence));
314    }
315    findings
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn control_id_display() {
324        let id = ControlId::new("review-independence");
325        assert_eq!(id.to_string(), "review-independence");
326        assert_eq!(id.as_str(), "review-independence");
327    }
328
329    #[test]
330    fn control_id_from_str() {
331        let id: ControlId = "source-authenticity".into();
332        assert_eq!(id.as_str(), "source-authenticity");
333    }
334
335    #[test]
336    fn builtin_ids_are_unique() {
337        let mut seen = std::collections::HashSet::new();
338        for id in builtin::ALL {
339            assert!(seen.insert(id), "duplicate built-in ID: {id}");
340        }
341    }
342}