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::{
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}