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::{
62 EvidenceGap, EvidenceState, PrivilegedWorkflow, RepositoryPosture,
63 };
64
65 fn posture(workflows: Vec<PrivilegedWorkflow>) -> RepositoryPosture {
66 RepositoryPosture {
67 privileged_workflows: workflows,
68 ..Default::default()
69 }
70 }
71
72 fn bundle(state: EvidenceState<RepositoryPosture>) -> EvidenceBundle {
73 EvidenceBundle {
74 repository_posture: state,
75 ..Default::default()
76 }
77 }
78
79 #[test]
80 fn not_applicable_when_posture_not_applicable() {
81 let findings = PrivilegedWorkflowDetectionControl
82 .evaluate(&bundle(EvidenceState::not_applicable()));
83 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
84 }
85
86 #[test]
87 fn indeterminate_when_posture_missing() {
88 let findings = PrivilegedWorkflowDetectionControl
89 .evaluate(&bundle(EvidenceState::missing(vec![
90 EvidenceGap::CollectionFailed {
91 source: "github".to_string(),
92 subject: "posture".to_string(),
93 detail: "API error".to_string(),
94 },
95 ])));
96 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
97 }
98
99 #[test]
100 fn satisfied_when_no_privileged_workflows() {
101 let findings = PrivilegedWorkflowDetectionControl
102 .evaluate(&bundle(EvidenceState::complete(posture(vec![]))));
103 assert_eq!(findings[0].status, ControlStatus::Satisfied);
104 }
105
106 #[test]
107 fn violated_when_privileged_workflows_detected() {
108 let workflows = vec![
109 PrivilegedWorkflow {
110 file: ".github/workflows/ci.yml".to_string(),
111 trigger: "pull_request_target".to_string(),
112 risk: "checks out PR head with write access".to_string(),
113 },
114 PrivilegedWorkflow {
115 file: ".github/workflows/label.yml".to_string(),
116 trigger: "pull_request_target".to_string(),
117 risk: "runs untrusted code with secrets".to_string(),
118 },
119 ];
120 let findings = PrivilegedWorkflowDetectionControl
121 .evaluate(&bundle(EvidenceState::complete(posture(workflows))));
122 assert_eq!(findings[0].status, ControlStatus::Violated);
123 assert!(findings[0].rationale.contains("2 workflow(s)"));
124 assert_eq!(findings[0].subjects.len(), 2);
125 assert!(findings[0].subjects[0].contains("ci.yml"));
126 assert!(findings[0].subjects[1].contains("label.yml"));
127 }
128}