@@ -2,28 +2,65 @@ use super::{audit_meta, WorkflowAudit};
2
2
use crate :: finding:: { Confidence , Finding , Severity } ;
3
3
use crate :: models:: Step ;
4
4
use crate :: state:: AuditState ;
5
+ use anyhow:: Context ;
5
6
use github_actions_models:: workflow:: job:: StepBody ;
6
- use regex :: RegexSet ;
7
+ use std :: cell :: RefCell ;
7
8
use std:: ops:: Deref ;
8
- use std :: sync :: LazyLock ;
9
+ use tree_sitter :: Parser ;
9
10
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
+ }
21
15
22
16
audit_meta ! ( GitHubEnv , "github-env" , "dangerous use of GITHUB_ENV" ) ;
23
17
24
18
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
+ }
27
64
}
28
65
}
29
66
@@ -32,7 +69,14 @@ impl WorkflowAudit for GitHubEnv {
32
69
where
33
70
Self : Sized ,
34
71
{
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
+ } )
36
80
}
37
81
38
82
fn audit_step < ' w > ( & self , step : & Step < ' w > ) -> anyhow:: Result < Vec < Finding < ' w > > > {
@@ -47,8 +91,9 @@ impl WorkflowAudit for GitHubEnv {
47
91
return Ok ( findings) ;
48
92
}
49
93
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) ? {
52
97
findings. push (
53
98
Self :: finding ( )
54
99
. severity ( Severity :: High )
@@ -70,35 +115,52 @@ impl WorkflowAudit for GitHubEnv {
70
115
#[ cfg( test) ]
71
116
mod tests {
72
117
use crate :: audit:: github_env:: GitHubEnv ;
118
+ use crate :: audit:: WorkflowAudit ;
119
+ use crate :: state:: { AuditState , Caches } ;
73
120
74
121
#[ test]
75
- fn test_shell_patterns ( ) {
76
- for case in & [
122
+ fn test_exploitable_bash_patterns ( ) {
123
+ for ( case, expected ) in & [
77
124
// 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 ) ,
82
129
// 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 ) ,
87
134
// 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 ) ,
92
139
// 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 ) ,
100
150
] {
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) ;
102
164
}
103
165
}
104
166
}
0 commit comments