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 AsRef<str> for ControlId {
33    fn as_ref(&self) -> &str {
34        &self.0
35    }
36}
37
38impl std::borrow::Borrow<str> for ControlId {
39    fn borrow(&self) -> &str {
40        &self.0
41    }
42}
43
44impl From<&str> for ControlId {
45    fn from(s: &str) -> Self {
46        Self(s.to_string())
47    }
48}
49
50impl From<String> for ControlId {
51    fn from(s: String) -> Self {
52        Self(s)
53    }
54}
55
56// --- Built-in control IDs (constants for compile-time safety) ---
57
58pub mod builtin {
59    use super::ControlId;
60
61    // Source Track
62    pub const SOURCE_AUTHENTICITY: &str = "source-authenticity";
63    pub const REVIEW_INDEPENDENCE: &str = "review-independence";
64    pub const BRANCH_HISTORY_INTEGRITY: &str = "branch-history-integrity";
65    pub const BRANCH_PROTECTION_ENFORCEMENT: &str = "branch-protection-enforcement";
66    pub const TWO_PARTY_REVIEW: &str = "two-party-review";
67
68    // Build Track
69    pub const BUILD_PROVENANCE: &str = "build-provenance";
70    pub const REQUIRED_STATUS_CHECKS: &str = "required-status-checks";
71    pub const HOSTED_BUILD_PLATFORM: &str = "hosted-build-platform";
72    pub const PROVENANCE_AUTHENTICITY: &str = "provenance-authenticity";
73    pub const BUILD_ISOLATION: &str = "build-isolation";
74
75    // Dependencies Track
76    pub const DEPENDENCY_SIGNATURE: &str = "dependency-signature";
77    pub const DEPENDENCY_PROVENANCE_CHECK: &str = "dependency-provenance";
78    pub const DEPENDENCY_SIGNER_VERIFIED: &str = "dependency-signer-verified";
79    pub const DEPENDENCY_COMPLETENESS: &str = "dependency-completeness";
80
81    // Compliance (platform-neutral naming)
82    pub const CHANGE_REQUEST_SIZE: &str = "change-request-size";
83    pub const TEST_COVERAGE: &str = "test-coverage";
84    pub const SCOPED_CHANGE: &str = "scoped-change";
85    pub const ISSUE_LINKAGE: &str = "issue-linkage";
86    pub const STALE_REVIEW: &str = "stale-review";
87    pub const DESCRIPTION_QUALITY: &str = "description-quality";
88    pub const MERGE_COMMIT_POLICY: &str = "merge-commit-policy";
89    pub const CONVENTIONAL_TITLE: &str = "conventional-title";
90    pub const SECURITY_FILE_CHANGE: &str = "security-file-change";
91    pub const RELEASE_TRACEABILITY: &str = "release-traceability";
92
93    // ASPM / Repository Posture
94    pub const CODEOWNERS_COVERAGE: &str = "codeowners-coverage";
95    pub const SECRET_SCANNING: &str = "secret-scanning";
96    pub const VULNERABILITY_SCANNING: &str = "vulnerability-scanning";
97    pub const SECURITY_POLICY: &str = "security-policy";
98
99    // Enterprise Posture
100    pub const CODE_SCANNING_ALERTS_RESOLVED: &str = "code-scanning-alerts-resolved";
101    pub const RELEASE_ASSET_ATTESTATION: &str = "release-asset-attestation";
102    pub const PRIVILEGED_WORKFLOW_DETECTION: &str = "privileged-workflow-detection";
103    pub const SECURITY_TEST_IN_CI: &str = "security-test-in-ci";
104
105    // AI-ops (agent execution verification)
106    pub const AGENT_SPEC_CONFORMANCE: &str = "agent-spec-conformance";
107    pub const PRIVILEGED_OPERATION_AUDIT: &str = "privileged-operation-audit";
108
109    /// All 34 built-in control IDs.
110    pub const ALL: &[&str] = &[
111        SOURCE_AUTHENTICITY,
112        REVIEW_INDEPENDENCE,
113        BRANCH_HISTORY_INTEGRITY,
114        BRANCH_PROTECTION_ENFORCEMENT,
115        TWO_PARTY_REVIEW,
116        BUILD_PROVENANCE,
117        REQUIRED_STATUS_CHECKS,
118        HOSTED_BUILD_PLATFORM,
119        PROVENANCE_AUTHENTICITY,
120        BUILD_ISOLATION,
121        DEPENDENCY_SIGNATURE,
122        DEPENDENCY_PROVENANCE_CHECK,
123        DEPENDENCY_SIGNER_VERIFIED,
124        DEPENDENCY_COMPLETENESS,
125        CHANGE_REQUEST_SIZE,
126        TEST_COVERAGE,
127        SCOPED_CHANGE,
128        ISSUE_LINKAGE,
129        STALE_REVIEW,
130        DESCRIPTION_QUALITY,
131        MERGE_COMMIT_POLICY,
132        CONVENTIONAL_TITLE,
133        SECURITY_FILE_CHANGE,
134        RELEASE_TRACEABILITY,
135        CODEOWNERS_COVERAGE,
136        SECRET_SCANNING,
137        VULNERABILITY_SCANNING,
138        SECURITY_POLICY,
139        CODE_SCANNING_ALERTS_RESOLVED,
140        RELEASE_ASSET_ATTESTATION,
141        PRIVILEGED_WORKFLOW_DETECTION,
142        SECURITY_TEST_IN_CI,
143        AGENT_SPEC_CONFORMANCE,
144        PRIVILEGED_OPERATION_AUDIT,
145    ];
146
147    /// Returns a ControlId for a built-in constant.
148    pub fn id(s: &str) -> ControlId {
149        ControlId::new(s)
150    }
151}
152
153/// Outcome of evaluating a single control against evidence.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ControlStatus {
157    Satisfied,
158    Violated,
159    Indeterminate,
160    NotApplicable,
161}
162
163impl ControlStatus {
164    pub fn as_str(&self) -> &'static str {
165        match self {
166            Self::Satisfied => "satisfied",
167            Self::Violated => "violated",
168            Self::Indeterminate => "indeterminate",
169            Self::NotApplicable => "not_applicable",
170        }
171    }
172}
173
174impl fmt::Display for ControlStatus {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        f.write_str(self.as_str())
177    }
178}
179
180/// Result of a single control evaluation.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct ControlFinding {
183    pub control_id: ControlId,
184    pub status: ControlStatus,
185    pub rationale: String,
186    pub subjects: Vec<String>,
187    pub evidence_gaps: Vec<EvidenceGap>,
188}
189
190impl ControlFinding {
191    pub fn satisfied(
192        control_id: ControlId,
193        rationale: impl Into<String>,
194        subjects: Vec<String>,
195    ) -> Self {
196        Self {
197            control_id,
198            status: ControlStatus::Satisfied,
199            rationale: rationale.into(),
200            subjects,
201            evidence_gaps: Vec::new(),
202        }
203    }
204
205    pub fn violated(
206        control_id: ControlId,
207        rationale: impl Into<String>,
208        subjects: Vec<String>,
209    ) -> Self {
210        Self {
211            control_id,
212            status: ControlStatus::Violated,
213            rationale: rationale.into(),
214            subjects,
215            evidence_gaps: Vec::new(),
216        }
217    }
218
219    pub fn indeterminate(
220        control_id: ControlId,
221        rationale: impl Into<String>,
222        subjects: Vec<String>,
223        evidence_gaps: Vec<EvidenceGap>,
224    ) -> Self {
225        Self {
226            control_id,
227            status: ControlStatus::Indeterminate,
228            rationale: rationale.into(),
229            subjects,
230            evidence_gaps,
231        }
232    }
233
234    pub fn not_applicable(control_id: ControlId, rationale: impl Into<String>) -> Self {
235        Self {
236            control_id,
237            status: ControlStatus::NotApplicable,
238            rationale: rationale.into(),
239            subjects: Vec::new(),
240            evidence_gaps: Vec::new(),
241        }
242    }
243
244    /// Extracts `RepositoryPosture` from evidence, returning appropriate
245    /// `Indeterminate` or `NotApplicable` findings for non-complete states.
246    ///
247    /// Use in posture controls to eliminate repeated `match` boilerplate:
248    /// ```ignore
249    /// let posture = match ControlFinding::extract_posture(self.id(), evidence) {
250    ///     Ok(p) => p,
251    ///     Err(findings) => return findings,
252    /// };
253    /// ```
254    pub fn extract_posture(
255        id: ControlId,
256        evidence: &EvidenceBundle,
257    ) -> Result<&RepositoryPosture, Vec<ControlFinding>> {
258        match &evidence.repository_posture {
259            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => Ok(value),
260            EvidenceState::Missing { gaps } => Err(vec![ControlFinding::indeterminate(
261                id,
262                "Repository posture evidence could not be collected",
263                vec![],
264                gaps.clone(),
265            )]),
266            EvidenceState::NotApplicable => Err(vec![ControlFinding::not_applicable(
267                id,
268                "Repository posture not applicable",
269            )]),
270        }
271    }
272}
273
274/// A verifiable SDLC control that produces findings from evidence.
275pub trait Control: Send + Sync {
276    /// Returns the unique identifier for this control.
277    fn id(&self) -> ControlId;
278
279    /// Human-readable description for SARIF rule output.
280    fn description(&self) -> &'static str {
281        "Custom control"
282    }
283
284    /// SOC2 Trust Services Criteria this control maps to (e.g., &["CC6.1", "CC8.1"]).
285    /// Returns empty slice for controls not mapped to SOC2.
286    fn tsc_criteria(&self) -> &'static [&'static str] {
287        builtin_tsc_mapping(self.id().as_str())
288    }
289
290    /// Actionable remediation hint shown when the control fails or needs review.
291    fn remediation_hint(&self) -> Option<&'static str> {
292        builtin_remediation_hint(self.id().as_str())
293    }
294
295    /// Evaluates the evidence bundle and returns one finding per subject.
296    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding>;
297}
298
299/// Returns an actionable remediation hint for a built-in control ID.
300pub fn builtin_remediation_hint(id: &str) -> Option<&'static str> {
301    match id {
302        builtin::SOURCE_AUTHENTICITY => Some("Sign commits: git config commit.gpgsign true"),
303        builtin::REVIEW_INDEPENDENCE => {
304            Some("Ensure PRs are reviewed by someone other than the author")
305        }
306        builtin::BRANCH_HISTORY_INTEGRITY => {
307            Some("Use linear history (rebase/squash, avoid merge commits)")
308        }
309        builtin::BRANCH_PROTECTION_ENFORCEMENT => {
310            Some("Enable branch protection rules at Settings > Branches")
311        }
312        builtin::TWO_PARTY_REVIEW => {
313            Some("Require at least 2 reviewers in branch protection rules")
314        }
315        builtin::REQUIRED_STATUS_CHECKS => {
316            Some("Add required status checks in branch protection rules")
317        }
318        builtin::BUILD_PROVENANCE => {
319            Some("Generate SLSA provenance with slsa-framework/slsa-github-generator")
320        }
321        builtin::HOSTED_BUILD_PLATFORM => Some("Use GitHub-hosted runners instead of self-hosted"),
322        builtin::PROVENANCE_AUTHENTICITY => {
323            Some("Verify build provenance signatures with cosign/slsa-verifier")
324        }
325        builtin::BUILD_ISOLATION => Some("Ensure builds run in ephemeral, isolated environments"),
326        builtin::DEPENDENCY_SIGNATURE => {
327            Some("Use signed dependencies; verify with cosign or sigstore")
328        }
329        builtin::DEPENDENCY_PROVENANCE_CHECK => {
330            Some("Ensure dependencies publish SLSA provenance attestations")
331        }
332        builtin::DEPENDENCY_SIGNER_VERIFIED => {
333            Some("Verify dependency signers against a trusted list")
334        }
335        builtin::DEPENDENCY_COMPLETENESS => {
336            Some("Ensure all transitive dependencies have provenance")
337        }
338        builtin::CHANGE_REQUEST_SIZE => Some(
339            "Keep PRs small and focused; split large changes. Monorepo cross-package PRs may false-positive here -- use --exclude change-request-size",
340        ),
341        builtin::TEST_COVERAGE => Some(
342            "Add or update tests for changed source files. Dependency-only PRs may false-positive here -- use --exclude test-coverage",
343        ),
344        builtin::SCOPED_CHANGE => Some(
345            "Limit PR to a single logical change; split unrelated changes. In monorepos, features spanning multiple packages are expected -- use --exclude scoped-change",
346        ),
347        builtin::ISSUE_LINKAGE => Some(
348            "Reference an issue in the PR body: Fixes #123 or Closes #456. Bot PRs (Dependabot/Renovate) don't link issues -- use --exclude issue-linkage",
349        ),
350        builtin::DESCRIPTION_QUALITY => {
351            Some("Add a meaningful PR description explaining the change")
352        }
353        builtin::MERGE_COMMIT_POLICY => {
354            Some("Use squash or rebase merge strategy instead of merge commits")
355        }
356        builtin::CONVENTIONAL_TITLE => Some(
357            "Use Conventional Commits format: type(scope): description. Bot PRs use their own title format -- use --exclude conventional-title",
358        ),
359        builtin::STALE_REVIEW => Some("Re-request review if changes were pushed after approval"),
360        builtin::SECURITY_FILE_CHANGE => {
361            Some("Security-sensitive file changes require additional review")
362        }
363        builtin::RELEASE_TRACEABILITY => Some("Link release to merged PRs and resolved issues"),
364        builtin::CODEOWNERS_COVERAGE => Some("Add a CODEOWNERS file to define code ownership"),
365        builtin::SECRET_SCANNING => {
366            Some("Enable secret scanning at Settings > Code security and analysis")
367        }
368        builtin::VULNERABILITY_SCANNING => {
369            Some("Enable Dependabot alerts at Settings > Code security and analysis")
370        }
371        builtin::SECURITY_POLICY => {
372            Some("Add a SECURITY.md file with vulnerability reporting instructions")
373        }
374        builtin::CODE_SCANNING_ALERTS_RESOLVED => {
375            Some("Resolve open code scanning alerts at Security > Code scanning alerts")
376        }
377        builtin::RELEASE_ASSET_ATTESTATION => {
378            Some("Attest release assets with gh attestation or sigstore/cosign")
379        }
380        builtin::PRIVILEGED_WORKFLOW_DETECTION => {
381            Some("Avoid pull_request_target with checkout of PR code in workflows")
382        }
383        builtin::SECURITY_TEST_IN_CI => {
384            Some("Add CodeQL or Semgrep to GitHub Actions: github/codeql-action/analyze")
385        }
386        builtin::AGENT_SPEC_CONFORMANCE => Some(
387            "Define allowed_paths, forbidden_paths, and budget in agent spec to constrain agent scope",
388        ),
389        builtin::PRIVILEGED_OPERATION_AUDIT => Some(
390            "Review privileged git operations (force push, admin bypass, tag deletion) and restrict agent permissions",
391        ),
392        _ => None,
393    }
394}
395
396/// Returns SOC2 Trust Services Criteria for a built-in control ID.
397pub fn builtin_tsc_mapping(id: &str) -> &'static [&'static str] {
398    match id {
399        // CC6: Logical and Physical Access Controls
400        builtin::SOURCE_AUTHENTICITY => &["CC6.1"],
401        builtin::BRANCH_PROTECTION_ENFORCEMENT => &["CC6.1", "CC8.1"],
402        builtin::CODEOWNERS_COVERAGE => &["CC6.1"],
403        builtin::SECRET_SCANNING => &["CC6.1", "CC6.6"],
404        // CC7: System Operations
405        builtin::ISSUE_LINKAGE => &["CC7.2"],
406        builtin::STALE_REVIEW => &["CC7.2"],
407        builtin::SECURITY_FILE_CHANGE => &["CC7.2"],
408        builtin::RELEASE_TRACEABILITY => &["CC7.2"],
409        builtin::REQUIRED_STATUS_CHECKS => &["CC7.1"],
410        builtin::VULNERABILITY_SCANNING => &["CC7.1"],
411        builtin::SECURITY_POLICY => &["CC7.3", "CC7.4"],
412        // CC8: Change Management
413        builtin::REVIEW_INDEPENDENCE => &["CC8.1"],
414        builtin::TWO_PARTY_REVIEW => &["CC8.1"],
415        builtin::CHANGE_REQUEST_SIZE => &["CC8.1"],
416        builtin::TEST_COVERAGE => &["CC8.1"],
417        builtin::SCOPED_CHANGE => &["CC8.1"],
418        builtin::DESCRIPTION_QUALITY => &["CC8.1"],
419        builtin::MERGE_COMMIT_POLICY => &["CC8.1"],
420        builtin::CONVENTIONAL_TITLE => &["CC8.1"],
421        builtin::BRANCH_HISTORY_INTEGRITY => &["CC8.1"],
422        // PI: Processing Integrity
423        builtin::BUILD_PROVENANCE => &["PI1.4"],
424        builtin::HOSTED_BUILD_PLATFORM => &["PI1.4"],
425        builtin::PROVENANCE_AUTHENTICITY => &["PI1.4"],
426        builtin::BUILD_ISOLATION => &["PI1.4"],
427        // Dependencies (CC7.1 + PI)
428        builtin::DEPENDENCY_SIGNATURE => &["CC7.1", "PI1.4"],
429        builtin::DEPENDENCY_PROVENANCE_CHECK => &["CC7.1", "PI1.4"],
430        builtin::DEPENDENCY_SIGNER_VERIFIED => &["CC7.1", "PI1.4"],
431        builtin::DEPENDENCY_COMPLETENESS => &["CC7.1", "PI1.4"],
432        // Enterprise Posture
433        builtin::CODE_SCANNING_ALERTS_RESOLVED => &["CC7.1"],
434        builtin::RELEASE_ASSET_ATTESTATION => &["PI1.4"],
435        builtin::PRIVILEGED_WORKFLOW_DETECTION => &["CC6.1", "CC8.1"],
436        // AI-ops (agent execution verification)
437        builtin::AGENT_SPEC_CONFORMANCE => &["CC6.1", "CC8.1"],
438        builtin::PRIVILEGED_OPERATION_AUDIT => &["CC6.1", "CC7.2", "CC8.1"],
439        _ => &[],
440    }
441}
442
443/// Runs every control against the evidence bundle and collects all findings.
444pub fn evaluate_all(
445    controls: &[Box<dyn Control>],
446    evidence: &EvidenceBundle,
447) -> Vec<ControlFinding> {
448    let mut findings = Vec::new();
449    for control in controls {
450        findings.extend(control.evaluate(evidence));
451    }
452    findings
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn control_id_display() {
461        let id = ControlId::new("review-independence");
462        assert_eq!(id.to_string(), "review-independence");
463        assert_eq!(id.as_str(), "review-independence");
464    }
465
466    #[test]
467    fn control_id_from_str() {
468        let id: ControlId = "source-authenticity".into();
469        assert_eq!(id.as_str(), "source-authenticity");
470    }
471
472    #[test]
473    fn all_builtins_have_remediation_hints() {
474        for id in builtin::ALL {
475            assert!(
476                builtin_remediation_hint(id).is_some(),
477                "missing remediation hint for built-in control: {id}"
478            );
479        }
480    }
481
482    #[test]
483    fn builtin_ids_are_unique() {
484        let mut seen = std::collections::HashSet::new();
485        for id in builtin::ALL {
486            assert!(seen.insert(id), "duplicate built-in ID: {id}");
487        }
488    }
489}