Skip to main content

github_actions_maintainer/
policy.rs

1use std::{fs, path::PathBuf, sync::LazyLock};
2
3use anyhow::{Context, Result};
4use regex::Regex;
5
6use crate::{
7    model::{PolicySeverity, PolicyViolation, PolicyViolationType, ScriptType, ScriptUsage},
8    workflow::{discover_workflow_files, scan_workflow, scan_workflow_scripts},
9};
10
11static PERMISSIONS_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
12    Regex::new(r"^(?P<indent>\s*)permissions:\s*(?P<value>[^#]*)").expect("valid permissions regex")
13});
14
15static PERMISSION_ENTRY_RE: LazyLock<Regex> = LazyLock::new(|| {
16    Regex::new(r"^\s*(?P<scope>[A-Za-z0-9_-]+):\s*(?P<value>[A-Za-z0-9_-]+)")
17        .expect("valid permission entry regex")
18});
19
20static TOP_LEVEL_KEY_RE: LazyLock<Regex> = LazyLock::new(|| {
21    Regex::new(r"^[A-Za-z_][A-Za-z0-9_-]*\s*:").expect("valid top-level yaml key regex")
22});
23
24static YAML_KEY_RE: LazyLock<Regex> =
25    LazyLock::new(|| Regex::new(r"^\s*[A-Za-z_][A-Za-z0-9_-]*\s*:").expect("valid yaml key regex"));
26
27static JOB_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
28    Regex::new(r"^  (?P<name>[A-Za-z0-9_-]+):\s*(?:#.*)?$").expect("valid job line regex")
29});
30
31static JOB_FIELD_RE: LazyLock<Regex> =
32    LazyLock::new(|| Regex::new(r"^\s+(?P<field>[A-Za-z0-9_-]+):").expect("valid job field regex"));
33
34#[derive(Debug, Clone, Eq, PartialEq)]
35pub struct PolicyOptions {
36    pub repo_root: PathBuf,
37    pub workflows_path: PathBuf,
38    pub check_scripts: bool,
39    pub check_policies: bool,
40}
41
42#[derive(Debug, Clone, Eq, PartialEq)]
43pub struct PolicyReport {
44    pub workflow_files: usize,
45    pub script_usages: Vec<ScriptUsage>,
46    pub policy_violations: Vec<PolicyViolation>,
47    pub summary: PolicySummary,
48}
49
50impl PolicyReport {
51    #[must_use]
52    pub const fn has_findings(&self) -> bool {
53        !self.script_usages.is_empty() || !self.policy_violations.is_empty()
54    }
55}
56
57#[derive(Debug, Clone, Eq, PartialEq, Default)]
58pub struct PolicySummary {
59    pub total_scripts: usize,
60    pub bash_scripts: usize,
61    pub python_scripts: usize,
62    pub high_violations: usize,
63    pub medium_violations: usize,
64    pub low_violations: usize,
65}
66
67#[derive(Debug, Clone, Copy, Default)]
68pub struct PolicyScanner;
69
70impl PolicyScanner {
71    #[must_use]
72    pub const fn new() -> Self {
73        Self
74    }
75
76    pub fn scan(&self, options: &PolicyOptions) -> Result<PolicyReport> {
77        let repo_root = options.repo_root.canonicalize().with_context(|| {
78            format!("failed to resolve repository root '{}'", options.repo_root.display())
79        })?;
80        let workflow_root = if options.workflows_path.is_absolute() {
81            options.workflows_path.clone()
82        } else {
83            repo_root.join(&options.workflows_path)
84        };
85
86        if !workflow_root.exists() {
87            return Ok(PolicyReport {
88                workflow_files: 0,
89                script_usages: Vec::new(),
90                policy_violations: Vec::new(),
91                summary: PolicySummary::default(),
92            });
93        }
94
95        let workflow_files = discover_workflow_files(&repo_root, &options.workflows_path)?;
96        let mut script_usages = Vec::new();
97        let mut policy_violations = Vec::new();
98
99        for workflow_file in &workflow_files {
100            if options.check_scripts {
101                script_usages.extend(scan_workflow_scripts(workflow_file)?);
102            }
103            if options.check_policies {
104                policy_violations.extend(scan_policy_violations(workflow_file)?);
105            }
106        }
107
108        let summary = summarize(&script_usages, &policy_violations);
109
110        Ok(PolicyReport {
111            workflow_files: workflow_files.len(),
112            script_usages,
113            policy_violations,
114            summary,
115        })
116    }
117}
118
119fn scan_policy_violations(workflow_file: &std::path::Path) -> Result<Vec<PolicyViolation>> {
120    let mut violations = Vec::new();
121
122    for action in scan_workflow(workflow_file)? {
123        if !action.is_pinned() {
124            violations.push(PolicyViolation {
125                file: action.file,
126                line_number: action.line_number,
127                violation_type: PolicyViolationType::UnpinnedAction,
128                severity: PolicySeverity::High,
129                description: format!(
130                    "external action '{}' should be pinned to a full 40-character commit SHA",
131                    action.action_slug
132                ),
133                context: action.original_line,
134            });
135        }
136    }
137
138    let content = fs::read_to_string(workflow_file)
139        .with_context(|| format!("failed to read workflow '{}'", workflow_file.display()))?;
140    violations.extend(scan_permission_violations(workflow_file, &content));
141    violations.extend(scan_timeout_violations(workflow_file, &content));
142
143    Ok(violations)
144}
145
146fn scan_permission_violations(path: &std::path::Path, content: &str) -> Vec<PolicyViolation> {
147    let mut violations = Vec::new();
148    let mut has_top_level_permissions = false;
149    let mut permissions_block_indent = None;
150
151    for (index, line) in content.lines().enumerate() {
152        let line_number = index + 1;
153        if let Some(block_indent) = permissions_block_indent {
154            if !line.trim().is_empty()
155                && leading_spaces(line) <= block_indent
156                && YAML_KEY_RE.is_match(line)
157            {
158                permissions_block_indent = None;
159            } else if let Some(captures) = PERMISSION_ENTRY_RE.captures(line) {
160                let scope = captures.name("scope").expect("scope capture is required").as_str();
161                let value = captures.name("value").expect("value capture is required").as_str();
162                push_permission_value_violation(
163                    path,
164                    line_number,
165                    scope,
166                    value,
167                    line,
168                    &mut violations,
169                );
170            }
171        }
172
173        let Some(captures) = PERMISSIONS_LINE_RE.captures(line) else {
174            continue;
175        };
176
177        let indent = captures.name("indent").map_or(0, |capture| capture.as_str().chars().count());
178        if indent == 0 {
179            has_top_level_permissions = true;
180        }
181
182        let value = captures.name("value").expect("value capture is required").as_str().trim();
183        if value.is_empty() {
184            permissions_block_indent = Some(indent);
185        } else {
186            push_permissions_shorthand_violation(path, line_number, value, line, &mut violations);
187        }
188    }
189
190    if !has_top_level_permissions {
191        violations.push(PolicyViolation {
192            file: path.to_path_buf(),
193            line_number: 1,
194            violation_type: PolicyViolationType::MissingPermissions,
195            severity: PolicySeverity::Medium,
196            description: "workflow should declare explicit top-level permissions".to_owned(),
197            context: String::new(),
198        });
199    }
200
201    violations
202}
203
204fn push_permissions_shorthand_violation(
205    path: &std::path::Path,
206    line_number: usize,
207    value: &str,
208    context: &str,
209    violations: &mut Vec<PolicyViolation>,
210) {
211    match value {
212        "write-all" => violations.push(PolicyViolation {
213            file: path.to_path_buf(),
214            line_number,
215            violation_type: PolicyViolationType::ExcessivePermissions,
216            severity: PolicySeverity::High,
217            description: "permissions should not use write-all".to_owned(),
218            context: context.to_owned(),
219        }),
220        "read-all" | "{}" | "read" => {}
221        other if other.contains("write") => violations.push(PolicyViolation {
222            file: path.to_path_buf(),
223            line_number,
224            violation_type: PolicyViolationType::ExcessivePermissions,
225            severity: PolicySeverity::Medium,
226            description: format!("permission shorthand '{other}' should be reviewed"),
227            context: context.to_owned(),
228        }),
229        _ => {}
230    }
231}
232
233fn push_permission_value_violation(
234    path: &std::path::Path,
235    line_number: usize,
236    scope: &str,
237    value: &str,
238    context: &str,
239    violations: &mut Vec<PolicyViolation>,
240) {
241    if value != "write" {
242        return;
243    }
244
245    violations.push(PolicyViolation {
246        file: path.to_path_buf(),
247        line_number,
248        violation_type: PolicyViolationType::ExcessivePermissions,
249        severity: if scope == "id-token" { PolicySeverity::Low } else { PolicySeverity::Medium },
250        description: format!("permission '{scope}: write' should be minimized or justified"),
251        context: context.to_owned(),
252    });
253}
254
255fn scan_timeout_violations(path: &std::path::Path, content: &str) -> Vec<PolicyViolation> {
256    let mut violations = Vec::new();
257    let mut in_jobs = false;
258    let mut current_job = None;
259
260    for (index, line) in content.lines().enumerate() {
261        let line_number = index + 1;
262        if line.starts_with("jobs:") {
263            in_jobs = true;
264            continue;
265        }
266
267        if in_jobs && TOP_LEVEL_KEY_RE.is_match(line) && !line.starts_with("jobs:") {
268            finish_job(path, current_job.take(), &mut violations);
269            in_jobs = false;
270        }
271
272        if !in_jobs {
273            continue;
274        }
275
276        if let Some(captures) = JOB_LINE_RE.captures(line) {
277            finish_job(path, current_job.take(), &mut violations);
278            let name = captures.name("name").expect("name capture is required").as_str().to_owned();
279            current_job = Some(JobState {
280                name,
281                line_number,
282                context: line.to_owned(),
283                has_timeout: false,
284                has_runnable_content: false,
285            });
286            continue;
287        }
288
289        if let Some(job) = &mut current_job
290            && let Some(captures) = JOB_FIELD_RE.captures(line)
291        {
292            let field = captures.name("field").expect("field capture is required").as_str();
293            match field {
294                "timeout-minutes" => job.has_timeout = true,
295                "runs-on" | "steps" => job.has_runnable_content = true,
296                _ => {}
297            }
298        }
299    }
300
301    finish_job(path, current_job, &mut violations);
302
303    violations
304}
305
306fn finish_job(
307    path: &std::path::Path,
308    job: Option<JobState>,
309    violations: &mut Vec<PolicyViolation>,
310) {
311    let Some(job) = job else {
312        return;
313    };
314
315    if job.has_runnable_content && !job.has_timeout {
316        violations.push(PolicyViolation {
317            file: path.to_path_buf(),
318            line_number: job.line_number,
319            violation_type: PolicyViolationType::MissingTimeoutMinutes,
320            severity: PolicySeverity::Medium,
321            description: format!("job '{}' should declare timeout-minutes", job.name),
322            context: job.context,
323        });
324    }
325}
326
327#[derive(Debug, Clone, Eq, PartialEq)]
328struct JobState {
329    name: String,
330    line_number: usize,
331    context: String,
332    has_timeout: bool,
333    has_runnable_content: bool,
334}
335
336fn summarize(scripts: &[ScriptUsage], violations: &[PolicyViolation]) -> PolicySummary {
337    PolicySummary {
338        total_scripts: scripts.len(),
339        bash_scripts: scripts.iter().filter(|usage| usage.script_type == ScriptType::Bash).count(),
340        python_scripts: scripts
341            .iter()
342            .filter(|usage| usage.script_type == ScriptType::Python)
343            .count(),
344        high_violations: violations
345            .iter()
346            .filter(|violation| violation.severity == PolicySeverity::High)
347            .count(),
348        medium_violations: violations
349            .iter()
350            .filter(|violation| violation.severity == PolicySeverity::Medium)
351            .count(),
352        low_violations: violations
353            .iter()
354            .filter(|violation| violation.severity == PolicySeverity::Low)
355            .count(),
356    }
357}
358
359fn leading_spaces(value: &str) -> usize {
360    value.chars().take_while(|character| *character == ' ').count()
361}
362
363#[cfg(test)]
364mod tests {
365    use std::{fs, path::PathBuf};
366
367    use tempfile::tempdir;
368
369    use super::{PolicyOptions, PolicyScanner};
370    use crate::model::{PolicySeverity, PolicyViolationType, ScriptType};
371
372    #[test]
373    fn scanner_reports_scripts_and_policy_violations() {
374        let temp_dir = tempdir().expect("tempdir");
375        let workflow_dir = temp_dir.path().join(".github").join("workflows");
376        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
377        fs::write(
378            workflow_dir.join("ci.yml"),
379            r"name: CI
380
381on:
382  pull_request:
383
384permissions:
385  contents: write
386
387jobs:
388  lint:
389    runs-on: ubuntu-latest
390    steps:
391      - uses: actions/checkout@v4
392      - run: python3 scripts/check.py
393      - run: ./bin/bootstrap.sh
394",
395        )
396        .expect("write workflow");
397
398        let scanner = PolicyScanner::new();
399        let report = scanner
400            .scan(&PolicyOptions {
401                repo_root: temp_dir.path().to_path_buf(),
402                workflows_path: PathBuf::from(".github/workflows"),
403                check_scripts: true,
404                check_policies: true,
405            })
406            .expect("scan workflows");
407
408        assert_eq!(report.workflow_files, 1);
409        assert_eq!(report.summary.total_scripts, 2);
410        assert_eq!(report.summary.bash_scripts, 1);
411        assert_eq!(report.summary.python_scripts, 1);
412        assert!(report.script_usages.iter().any(|usage| usage.script_type == ScriptType::Bash));
413        assert!(report.script_usages.iter().any(|usage| usage.script_type == ScriptType::Python));
414        assert!(
415            report
416                .policy_violations
417                .iter()
418                .any(|violation| violation.violation_type == PolicyViolationType::UnpinnedAction)
419        );
420        assert!(
421            report
422                .policy_violations
423                .iter()
424                .any(|violation| violation.violation_type
425                    == PolicyViolationType::ExcessivePermissions)
426        );
427        assert!(report.policy_violations.iter().any(
428            |violation| violation.violation_type == PolicyViolationType::MissingTimeoutMinutes
429        ));
430    }
431
432    #[test]
433    fn scanner_treats_missing_workflow_directory_as_empty_report() {
434        let temp_dir = tempdir().expect("tempdir");
435        let scanner = PolicyScanner::new();
436        let report = scanner
437            .scan(&PolicyOptions {
438                repo_root: temp_dir.path().to_path_buf(),
439                workflows_path: PathBuf::from(".github/workflows"),
440                check_scripts: true,
441                check_policies: true,
442            })
443            .expect("scan workflows");
444
445        assert_eq!(report.workflow_files, 0);
446        assert!(!report.has_findings());
447    }
448
449    #[test]
450    fn scanner_accepts_compliant_workflow() {
451        let temp_dir = tempdir().expect("tempdir");
452        let workflow_dir = temp_dir.path().join(".github").join("workflows");
453        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
454        fs::write(
455            workflow_dir.join("ci.yml"),
456            r"name: CI
457
458on:
459  pull_request:
460
461permissions:
462  contents: read
463
464jobs:
465  lint:
466    timeout-minutes: 10
467    runs-on: ubuntu-latest
468    steps:
469      - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567 # v4
470      - run: cargo test
471",
472        )
473        .expect("write workflow");
474
475        let scanner = PolicyScanner::new();
476        let report = scanner
477            .scan(&PolicyOptions {
478                repo_root: temp_dir.path().to_path_buf(),
479                workflows_path: PathBuf::from(".github/workflows"),
480                check_scripts: true,
481                check_policies: true,
482            })
483            .expect("scan workflows");
484
485        assert_eq!(report.workflow_files, 1);
486        assert!(!report.has_findings());
487    }
488
489    #[test]
490    fn scanner_stops_job_permission_blocks_at_sibling_fields() {
491        let temp_dir = tempdir().expect("tempdir");
492        let workflow_dir = temp_dir.path().join(".github").join("workflows");
493        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
494        fs::write(
495            workflow_dir.join("ci.yml"),
496            r"name: CI
497
498on:
499  pull_request:
500
501permissions:
502  contents: read
503
504jobs:
505  lint:
506    permissions:
507      contents: read
508    timeout-minutes: 10
509    runs-on: ubuntu-latest
510    steps:
511      - run: cargo test
512",
513        )
514        .expect("write workflow");
515
516        let scanner = PolicyScanner::new();
517        let report = scanner
518            .scan(&PolicyOptions {
519                repo_root: temp_dir.path().to_path_buf(),
520                workflows_path: PathBuf::from(".github/workflows"),
521                check_scripts: false,
522                check_policies: true,
523            })
524            .expect("scan workflows");
525
526        assert!(
527            !report
528                .policy_violations
529                .iter()
530                .any(|violation| violation.violation_type
531                    == PolicyViolationType::ExcessivePermissions)
532        );
533    }
534
535    #[test]
536    fn scanner_flags_missing_top_level_permissions_and_write_all() {
537        let temp_dir = tempdir().expect("tempdir");
538        let workflow_dir = temp_dir.path().join(".github").join("workflows");
539        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
540        fs::write(
541            workflow_dir.join("release.yml"),
542            r"name: Release
543
544on:
545  workflow_dispatch:
546
547jobs:
548  release:
549    permissions: write-all
550    timeout-minutes: 20
551    runs-on: ubuntu-latest
552    steps:
553      - run: cargo test
554",
555        )
556        .expect("write workflow");
557
558        let scanner = PolicyScanner::new();
559        let report = scanner
560            .scan(&PolicyOptions {
561                repo_root: temp_dir.path().to_path_buf(),
562                workflows_path: PathBuf::from(".github/workflows"),
563                check_scripts: false,
564                check_policies: true,
565            })
566            .expect("scan workflows");
567
568        assert!(
569            report.policy_violations.iter().any(
570                |violation| violation.violation_type == PolicyViolationType::MissingPermissions
571            )
572        );
573        assert!(
574            report
575                .policy_violations
576                .iter()
577                .any(|violation| violation.severity == PolicySeverity::High)
578        );
579    }
580}