1use std::path::{Path, PathBuf};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6use super::{LanguageParser, ParsedFile};
7use crate::error::Result;
8use crate::ir::execution_surface::*;
9use crate::ir::{ArgumentSource, Language, SourceLocation};
10
11pub struct ShellParser;
12
13static CURL_WGET_RE: Lazy<Regex> = Lazy::new(|| {
14 Regex::new(r"(?m)\b(curl|wget|aria2c|http|https)\s+").expect("static regex pattern is valid")
15});
16
17static EVAL_RE: Lazy<Regex> =
18 Lazy::new(|| Regex::new(r"(?m)\beval\s+").expect("static regex pattern is valid"));
19
20static INSTALL_RE: Lazy<Regex> = Lazy::new(|| {
21 Regex::new(r"(?m)\b(pip3?\s+install|npm\s+install|npm\s+i\b|yarn\s+add|pnpm\s+add|cargo\s+install|gem\s+install|go\s+install)")
22 .expect("static regex pattern is valid")
23});
24
25static BACKTICK_RE: Lazy<Regex> =
26 Lazy::new(|| Regex::new(r"`[^`]+`").expect("static regex pattern is valid"));
27
28static SENSITIVE_VAR_RE: Lazy<Regex> = Lazy::new(|| {
29 Regex::new(r"(?i)\$\{?(AWS_|SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY)")
30 .expect("static regex pattern is valid")
31});
32
33static SHELL_VARIABLE_RE: Lazy<Regex> = Lazy::new(|| {
36 Regex::new(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*)|([0-9]+|[@*#?]))")
37 .expect("static regex pattern is valid")
38});
39
40static PATH_SANITIZER_ASSIGN_RE: Lazy<Regex> = Lazy::new(|| {
43 Regex::new(
44 r#"(?m)^\s*(?:local\s+|readonly\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"?\$\(\s*(realpath|readlink\s+-f)\b"#,
45 )
46 .expect("static regex pattern is valid")
47});
48
49#[derive(Clone, Copy, PartialEq, Eq)]
50enum ShellQuoteState {
51 Unquoted,
52 SingleQuoted,
53 DoubleQuoted,
54}
55
56impl LanguageParser for ShellParser {
57 fn language(&self) -> Language {
58 Language::Shell
59 }
60
61 fn parse_file(&self, path: &Path, content: &str) -> Result<ParsedFile> {
62 let mut parsed = ParsedFile::default();
63 let file_path = PathBuf::from(path);
64
65 for capture in PATH_SANITIZER_ASSIGN_RE.captures_iter(content) {
66 let variable = capture.get(1).expect("sanitizer variable capture").as_str();
67 let helper = capture.get(2).expect("sanitizer helper capture").as_str();
68 parsed.sanitized_vars.insert(variable.to_string());
69 parsed
70 .sanitized_vars
71 .insert(format!("{variable}::path:{helper}"));
72 }
73
74 for (line_idx, line) in content.lines().enumerate() {
75 let line_num = line_idx + 1;
76 let trimmed = line.trim();
77
78 if trimmed.starts_with('#') || trimmed.is_empty() {
79 continue;
80 }
81
82 if let Some(cap) = CURL_WGET_RE.find(trimmed) {
84 let func = cap.as_str().trim();
85 let command_offset = line.find(trimmed).unwrap_or_default() + cap.end();
86 let (url_arg, url_location) = network_argument(
87 func,
88 &line[command_offset..],
89 command_offset,
90 &file_path,
91 line_num,
92 );
93 let arg_source = shell_arg_source(&url_arg, &parsed.sanitized_vars);
94 parsed.network_operations.push(NetworkOperation {
95 function: func.to_string(),
96 url_arg: arg_source,
97 method: None,
98 sends_data: trimmed.contains("-d ") || trimmed.contains("--data"),
99 location: url_location,
100 });
101 }
102
103 if EVAL_RE.is_match(trimmed) {
105 parsed.dynamic_exec.push(DynamicExec {
106 function: "eval".into(),
107 code_arg: shell_arg_source(trimmed, &parsed.sanitized_vars),
108 location: loc(&file_path, line_num),
109 });
110 }
111
112 for mat in BACKTICK_RE.find_iter(trimmed) {
114 if is_active_backtick(trimmed, mat.start()) {
115 parsed.commands.push(CommandInvocation {
116 function: "backtick".into(),
117 command_arg: ArgumentSource::Interpolated,
118 location: loc(&file_path, line_num),
119 });
120 }
121 }
122
123 if INSTALL_RE.is_match(trimmed) {
125 parsed.commands.push(CommandInvocation {
126 function: "package_install".into(),
127 command_arg: shell_arg_source(trimmed, &parsed.sanitized_vars),
128 location: loc(&file_path, line_num),
129 });
130 }
131
132 for cap in SENSITIVE_VAR_RE.captures_iter(trimmed) {
134 let var = cap.get(0).map(|m| m.as_str()).unwrap_or("");
135 parsed.env_accesses.push(EnvAccess {
136 var_name: ArgumentSource::Literal(var.to_string()),
137 is_sensitive: true,
138 location: loc(&file_path, line_num),
139 });
140 }
141 }
142
143 Ok(parsed)
144 }
145}
146
147fn is_active_backtick(line: &str, backtick_idx: usize) -> bool {
148 let mut state = ShellQuoteState::Unquoted;
149 let mut escaped = false;
150
151 for (idx, ch) in line.char_indices() {
152 if idx >= backtick_idx {
153 return state != ShellQuoteState::SingleQuoted && !escaped;
154 }
155
156 if escaped {
157 escaped = false;
158 continue;
159 }
160
161 match (state, ch) {
162 (ShellQuoteState::SingleQuoted, '\'') => state = ShellQuoteState::Unquoted,
163 (ShellQuoteState::SingleQuoted, _) => {}
164 (_, '\\') => escaped = state != ShellQuoteState::SingleQuoted,
165 (ShellQuoteState::Unquoted, '\'') => state = ShellQuoteState::SingleQuoted,
166 (ShellQuoteState::Unquoted, '"') => state = ShellQuoteState::DoubleQuoted,
167 (ShellQuoteState::DoubleQuoted, '"') => state = ShellQuoteState::Unquoted,
168 _ => {}
169 }
170 }
171
172 false
173}
174
175fn shell_arg_source(
176 command: &str,
177 sanitized_vars: &std::collections::HashSet<String>,
178) -> ArgumentSource {
179 if command.contains('`') || command.contains("$(") {
180 return ArgumentSource::Interpolated;
181 }
182
183 let variables = SHELL_VARIABLE_RE.captures_iter(command).collect::<Vec<_>>();
184 if variables.is_empty() {
185 return ArgumentSource::Literal(command.to_string());
186 }
187 if variables.len() != 1 {
188 return ArgumentSource::Interpolated;
189 }
190
191 let variable = &variables[0];
192 if let Some(positional) = variable.get(3).map(|value| value.as_str()) {
193 return ArgumentSource::Parameter {
194 name: format!("${positional}"),
195 };
196 }
197 let name = variable
198 .get(1)
199 .or_else(|| variable.get(2))
200 .expect("named variable capture")
201 .as_str();
202 if let Some(marker) = sanitized_vars
203 .iter()
204 .find(|value| value.starts_with(&format!("{name}::path:")))
205 {
206 return ArgumentSource::Sanitized {
207 sanitizer: marker
208 .split_once("::")
209 .expect("sanitizer marker includes separator")
210 .1
211 .to_string(),
212 };
213 }
214 ArgumentSource::EnvVar {
215 name: name.to_string(),
216 }
217}
218
219#[derive(Debug)]
220struct ShellToken {
221 value: String,
222 start: usize,
223 end: usize,
224}
225
226fn network_argument(
227 command: &str,
228 args: &str,
229 offset: usize,
230 file: &Path,
231 line: usize,
232) -> (String, SourceLocation) {
233 let tokens = shell_tokens(args, offset);
234 let mut skip_next = false;
235
236 for token in &tokens {
237 if skip_next {
238 skip_next = false;
239 continue;
240 }
241 if let Some(url) = token.value.strip_prefix("--url=") {
242 return (
243 url.to_string(),
244 loc_from_range(file, line, token.start + "--url=".len(), token.end),
245 );
246 }
247 if token.value == "--url" {
248 skip_next = false;
249 continue;
250 }
251 if takes_value(command, &token.value) {
252 skip_next = true;
253 continue;
254 }
255 if token.value.starts_with('-') {
256 continue;
257 }
258 return (
259 token.value.clone(),
260 loc_from_range(file, line, token.start, token.end),
261 );
262 }
263
264 (String::new(), loc(file, line))
265}
266
267fn takes_value(command: &str, option: &str) -> bool {
268 matches!(
269 option,
270 "-d" | "--data"
271 | "--data-raw"
272 | "--data-binary"
273 | "-H"
274 | "--header"
275 | "-X"
276 | "--request"
277 | "-o"
278 | "--output"
279 | "-O"
280 | "--output-document"
281 | "-e"
282 | "--referer"
283 | "-A"
284 | "--user-agent"
285 | "-u"
286 | "--user"
287 ) || (command == "wget" && matches!(option, "-P" | "--directory-prefix"))
288}
289
290fn shell_tokens(input: &str, offset: usize) -> Vec<ShellToken> {
291 let mut tokens = Vec::new();
292 let mut token_start = None;
293 let mut value = String::new();
294 let mut quote = None;
295
296 for (index, ch) in input.char_indices() {
297 match quote {
298 Some(current) if ch == current => quote = None,
299 Some(_) => value.push(ch),
300 None if matches!(ch, '\'' | '"') => {
301 quote = Some(ch);
302 token_start.get_or_insert(index);
303 }
304 None if ch.is_whitespace() => {
305 if let Some(start) = token_start.take() {
306 tokens.push(ShellToken {
307 value: std::mem::take(&mut value),
308 start: offset + start,
309 end: offset + index,
310 });
311 }
312 }
313 None => {
314 token_start.get_or_insert(index);
315 value.push(ch);
316 }
317 }
318 }
319 if let Some(start) = token_start {
320 tokens.push(ShellToken {
321 value,
322 start: offset + start,
323 end: offset + input.len(),
324 });
325 }
326 tokens
327}
328
329fn loc_from_range(file: &Path, line: usize, start: usize, end: usize) -> SourceLocation {
330 SourceLocation {
331 file: file.to_path_buf(),
332 line,
333 column: start,
334 end_line: Some(line),
335 end_column: Some(end),
336 }
337}
338
339fn loc(file: &Path, line: usize) -> SourceLocation {
340 SourceLocation {
341 file: file.to_path_buf(),
342 line,
343 column: 0,
344 end_line: Some(line),
345 end_column: Some(0),
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn detects_curl() {
355 let code = "curl https://example.com/data\n";
356 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
357 assert_eq!(parsed.network_operations.len(), 1);
358 assert!(matches!(
359 parsed.network_operations[0].url_arg,
360 ArgumentSource::Literal(_)
361 ));
362 assert_eq!(parsed.network_operations[0].location.end_line, Some(1));
363 }
364
365 #[test]
366 fn classifies_positional_environment_and_sanitized_shell_sources() {
367 let code = r#"
368curl "$1"
369curl "https://$API_HOST/v1"
370safe_path="$(realpath "$1")"
371curl "$safe_path"
372"#;
373 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
374 assert!(matches!(
375 parsed.network_operations[0].url_arg,
376 ArgumentSource::Parameter { ref name } if name == "$1"
377 ));
378 assert!(matches!(
379 parsed.network_operations[1].url_arg,
380 ArgumentSource::EnvVar { ref name } if name == "API_HOST"
381 ));
382 assert!(matches!(
383 parsed.network_operations[2].url_arg,
384 ArgumentSource::Sanitized { ref sanitizer } if sanitizer == "path:realpath"
385 ));
386 }
387
388 #[test]
389 fn classifies_the_curl_url_not_a_data_option() {
390 let code = "curl --data \"$payload\" https://api.example.test/v1\n";
391 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
392 assert!(matches!(
393 parsed.network_operations[0].url_arg,
394 ArgumentSource::Literal(ref url) if url == "https://api.example.test/v1"
395 ));
396 assert!(parsed.network_operations[0].location.column > 0);
397 }
398
399 #[test]
400 fn classifies_explicit_curl_url_option() {
401 let code = "curl --url \"$1\" --data payload\n";
402 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
403 assert!(matches!(
404 parsed.network_operations[0].url_arg,
405 ArgumentSource::Parameter { ref name } if name == "$1"
406
407 ));
408 }
409
410 #[test]
411 fn detects_eval() {
412 let code = "eval $USER_INPUT\n";
413 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
414 assert_eq!(parsed.dynamic_exec.len(), 1);
415 }
416
417 #[test]
418 fn detects_pip_install() {
419 let code = "pip install requests\n";
420 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
421 assert_eq!(parsed.commands.len(), 1);
422 assert!(parsed.commands[0].function.contains("package_install"));
423 }
424
425 #[test]
426 fn detects_backticks() {
427 let code = "echo `whoami`";
428 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
429 assert_eq!(parsed.commands.len(), 1);
430 assert_eq!(parsed.commands[0].function, "backtick");
431 }
432
433 #[test]
434 fn ignores_escaped_backticks() {
435 let code = "echo \\`whoami\\`";
436 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
437 assert_eq!(parsed.commands.len(), 0);
438 }
439
440 #[test]
441 fn ignores_single_quoted_backticks() {
442 let code = "echo '`whoami`'\n";
443 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
444 assert_eq!(parsed.commands.len(), 0);
445 }
446
447 #[test]
448 fn detects_backticks_after_apostrophe_in_double_quotes() {
449 let code = "echo \"it's\" `whoami`";
450 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
451 assert_eq!(parsed.commands.len(), 1);
452 }
453
454 #[test]
455 fn detects_double_escaped_backticks() {
456 let code = "echo \\\\`whoami`";
458 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
459 assert_eq!(parsed.commands.len(), 1);
460 }
461
462 #[test]
463 fn detects_multiple_backticks_per_line() {
464 let code = "res=\"`cmd1` `cmd2`\"";
465 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
466 assert_eq!(parsed.commands.len(), 2);
467 }
468
469 #[test]
470 fn detects_aria2c_and_httpie() {
471 let code = "aria2c https://example.com/file.tar.gz\nhttp https://api.example.com/data\n";
472 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
473 assert_eq!(parsed.network_operations.len(), 2);
474 assert_eq!(parsed.network_operations[0].function, "aria2c");
475 assert_eq!(parsed.network_operations[1].function, "http");
476 }
477
478 #[test]
479 fn detects_cargo_gem_and_go_install() {
480 let code = "cargo install evil-crate\ngem install evil-gem\ngo install github.com/evil/pkg@latest\n";
481 let parsed = ShellParser.parse_file(Path::new("test.sh"), code).unwrap();
482 assert_eq!(parsed.commands.len(), 3);
483 assert!(
484 parsed
485 .commands
486 .iter()
487 .all(|c| c.function == "package_install")
488 );
489 }
490}