Skip to main content

agentshield/parser/
shell.rs

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
33// Shell positional arguments represent values supplied to a function or script
34// invocation. Named variables can come from the caller environment.
35static 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
40// Recognize only canonicalization helpers that map to the existing path
41// sanitizer contract. Quoting by itself is not a sanitizer.
42static 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            // curl/wget = network operations
83            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            // eval
104            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            // backtick execution
113            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            // pip/npm install
124            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            // Sensitive env var access
133            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        // e.g. \\`whoami` - the backslash is escaped, so the backtick is active
457        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}