Skip to main content

libverify_core/controls/
actions_pinned_dependencies.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4/// Validates that GitHub Actions workflow `uses:` references are pinned to commit SHAs.
5///
6/// Maps to SOC2 CC7.1 / PI1.4: prevent supply-chain attacks via mutable action tags.
7/// Unpinned action references (e.g. `actions/checkout@v4`) can be silently replaced
8/// by a compromised upstream, whereas SHA-pinned references are immutable.
9///
10/// Evaluation:
11/// - **Satisfied**: no unpinned action references found
12/// - **Violated**: one or more workflow files contain unpinned `uses:` references
13pub struct ActionsPinnedDependenciesControl;
14
15impl Control for ActionsPinnedDependenciesControl {
16    fn id(&self) -> ControlId {
17        builtin::id(builtin::ACTIONS_PINNED_DEPENDENCIES)
18    }
19
20    fn description(&self) -> &'static str {
21        "GitHub Actions must pin action references to commit SHAs to prevent supply-chain attacks"
22    }
23
24    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
25        let posture = match ControlFinding::extract_posture(self.id(), evidence) {
26            Ok(p) => p,
27            Err(findings) => return findings,
28        };
29
30        if posture.unpinned_action_refs.is_empty() {
31            return vec![ControlFinding::satisfied(
32                self.id(),
33                "All GitHub Actions references are pinned to commit SHAs",
34                vec!["repository:actions:pinned".to_string()],
35            )];
36        }
37
38        let subjects: Vec<String> = posture
39            .unpinned_action_refs
40            .iter()
41            .map(|r| format!("{}:{}", r.workflow_file, r.action_ref))
42            .collect();
43
44        let count = posture.unpinned_action_refs.len();
45        vec![ControlFinding::violated(
46            self.id(),
47            format!(
48                "{count} unpinned action reference(s) found — \
49                 pin to commit SHAs to prevent supply-chain attacks"
50            ),
51            subjects,
52        )]
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use crate::control::ControlStatus;
60    use crate::evidence::{
61        EvidenceGap, EvidenceState, RepositoryPosture, UnpinnedActionRef,
62    };
63
64    fn posture(unpinned: Vec<UnpinnedActionRef>) -> RepositoryPosture {
65        RepositoryPosture {
66            unpinned_action_refs: unpinned,
67            ..Default::default()
68        }
69    }
70
71    fn bundle(state: EvidenceState<RepositoryPosture>) -> EvidenceBundle {
72        EvidenceBundle {
73            repository_posture: state,
74            ..Default::default()
75        }
76    }
77
78    #[test]
79    fn not_applicable_when_posture_not_applicable() {
80        let findings =
81            ActionsPinnedDependenciesControl.evaluate(&bundle(EvidenceState::not_applicable()));
82        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
83    }
84
85    #[test]
86    fn indeterminate_when_posture_missing() {
87        let findings =
88            ActionsPinnedDependenciesControl.evaluate(&bundle(EvidenceState::missing(vec![
89                EvidenceGap::CollectionFailed {
90                    source: "github".to_string(),
91                    subject: "posture".to_string(),
92                    detail: "API error".to_string(),
93                },
94            ])));
95        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
96    }
97
98    #[test]
99    fn satisfied_when_all_pinned() {
100        let findings = ActionsPinnedDependenciesControl
101            .evaluate(&bundle(EvidenceState::complete(posture(vec![]))));
102        assert_eq!(findings[0].status, ControlStatus::Satisfied);
103        assert!(findings[0].rationale.contains("pinned"));
104    }
105
106    #[test]
107    fn violated_when_unpinned_refs_exist() {
108        let unpinned = vec![
109            UnpinnedActionRef {
110                workflow_file: ".github/workflows/ci.yml".to_string(),
111                action_ref: "actions/checkout@v4".to_string(),
112            },
113            UnpinnedActionRef {
114                workflow_file: ".github/workflows/release.yml".to_string(),
115                action_ref: "actions/setup-node@v3".to_string(),
116            },
117        ];
118        let findings = ActionsPinnedDependenciesControl
119            .evaluate(&bundle(EvidenceState::complete(posture(unpinned))));
120        assert_eq!(findings[0].status, ControlStatus::Violated);
121        assert!(findings[0].rationale.contains("2 unpinned"));
122        assert_eq!(findings[0].subjects.len(), 2);
123        assert!(findings[0].subjects[0].contains("ci.yml"));
124    }
125}