check_run_patterns.rs

  1use annotate_snippets::{AnnotationKind, Group, Level, Snippet};
  2use anyhow::{Result, anyhow};
  3use regex::Regex;
  4use serde_yaml::Value;
  5use std::{
  6    collections::HashMap,
  7    fs,
  8    ops::Range,
  9    path::{Path, PathBuf},
 10    sync::LazyLock,
 11};
 12
 13static GITHUB_INPUT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
 14    Regex::new(r#"\$\{\{[[:blank:]]*([[:alnum:]]|[[:punct:]])+?[[:blank:]]*\}\}"#)
 15        .expect("Should compile")
 16});
 17
 18pub struct WorkflowFile {
 19    raw_content: String,
 20    pub parsed_content: Value,
 21}
 22
 23impl WorkflowFile {
 24    pub fn load(workflow_file_path: &Path) -> Result<Self> {
 25        fs::read_to_string(workflow_file_path)
 26            .map_err(|_| {
 27                anyhow!(
 28                    "Could not read workflow file at {}",
 29                    workflow_file_path.display()
 30                )
 31            })
 32            .and_then(|file_content| {
 33                serde_yaml::from_str(&file_content)
 34                    .map(|parsed_content| Self {
 35                        raw_content: file_content,
 36                        parsed_content,
 37                    })
 38                    .map_err(|e| anyhow!("Failed to parse workflow file: {e:?}"))
 39            })
 40    }
 41}
 42
 43pub struct WorkflowValidationError {
 44    file_path: PathBuf,
 45    contents: WorkflowFile,
 46    errors: Vec<RunValidationError>,
 47}
 48
 49impl WorkflowValidationError {
 50    pub fn new(
 51        errors: Vec<RunValidationError>,
 52        contents: WorkflowFile,
 53        file_path: PathBuf,
 54    ) -> Self {
 55        Self {
 56            file_path,
 57            contents,
 58            errors,
 59        }
 60    }
 61
 62    pub fn annotation_group<'a>(&'a self) -> Group<'a> {
 63        let raw_content = &self.contents.raw_content;
 64        let mut identical_lines = HashMap::new();
 65
 66        let ranges = self
 67            .errors
 68            .iter()
 69            .flat_map(|error| error.found_injection_patterns.iter())
 70            .map(|(line, pattern_range)| {
 71                let initial_offset = identical_lines
 72                    .get(&(line.as_str(), pattern_range.start))
 73                    .copied()
 74                    .unwrap_or_default();
 75
 76                let line_start = raw_content[initial_offset..]
 77                    .find(line.as_str())
 78                    .map(|offset| offset + initial_offset)
 79                    .unwrap_or_default();
 80
 81                let pattern_start = line_start + pattern_range.start;
 82                let pattern_end = pattern_start + pattern_range.len();
 83
 84                identical_lines.insert((line.as_str(), pattern_range.start), pattern_end);
 85
 86                pattern_start..pattern_end
 87            });
 88
 89        Level::ERROR
 90            .primary_title("Found GitHub input injection in run command")
 91            .element(
 92                Snippet::source(&self.contents.raw_content)
 93                    .path(self.file_path.display().to_string())
 94                    .annotations(ranges.map(|range| {
 95                        AnnotationKind::Primary
 96                            .span(range)
 97                            .label("This should be passed via an environment variable")
 98                    })),
 99            )
100    }
101}
102
103pub struct RunValidationError {
104    found_injection_patterns: Vec<(String, Range<usize>)>,
105}
106
107pub fn validate_run_command(command: &str) -> Result<(), RunValidationError> {
108    let patterns: Vec<_> = command
109        .lines()
110        .flat_map(move |line| {
111            GITHUB_INPUT_PATTERN
112                .find_iter(line)
113                .map(|m| (line.to_owned(), m.range()))
114        })
115        .collect();
116
117    if patterns.is_empty() {
118        Ok(())
119    } else {
120        Err(RunValidationError {
121            found_injection_patterns: patterns,
122        })
123    }
124}