Skip to main content

libverify_core/controls/
workflow_permissions_restricted.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4/// Validates that default workflow permissions are set to read-only.
5///
6/// Maps to SOC2 CC6.8 (Prevention of Unauthorized Software),
7/// OpenSSF Scorecard Token-Permissions (High risk).
8///
9/// GitHub Actions workflows should use the principle of least privilege
10/// for the GITHUB_TOKEN. The default should be "read" not "write".
11pub struct WorkflowPermissionsRestrictedControl;
12
13impl Control for WorkflowPermissionsRestrictedControl {
14    fn id(&self) -> ControlId {
15        builtin::id(builtin::WORKFLOW_PERMISSIONS_RESTRICTED)
16    }
17
18    fn description(&self) -> &'static str {
19        "Default workflow permissions must be restricted to read-only"
20    }
21
22    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
23        let posture = match ControlFinding::extract_posture(self.id(), evidence) {
24            Ok(p) => p,
25            Err(findings) => return findings,
26        };
27
28        if posture.default_workflow_permissions.is_empty() {
29            return vec![ControlFinding::indeterminate(
30                self.id(),
31                "Cannot determine default workflow permissions — API token may lack admin permissions",
32                vec!["repository:workflow-permissions".into()],
33                vec![],
34            )];
35        }
36
37        if posture.default_workflow_permissions == "read" {
38            vec![ControlFinding::satisfied(
39                self.id(),
40                "Default workflow permissions are set to read-only",
41                vec!["repository:workflow-permissions".into()],
42            )]
43        } else {
44            vec![ControlFinding::violated(
45                self.id(),
46                format!(
47                    "Default workflow permissions are '{}' — should be 'read' for least privilege",
48                    posture.default_workflow_permissions
49                ),
50                vec!["repository:workflow-permissions".into()],
51            )]
52        }
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use crate::control::ControlStatus;
60    use crate::evidence::{EvidenceState, RepositoryPosture};
61
62    fn bundle_with_perms(perms: &str) -> EvidenceBundle {
63        EvidenceBundle {
64            repository_posture: EvidenceState::complete(RepositoryPosture {
65                default_workflow_permissions: perms.to_string(),
66                ..Default::default()
67            }),
68            ..Default::default()
69        }
70    }
71
72    #[test]
73    fn satisfied_when_read_only() {
74        let findings = WorkflowPermissionsRestrictedControl.evaluate(&bundle_with_perms("read"));
75        assert_eq!(findings[0].status, ControlStatus::Satisfied);
76    }
77
78    #[test]
79    fn violated_when_write() {
80        let findings = WorkflowPermissionsRestrictedControl.evaluate(&bundle_with_perms("write"));
81        assert_eq!(findings[0].status, ControlStatus::Violated);
82        assert!(findings[0].rationale.contains("write"));
83    }
84
85    #[test]
86    fn indeterminate_when_empty() {
87        let findings = WorkflowPermissionsRestrictedControl.evaluate(&bundle_with_perms(""));
88        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
89    }
90
91    #[test]
92    fn indeterminate_when_posture_missing() {
93        let findings = WorkflowPermissionsRestrictedControl.evaluate(&EvidenceBundle {
94            repository_posture: EvidenceState::missing(vec![]),
95            ..Default::default()
96        });
97        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
98    }
99
100    #[test]
101    fn not_applicable_when_posture_not_applicable() {
102        let findings = WorkflowPermissionsRestrictedControl.evaluate(&EvidenceBundle {
103            repository_posture: EvidenceState::not_applicable(),
104            ..Default::default()
105        });
106        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
107    }
108}