Skip to main content

libverify_core/controls/
privileged_workflow_detection.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4/// Detects workflows using `pull_request_target` with elevated permissions,
5/// which is a known attack vector for CI/CD pipeline exploitation.
6///
7/// Maps to SOC2 CC6.1 / CC7.1: access control and threat detection.
8/// A `pull_request_target` workflow runs in the context of the *base* branch
9/// with write access to secrets. If combined with `actions/checkout` of the
10/// PR head, an external contributor can exfiltrate secrets or inject code.
11///
12/// Evaluation tiers:
13/// - **Satisfied**: no privileged workflow patterns detected
14/// - **Violated**: one or more workflows use dangerous `pull_request_target` patterns
15pub 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}