/** * Bash tool safety layers. * * Seven layers of defense-in-depth for shell command execution, plus a * final UX gate: * 1. Environment variable stripping * 2. Critical path protection * 3. Command classification * 4. Path validation for write commands * 5. Obfuscation and injection detection * 6. Script preflight * 6.5. Interactive command detection (UX gate — prevents silent timeouts * on editors, pagers, REPLs, and interactive DB clients) * 7. Auto-mode classifier (utility model LLM call) * * Reference: docs/cortex/tools/bash.md (Safety Architecture) */ import * as path from 'node:path'; import * as fs from 'node:fs'; import { buildSafeEnv as buildSafeEnvShared } from '../shared/safe-env.js'; import { checkInteractive } from './interactive.js'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type CommandClassification = | 'read' | 'write' | 'create' | 'network' | 'safe-stdin' | 'unknown'; export interface SafetyCheckResult { allowed: boolean; reason?: string | undefined; classification?: CommandClassification | undefined; } // --------------------------------------------------------------------------- // Layer 1: Environment Variable Security // --------------------------------------------------------------------------- /** * Build a safe environment for child processes by stripping dangerous variables. * Adds CORTEX_SHELL=exec as a context marker. * * Delegates to the shared buildSafeEnv utility so that both the Bash tool * and the MCP client use the same blocklist. * * @param parentEnv - The source environment (typically process.env) * @param overrides - Optional env var overrides that bypass the blocklist */ export function buildSafeEnv( parentEnv: NodeJS.ProcessEnv, overrides?: Record, ): Record { return buildSafeEnvShared(parentEnv, 'exec', overrides); } // --------------------------------------------------------------------------- // Layer 2: Critical Path Protection // --------------------------------------------------------------------------- const UNIX_CRITICAL_PATHS = [ '/', '/usr', '/etc', '/boot', '/sbin', '/var', '/System', '/proc', '/sys', ]; const MACOS_CRITICAL_PATHS = [ path.join(process.env['HOME'] ?? '', 'Library'), ]; const WINDOWS_CRITICAL_PATHS = [ 'C:\\Windows', 'C:\\Windows\\System32', 'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData', ]; /** * Check if a target path resolves to a critical system directory. */ function getCriticalPaths(): string[] { return process.platform === 'win32' ? WINDOWS_CRITICAL_PATHS : [...UNIX_CRITICAL_PATHS, ...(process.platform === 'darwin' ? MACOS_CRITICAL_PATHS : [])]; } function normalizePathForSafety(targetPath: string): string { return path.resolve(targetPath).replace(/\\/g, '/').replace(/\/+$/, ''); } export function isCriticalPath(targetPath: string): boolean { const normalized = normalizePathForSafety(targetPath); for (const cp of getCriticalPaths()) { const normalizedCp = normalizePathForSafety(cp); if (normalized === normalizedCp || normalized.toLowerCase() === normalizedCp.toLowerCase()) { return true; } } // Check for Windows AppData if (process.platform === 'win32') { const userProfile = process.env['USERPROFILE']; if (userProfile) { const appDataPath = normalizePathForSafety(path.join(userProfile, 'AppData')); if (normalized.toLowerCase().startsWith(appDataPath.toLowerCase())) { return true; } } } return false; } export function isCriticalPathOrDescendant(targetPath: string): boolean { const normalized = normalizePathForSafety(targetPath); const normalizedLower = normalized.toLowerCase(); for (const cp of getCriticalPaths()) { const normalizedCp = normalizePathForSafety(cp); const normalizedCpLower = normalizedCp.toLowerCase(); if (normalizedLower === normalizedCpLower) return true; // Do not treat broad system roots as prefixes. For example, macOS temp // directories commonly live under /var/folders, and developer tools often // live under /usr/local. The exact paths are still critical. if (normalizedCp === '' || normalizedCp === '/usr' || normalizedCp === '/var' || /^[A-Za-z]:$/.test(normalizedCp)) continue; if (normalizedLower.startsWith(`${normalizedCpLower}/`)) return true; } if (process.platform === 'win32') { const userProfile = process.env['USERPROFILE']; if (userProfile) { const appDataPath = normalizePathForSafety(path.join(userProfile, 'AppData')).toLowerCase(); if (normalizedLower === appDataPath || normalizedLower.startsWith(`${appDataPath}/`)) { return true; } } } return false; } // --------------------------------------------------------------------------- // Layer 3: Command Classification // --------------------------------------------------------------------------- const UNIX_READ_COMMANDS = new Set([ 'cd', 'ls', 'find', 'cat', 'head', 'tail', 'sort', 'wc', 'diff', 'grep', 'echo', 'pwd', 'env', 'which', 'file', 'stat', 'strings', 'hexdump', 'less', 'more', 'tree', ]); const UNIX_WRITE_COMMANDS = new Set([ 'rm', 'rmdir', 'mv', 'cp', 'chmod', 'chown', ]); const UNIX_CREATE_COMMANDS = new Set([ 'mkdir', 'touch', 'tee', ]); const UNIX_NETWORK_COMMANDS = new Set([ 'curl', 'wget', 'ssh', 'scp', 'rsync', 'nc', 'nmap', ]); const UNIX_SAFE_STDIN_COMMANDS = new Set([ 'jq', 'cut', 'uniq', 'head', 'tail', 'tr', 'wc', ]); const PS_READ_COMMANDS = new Set([ 'get-content', 'get-childitem', 'get-item', 'get-location', 'select-string', 'compare-object', 'test-path', 'get-process', 'dir', 'type', 'where', ]); const PS_WRITE_COMMANDS = new Set([ 'remove-item', 'move-item', 'copy-item', 'set-content', 'rename-item', 'set-itemproperty', ]); const PS_CREATE_COMMANDS = new Set([ 'new-item', 'out-file', 'add-content', ]); const PS_NETWORK_COMMANDS = new Set([ 'invoke-webrequest', 'invoke-restmethod', 'test-netconnection', 'ssh', ]); /** * Git subcommands that are read-only. */ const GIT_READ_SUBCOMMANDS = new Set([ 'status', 'log', 'diff', 'show', 'branch', 'tag', 'remote', 'stash', 'blame', 'shortlog', 'describe', 'rev-parse', 'ls-files', 'ls-tree', ]); /** * Safe-stdin denied flags per binary. */ const SAFE_STDIN_DENIED_FLAGS: Record> = { grep: new Set(['-r', '-R', '-d', '-f', '--recursive', '--dereference-recursive', '--directories', '--file', '--exclude-from']), jq: new Set(['-f', '-L', '--from-file', '--library-path', '--argfile', '--rawfile', '--slurpfile']), sort: new Set(['-o', '-T', '--output', '--temporary-directory', '--compress-program', '--files0-from', '--random-source']), wc: new Set(['--files0-from']), }; /** * Split a command string on shell operators (; && || |) while respecting * quoted strings. Returns the individual sub-commands. */ export function splitOnShellOperators(command: string): string[] { const subCommands: string[] = []; let current = ''; let inSingle = false; let inDouble = false; let escaped = false; let i = 0; while (i < command.length) { const ch = command[i]!; if (escaped) { current += ch; escaped = false; i++; continue; } if (ch === '\\') { escaped = true; current += ch; i++; continue; } if (ch === "'" && !inDouble) { inSingle = !inSingle; current += ch; i++; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; current += ch; i++; continue; } // Only split when outside quotes if (!inSingle && !inDouble) { // Check for && or || if ((ch === '&' && command[i + 1] === '&') || (ch === '|' && command[i + 1] === '|')) { if (current.trim()) subCommands.push(current.trim()); current = ''; i += 2; continue; } // Check for single pipe (not ||) or semicolon if (ch === ';' || (ch === '|' && command[i + 1] !== '|')) { if (current.trim()) subCommands.push(current.trim()); current = ''; i++; continue; } } current += ch; i++; } if (current.trim()) subCommands.push(current.trim()); return subCommands; } function readRedirectionOperator(command: string, index: number): string | null { let cursor = index; while (/\d/.test(command[cursor] ?? '')) cursor++; const fdPrefix = cursor > index ? command.slice(index, cursor) : ''; const rest = command.slice(cursor); if (rest.startsWith('&>>')) return '&>>'; if (rest.startsWith('&>')) return '&>'; if (rest.startsWith('<<-')) return `${fdPrefix}<<-`; if (rest.startsWith('<<<')) return `${fdPrefix}<<<`; if (rest.startsWith('>>')) return `${fdPrefix}>>`; if (rest.startsWith('<<')) return `${fdPrefix}<<`; if (rest.startsWith('>|')) return `${fdPrefix}>|`; if (rest.startsWith('>&')) return `${fdPrefix}>&`; if (rest.startsWith('<>')) return `${fdPrefix}<>`; if (rest.startsWith('>')) return `${fdPrefix}>`; if (rest.startsWith('<')) return `${fdPrefix}<`; return null; } function tokenizeShellWords(command: string): string[] { const tokens: string[] = []; let current = ''; let inSingle = false; let inDouble = false; let escaped = false; let i = 0; const pushCurrent = () => { if (current) { tokens.push(current); current = ''; } }; while (i < command.length) { const ch = command[i]!; if (escaped) { current += ch; escaped = false; i++; continue; } if (ch === '\\' && !inSingle) { escaped = true; i++; continue; } if (ch === "'" && !inDouble) { inSingle = !inSingle; i++; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; i++; continue; } if (!inSingle && !inDouble) { if (/\s/.test(ch)) { pushCurrent(); i++; continue; } const redirection = readRedirectionOperator(command, i); if (redirection) { pushCurrent(); tokens.push(redirection); i += redirection.length; continue; } } current += ch; i++; } pushCurrent(); return tokens; } function isRedirectionOperator(token: string): boolean { return /^(?:\d*)?(?:>|>>|>\||>&|<>|<|<<|<<-|<<<)$/.test(token) || token === '&>' || token === '&>>'; } function isWriteRedirectionOperator(token: string): boolean { return /^(?:\d*)?(?:>|>>|>\||<>)$/.test(token) || token === '&>' || token === '&>>'; } function isFileDescriptorTarget(token: string): boolean { return /^&(?:\d+|-)$/.test(token); } function extractRedirectionWritePaths(tokens: string[]): string[] { const paths: string[] = []; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]!; if (!isWriteRedirectionOperator(token)) continue; const target = tokens[i + 1]; if (!target || isRedirectionOperator(target) || isFileDescriptorTarget(target)) continue; paths.push(target); } return paths; } function removeRedirectionTokens(tokens: string[]): string[] { const result: string[] = []; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]!; if (isRedirectionOperator(token)) { const target = tokens[i + 1]; if (target && !isRedirectionOperator(target)) i++; continue; } result.push(token); } return result; } function collectNonOptionOperands(tokens: string[]): string[] { return tokens.filter((token) => token !== '--' && !token.startsWith('-')); } function extractTargetDirectoryOption(tokens: string[]): string | null { for (let i = 1; i < tokens.length; i++) { const token = tokens[i]!; if (token === '-t' || token === '--target-directory') { return tokens[i + 1] ?? null; } if (token.startsWith('--target-directory=')) { return token.slice('--target-directory='.length); } } return null; } function hasSedInPlaceFlag(tokens: string[]): boolean { return tokens.slice(1).some((token) => token === '-i' || /^-i.+/.test(token)); } function extractSedInPlacePaths(tokens: string[]): string[] { if (!hasSedInPlaceFlag(tokens)) return []; const paths: string[] = []; let scriptConsumed = false; for (let i = 1; i < tokens.length; i++) { const token = tokens[i]!; if (token === '--') { paths.push(...tokens.slice(i + 1)); break; } if (token === '-i' || /^-i.+/.test(token)) continue; if (token === '-e' || token === '-f') { scriptConsumed = true; i++; continue; } if (/^-e.+/.test(token) || /^-f.+/.test(token)) { scriptConsumed = true; continue; } if (token.startsWith('-')) continue; if (!scriptConsumed) { scriptConsumed = true; continue; } paths.push(token); } return paths; } /** * Extract the command name from a single (non-compound) command string. */ function extractCommandName(singleCommand: string): string { const trimmed = singleCommand.trim(); // Handle 'sed -i' specifically if (/^sed\s+.*-i/.test(trimmed)) return 'sed-i'; // Get the first token (the command name) const tokens = trimmed.split(/\s+/); return (tokens[0] ?? '').toLowerCase(); } /** * Risk ordering from lowest to highest. Used to pick the most dangerous * classification when a compound command contains multiple sub-commands. */ const CLASSIFICATION_RISK_ORDER: readonly CommandClassification[] = [ 'read', 'safe-stdin', 'create', 'write', 'network', 'unknown', ]; /** * Return the higher-risk classification of two values. */ function higherRisk(a: CommandClassification, b: CommandClassification): CommandClassification { const aIdx = CLASSIFICATION_RISK_ORDER.indexOf(a); const bIdx = CLASSIFICATION_RISK_ORDER.indexOf(b); return aIdx >= bIdx ? a : b; } /** * Classify a single (non-compound) command by its potential impact. */ function classifySingleCommand(singleCommand: string): CommandClassification { const tokens = tokenizeShellWords(singleCommand); if (extractRedirectionWritePaths(tokens).length > 0) return 'write'; const cmdName = extractCommandName(singleCommand); const isWindows = process.platform === 'win32'; if (isWindows) { const psCmd = cmdName.toLowerCase(); if (PS_READ_COMMANDS.has(psCmd)) return 'read'; if (PS_WRITE_COMMANDS.has(psCmd)) return 'write'; if (PS_CREATE_COMMANDS.has(psCmd)) return 'create'; if (PS_NETWORK_COMMANDS.has(psCmd)) return 'network'; // Handle PS aliases if (psCmd === 'curl' || psCmd === 'wget') return 'network'; return 'unknown'; } // Unix // Handle git subcommands if (cmdName === 'git') { const parts = singleCommand.trim().split(/\s+/); const subcommand = parts[1]?.toLowerCase(); if (subcommand && GIT_READ_SUBCOMMANDS.has(subcommand)) return 'read'; return 'unknown'; } // Handle sed -i (write) if (cmdName === 'sed-i') return 'write'; if (UNIX_READ_COMMANDS.has(cmdName)) return 'read'; if (UNIX_WRITE_COMMANDS.has(cmdName)) return 'write'; if (UNIX_CREATE_COMMANDS.has(cmdName)) return 'create'; if (UNIX_NETWORK_COMMANDS.has(cmdName)) return 'network'; // Check safe-stdin if (UNIX_SAFE_STDIN_COMMANDS.has(cmdName)) { // Verify no denied flags and no file args const tokens = singleCommand.trim().split(/\s+/); const deniedFlags = SAFE_STDIN_DENIED_FLAGS[cmdName]; if (deniedFlags) { for (const token of tokens.slice(1)) { if (deniedFlags.has(token)) return 'unknown'; } } // Check for path-like positional arguments (simple heuristic) const args = tokens.slice(1).filter((t) => !t.startsWith('-')); const hasPathArgs = args.some((a) => a.includes('/') || a.includes('.')); if (hasPathArgs) return 'unknown'; return 'safe-stdin'; } return 'unknown'; } /** * Classify a command (potentially compound) by its potential impact. * For compound commands, returns the highest-risk classification * among all sub-commands. */ export function classifyCommand(command: string): CommandClassification { const subCommands = splitOnShellOperators(command); if (subCommands.length === 0) return 'unknown'; let result: CommandClassification = classifySingleCommand(subCommands[0]!); for (let i = 1; i < subCommands.length; i++) { result = higherRisk(result, classifySingleCommand(subCommands[i]!)); } return result; } // --------------------------------------------------------------------------- // Layer 4: Path Validation // --------------------------------------------------------------------------- /** * Extract target paths from write/create commands in a single sub-command. */ function extractWritePathsFromSingle(singleCommand: string): string[] { const rawTokens = tokenizeShellWords(singleCommand); const paths = extractRedirectionWritePaths(rawTokens); const tokens = removeRedirectionTokens(rawTokens); const cmd = (tokens[0] ?? '').toLowerCase(); if (['rm', 'rmdir', 'touch', 'mkdir'].includes(cmd)) { paths.push(...collectNonOptionOperands(tokens.slice(1))); } else if (['mv', 'cp'].includes(cmd)) { const targetDirectory = extractTargetDirectoryOption(tokens); if (targetDirectory) { paths.push(targetDirectory); } else { const operands = collectNonOptionOperands(tokens.slice(1)); const destination = operands.at(-1); if (destination) paths.push(destination); } } else if (['chmod', 'chown'].includes(cmd)) { const operands = collectNonOptionOperands(tokens.slice(1)); paths.push(...operands.slice(1)); } else if (cmd === 'tee') { paths.push(...collectNonOptionOperands(tokens.slice(1))); } else if (cmd === 'sed') { paths.push(...extractSedInPlacePaths(tokens)); } return paths; } /** * Extract target paths from write/create commands. * Returns the paths that would be modified by the command. * Handles compound commands by extracting paths from all sub-commands. */ export function extractWritePaths(command: string): string[] { const subCommands = splitOnShellOperators(command); const paths: string[] = []; for (const sub of subCommands) { paths.push(...extractWritePathsFromSingle(sub)); } return paths; } /** * Resolve a path, following symlinks when the target exists. * Falls back to path.resolve() if the path does not yet exist. */ function resolveWithSymlinks(targetPath: string): string { const absoluteTarget = path.resolve(targetPath); let current = absoluteTarget; const remainder: string[] = []; while (true) { try { return path.resolve(fs.realpathSync(current), ...remainder); } catch { const parent = path.dirname(current); if (parent === current) return absoluteTarget; remainder.unshift(path.basename(current)); current = parent; } } } function isPathSameOrDescendant(targetPath: string, parentPath: string): boolean { const normalizedTarget = process.platform === 'win32' ? targetPath.toLowerCase() : targetPath; const normalizedParent = process.platform === 'win32' ? parentPath.toLowerCase() : parentPath; const relative = path.relative(normalizedParent, normalizedTarget); return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)); } function isAllowedExternalWriteTarget(resolvedPath: string): boolean { if (process.platform === 'win32') return false; return normalizePathForSafety(resolvedPath) === '/dev/null'; } /** * Validate that write paths are within the allowed working directory. */ export function validateWritePaths( command: string, workingDirectory: string, currentCwd: string, ): SafetyCheckResult { const classification = classifyCommand(command); if (classification !== 'write' && classification !== 'create') { return { allowed: true, classification }; } const writePaths = extractWritePaths(command); const resolvedWorkingDirectory = resolveWithSymlinks(workingDirectory); for (const wp of writePaths) { // Resolve relative to current CWD, then resolve symlinks const rawResolved = path.resolve(currentCwd, wp); const resolved = resolveWithSymlinks(rawResolved); // Check critical paths if (isCriticalPathOrDescendant(resolved)) { return { allowed: false, reason: 'This command would modify a critical system directory. This cannot be auto-allowed.', classification, }; } if (isAllowedExternalWriteTarget(resolved)) continue; if (!isPathSameOrDescendant(resolved, resolvedWorkingDirectory)) { return { allowed: false, reason: 'This command would write outside the working directory. Use a path inside the project.', classification, }; } } return { allowed: true, classification }; } // --------------------------------------------------------------------------- // Layer 5: Obfuscation and Injection Detection // --------------------------------------------------------------------------- /** * Strip invisible Unicode characters that could be used for obfuscation. */ export function stripInvisibleChars(command: string): string { // Zero-width characters, BiDi markers, variation selectors, tag characters return command.replace( /[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD\u034F\u061C\u180E\u2060-\u2069\uFFF9-\uFFFB\u{E0001}-\u{E007F}\u{FE00}-\u{FE0F}]/gu, '', ); } /** * Safe URL allowlist for download-and-execute patterns. */ const SAFE_DOWNLOAD_URLS: Array<{ host: string; pathPrefix?: string | undefined }> = [ { host: 'brew.sh' }, { host: 'get.pnpm.io' }, { host: 'bun.sh', pathPrefix: '/install' }, { host: 'sh.rustup.rs' }, { host: 'get.docker.com' }, { host: 'install.python-poetry.org' }, { host: 'raw.githubusercontent.com', pathPrefix: '/Homebrew/' }, { host: 'raw.githubusercontent.com', pathPrefix: '/nvm-sh/nvm/' }, ]; /** * Check if a URL is in the safe download allowlist. */ function isSafeDownloadUrl(url: string): boolean { try { const parsed = new URL(url); // Reject URLs with credentials if (parsed.username || parsed.password) return false; const host = parsed.hostname.toLowerCase(); const pathname = parsed.pathname; for (const entry of SAFE_DOWNLOAD_URLS) { if (host === entry.host || host === `www.${entry.host}`) { if (!entry.pathPrefix || pathname.startsWith(entry.pathPrefix)) { return true; } } } } catch { // Invalid URL } return false; } /** * Extract URLs from a command string. */ function extractUrls(command: string): string[] { const urlRegex = /https?:\/\/[^\s'"]+/g; return command.match(urlRegex) ?? []; } interface ObfuscationPattern { pattern: RegExp; description: string; /** When true, only match against unquoted portions of the command. */ quoteAware?: boolean; } /** * Unix obfuscation and injection patterns. */ const UNIX_OBFUSCATION_PATTERNS: ObfuscationPattern[] = [ // Encoded execution { pattern: /base64\s+(-d|--decode)\s*\|.*\b(ba)?sh\b/i, description: 'Base64 decode piped to shell' }, { pattern: /xxd\s+-r\s*\|.*\b(ba)?sh\b/i, description: 'Hex decode piped to shell' }, { pattern: /printf\s+.*\\x.*\|.*\b(ba)?sh\b/i, description: 'Printf escape sequences piped to shell' }, // Eval injection { pattern: /\beval\s+.*(\$\(|`|base64|\\x|\\[0-7])/i, description: 'Eval with encoded/obfuscated input' }, // Heredoc execution { pattern: /<<\s*['"]?\w+['"]?\s*\n.*\b(ba)?sh\b/is, description: 'Heredoc used to construct and execute commands' }, // Escape sequences { pattern: /\$'\\[0-7]{3}.*\\[0-7]{3}'/, description: 'Bash octal escape sequences constructing commands' }, { pattern: /\$'\\x[0-9a-f]{2}.*\\x[0-9a-f]{2}'/i, description: 'Bash hex escape sequences constructing commands' }, // Polyglot injection { pattern: /python[23]?\s+-c\s+.*(?:base64|eval|exec|__import__)/i, description: 'Python with obfuscation patterns' }, { pattern: /perl\s+-e\s+.*(?:eval|unpack|decode_base64)/i, description: 'Perl with obfuscation patterns' }, { pattern: /ruby\s+-e\s+.*(?:eval|Base64|decode64)/i, description: 'Ruby with obfuscation patterns' }, // Variable obfuscation { pattern: /\w+=[^;]*;\s*\w+=[^;]*;\s*\$\{?\w+\}?\$\{?\w+\}?/i, description: 'Variable assignment chains constructing commands' }, // Process substitution with remote content { pattern: /<\(.*(?:curl|wget|nc)\s+/i, description: 'Remote content via process substitution' }, // Shell metacharacters — uses quote-aware matching in checkObfuscation() // so that legitimate regex patterns inside quotes (e.g., grep "foo\|bar") are not flagged. { pattern: /\\[;&|]/, description: 'Backslash-escaped operators or whitespace', quoteAware: true }, { pattern: /[\u200B\u200C\u200D\uFEFF\u00A0]/, description: 'Unicode whitespace characters' }, { pattern: /[\x00-\x08\x0E-\x1F]/, description: 'Control characters in command' }, { pattern: /\w#\w/, description: 'Mid-word hash (potential comment injection)' }, { pattern: /['"]-+\w/, description: 'Obfuscated flags via quotes' }, // Structural { pattern: /#.*['"].*\n/, description: 'Comment/quote desync pattern' }, { pattern: /'[^']*\n[^']*'/, description: 'Embedded newlines in single-quoted strings' }, { pattern: /[|;&]\s*$/, description: 'Incomplete command (trailing pipe or semicolon)' }, // NOTE: IFS manipulation and /proc access are handled by dedicated // quote-aware validators below (checkIfsInjection, checkProcSysAccess). ]; /** * PowerShell obfuscation patterns. */ const PS_OBFUSCATION_PATTERNS: ObfuscationPattern[] = [ { pattern: /-EncodedCommand\b/i, description: 'PowerShell encoded command' }, { pattern: /\[Convert\]::FromBase64String.*\|\s*iex/i, description: 'Base64 decode piped to Invoke-Expression' }, { pattern: /Invoke-Expression\s+.*(\+|\[char\]|\.Replace)/i, description: 'Invoke-Expression with constructed strings' }, { pattern: /Net\.WebClient.*DownloadString.*\|\s*iex/i, description: 'Download cradle piped to iex' }, { pattern: /Invoke-WebRequest.*\|\s*iex/i, description: 'Web request piped to Invoke-Expression' }, { pattern: /Start-Process.*-WindowStyle\s+Hidden/i, description: 'Hidden process execution' }, { pattern: /\[Reflection\.Assembly\]::Load/i, description: 'Reflection-based assembly loading' }, { pattern: /-ExecutionPolicy\s+Bypass/i, description: 'Execution policy bypass' }, ]; /** * Strip the content of single-quoted and double-quoted strings from a command, * preserving the quotes themselves. This lets obfuscation patterns check only * the unquoted portions of a command so that legitimate regex syntax inside * quotes (e.g., grep "foo\|bar") is not flagged as shell obfuscation. */ function stripQuotedContent(command: string): string { return command.replace(/"[^"]*"|'[^']*'/g, (match) => { // Preserve the quote characters but empty the content const quote = match[0]; return `${quote}${quote}`; }); } // --------------------------------------------------------------------------- // Quote State Machine (shared utility for quote-aware validators) // --------------------------------------------------------------------------- /** * Per-character quote context. Describes the quoting state at a given position. */ export type QuoteContext = 'none' | 'single' | 'double' | 'backtick' | 'escaped'; /** * Analyze the quoting context of each character in a shell command. * Returns an array of QuoteContext values, one per character, indicating * whether that position is inside single quotes, double quotes, backticks, * escaped, or unquoted. Handles nested escapes correctly (e.g., `\"` inside * double quotes keeps the next character as "double", not "escaped"). */ export function analyzeQuoteState(command: string): QuoteContext[] { const states: QuoteContext[] = new Array(command.length); let context: 'none' | 'single' | 'double' | 'backtick' = 'none'; // Track the context to return to when a backtick closes (for backtick-in-double-quote) let returnContext: 'none' | 'double' = 'none'; for (let i = 0; i < command.length; i++) { const ch = command[i]!; if (context === 'single') { // Inside single quotes, only a closing single quote ends the context. // No escape processing at all inside single quotes. if (ch === "'") { states[i] = 'none'; context = 'none'; } else { states[i] = 'single'; } continue; } if (context === 'double') { // Inside double quotes, backslash only escapes: $, `, ", \, and newline. if (ch === '\\' && i + 1 < command.length) { const next = command[i + 1]!; if ('$`"\\'.includes(next) || next === '\n') { states[i] = 'escaped'; states[i + 1] = 'escaped'; i++; // skip the escaped character continue; } } if (ch === '"') { states[i] = 'none'; context = 'none'; } else if (ch === '`') { // Backticks nest inside double quotes. Track return context so we // resume double-quote context when the backtick closes. states[i] = 'backtick'; returnContext = 'double'; context = 'backtick'; } else { states[i] = 'double'; } continue; } if (context === 'backtick') { if (ch === '\\' && i + 1 < command.length) { states[i] = 'escaped'; states[i + 1] = 'escaped'; i++; continue; } if (ch === '`') { states[i] = returnContext === 'double' ? 'double' : 'none'; context = returnContext; returnContext = 'none'; } else { states[i] = 'backtick'; } continue; } // context === 'none' (unquoted) if (ch === '\\' && i + 1 < command.length) { states[i] = 'escaped'; states[i + 1] = 'escaped'; i++; continue; } if (ch === "'") { states[i] = 'single'; // the quote character itself is "in" single-quote context context = 'single'; continue; } if (ch === '"') { states[i] = 'double'; context = 'double'; continue; } if (ch === '`') { states[i] = 'backtick'; context = 'backtick'; continue; } states[i] = 'none'; } return states; } /** * Extract the unquoted portions of a command using the quote state machine. * Returns a string where quoted characters are replaced with spaces (preserving * positions) so that regex matches on the result correspond to unquoted regions. */ function getUnquotedText(command: string, states: QuoteContext[]): string { const chars: string[] = []; for (let i = 0; i < command.length; i++) { chars.push(states[i] === 'none' ? command[i]! : ' '); } return chars.join(''); } // --------------------------------------------------------------------------- // Validator 2: Enhanced IFS Injection // --------------------------------------------------------------------------- /** * Detect IFS variable manipulation in unquoted context. * `IFS=` inside quotes is harmless (just a string literal). * Unquoted `IFS=` is a shell variable assignment that can enable attacks. */ export function checkIfsInjection(command: string, states: QuoteContext[]): SafetyCheckResult { const unquoted = getUnquotedText(command, states); if (/\bIFS\s*=/.test(unquoted)) { return { allowed: false, reason: 'Obfuscation pattern detected: IFS variable manipulation', }; } return { allowed: true }; } // --------------------------------------------------------------------------- // Validator 3: Enhanced proc/sys Access // --------------------------------------------------------------------------- /** * Sensitive paths under /proc and /sys that can be used for exfiltration * or system introspection attacks. */ const PROC_SYS_PATTERNS: RegExp[] = [ // /proc exfiltration vectors /\/proc\/[^/]*\/environ/, /\/proc\/[^/]*\/cmdline/, /\/proc\/[^/]*\/maps/, /\/proc\/[^/]*\/mem\b/, /\/proc\/[^/]*\/fd\//, /\/proc\/[^/]*\/exe\b/, /\/proc\/[^/]*\/cwd\b/, /\/proc\/[^/]*\/root\b/, /\/proc\/[^/]*\/mountinfo/, /\/proc\/[^/]*\/status/, // /sys sensitive paths /\/sys\/class\/net\b/, /\/sys\/kernel\//, /\/sys\/firmware\//, /\/sys\/fs\/cgroup\//, ]; /** * Detect access to sensitive /proc and /sys paths in unquoted context. * Quoted references (e.g., `echo "/proc/self/environ"`) are harmless string * literals. Unquoted references indicate actual filesystem access attempts. */ export function checkProcSysAccess(command: string, states: QuoteContext[]): SafetyCheckResult { const unquoted = getUnquotedText(command, states); for (const pattern of PROC_SYS_PATTERNS) { if (pattern.test(unquoted)) { return { allowed: false, reason: 'Obfuscation pattern detected: Access to sensitive /proc or /sys path', }; } } return { allowed: true }; } // --------------------------------------------------------------------------- // Validator 4: jq system() Blocking // --------------------------------------------------------------------------- /** * Detect jq command abuse: system() calls, @sh filter for shell injection, * and -n with module imports that could load malicious jq modules. */ export function checkJqAbuse(command: string): SafetyCheckResult { // Only check commands that invoke jq if (!/\bjq\b/.test(command)) { return { allowed: true }; } // Block jq filters containing system( -- executes shell commands from jq // Use dotAll (s) flag so multi-line jq filters are caught if (/\bjq\b.*\bsystem\s*\(/s.test(command)) { return { allowed: false, reason: 'Obfuscation pattern detected: jq system() call can execute arbitrary shell commands', }; } // Block @sh filter used for shell injection if (/\bjq\b.*@sh\b/s.test(command)) { return { allowed: false, reason: 'Obfuscation pattern detected: jq @sh filter can be used for shell injection', }; } // Block jq -n with import/include (module loading) if (/\bjq\b\s+.*-n\b.*\b(import|include)\b/s.test(command) || /\bjq\b\s+.*\b(import|include)\b.*-n\b/s.test(command)) { return { allowed: false, reason: 'Obfuscation pattern detected: jq module import with -n flag', }; } return { allowed: true }; } // --------------------------------------------------------------------------- // Validator 5: ANSI-C Quoting Detection // --------------------------------------------------------------------------- /** * Detect ANSI-C quoting ($'...') with hex or octal escape sequences that * encode potentially dangerous content. Simple escapes like $'\n' and $'\t' * are legitimate and allowed. */ export function checkAnsiCQuoting(command: string): SafetyCheckResult { // Match $'...' patterns. We need to find all ANSI-C quoted strings and // check if they contain hex (\xHH) or octal (\0NNN or \NNN with 3 digits) escapes. const ansiCPattern = /\$'([^'\\]*(?:\\.[^'\\]*)*)'/g; let match: RegExpExecArray | null; while ((match = ansiCPattern.exec(command)) !== null) { const content = match[1] ?? ''; // Check for hex escapes (\xHH) const hasHex = /\\x[0-9a-fA-F]{2}/.test(content); // Check for octal escapes (\0NNN or \NNN where N are 3 octal digits) const hasOctal = /\\0[0-7]{1,3}/.test(content) || /\\[1-3][0-7]{2}/.test(content); if (hasHex || hasOctal) { return { allowed: false, reason: 'Obfuscation pattern detected: ANSI-C quoting with hex/octal escapes can encode hidden commands', }; } } return { allowed: true }; } // --------------------------------------------------------------------------- // Validator 6: Enhanced Heredoc Validation // --------------------------------------------------------------------------- /** * Detect heredoc patterns and validate their content. Unquoted heredoc * delimiters (<