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 SECRET_SCANNING_PUSH_PROTECTION: &str = "secret-scanning-push-protection";
101    pub const BRANCH_PROTECTION_ADMIN_ENFORCEMENT: &str = "branch-protection-admin-enforcement";
102    pub const DISMISS_STALE_REVIEWS_ON_PUSH: &str = "dismiss-stale-reviews-on-push";
103    pub const ACTIONS_PINNED_DEPENDENCIES: &str = "actions-pinned-dependencies";
104    pub const ENVIRONMENT_PROTECTION_RULES: &str = "environment-protection-rules";
105    pub const CODE_SCANNING_ALERTS_RESOLVED: &str = "code-scanning-alerts-resolved";
106    pub const DEPENDENCY_LICENSE_COMPLIANCE: &str = "dependency-license-compliance";
107    pub const SBOM_ATTESTATION: &str = "sbom-attestation";
108    pub const RELEASE_ASSET_ATTESTATION: &str = "release-asset-attestation";
109    pub const PRIVILEGED_WORKFLOW_DETECTION: &str = "privileged-workflow-detection";
110    pub const WORKFLOW_PERMISSIONS_RESTRICTED: &str = "workflow-permissions-restricted";
111    pub const DEPENDENCY_UPDATE_TOOL: &str = "dependency-update-tool";
112    pub const REPOSITORY_PERMISSIONS_AUDIT: &str = "repository-permissions-audit";
113    pub const DEFAULT_BRANCH_SETTINGS_BASELINE: &str = "default-branch-settings-baseline";
114    pub const SECURITY_TEST_IN_CI: &str = "security-test-in-ci";
115    pub const PROTECTED_TAGS: &str = "protected-tags";
116
117    /// All 44 built-in control IDs.
118    pub const ALL: &[&str] = &[
119        SOURCE_AUTHENTICITY,
120        REVIEW_INDEPENDENCE,
121        BRANCH_HISTORY_INTEGRITY,
122        BRANCH_PROTECTION_ENFORCEMENT,
123        TWO_PARTY_REVIEW,
124        BUILD_PROVENANCE,
125        REQUIRED_STATUS_CHECKS,
126        HOSTED_BUILD_PLATFORM,
127        PROVENANCE_AUTHENTICITY,
128        BUILD_ISOLATION,
129        DEPENDENCY_SIGNATURE,
130        DEPENDENCY_PROVENANCE_CHECK,
131        DEPENDENCY_SIGNER_VERIFIED,
132        DEPENDENCY_COMPLETENESS,
133        CHANGE_REQUEST_SIZE,
134        TEST_COVERAGE,
135        SCOPED_CHANGE,
136        ISSUE_LINKAGE,
137        STALE_REVIEW,
138        DESCRIPTION_QUALITY,
139        MERGE_COMMIT_POLICY,
140        CONVENTIONAL_TITLE,
141        SECURITY_FILE_CHANGE,
142        RELEASE_TRACEABILITY,
143        CODEOWNERS_COVERAGE,
144        SECRET_SCANNING,
145        VULNERABILITY_SCANNING,
146        SECURITY_POLICY,
147        SECRET_SCANNING_PUSH_PROTECTION,
148        BRANCH_PROTECTION_ADMIN_ENFORCEMENT,
149        DISMISS_STALE_REVIEWS_ON_PUSH,
150        ACTIONS_PINNED_DEPENDENCIES,
151        ENVIRONMENT_PROTECTION_RULES,
152        CODE_SCANNING_ALERTS_RESOLVED,
153        DEPENDENCY_LICENSE_COMPLIANCE,
154        SBOM_ATTESTATION,
155        RELEASE_ASSET_ATTESTATION,
156        PRIVILEGED_WORKFLOW_DETECTION,
157        WORKFLOW_PERMISSIONS_RESTRICTED,
158        DEPENDENCY_UPDATE_TOOL,
159        REPOSITORY_PERMISSIONS_AUDIT,
160        DEFAULT_BRANCH_SETTINGS_BASELINE,
161        SECURITY_TEST_IN_CI,
162        PROTECTED_TAGS,
163    ];
164
165    /// Returns a ControlId for a built-in constant.
166    pub fn id(s: &str) -> ControlId {
167        ControlId::new(s)
168    }
169}
170
171/// Outcome of evaluating a single control against evidence.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum ControlStatus {
175    Satisfied,
176    Violated,
177    Indeterminate,
178    NotApplicable,
179}
180
181impl ControlStatus {
182    pub fn as_str(&self) -> &'static str {
183        match self {
184            Self::Satisfied => "satisfied",
185            Self::Violated => "violated",
186            Self::Indeterminate => "indeterminate",
187            Self::NotApplicable => "not_applicable",
188        }
189    }
190}
191
192impl fmt::Display for ControlStatus {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        f.write_str(self.as_str())
195    }
196}
197
198/// Result of a single control evaluation.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct ControlFinding {
201    pub control_id: ControlId,
202    pub status: ControlStatus,
203    pub rationale: String,
204    pub subjects: Vec<String>,
205    pub evidence_gaps: Vec<EvidenceGap>,
206}
207
208impl ControlFinding {
209    pub fn satisfied(
210        control_id: ControlId,
211        rationale: impl Into<String>,
212        subjects: Vec<String>,
213    ) -> Self {
214        Self {
215            control_id,
216            status: ControlStatus::Satisfied,
217            rationale: rationale.into(),
218            subjects,
219            evidence_gaps: Vec::new(),
220        }
221    }
222
223    pub fn violated(
224        control_id: ControlId,
225        rationale: impl Into<String>,
226        subjects: Vec<String>,
227    ) -> Self {
228        Self {
229            control_id,
230            status: ControlStatus::Violated,
231            rationale: rationale.into(),
232            subjects,
233            evidence_gaps: Vec::new(),
234        }
235    }
236
237    pub fn indeterminate(
238        control_id: ControlId,
239        rationale: impl Into<String>,
240        subjects: Vec<String>,
241        evidence_gaps: Vec<EvidenceGap>,
242    ) -> Self {
243        Self {
244            control_id,
245            status: ControlStatus::Indeterminate,
246            rationale: rationale.into(),
247            subjects,
248            evidence_gaps,
249        }
250    }
251
252    pub fn not_applicable(control_id: ControlId, rationale: impl Into<String>) -> Self {
253        Self {
254            control_id,
255            status: ControlStatus::NotApplicable,
256            rationale: rationale.into(),
257            subjects: Vec::new(),
258            evidence_gaps: Vec::new(),
259        }
260    }
261
262    /// Extracts `RepositoryPosture` from evidence, returning appropriate
263    /// `Indeterminate` or `NotApplicable` findings for non-complete states.
264    ///
265    /// Use in posture controls to eliminate repeated `match` boilerplate:
266    /// ```ignore
267    /// let posture = match ControlFinding::extract_posture(self.id(), evidence) {
268    ///     Ok(p) => p,
269    ///     Err(findings) => return findings,
270    /// };
271    /// ```
272    pub fn extract_posture(
273        id: ControlId,
274        evidence: &EvidenceBundle,
275    ) -> Result<&RepositoryPosture, Vec<ControlFinding>> {
276        match &evidence.repository_posture {
277            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => Ok(value),
278            EvidenceState::Missing { gaps } => Err(vec![ControlFinding::indeterminate(
279                id,
280                "Repository posture evidence could not be collected",
281                vec![],
282                gaps.clone(),
283            )]),
284            EvidenceState::NotApplicable => Err(vec![ControlFinding::not_applicable(
285                id,
286                "Repository posture not applicable",
287            )]),
288        }
289    }
290}
291
292/// A verifiable SDLC control that produces findings from evidence.
293pub trait Control: Send + Sync {
294    /// Returns the unique identifier for this control.
295    fn id(&self) -> ControlId;
296
297    /// Human-readable description for SARIF rule output.
298    fn description(&self) -> &'static str {
299        "Custom control"
300    }
301
302    /// SOC2 Trust Services Criteria this control maps to (e.g., &["CC6.1", "CC8.1"]).
303    /// Returns empty slice for controls not mapped to SOC2.
304    fn tsc_criteria(&self) -> &'static [&'static str] {
305        builtin_tsc_mapping(self.id().as_str())
306    }
307
308    /// Actionable remediation hint shown when the control fails or needs review.
309    fn remediation_hint(&self) -> Option<&'static str> {
310        builtin_remediation_hint(self.id().as_str())
311    }
312
313    /// Evaluates the evidence bundle and returns one finding per subject.
314    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding>;
315}
316
317/// Returns an actionable remediation hint for a built-in control ID.
318pub fn builtin_remediation_hint(id: &str) -> Option<&'static str> {
319    match id {
320        builtin::SOURCE_AUTHENTICITY => Some("Sign commits: git config commit.gpgsign true"),
321        builtin::REVIEW_INDEPENDENCE => {
322            Some("Ensure PRs are reviewed by someone other than the author")
323        }
324        builtin::BRANCH_HISTORY_INTEGRITY => {
325            Some("Use linear history (rebase/squash, avoid merge commits)")
326        }
327        builtin::BRANCH_PROTECTION_ENFORCEMENT => {
328            Some("Enable branch protection rules at Settings > Branches")
329        }
330        builtin::TWO_PARTY_REVIEW => {
331            Some("Require at least 2 reviewers in branch protection rules")
332        }
333        builtin::REQUIRED_STATUS_CHECKS => {
334            Some("Add required status checks in branch protection rules")
335        }
336        builtin::BUILD_PROVENANCE => {
337            Some("Generate SLSA provenance with slsa-framework/slsa-github-generator")
338        }
339        builtin::HOSTED_BUILD_PLATFORM => Some("Use GitHub-hosted runners instead of self-hosted"),
340        builtin::PROVENANCE_AUTHENTICITY => {
341            Some("Verify build provenance signatures with cosign/slsa-verifier")
342        }
343        builtin::BUILD_ISOLATION => Some("Ensure builds run in ephemeral, isolated environments"),
344        builtin::DEPENDENCY_SIGNATURE => {
345            Some("Use signed dependencies; verify with cosign or sigstore")
346        }
347        builtin::DEPENDENCY_PROVENANCE_CHECK => {
348            Some("Ensure dependencies publish SLSA provenance attestations")
349        }
350        builtin::DEPENDENCY_SIGNER_VERIFIED => {
351            Some("Verify dependency signers against a trusted list")
352        }
353        builtin::DEPENDENCY_COMPLETENESS => {
354            Some("Ensure all transitive dependencies have provenance")
355        }
356        builtin::CHANGE_REQUEST_SIZE => Some(
357            "Keep PRs small and focused; split large changes. Monorepo cross-package PRs may false-positive here -- use --exclude change-request-size",
358        ),
359        builtin::TEST_COVERAGE => Some(
360            "Add or update tests for changed source files. Dependency-only PRs may false-positive here -- use --exclude test-coverage",
361        ),
362        builtin::SCOPED_CHANGE => Some(
363            "Limit PR to a single logical change; split unrelated changes. In monorepos, features spanning multiple packages are expected -- use --exclude scoped-change",
364        ),
365        builtin::ISSUE_LINKAGE => Some(
366            "Reference an issue in the PR body: Fixes #123 or Closes #456. Bot PRs (Dependabot/Renovate) don't link issues -- use --exclude issue-linkage",
367        ),
368        builtin::DESCRIPTION_QUALITY => {
369            Some("Add a meaningful PR description explaining the change")
370        }
371        builtin::MERGE_COMMIT_POLICY => {
372            Some("Use squash or rebase merge strategy instead of merge commits")
373        }
374        builtin::CONVENTIONAL_TITLE => Some(
375            "Use Conventional Commits format: type(scope): description. Bot PRs use their own title format -- use --exclude conventional-title",
376        ),
377        builtin::STALE_REVIEW => Some("Re-request review if changes were pushed after approval"),
378        builtin::SECURITY_FILE_CHANGE => {
379            Some("Security-sensitive file changes require additional review")
380        }
381        builtin::RELEASE_TRACEABILITY => Some("Link release to merged PRs and resolved issues"),
382        builtin::CODEOWNERS_COVERAGE => Some("Add a CODEOWNERS file to define code ownership"),
383        builtin::SECRET_SCANNING => {
384            Some("Enable secret scanning at Settings > Code security and analysis")
385        }
386        builtin::VULNERABILITY_SCANNING => {
387            Some("Enable Dependabot alerts at Settings > Code security and analysis")
388        }
389        builtin::SECURITY_POLICY => {
390            Some("Add a SECURITY.md file with vulnerability reporting instructions")
391        }
392        builtin::SECRET_SCANNING_PUSH_PROTECTION => {
393            Some("Enable push protection at Settings > Code security > Secret scanning")
394        }
395        builtin::BRANCH_PROTECTION_ADMIN_ENFORCEMENT => {
396            Some("Enable 'Include administrators' in branch protection rules")
397        }
398        builtin::DISMISS_STALE_REVIEWS_ON_PUSH => {
399            Some("Enable 'Dismiss stale pull request approvals when new commits are pushed'")
400        }
401        builtin::ACTIONS_PINNED_DEPENDENCIES => {
402            Some("Pin GitHub Actions to full commit SHAs instead of tags")
403        }
404        builtin::ENVIRONMENT_PROTECTION_RULES => {
405            Some("Configure environment protection rules at Settings > Environments")
406        }
407        builtin::CODE_SCANNING_ALERTS_RESOLVED => {
408            Some("Resolve open code scanning alerts at Security > Code scanning alerts")
409        }
410        builtin::DEPENDENCY_LICENSE_COMPLIANCE => {
411            Some("Review dependency licenses; remove or replace copyleft dependencies")
412        }
413        builtin::SBOM_ATTESTATION => {
414            Some("Generate SBOM with gh attestation or anchore/sbom-action in CI")
415        }
416        builtin::RELEASE_ASSET_ATTESTATION => {
417            Some("Attest release assets with gh attestation or sigstore/cosign")
418        }
419        builtin::PRIVILEGED_WORKFLOW_DETECTION => {
420            Some("Avoid pull_request_target with checkout of PR code in workflows")
421        }
422        builtin::WORKFLOW_PERMISSIONS_RESTRICTED => {
423            Some("Set default workflow permissions to 'Read' at Settings > Actions > General")
424        }
425        builtin::DEPENDENCY_UPDATE_TOOL => Some(
426            "Add .github/dependabot.yml or renovate.json to enable automated dependency updates",
427        ),
428        builtin::REPOSITORY_PERMISSIONS_AUDIT => {
429            Some("Reduce admin count (<= 3), use team-based access instead of direct collaborators")
430        }
431        builtin::DEFAULT_BRANCH_SETTINGS_BASELINE => Some(
432            "Enable branch protection, admin enforcement, and stale review dismissal on default branch",
433        ),
434        builtin::SECURITY_TEST_IN_CI => {
435            Some("Add CodeQL or Semgrep to GitHub Actions: github/codeql-action/analyze")
436        }
437        builtin::PROTECTED_TAGS => {
438            Some("Add tag protection rules at Settings > Tags to prevent unauthorized releases")
439        }
440        _ => None,
441    }
442}
443
444/// Returns SOC2 Trust Services Criteria for a built-in control ID.
445pub fn builtin_tsc_mapping(id: &str) -> &'static [&'static str] {
446    match id {
447        // CC6: Logical and Physical Access Controls
448        builtin::SOURCE_AUTHENTICITY => &["CC6.1"],
449        builtin::BRANCH_PROTECTION_ENFORCEMENT => &["CC6.1", "CC8.1"],
450        builtin::CODEOWNERS_COVERAGE => &["CC6.1"],
451        builtin::SECRET_SCANNING => &["CC6.1", "CC6.6"],
452        // CC7: System Operations
453        builtin::ISSUE_LINKAGE => &["CC7.2"],
454        builtin::STALE_REVIEW => &["CC7.2"],
455        builtin::SECURITY_FILE_CHANGE => &["CC7.2"],
456        builtin::RELEASE_TRACEABILITY => &["CC7.2"],
457        builtin::REQUIRED_STATUS_CHECKS => &["CC7.1"],
458        builtin::VULNERABILITY_SCANNING => &["CC7.1"],
459        builtin::SECURITY_POLICY => &["CC7.3", "CC7.4"],
460        // CC8: Change Management
461        builtin::REVIEW_INDEPENDENCE => &["CC8.1"],
462        builtin::TWO_PARTY_REVIEW => &["CC8.1"],
463        builtin::CHANGE_REQUEST_SIZE => &["CC8.1"],
464        builtin::TEST_COVERAGE => &["CC8.1"],
465        builtin::SCOPED_CHANGE => &["CC8.1"],
466        builtin::DESCRIPTION_QUALITY => &["CC8.1"],
467        builtin::MERGE_COMMIT_POLICY => &["CC8.1"],
468        builtin::CONVENTIONAL_TITLE => &["CC8.1"],
469        builtin::BRANCH_HISTORY_INTEGRITY => &["CC8.1"],
470        // PI: Processing Integrity
471        builtin::BUILD_PROVENANCE => &["PI1.4"],
472        builtin::HOSTED_BUILD_PLATFORM => &["PI1.4"],
473        builtin::PROVENANCE_AUTHENTICITY => &["PI1.4"],
474        builtin::BUILD_ISOLATION => &["PI1.4"],
475        // Dependencies (CC7.1 + PI)
476        builtin::DEPENDENCY_SIGNATURE => &["CC7.1", "PI1.4"],
477        builtin::DEPENDENCY_PROVENANCE_CHECK => &["CC7.1", "PI1.4"],
478        builtin::DEPENDENCY_SIGNER_VERIFIED => &["CC7.1", "PI1.4"],
479        builtin::DEPENDENCY_COMPLETENESS => &["CC7.1", "PI1.4"],
480        // Enterprise Posture
481        builtin::SECRET_SCANNING_PUSH_PROTECTION => &["CC6.1", "CC6.6"],
482        builtin::BRANCH_PROTECTION_ADMIN_ENFORCEMENT => &["CC6.1", "CC8.1"],
483        builtin::DISMISS_STALE_REVIEWS_ON_PUSH => &["CC8.1"],
484        builtin::ACTIONS_PINNED_DEPENDENCIES => &["CC7.1", "PI1.4"],
485        builtin::ENVIRONMENT_PROTECTION_RULES => &["CC6.1", "CC8.1"],
486        builtin::CODE_SCANNING_ALERTS_RESOLVED => &["CC7.1"],
487        builtin::DEPENDENCY_LICENSE_COMPLIANCE => &["CC7.1"],
488        builtin::SBOM_ATTESTATION => &["CC7.1"],
489        builtin::RELEASE_ASSET_ATTESTATION => &["PI1.4"],
490        builtin::PRIVILEGED_WORKFLOW_DETECTION => &["CC6.1", "CC8.1"],
491        _ => &[],
492    }
493}
494
495/// Runs every control against the evidence bundle and collects all findings.
496pub fn evaluate_all(
497    controls: &[Box<dyn Control>],
498    evidence: &EvidenceBundle,
499) -> Vec<ControlFinding> {
500    let mut findings = Vec::new();
501    for control in controls {
502        findings.extend(control.evaluate(evidence));
503    }
504    findings
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn control_id_display() {
513        let id = ControlId::new("review-independence");
514        assert_eq!(id.to_string(), "review-independence");
515        assert_eq!(id.as_str(), "review-independence");
516    }
517
518    #[test]
519    fn control_id_from_str() {
520        let id: ControlId = "source-authenticity".into();
521        assert_eq!(id.as_str(), "source-authenticity");
522    }
523
524    #[test]
525    fn all_builtins_have_remediation_hints() {
526        for id in builtin::ALL {
527            assert!(
528                builtin_remediation_hint(id).is_some(),
529                "missing remediation hint for built-in control: {id}"
530            );
531        }
532    }
533
534    #[test]
535    fn builtin_ids_are_unique() {
536        let mut seen = std::collections::HashSet::new();
537        for id in builtin::ALL {
538            assert!(seen.insert(id), "duplicate built-in ID: {id}");
539        }
540    }
541}