Skip to main content

agentshield/parser/shell/
mod.rs

1pub(crate) mod classify;
2pub(crate) mod patterns;
3pub(crate) mod quote;
4
5use std::path::{Path, PathBuf};
6
7use classify::{loc, network_argument, shell_arg_source};
8use patterns::{
9    BACKTICK_RE, CURL_WGET_RE, EVAL_RE, INSTALL_RE, PATH_SANITIZER_ASSIGN_RE, SENSITIVE_VAR_RE,
10};
11use quote::is_active_backtick;
12
13use super::{LanguageParser, ParsedFile};
14use crate::error::Result;
15use crate::ir::execution_surface::*;
16use crate::ir::{ArgumentSource, Language};
17
18pub struct ShellParser;
19
20impl LanguageParser for ShellParser {
21    fn language(&self) -> Language {
22        Language::Shell
23    }
24
25    fn parse_file(&self, path: &Path, content: &str) -> Result<ParsedFile> {
26        let mut parsed = ParsedFile::default();
27        let file_path = PathBuf::from(path);
28
29        for capture in PATH_SANITIZER_ASSIGN_RE.captures_iter(content) {
30            let variable = capture.get(1).expect("sanitizer variable capture").as_str();
31            let helper = capture.get(2).expect("sanitizer helper capture").as_str();
32            parsed.sanitized_vars.insert(variable.to_string());
33            parsed
34                .sanitized_vars
35                .insert(format!("{variable}::path:{helper}"));
36        }
37
38        for (line_idx, line) in content.lines().enumerate() {
39            let line_num = line_idx + 1;
40            let trimmed = line.trim();
41
42            if trimmed.starts_with('#') || trimmed.is_empty() {
43                continue;
44            }
45
46            // curl/wget = network operations
47            if let Some(cap) = CURL_WGET_RE.find(trimmed) {
48                let func = cap.as_str().trim();
49                let command_offset = line.find(trimmed).unwrap_or_default() + cap.end();
50                let (url_arg, url_location) = network_argument(
51                    func,
52                    &line[command_offset..],
53                    command_offset,
54                    &file_path,
55                    line_num,
56                );
57                let arg_source = shell_arg_source(&url_arg, &parsed.sanitized_vars);
58                parsed.network_operations.push(NetworkOperation {
59                    function: func.to_string(),
60                    url_arg: arg_source,
61                    method: None,
62                    sends_data: trimmed.contains("-d ") || trimmed.contains("--data"),
63                    location: url_location,
64                });
65            }
66
67            // eval
68            if EVAL_RE.is_match(trimmed) {
69                parsed.dynamic_exec.push(DynamicExec {
70                    function: "eval".into(),
71                    code_arg: shell_arg_source(trimmed, &parsed.sanitized_vars),
72                    location: loc(&file_path, line_num),
73                });
74            }
75
76            // backtick execution
77            for mat in BACKTICK_RE.find_iter(trimmed) {
78                if is_active_backtick(trimmed, mat.start()) {
79                    parsed.commands.push(CommandInvocation {
80                        function: "backtick".into(),
81                        command_arg: ArgumentSource::Interpolated,
82                        location: loc(&file_path, line_num),
83                    });
84                }
85            }
86
87            // pip/npm install
88            if INSTALL_RE.is_match(trimmed) {
89                parsed.commands.push(CommandInvocation {
90                    function: "package_install".into(),
91                    command_arg: shell_arg_source(trimmed, &parsed.sanitized_vars),
92                    location: loc(&file_path, line_num),
93                });
94            }
95
96            // Sensitive env var access
97            for cap in SENSITIVE_VAR_RE.captures_iter(trimmed) {
98                let var = cap.get(0).map(|m| m.as_str()).unwrap_or("");
99                parsed.env_accesses.push(EnvAccess {
100                    var_name: ArgumentSource::Literal(var.to_string()),
101                    is_sensitive: true,
102                    location: loc(&file_path, line_num),
103                });
104            }
105        }
106
107        Ok(parsed)
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn detects_curl() {
117        let code = "curl https://example.com/data\n";
118        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
119        assert_eq!(parsed.network_operations.len(), 1);
120        assert!(matches!(
121            parsed.network_operations[0].url_arg,
122            ArgumentSource::Literal(_)
123        ));
124        assert_eq!(parsed.network_operations[0].location.end_line, Some(1));
125    }
126
127    #[test]
128    fn classifies_positional_environment_and_sanitized_shell_sources() {
129        let code = r#"
130curl "$1"
131curl "https://$API_HOST/v1"
132safe_path="$(realpath "$1")"
133curl "$safe_path"
134"#;
135        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
136        assert!(matches!(
137            parsed.network_operations[0].url_arg,
138            ArgumentSource::Parameter { ref name } if name == "$1"
139        ));
140        assert!(matches!(
141            parsed.network_operations[1].url_arg,
142            ArgumentSource::EnvVar { ref name } if name == "API_HOST"
143        ));
144        assert!(matches!(
145            parsed.network_operations[2].url_arg,
146            ArgumentSource::Sanitized { ref sanitizer } if sanitizer == "path:realpath"
147        ));
148    }
149
150    #[test]
151    fn classifies_the_curl_url_not_a_data_option() {
152        let code = "curl --data \"$payload\" https://api.example.test/v1\n";
153        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
154        assert!(matches!(
155            parsed.network_operations[0].url_arg,
156            ArgumentSource::Literal(ref url) if url == "https://api.example.test/v1"
157        ));
158        assert!(parsed.network_operations[0].location.column > 0);
159    }
160
161    #[test]
162    fn classifies_explicit_curl_url_option() {
163        let code = "curl --url \"$1\" --data payload\n";
164        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
165        assert!(matches!(
166            parsed.network_operations[0].url_arg,
167            ArgumentSource::Parameter { ref name } if name == "$1"
168
169        ));
170    }
171
172    #[test]
173    fn detects_eval() {
174        let code = "eval $USER_INPUT\n";
175        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
176        assert_eq!(parsed.dynamic_exec.len(), 1);
177    }
178
179    #[test]
180    fn detects_pip_install() {
181        let code = "pip install requests\n";
182        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
183        assert_eq!(parsed.commands.len(), 1);
184        assert!(parsed.commands[0].function.contains("package_install"));
185    }
186
187    #[test]
188    fn detects_backticks() {
189        let code = "echo `whoami`";
190        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
191        assert_eq!(parsed.commands.len(), 1);
192        assert_eq!(parsed.commands[0].function, "backtick");
193    }
194
195    #[test]
196    fn ignores_escaped_backticks() {
197        let code = "echo \\`whoami\\`";
198        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
199        assert_eq!(parsed.commands.len(), 0);
200    }
201
202    #[test]
203    fn ignores_single_quoted_backticks() {
204        let code = "echo '`whoami`'\n";
205        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
206        assert_eq!(parsed.commands.len(), 0);
207    }
208
209    #[test]
210    fn detects_backticks_after_apostrophe_in_double_quotes() {
211        let code = "echo \"it's\" `whoami`";
212        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
213        assert_eq!(parsed.commands.len(), 1);
214    }
215
216    #[test]
217    fn detects_double_escaped_backticks() {
218        // e.g. \\`whoami` - the backslash is escaped, so the backtick is active
219        let code = "echo \\\\`whoami`";
220        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
221        assert_eq!(parsed.commands.len(), 1);
222    }
223
224    #[test]
225    fn detects_multiple_backticks_per_line() {
226        let code = "res=\"`cmd1` `cmd2`\"";
227        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
228        assert_eq!(parsed.commands.len(), 2);
229    }
230
231    #[test]
232    fn detects_aria2c_and_httpie() {
233        let code = "aria2c https://example.com/file.tar.gz\nhttp https://api.example.com/data\n";
234        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
235        assert_eq!(parsed.network_operations.len(), 2);
236        assert_eq!(parsed.network_operations[0].function, "aria2c");
237        assert_eq!(parsed.network_operations[1].function, "http");
238    }
239
240    #[test]
241    fn detects_cargo_gem_and_go_install() {
242        let code = "cargo install evil-crate\ngem install evil-gem\ngo install github.com/evil/pkg@latest\n";
243        let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
244        assert_eq!(parsed.commands.len(), 3);
245        assert!(
246            parsed
247                .commands
248                .iter()
249                .all(|c| c.function == "package_install")
250        );
251    }
252}