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}