Skip to main content

gha_command_proof/
envfile.rs

1use serde::Serialize;
2
3use crate::command::{masks_contain_exact, redact_with_masks};
4use crate::receipt::{Check, Location};
5
6const STEP_SUMMARY_LIMIT_BYTES: usize = 1024 * 1024;
7
8#[derive(Clone, Debug, Serialize)]
9pub struct EnvFileAnalysis {
10    pub kind: EnvFileKind,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub source: Option<String>,
13    pub bytes: usize,
14    pub records: Vec<EnvFileRecord>,
15    #[serde(skip)]
16    pub checks: Vec<Check>,
17}
18
19impl EnvFileAnalysis {
20    fn with_checks(mut self, checks: Vec<Check>) -> Self {
21        self.checks = checks;
22        self
23    }
24}
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, clap::ValueEnum)]
27#[serde(rename_all = "kebab-case")]
28pub enum EnvFileKind {
29    Env,
30    Output,
31    State,
32    Path,
33    StepSummary,
34}
35
36impl EnvFileKind {
37    pub fn context_name(self) -> &'static str {
38        match self {
39            Self::Env => "GITHUB_ENV",
40            Self::Output => "GITHUB_OUTPUT",
41            Self::State => "GITHUB_STATE",
42            Self::Path => "GITHUB_PATH",
43            Self::StepSummary => "GITHUB_STEP_SUMMARY",
44        }
45    }
46
47    fn check_prefix(self) -> &'static str {
48        match self {
49            Self::Env => "env_file.env",
50            Self::Output => "env_file.output",
51            Self::State => "env_file.state",
52            Self::Path => "env_file.path",
53            Self::StepSummary => "env_file.step_summary",
54        }
55    }
56}
57
58#[derive(Clone, Debug, Serialize)]
59pub struct EnvFileRecord {
60    pub line: usize,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub end_line: Option<usize>,
63    pub kind: EnvFileRecordKind,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub name: Option<String>,
66    #[serde(skip_serializing_if = "String::is_empty")]
67    pub value: String,
68    pub value_bytes: usize,
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum EnvFileRecordKind {
74    Assignment,
75    Heredoc,
76    Path,
77    Summary,
78}
79
80pub fn analyze_env_file(
81    kind: EnvFileKind,
82    text: &str,
83    source: Option<String>,
84    masks: &[String],
85) -> EnvFileAnalysis {
86    analyze_env_file_with_checks(kind, text, source, masks)
87}
88
89pub(crate) fn analyze_env_file_with_checks(
90    kind: EnvFileKind,
91    text: &str,
92    source: Option<String>,
93    masks: &[String],
94) -> EnvFileAnalysis {
95    match kind {
96        EnvFileKind::Path => analyze_path_file(kind, text, source, masks),
97        EnvFileKind::StepSummary => analyze_step_summary(kind, text, source, masks),
98        EnvFileKind::Env | EnvFileKind::Output | EnvFileKind::State => {
99            analyze_key_value_file(kind, text, source, masks)
100        }
101    }
102}
103
104fn analyze_key_value_file(
105    kind: EnvFileKind,
106    text: &str,
107    source: Option<String>,
108    masks: &[String],
109) -> EnvFileAnalysis {
110    let mut checks = Vec::new();
111    let mut records = Vec::new();
112    let lines = logical_lines(text);
113    let mut index = 0;
114
115    while index < lines.len() {
116        let line_number = lines[index].number;
117        let line = lines[index].text.as_str();
118        index += 1;
119
120        if line.is_empty() {
121            continue;
122        }
123
124        let equals = line.find('=');
125        let heredoc = line.find("<<");
126        if let Some(equals) = equals
127            && heredoc.is_none_or(|heredoc| equals < heredoc)
128        {
129            let name = &line[..equals];
130            let value = &line[equals + 1..];
131            if name.is_empty() {
132                checks.push(Check::fail(
133                    format!("{}.name", kind.check_prefix()),
134                    "environment file assignment name must not be empty",
135                    Some(Location::line(source.as_deref(), line_number)),
136                ));
137                continue;
138            }
139            validate_key_value(
140                kind,
141                name,
142                value,
143                line_number,
144                source.as_deref(),
145                masks,
146                &mut checks,
147            );
148            records.push(record(
149                line_number,
150                None,
151                EnvFileRecordKind::Assignment,
152                Some(name),
153                value,
154                masks,
155            ));
156            continue;
157        }
158
159        if let Some(heredoc) = heredoc
160            && equals.is_none_or(|equals| heredoc < equals)
161        {
162            let name = &line[..heredoc];
163            let delimiter = &line[heredoc + 2..];
164            if name.is_empty() || delimiter.is_empty() {
165                checks.push(Check::fail(
166                    format!("{}.heredoc.header", kind.check_prefix()),
167                    "heredoc syntax requires non-empty name and delimiter",
168                    Some(Location::line(source.as_deref(), line_number)),
169                ));
170                continue;
171            }
172
173            let value_start = index;
174            let mut value_end = None;
175            while index < lines.len() {
176                if lines[index].text == delimiter {
177                    value_end = Some(index);
178                    break;
179                }
180                index += 1;
181            }
182
183            let Some(end_index) = value_end else {
184                checks.push(Check::fail(
185                    format!("{}.heredoc.delimiter", kind.check_prefix()),
186                    "matching heredoc delimiter was not found",
187                    Some(Location::line(source.as_deref(), line_number)),
188                ));
189                break;
190            };
191
192            let value = lines[value_start..end_index]
193                .iter()
194                .map(|line| line.text.as_str())
195                .collect::<Vec<_>>()
196                .join("\n");
197            validate_key_value(
198                kind,
199                name,
200                &value,
201                line_number,
202                source.as_deref(),
203                masks,
204                &mut checks,
205            );
206            records.push(record(
207                line_number,
208                Some(lines[end_index].number),
209                EnvFileRecordKind::Heredoc,
210                Some(name),
211                &value,
212                masks,
213            ));
214            index = end_index + 1;
215            continue;
216        }
217
218        checks.push(Check::fail(
219            format!("{}.format", kind.check_prefix()),
220            "environment file line must use `NAME=VALUE` or `NAME<<DELIMITER` syntax",
221            Some(Location::line(source.as_deref(), line_number)),
222        ));
223    }
224
225    if records.is_empty() {
226        checks.push(Check::skip(
227            format!("{}.records", kind.check_prefix()),
228            format!("{} contains no records", kind.context_name()),
229            source
230                .as_deref()
231                .map(|source| Location::new(Some(source.to_string()), None)),
232        ));
233    } else {
234        checks.push(Check::pass(
235            format!("{}.records", kind.check_prefix()),
236            format!("parsed {} {} records", records.len(), kind.context_name()),
237            source
238                .as_deref()
239                .map(|source| Location::new(Some(source.to_string()), None)),
240        ));
241    }
242
243    EnvFileAnalysis {
244        kind,
245        source,
246        bytes: text.len(),
247        records,
248        checks: Vec::new(),
249    }
250    .with_checks(checks)
251}
252
253fn analyze_path_file(
254    kind: EnvFileKind,
255    text: &str,
256    source: Option<String>,
257    masks: &[String],
258) -> EnvFileAnalysis {
259    let mut records = Vec::new();
260    for line in logical_lines(text) {
261        if line.text.is_empty() {
262            continue;
263        }
264        records.push(record(
265            line.number,
266            None,
267            EnvFileRecordKind::Path,
268            None,
269            &line.text,
270            masks,
271        ));
272    }
273
274    let location = source
275        .as_deref()
276        .map(|source| Location::new(Some(source.to_string()), None));
277    let checks = if records.is_empty() {
278        vec![Check::skip(
279            "env_file.path.records",
280            "GITHUB_PATH contains no path records",
281            location,
282        )]
283    } else {
284        vec![Check::pass(
285            "env_file.path.records",
286            format!("parsed {} GITHUB_PATH records", records.len()),
287            location,
288        )]
289    };
290
291    EnvFileAnalysis {
292        kind,
293        source,
294        bytes: text.len(),
295        records,
296        checks: Vec::new(),
297    }
298    .with_checks(checks)
299}
300
301fn analyze_step_summary(
302    kind: EnvFileKind,
303    text: &str,
304    source: Option<String>,
305    masks: &[String],
306) -> EnvFileAnalysis {
307    let mut checks = Vec::new();
308    let location = source
309        .as_deref()
310        .map(|source| Location::new(Some(source.to_string()), None));
311    if text.is_empty() {
312        checks.push(Check::skip(
313            "env_file.step_summary.content",
314            "GITHUB_STEP_SUMMARY is empty",
315            location.clone(),
316        ));
317    } else {
318        checks.push(Check::pass(
319            "env_file.step_summary.content",
320            "GITHUB_STEP_SUMMARY contains Markdown content",
321            location.clone(),
322        ));
323    }
324
325    if text.len() > STEP_SUMMARY_LIMIT_BYTES {
326        checks.push(Check::fail(
327            "env_file.step_summary.size",
328            format!(
329                "GITHUB_STEP_SUMMARY is {} bytes, above the 1 MiB runner attachment limit",
330                text.len()
331            ),
332            location,
333        ));
334    } else {
335        checks.push(Check::pass(
336            "env_file.step_summary.size",
337            "GITHUB_STEP_SUMMARY is within the 1 MiB runner attachment limit",
338            location,
339        ));
340    }
341
342    let records = if text.is_empty() {
343        Vec::new()
344    } else {
345        vec![record(
346            1,
347            Some(logical_lines(text).last().map_or(1, |line| line.number)),
348            EnvFileRecordKind::Summary,
349            None,
350            text,
351            masks,
352        )]
353    };
354
355    EnvFileAnalysis {
356        kind,
357        source,
358        bytes: text.len(),
359        records,
360        checks: Vec::new(),
361    }
362    .with_checks(checks)
363}
364
365fn validate_key_value(
366    kind: EnvFileKind,
367    name: &str,
368    value: &str,
369    line: usize,
370    source: Option<&str>,
371    masks: &[String],
372    checks: &mut Vec<Check>,
373) {
374    let location = Some(Location::line(source, line));
375    match kind {
376        EnvFileKind::Env => {
377            if name.eq_ignore_ascii_case("NODE_OPTIONS") {
378                checks.push(Check::fail(
379                    "env_file.env.node_options",
380                    "GITHUB_ENV cannot set NODE_OPTIONS on GitHub runners",
381                    location.clone(),
382                ));
383            }
384            if is_default_runner_variable(name) {
385                checks.push(Check::warn(
386                    "env_file.env.default_variable",
387                    format!(
388                        "`{name}` is a default runner variable and cannot be reliably overwritten"
389                    ),
390                    location,
391                ));
392            }
393        }
394        EnvFileKind::Output => {
395            if masks_contain_exact(masks, value) {
396                checks.push(Check::fail(
397                    "env_file.output.masked_value",
398                    "GITHUB_OUTPUT attempts to set a value previously registered with add-mask",
399                    location,
400                ));
401            }
402        }
403        EnvFileKind::State | EnvFileKind::Path | EnvFileKind::StepSummary => {}
404    }
405}
406
407fn record(
408    line: usize,
409    end_line: Option<usize>,
410    kind: EnvFileRecordKind,
411    name: Option<&str>,
412    value: &str,
413    masks: &[String],
414) -> EnvFileRecord {
415    EnvFileRecord {
416        line,
417        end_line,
418        kind,
419        name: name.map(ToString::to_string),
420        value: redact_with_masks(value, masks),
421        value_bytes: value.len(),
422    }
423}
424
425fn is_default_runner_variable(name: &str) -> bool {
426    if name.eq_ignore_ascii_case("CI") {
427        return false;
428    }
429    let upper = name.to_ascii_uppercase();
430    upper.starts_with("GITHUB_") || upper.starts_with("RUNNER_")
431}
432
433#[derive(Clone, Debug)]
434struct LogicalLine {
435    number: usize,
436    text: String,
437}
438
439fn logical_lines(text: &str) -> Vec<LogicalLine> {
440    let normalized = text.replace("\r\n", "\n");
441    normalized
442        .split('\n')
443        .enumerate()
444        .filter_map(|(index, line)| {
445            if index == normalized.matches('\n').count() && line.is_empty() {
446                None
447            } else {
448                Some(LogicalLine {
449                    number: index + 1,
450                    text: line.trim_end_matches('\r').to_string(),
451                })
452            }
453        })
454        .collect()
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn parses_assignments_and_heredocs() {
463        let analysis = analyze_env_file_with_checks(
464            EnvFileKind::Output,
465            "ONE=two\nJSON<<EOF\n{\"ok\":true}\nEOF\n",
466            Some("GITHUB_OUTPUT".to_string()),
467            &[],
468        );
469        assert_eq!(analysis.records.len(), 2);
470        assert_eq!(analysis.records[0].name.as_deref(), Some("ONE"));
471        assert_eq!(analysis.records[1].value, "{\"ok\":true}");
472        assert!(
473            analysis
474                .checks
475                .iter()
476                .any(|check| check.id == "env_file.output.records")
477        );
478    }
479
480    #[test]
481    fn reports_missing_heredoc_delimiter() {
482        let analysis =
483            analyze_env_file_with_checks(EnvFileKind::Env, "NAME<<EOF\nvalue\n", None, &[]);
484        assert!(
485            analysis
486                .checks
487                .iter()
488                .any(|check| check.id == "env_file.env.heredoc.delimiter")
489        );
490    }
491
492    #[test]
493    fn blocks_node_options() {
494        let analysis =
495            analyze_env_file_with_checks(EnvFileKind::Env, "NODE_OPTIONS=--inspect\n", None, &[]);
496        assert!(
497            analysis
498                .checks
499                .iter()
500                .any(|check| check.id == "env_file.env.node_options")
501        );
502    }
503
504    #[test]
505    fn flags_masked_output_values() {
506        let analysis = analyze_env_file_with_checks(
507            EnvFileKind::Output,
508            "token=s3cr3t\n",
509            None,
510            &["s3cr3t".to_string()],
511        );
512        assert_eq!(analysis.records[0].value, "***");
513        assert!(
514            analysis
515                .checks
516                .iter()
517                .any(|check| check.id == "env_file.output.masked_value")
518        );
519    }
520
521    #[test]
522    fn rejects_large_step_summary() {
523        let huge = "x".repeat(STEP_SUMMARY_LIMIT_BYTES + 1);
524        let analysis = analyze_env_file_with_checks(EnvFileKind::StepSummary, &huge, None, &[]);
525        assert!(
526            analysis
527                .checks
528                .iter()
529                .any(|check| check.id == "env_file.step_summary.size")
530        );
531    }
532}