libverify_core/controls/
actions_pinned_dependencies.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4pub 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}