Skip to content

Commit a0e1723

Browse files
feat: even more precision for bash steps in github-env (#208)
Co-authored-by: William Woodruff <[email protected]>
1 parent bb71076 commit a0e1723

File tree

3 files changed

+116
-40
lines changed

3 files changed

+116
-40
lines changed

Diff for: Cargo.lock

+12
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ serde-sarif = "0.6.5"
3232
serde_json = "1.0.133"
3333
serde_yaml = "0.9.34"
3434
terminal-link = "0.1.0"
35+
tree-sitter = "0.23.2"
36+
tree-sitter-bash = "0.23.3"
3537
yamlpath = "0.12.0"
3638

3739
[profile.release]

Diff for: src/audit/github_env.rs

+102-40
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,65 @@ use super::{audit_meta, WorkflowAudit};
22
use crate::finding::{Confidence, Finding, Severity};
33
use crate::models::Step;
44
use crate::state::AuditState;
5+
use anyhow::Context;
56
use github_actions_models::workflow::job::StepBody;
6-
use regex::RegexSet;
7+
use std::cell::RefCell;
78
use std::ops::Deref;
8-
use std::sync::LazyLock;
9+
use tree_sitter::Parser;
910

10-
static GITHUB_ENV_WRITE_SHELL: LazyLock<RegexSet> = LazyLock::new(|| {
11-
RegexSet::new([
12-
// matches the `... >> $GITHUB_ENV` pattern
13-
r#"(?m)^.+\s*>>?\s*"?\$\{?GITHUB_ENV\}?"?.*$"#,
14-
// matches the `... | tee $GITHUB_ENV` pattern
15-
r#"(?m)^.*\|\s*tee\s+"?\$\{?GITHUB_ENV\}?"?.*$"#,
16-
])
17-
.unwrap()
18-
});
19-
20-
pub(crate) struct GitHubEnv;
11+
pub(crate) struct GitHubEnv {
12+
// NOTE: interior mutability used since Parser::parse requires &mut self
13+
bash_parser: RefCell<Parser>,
14+
}
2115

2216
audit_meta!(GitHubEnv, "github-env", "dangerous use of GITHUB_ENV");
2317

2418
impl GitHubEnv {
25-
fn uses_github_environment(run_step_body: &str) -> bool {
26-
GITHUB_ENV_WRITE_SHELL.is_match(run_step_body)
19+
fn bash_uses_github_env(&self, script_body: &str) -> anyhow::Result<bool> {
20+
let tree = &self
21+
.bash_parser
22+
.borrow_mut()
23+
.parse(script_body, None)
24+
.context("failed to parse bash script body")?;
25+
26+
let mut stack = vec![tree.root_node()];
27+
28+
while let Some(node) = stack.pop() {
29+
if node.is_named() && (node.kind() == "file_redirect" || node.kind() == "pipeline") {
30+
let tree_expansion = &script_body[node.start_byte()..node.end_byte()];
31+
let targets_github_env = tree_expansion.contains("GITHUB_ENV");
32+
let exploitable_redirects =
33+
tree_expansion.contains(">>") || tree_expansion.contains(">");
34+
35+
// Eventually we can detect specific commands within the expansion,
36+
// like tee and others
37+
let piped = tree_expansion.contains("|");
38+
39+
if (piped || exploitable_redirects) && targets_github_env {
40+
return Ok(true);
41+
}
42+
}
43+
44+
for child in node.named_children(&mut node.walk()) {
45+
stack.push(child);
46+
}
47+
}
48+
49+
Ok(false)
50+
}
51+
52+
fn uses_github_env(&self, run_step_body: &str, shell: &str) -> anyhow::Result<bool> {
53+
// TODO: handle `run:` bodies other than bash.
54+
match shell {
55+
"bash" => self.bash_uses_github_env(run_step_body),
56+
&_ => {
57+
log::warn!(
58+
"'{}' shell not supported when evaluating usage of GITHUB_ENV",
59+
shell
60+
);
61+
Ok(false)
62+
}
63+
}
2764
}
2865
}
2966

@@ -32,7 +69,14 @@ impl WorkflowAudit for GitHubEnv {
3269
where
3370
Self: Sized,
3471
{
35-
Ok(Self)
72+
let bash = tree_sitter_bash::LANGUAGE;
73+
let mut parser = Parser::new();
74+
parser
75+
.set_language(&bash.into())
76+
.context("failed to load bash parser")?;
77+
Ok(Self {
78+
bash_parser: RefCell::new(parser),
79+
})
3680
}
3781

3882
fn audit_step<'w>(&self, step: &Step<'w>) -> anyhow::Result<Vec<Finding<'w>>> {
@@ -47,8 +91,9 @@ impl WorkflowAudit for GitHubEnv {
4791
return Ok(findings);
4892
}
4993

50-
if let StepBody::Run { run, .. } = &step.deref().body {
51-
if Self::uses_github_environment(run) {
94+
if let StepBody::Run { run, shell, .. } = &step.deref().body {
95+
let interpreter = shell.clone().unwrap_or("bash".into());
96+
if self.uses_github_env(run, &interpreter)? {
5297
findings.push(
5398
Self::finding()
5499
.severity(Severity::High)
@@ -70,35 +115,52 @@ impl WorkflowAudit for GitHubEnv {
70115
#[cfg(test)]
71116
mod tests {
72117
use crate::audit::github_env::GitHubEnv;
118+
use crate::audit::WorkflowAudit;
119+
use crate::state::{AuditState, Caches};
73120

74121
#[test]
75-
fn test_shell_patterns() {
76-
for case in &[
122+
fn test_exploitable_bash_patterns() {
123+
for (case, expected) in &[
77124
// Common cases
78-
"echo foo >> $GITHUB_ENV",
79-
"echo foo >> \"$GITHUB_ENV\"",
80-
"echo foo >> ${GITHUB_ENV}",
81-
"echo foo >> \"${GITHUB_ENV}\"",
125+
("echo foo >> $GITHUB_ENV", true),
126+
("echo foo >> \"$GITHUB_ENV\"", true),
127+
("echo foo >> ${GITHUB_ENV}", true),
128+
("echo foo >> \"${GITHUB_ENV}\"", true),
82129
// Single > is buggy most of the time, but still exploitable
83-
"echo foo > $GITHUB_ENV",
84-
"echo foo > \"$GITHUB_ENV\"",
85-
"echo foo > ${GITHUB_ENV}",
86-
"echo foo > \"${GITHUB_ENV}\"",
130+
("echo foo > $GITHUB_ENV", true),
131+
("echo foo > \"$GITHUB_ENV\"", true),
132+
("echo foo > ${GITHUB_ENV}", true),
133+
("echo foo > \"${GITHUB_ENV}\"", true),
87134
// No spaces
88-
"echo foo>>$GITHUB_ENV",
89-
"echo foo>>\"$GITHUB_ENV\"",
90-
"echo foo>>${GITHUB_ENV}",
91-
"echo foo>>\"${GITHUB_ENV}\"",
135+
("echo foo>>$GITHUB_ENV", true),
136+
("echo foo>>\"$GITHUB_ENV\"", true),
137+
("echo foo>>${GITHUB_ENV}", true),
138+
("echo foo>>\"${GITHUB_ENV}\"", true),
92139
// tee cases
93-
"something | tee $GITHUB_ENV",
94-
"something | tee \"$GITHUB_ENV\"",
95-
"something | tee ${GITHUB_ENV}",
96-
"something | tee \"${GITHUB_ENV}\"",
97-
"something|tee $GITHUB_ENV",
98-
"something |tee $GITHUB_ENV",
99-
"something| tee $GITHUB_ENV",
140+
("something | tee $GITHUB_ENV", true),
141+
("something | tee \"$GITHUB_ENV\"", true),
142+
("something | tee ${GITHUB_ENV}", true),
143+
("something | tee \"${GITHUB_ENV}\"", true),
144+
("something|tee $GITHUB_ENV", true),
145+
("something |tee $GITHUB_ENV", true),
146+
("something| tee $GITHUB_ENV", true),
147+
// negative cases (comments should not be detected)
148+
("echo foo >> $OTHER_ENV # not $GITHUB_ENV", false),
149+
("something | tee \"${$OTHER_ENV}\" # not $GITHUB_ENV", false),
100150
] {
101-
assert!(GitHubEnv::uses_github_environment(case));
151+
let audit_state = AuditState {
152+
pedantic: false,
153+
offline: false,
154+
gh_token: None,
155+
caches: Caches::new(),
156+
};
157+
158+
let sut = GitHubEnv::new(audit_state).expect("failed to create audit");
159+
160+
let uses_github_env = sut
161+
.uses_github_env(case, "bash")
162+
.expect("test case is not valid Bash");
163+
assert_eq!(uses_github_env, *expected);
102164
}
103165
}
104166
}

0 commit comments

Comments
 (0)