/** * Bash command analysis via tree-sitter (real AST), with a regex fallback. * * Replaces the foolable token scan for the common case: `extractCommands` walks * the tree-sitter-bash CST into the list of commands in a line — including those * nested in `$(...)`, backticks, and subshells — so privilege escalation and * out-of-project path arguments are detected even when hidden inside * substitutions. Shell `-c` scripts (`bash -c '…'`) are re-parsed recursively so * their inner commands are seen too, and privilege escalation is detected * through wrapper commands (`env sudo …`, `nice -n 10 sudo …`). When the WASM * grammar can't be loaded, `analyzeBash` degrades to the original * `bashConfirmReason` heuristic (heuristics.ts), so behavior is never worse * than before. * * `extractCommands` is pure and works over a minimal node shape, so it's * unit-tested with hand-built trees (no WASM); only the lazy parser init touches * the runtime. */ import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { bashConfirmReason, PRIVILEGE_RE } from "./heuristics.ts"; import { isOutside, SAFE_OUTSIDE_RE } from "./paths.ts"; /** One command extracted from a bash line. */ export interface BashCommand { /** Command head, e.g. "git", "sudo", "cat". */ name: string; /** Remaining tokens (args), quotes stripped. */ args: string[]; /** True when the command sits inside `$(...)`, backticks, or a subshell. */ isNested: boolean; } /** Minimal structural view of a tree-sitter node (real SyntaxNode satisfies it). */ export interface SyntaxNodeLike { type: string; text: string; children: SyntaxNodeLike[]; } /** Node types that introduce a nested execution context. */ const NESTING = new Set(["command_substitution", "subshell", "process_substitution"]); /** Child node types treated as command arguments. */ const ARG_TYPES = new Set([ "word", "string", "raw_string", "ansi_c_string", "concatenation", "number", "simple_expansion", "expansion", ]); const stripQuotes = (s: string): string => s.replace(/^['"]+|['"]+$/g, ""); function parseCommand(node: SyntaxNodeLike, isNested: boolean): BashCommand { let name = ""; const args: string[] = []; for (const child of node.children ?? []) { if (child.type === "command_name") { if (!name) name = child.text.trim(); } else if (ARG_TYPES.has(child.type)) { args.push(stripQuotes(child.text)); } } return { name, args, isNested }; } /** Walk a CST (or fake tree) into the list of commands, marking nested ones. */ export function extractCommands(root: SyntaxNodeLike): BashCommand[] { const out: BashCommand[] = []; const walk = (node: SyntaxNodeLike, nested: boolean) => { const inNest = nested || NESTING.has(node.type); if (node.type === "command") out.push(parseCommand(node, inNest)); for (const c of node.children ?? []) walk(c, inNest); }; walk(root, false); return out; } /** * Command heads that run their argument list as another command, so privilege * escalation can hide one level down (`env sudo …`, `nice -n 10 sudo …`, * `xargs sudo …`). Shells with `-c` are handled separately (the script is a * string needing a re-parse — see `expandShellCommands`). */ const WRAPPER_COMMANDS = new Set([ "env", "command", "nice", "ionice", "nohup", "setsid", "stdbuf", "timeout", "time", "xargs", "exec", "builtin", ]); /** Wrapper arguments to skip when looking for the wrapped command: flags * (`-n`, `--`), VAR=value assignments (env), and bare numbers (timeout 5, * nice -n 10). */ const SKIPPABLE_WRAPPER_ARG = /^(-|\w+=|\d+$)/; /** * True when the command escalates privileges — directly (`sudo …`) or through * known wrapper commands (`env PATH=/x sudo …`, `nice -n 10 doas …`): each * wrapper is unwrapped (skipping its flags/assignments/numeric args) and every * effective command head is tested. Bounded, so a pathological chain of * wrappers can't loop. */ export function isPrivilegeEscalation(c: BashCommand): boolean { let head = c.name; let rest = c.args; for (let hops = 0; hops < 8; hops++) { if (PRIVILEGE_RE.test(path.basename(head))) return true; if (!WRAPPER_COMMANDS.has(path.basename(head))) return false; const idx = rest.findIndex((a) => !SKIPPABLE_WRAPPER_ARG.test(a)); if (idx === -1) return false; head = rest[idx]; rest = rest.slice(idx + 1); } return false; } /** Shells whose `-c