libverify_core/controls/
privileged_workflow_detection.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4pub struct PrivilegedWorkflowDetectionControl;
16
17impl Control for PrivilegedWorkflowDetectionControl {
18 fn id(&self) -> ControlId {
19 builtin::id(builtin::PRIVILEGED_WORKFLOW_DETECTION)
20 }
21
22 fn description(&self) -> &'static str {
23 "Workflows must not use pull_request_target with elevated permissions"
24 }
25
26 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
27 let posture = match ControlFinding::extract_posture(self.id(), evidence) {
28 Ok(p) => p,
29 Err(findings) => return findings,
30 };
31
32 if posture.privileged_workflows.is_empty() {
33 vec![ControlFinding::satisfied(
34 self.id(),
35 "No privileged workflow patterns detected",
36 vec!["workflows".to_string()],
37 )]
38 } else {
39 let subjects: Vec<String> = posture
40 .privileged_workflows
41 .iter()
42 .map(|w| format!("{}:{} ({})", w.file, w.trigger, w.risk))
43 .collect();
44 let count = posture.privileged_workflows.len();
45 vec![ControlFinding::violated(
46 self.id(),
47 format!(
48 "{count} workflow(s) use pull_request_target with elevated permissions — \
49 external contributors may exploit these to exfiltrate secrets"
50 ),
51 subjects,
52 )]
53 }
54 }
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60 use crate::control::ControlStatus;
61 use crate::evidence::{EvidenceGap, EvidenceState, PrivilegedWorkflow, RepositoryPosture};
62
63 fn posture(workflows: Vec<PrivilegedWorkflow>) -> RepositoryPosture {
64 RepositoryPosture {
65 privileged_workflows: workflows,
66 ..Default::default()
67 }
68 }
69
70 fn bundle(state: EvidenceState<RepositoryPosture>) -> EvidenceBundle {
71 EvidenceBundle {
72 repository_posture: state,
73 ..Default::default()
74 }
75 }
76
77 #[test]
78 fn not_applicable_when_posture_not_applicable() {
79 let findings =
80 PrivilegedWorkflowDetectionControl.evaluate(&bundle(EvidenceState::not_applicable()));
81 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
82 }
83
84 #[test]
85 fn indeterminate_when_posture_missing() {
86 let findings =
87 PrivilegedWorkflowDetectionControl.evaluate(&bundle(EvidenceState::missing(vec![
88 EvidenceGap::CollectionFailed {
89 source: "github".to_string(),
90 subject: "posture".to_string(),
91 detail: "API error".to_string(),
92 },
93 ])));
94 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
95 }
96
97 #[test]
98 fn satisfied_when_no_privileged_workflows() {
99 let findings = PrivilegedWorkflowDetectionControl
100 .evaluate(&bundle(EvidenceState::complete(posture(vec![]))));
101 assert_eq!(findings[0].status, ControlStatus::Satisfied);
102 }
103
104 #[test]
105 fn violated_when_privileged_workflows_detected() {
106 let workflows = vec![
107 PrivilegedWorkflow {
108 file: ".github/workflows/ci.yml".to_string(),
109 trigger: "pull_request_target".to_string(),
110 risk: "checks out PR head with write access".to_string(),
111 },
112 PrivilegedWorkflow {
113 file: ".github/workflows/label.yml".to_string(),
114 trigger: "pull_request_target".to_string(),
115 risk: "runs untrusted code with secrets".to_string(),
116 },
117 ];
118 let findings = PrivilegedWorkflowDetectionControl
119 .evaluate(&bundle(EvidenceState::complete(posture(workflows))));
120 assert_eq!(findings[0].status, ControlStatus::Violated);
121 assert!(findings[0].rationale.contains("2 workflow(s)"));
122 assert_eq!(findings[0].subjects.len(), 2);
123 assert!(findings[0].subjects[0].contains("ci.yml"));
124 assert!(findings[0].subjects[1].contains("label.yml"));
125 }
126}