返回 CodeWhale
shell_expand.rs
根目录 / crates / execpolicy / src / shell_expand.rs
1 //! Expand a shell command line into the set of commands a shell would run.
2 //!
3 //! Deny rules are the one gate that holds under `AskForApproval::Never`, so
4 //! they cannot be matched against the raw command string: the string a user
5 //! types and the set of commands the shell executes are different things. A
6 //! command substitution runs its body (`` `rm -rf /` ``, `$(rm -rf /)`), a
7 //! quoted argument executes with the quotes removed (`rm -rf "/"`), and a
8 //! wrapper hands its payload straight back to a shell (`bash -c '…'`,
9 //! `eval '…'`, `sudo …`).
10 //!
11 //! Matching one string pattern per metacharacter loses that race by
12 //! construction — every new quoting or wrapping form is another bypass. This
13 //! module instead tokenizes the command the way a POSIX shell word-splits it
14 //! and returns *every* command line that would actually be executed, so deny
15 //! rules can be matched against each one.
16 //!
17 //! Deliberately conservative in the deny direction: when a construct is
18 //! ambiguous the expander emits extra candidate command lines rather than
19 //! fewer. Over-emitting only makes deny matching stricter — `denied_prefix_matches`
20 //! stays anchored at the first positional token, so an extra candidate that no
21 //! rule names is inert. Under-emitting is a bypass.
22 //!
23 //! What it does *not* do is evaluate anything: `$VAR` is left as literal text,
24 //! and single-quoted text is never treated as code (`echo '` + "`" + `rm -rf /`" +
25 //! "`" + `'` really does just print). Fidelity to shell semantics is the point in
26 //! both directions.
27
28 use std::collections::HashSet;
29
30 /// Maximum nesting depth followed through substitutions and `-c` payloads.
31 const MAX_DEPTH: usize = 8;
32
33 /// Upper bound on emitted command lines, so a pathological input cannot turn
34 /// one policy check into unbounded work.
35 const MAX_COMMANDS: usize = 256;
36
37 /// How far into a command the search for a wrapper head (`bash -c`, `eval`)
38 /// will walk past flags and wrapper words.
39 const MAX_HEAD_SCAN: usize = 8;
40
41 /// Words that prefix another command rather than being the command: the real
42 /// invocation is what follows. Stripping them keeps `sudo rm -rf /` matchable
43 /// by an `rm -rf /` rule.
44 const PASSTHROUGH_WRAPPERS: &[&str] = &[
45 "sudo", "doas", "env", "nohup", "nice", "ionice", "time", "timeout", "stdbuf", "setsid",
46 "command", "builtin", "exec", "xargs", "unbuffer", "busybox", "chroot", "proot",
47 ];
48
49 /// Shells whose `-c` argument is a command line to be parsed, not an operand.
50 const SHELL_NAMES: &[&str] = &[
51 "sh", "bash", "zsh", "dash", "ksh", "ksh93", "mksh", "ash", "fish", "csh", "tcsh", "rbash",
52 "yash",
53 ];
54
55 /// Returns every command line the shell would execute for `command`.
56 ///
57 /// The raw input is always included first, so callers keep whatever matching
58 /// they already did against it. Subsequent entries are the word-split, quote-
59 /// stripped command lines drawn from top-level chaining, command substitutions,
60 /// process substitutions, grouping, and wrapper payloads. Results are
61 /// de-duplicated and order-stable.
62 pub fn expanded_commands(command: &str) -> Vec<String> {
63 let mut expander = Expander {
64 out: Vec::new(),
65 seen: HashSet::new(),
66 };
67 let trimmed = command.trim();
68 if !trimmed.is_empty() {
69 expander.seen.insert(trimmed.to_string());
70 expander.out.push(trimmed.to_string());
71 }
72 expander.expand(command, 0);
73 expander.out
74 }
75
76 struct Expander {
77 out: Vec<String>,
78 seen: HashSet<String>,
79 }
80
81 impl Expander {
82 fn emit(&mut self, tokens: &[String]) {
83 if self.out.len() >= MAX_COMMANDS {
84 return;
85 }
86 let joined = tokens
87 .iter()
88 .filter(|token| !token.is_empty())
89 .cloned()
90 .collect::<Vec<_>>()
91 .join(" ");
92 if joined.is_empty() {
93 return;
94 }
95 if self.seen.insert(joined.clone()) {
96 self.out.push(joined);
97 }
98 }
99
100 /// Word-split `input` into command lines and record each one, recursing
101 /// into every nested command text found along the way.
102 fn expand(&mut self, input: &str, depth: usize) {
103 if depth > MAX_DEPTH || self.out.len() >= MAX_COMMANDS {
104 return;
105 }
106 let chars: Vec<char> = input.chars().collect();
107 let n = chars.len();
108 let mut i = 0usize;
109 let mut commands: Vec<Vec<String>> = Vec::new();
110 let mut words: Vec<String> = Vec::new();
111 let mut word = String::new();
112 let mut started = false;
113 let mut nested: Vec<String> = Vec::new();
114
115 while i < n {
116 let c = chars[i];
117 match c {
118 // A backslash outside quotes escapes exactly one character,
119 // including an operator: `echo a\;b` is one word, not two
120 // commands. A backslash-newline is a line continuation.
121 '\\' => {
122 if i + 1 < n {
123 if chars[i + 1] != '\n' {
124 word.push(chars[i + 1]);
125 started = true;
126 }
127 i += 2;
128 } else {
129 i += 1;
130 }
131 }
132 // Single quotes are fully literal: no substitution, no escapes.
133 '\'' => {
134 started = true;
135 i += 1;
136 while i < n && chars[i] != '\'' {
137 word.push(chars[i]);
138 i += 1;
139 }
140 i = (i + 1).min(n);
141 }
142 // Double quotes suppress word splitting but NOT substitution.
143 '"' => {
144 started = true;
145 i += 1;
146 while i < n && chars[i] != '"' {
147 match chars[i] {
148 '\\' if i + 1 < n => {
149 word.push(chars[i + 1]);
150 i += 2;
151 }
152 '`' => {
153 let (inner, next) = read_backtick(&chars, i);
154 nested.push(inner);
155 i = next;
156 }
157 '$' if i + 1 < n && chars[i + 1] == '(' => {
158 let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
159 nested.push(inner);
160 i = next;
161 }
162 '$' if i + 1 < n && chars[i + 1] == '{' => {
163 let (inner, next) = read_delimited(&chars, i + 1, '{', '}');
164 nested.push(inner);
165 i = next;
166 }
167 ch => {
168 word.push(ch);
169 i += 1;
170 }
171 }
172 }
173 i = (i + 1).min(n);
174 }
175 // `$'…'` (ANSI-C quoting) is literal text with C escapes.
176 '$' if i + 1 < n && chars[i + 1] == '\'' => {
177 started = true;
178 i += 2;
179 while i < n && chars[i] != '\'' {
180 if chars[i] == '\\' && i + 1 < n {
181 word.push(chars[i + 1]);
182 i += 2;
183 } else {
184 word.push(chars[i]);
185 i += 1;
186 }
187 }
188 i = (i + 1).min(n);
189 }
190 // Command substitution, both spellings. The body is a command
191 // line in its own right; the substitution contributes no text
192 // to the enclosing word (we do not evaluate output).
193 '`' => {
194 let (inner, next) = read_backtick(&chars, i);
195 nested.push(inner);
196 i = next;
197 }
198 '$' if i + 1 < n && chars[i + 1] == '(' => {
199 let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
200 nested.push(inner);
201 i = next;
202 }
203 // `${…}` is an expansion, not a command — but it can *contain*
204 // one (`${x:-$(rm -rf /)}`), so the body is rescanned.
205 '$' if i + 1 < n && chars[i + 1] == '{' => {
206 let (inner, next) = read_delimited(&chars, i + 1, '{', '}');
207 nested.push(inner);
208 i = next;
209 }
210 // Process substitution `<(…)` / `>(…)` also runs its body.
211 '<' | '>' if i + 1 < n && chars[i + 1] == '(' => {
212 let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
213 nested.push(inner);
214 i = next;
215 }
216 ' ' | '\t' => {
217 flush_word(&mut words, &mut word, &mut started);
218 i += 1;
219 }
220 // A subshell boundary. `$(`, `<(` and `>(` were consumed by the
221 // arms above, so a bare paren here is grouping: the body is a
222 // command list of its own, not part of the surrounding word.
223 '(' | ')' => {
224 flush_word(&mut words, &mut word, &mut started);
225 end_command(&mut commands, &mut words);
226 i += 1;
227 }
228 // Control operators end the current command line. `&&`, `||`,
229 // `;;`, `|&` and runs of newlines collapse into one break.
230 '\n' | '\r' | ';' | '&' | '|' => {
231 flush_word(&mut words, &mut word, &mut started);
232 end_command(&mut commands, &mut words);
233 i += 1;
234 while i < n && matches!(chars[i], '\n' | '\r' | ';' | '&' | '|') {
235 i += 1;
236 }
237 }
238 _ => {
239 word.push(c);
240 started = true;
241 i += 1;
242 }
243 }
244 }
245 flush_word(&mut words, &mut word, &mut started);
246 end_command(&mut commands, &mut words);
247
248 for tokens in &commands {
249 self.record(tokens, depth);
250 }
251 for inner in nested {
252 self.expand(&inner, depth + 1);
253 }
254 }
255
256 /// Record one word-split command line, plus the invocation hiding inside it
257 /// when the head is a wrapper.
258 fn record(&mut self, tokens: &[String], depth: usize) {
259 if tokens.is_empty() {
260 return;
261 }
262 self.emit(tokens);
263
264 // `sudo rm -rf /` is an `rm -rf /`. Strip wrapper words (and the scalar
265 // arguments that belong to them, e.g. `timeout 5`) and emit what's left.
266 let stripped = strip_leading_wrappers(tokens);
267 if stripped.len() != tokens.len() {
268 self.emit(stripped);
269 }
270
271 // `eval …` and `sh -c …` take a *command line* as data. Parse it.
272 if let Some(head) = find_wrapper_head(tokens) {
273 let name = basename(&tokens[head]).to_ascii_lowercase();
274 if name == "eval" {
275 let payload = tokens[head + 1..].join(" ");
276 self.expand(&payload, depth + 1);
277 } else if let Some(script) = shell_c_argument(&tokens[head..]) {
278 self.expand(script, depth + 1);
279 }
280 }
281 }
282 }
283
284 fn flush_word(words: &mut Vec<String>, word: &mut String, started: &mut bool) {
285 if *started || !word.is_empty() {
286 words.push(std::mem::take(word));
287 *started = false;
288 }
289 }
290
291 fn end_command(commands: &mut Vec<Vec<String>>, words: &mut Vec<String>) {
292 // `{` and `}` stand alone as reserved words in `{ cmd; }` — they group a
293 // command rather than being part of one. Dropping them here keeps every
294 // downstream consumer (wrapper detection, emission) looking at real
295 // command words only.
296 words.retain(|word| !matches!(word.as_str(), "{" | "}"));
297 if !words.is_empty() {
298 commands.push(std::mem::take(words));
299 }
300 }
301
302 /// Read a backtick substitution. `start` indexes the opening backtick; returns
303 /// the body and the index just past the closing backtick.
304 fn read_backtick(chars: &[char], start: usize) -> (String, usize) {
305 let mut i = start + 1;
306 let mut inner = String::new();
307 while i < chars.len() {
308 match chars[i] {
309 '\\' if i + 1 < chars.len() => {
310 inner.push(chars[i]);
311 inner.push(chars[i + 1]);
312 i += 2;
313 }
314 '`' => return (inner, i + 1),
315 c => {
316 inner.push(c);
317 i += 1;
318 }
319 }
320 }
321 (inner, i)
322 }
323
324 /// Read a balanced `open`/`close` region. `open_at` indexes the opening
325 /// delimiter; returns the body and the index just past the matching close.
326 fn read_delimited(chars: &[char], open_at: usize, open: char, close: char) -> (String, usize) {
327 let mut depth = 1usize;
328 let mut i = open_at + 1;
329 let mut inner = String::new();
330 while i < chars.len() {
331 let c = chars[i];
332 if c == '\\' && i + 1 < chars.len() {
333 inner.push(c);
334 inner.push(chars[i + 1]);
335 i += 2;
336 continue;
337 }
338 if c == open {
339 depth += 1;
340 } else if c == close {
341 depth -= 1;
342 if depth == 0 {
343 return (inner, i + 1);
344 }
345 }
346 inner.push(c);
347 i += 1;
348 }
349 (inner, i)
350 }
351
352 /// The final path component, so `/usr/bin/sudo` reads as `sudo`.
353 fn basename(token: &str) -> &str {
354 token
355 .rsplit(['/', '\\'])
356 .next()
357 .filter(|part| !part.is_empty())
358 .unwrap_or(token)
359 }
360
361 fn is_env_assignment(token: &str) -> bool {
362 match token.split_once('=') {
363 Some((name, _)) => {
364 !name.is_empty()
365 && !name.starts_with('-')
366 && name
367 .chars()
368 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
369 }
370 None => false,
371 }
372 }
373
374 /// True for a bare scalar operand that belongs to a wrapper word rather than
375 /// starting a command — `timeout 5`, `nice -n 10`, `timeout 1.5s`.
376 fn is_scalar_operand(token: &str) -> bool {
377 let body = token.trim_end_matches(['s', 'm', 'h', 'd']);
378 !body.is_empty() && body.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
379 }
380
381 fn is_passthrough_wrapper(token: &str) -> bool {
382 let name = basename(token).to_ascii_lowercase();
383 PASSTHROUGH_WRAPPERS.contains(&name.as_str())
384 }
385
386 fn is_shell_name(token: &str) -> bool {
387 let name = basename(token).to_ascii_lowercase();
388 SHELL_NAMES.contains(&name.as_str())
389 }
390
391 /// Drop leading environment assignments, wrapper words, and the scalar operands
392 /// those wrappers take, returning the remaining slice.
393 ///
394 /// Flags are deliberately *not* dropped: `denied_prefix_matches` already skips
395 /// unrelated flags (and, ambiguously, their values) when anchoring a rule, so
396 /// leaving `-u root` in place is both correct and matchable.
397 fn strip_leading_wrappers(tokens: &[String]) -> &[String] {
398 let mut start = 0usize;
399 let mut dropped_wrapper = false;
400 while start < tokens.len() {
401 let token = &tokens[start];
402 if is_env_assignment(token) {
403 start += 1;
404 } else if is_passthrough_wrapper(token) {
405 dropped_wrapper = true;
406 start += 1;
407 } else if dropped_wrapper && is_scalar_operand(token) {
408 start += 1;
409 } else {
410 break;
411 }
412 }
413 &tokens[start..]
414 }
415
416 /// Index of the `eval` / shell word that introduces a nested command line, if
417 /// this invocation has one.
418 ///
419 /// The scan walks past environment assignments, wrapper words, flags, and the
420 /// operand immediately following a single-dash flag (which may be that flag's
421 /// value, as in `sudo -u root bash -c …`). It stops at the first token that
422 /// cannot plausibly precede the real command, which is what keeps
423 /// `echo bash -c 'rm -rf /'` — a command that only prints — from being read as
424 /// a shell invocation.
425 fn find_wrapper_head(tokens: &[String]) -> Option<usize> {
426 let mut previous_was_short_flag = false;
427 for (index, token) in tokens.iter().enumerate().take(MAX_HEAD_SCAN) {
428 if is_shell_name(token) || basename(token).eq_ignore_ascii_case("eval") {
429 return Some(index);
430 }
431 let skippable = is_env_assignment(token)
432 || is_passthrough_wrapper(token)
433 || token.starts_with('-')
434 || is_scalar_operand(token)
435 || previous_was_short_flag;
436 if !skippable {
437 return None;
438 }
439 previous_was_short_flag = token.starts_with('-') && !token.starts_with("--");
440 }
441 None
442 }
443
444 /// The command-line argument of a shell's `-c` flag, if present.
445 ///
446 /// `tokens[0]` is the shell. Combined short flags count (`bash -lc '…'`).
447 /// The scan deliberately does NOT stop at the first non-flag operand: an
448 /// earlier version did, and `bash -o vi -c 'payload'` walked straight past
449 /// the deny expander because `vi` (the argument of `-o`) ended the scan
450 /// before `-c` was seen (2026-08-04 review). Continuing the scan can
451 /// over-read a `-c` that is really an argument to a script
452 /// (`bash script.sh -c x`), but this expander's contract is explicit that
453 /// over-emitting targets is safe and under-emitting is a bypass.
454 fn shell_c_argument(tokens: &[String]) -> Option<&str> {
455 let mut index = 1usize;
456 while index < tokens.len() {
457 let token = tokens[index].as_str();
458 let takes_command_line = match token.strip_prefix("--") {
459 Some(long) => long.eq_ignore_ascii_case("command"),
460 None => token
461 .strip_prefix('-')
462 .is_some_and(|flags| flags.contains('c')),
463 };
464 if takes_command_line {
465 return tokens.get(index + 1).map(String::as_str);
466 }
467 index += 1;
468 }
469 None
470 }
471
472 #[cfg(test)]
473 mod tests {
474 use super::*;
475
476 fn expand(command: &str) -> Vec<String> {
477 expanded_commands(command)
478 }
479
480 fn contains(command: &str, expected: &str) -> bool {
481 expand(command).iter().any(|target| target == expected)
482 }
483
484 #[test]
485 fn backtick_body_is_a_command() {
486 assert!(contains("`rm -rf /`", "rm -rf /"));
487 assert!(contains("echo `rm -rf /`", "rm -rf /"));
488 assert!(contains("echo `rm -rf /`", "echo"));
489 }
490
491 #[test]
492 fn dollar_paren_body_is_a_command() {
493 assert!(contains("echo $(rm -rf /)", "rm -rf /"));
494 assert!(contains("x=$(rm -rf /)", "rm -rf /"));
495 assert!(contains("echo \"$(rm -rf /)\"", "rm -rf /"));
496 }
497
498 #[test]
499 fn nested_substitution_is_followed() {
500 assert!(contains("echo $(echo `rm -rf /`)", "rm -rf /"));
501 }
502
503 #[test]
504 fn quotes_are_removed_from_operands() {
505 assert!(contains("rm -rf \"/\"", "rm -rf /"));
506 assert!(contains("rm -rf '/'", "rm -rf /"));
507 assert!(contains("\"rm\" -rf /", "rm -rf /"));
508 assert!(contains("rm -r\"f\" /", "rm -rf /"));
509 }
510
511 #[test]
512 fn single_quoted_text_is_not_a_command() {
513 // A literal backtick inside single quotes is printed, not executed.
514 let targets = expand("echo '`rm -rf /`'");
515 assert!(
516 !targets.iter().any(|t| t == "rm -rf /"),
517 "single-quoted text must not become a command: {targets:?}"
518 );
519 }
520
521 #[test]
522 fn escaped_operators_do_not_split() {
523 let targets = expand("echo a\\;b");
524 assert_eq!(targets.len(), 2, "{targets:?}");
525 assert!(targets.contains(&"echo a;b".to_string()), "{targets:?}");
526 }
527
528 #[test]
529 fn control_operators_split_commands() {
530 for command in [
531 "ls && rm -rf /",
532 "ls || rm -rf /",
533 "ls ; rm -rf /",
534 "ls | rm -rf /",
535 "ls & rm -rf /",
536 "ls\nrm -rf /",
537 ] {
538 assert!(contains(command, "rm -rf /"), "{command}");
539 }
540 }
541
542 #[test]
543 fn wrappers_and_payloads_are_unwrapped() {
544 for command in [
545 "sudo rm -rf /",
546 "env rm -rf /",
547 "timeout 5 rm -rf /",
548 "nohup rm -rf /",
549 "xargs rm -rf /",
550 "/usr/bin/sudo rm -rf /",
551 "eval 'rm -rf /'",
552 "bash -c 'rm -rf /'",
553 "sh -lc \"rm -rf /\"",
554 "sudo -u root bash -c 'rm -rf /'",
555 // 2026-08-04: `-o vi` used to end the flag scan before `-c` was
556 // seen, so the payload skipped deny expansion entirely.
557 "bash -o vi -c 'rm -rf /'",
558 "zsh --norcs -c 'rm -rf /'",
559 ] {
560 assert!(
561 contains(command, "rm -rf /"),
562 "{command}: {:?}",
563 expand(command)
564 );
565 }
566 }
567
568 #[test]
569 fn wrapper_head_scan_stops_at_a_real_command() {
570 // `echo` prints its arguments; nothing here is executed as a shell.
571 let targets = expand("echo bash -c 'rm -rf /'");
572 assert!(
573 !targets.iter().any(|t| t == "rm -rf /"),
574 "arguments of a printing command must not be parsed as code: {targets:?}"
575 );
576 }
577
578 #[test]
579 fn process_and_parameter_substitution_bodies_are_commands() {
580 assert!(contains("diff <(rm -rf /) b", "rm -rf /"));
581 assert!(contains("echo ${x:-$(rm -rf /)}", "rm -rf /"));
582 }
583
584 #[test]
585 fn expansion_is_bounded() {
586 let deep = "$(".repeat(64) + "rm -rf /" + &")".repeat(64);
587 let targets = expand(&deep);
588 assert!(targets.len() <= MAX_COMMANDS);
589 }
590
591 #[test]
592 fn grouping_is_a_command_boundary() {
593 assert!(contains("(rm -rf /)", "rm -rf /"));
594 assert!(contains("{ rm -rf /; }", "rm -rf /"));
595 assert!(contains("(cd /tmp && rm -rf /)", "rm -rf /"));
596 // Escaped and quoted parens are operands, not grouping.
597 assert!(contains(
598 "find . \\( -name a \\) -print",
599 "find . ( -name a ) -print"
600 ));
601 }
602
603 #[test]
604 fn plain_command_expands_to_itself() {
605 assert_eq!(expand("git status -s"), vec!["git status -s".to_string()]);
606 }
607 }
608
608 lines RUST