agentshield/parser/shell/
mod.rs1pub(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 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 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 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 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 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 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}