/** * Shell sanitization utilities for secure command execution * * Provides functions to sanitize user input before shell execution * to prevent command injection attacks. */ /** * Whitelist of allowed environment variables that can be expanded in commands * These are considered safe to expose to custom commands */ export const ALLOWED_ENV_VARS = new Set([ // Project/directory context "PWD", // Common development paths "HOME", "USER", // Editor settings "EDITOR", "VISUAL", // Common tools "PATH", "SHELL", // Node.js "NODE_VERSION", "NODE_PATH", // Python "PYTHONPATH", "VIRTUAL_ENV", // Rust "CARGO_HOME", "RUSTUP_HOME", // Go "GOPATH", "GOROOT", // Git "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", ]); /** * Characters that are potentially dangerous in shell commands * These characters can be used for command injection */ const DANGEROUS_SHELL_CHARS = /[;&|`$(){}[\]\\<>!]/; /** * Check if a string contains potentially dangerous shell characters */ export function containsDangerousChars(input: string): boolean { return DANGEROUS_SHELL_CHARS.test(input); } /** * Escape a string for safe use in shell commands * Uses single quotes and escapes any existing single quotes */ export function escapeShellArg(input: string): string { // Escape single quotes by ending the quote, adding escaped quote, and starting again return `'${input.replace(/'/g, "'\\''")}'`; } /** * Validate that a path is safe for use in commands * - Must be an absolute path or start with ~/ * - Must not contain null bytes * - Must not contain dangerous shell characters */ export function validatePathForShell(path: string): { valid: boolean; error?: string } { // Check for null bytes (potential path traversal) if (path.includes("\0")) { return { valid: false, error: "Path contains null bytes" }; } // Check for dangerous characters (but allow / and ~ for paths) const pathWithoutValidChars = path.replace(/[\/~]/g, ""); if (containsDangerousChars(pathWithoutValidChars)) { return { valid: false, error: "Path contains dangerous shell characters" }; } // Must be absolute or home-relative if (!path.startsWith("/") && !path.startsWith("~/")) { return { valid: false, error: "Path must be absolute or start with ~/" }; } // Check for path traversal attempts if (path.includes("..")) { // Allow .. only if it's not at the start (e.g., /some/path/../other is ok) const normalized = normalizePath(path); if (normalized.includes("..")) { return { valid: false, error: "Path contains suspicious traversal patterns" }; } } return { valid: true }; } /** * Normalize a path by resolving .. and . segments * Does NOT access the filesystem */ export function normalizePath(path: string): string { const parts = path.split("/"); const result: string[] = []; for (const part of parts) { if (part === "..") { if (result.length > 0 && result[result.length - 1] !== "..") { result.pop(); } else { result.push(".."); } } else if (part !== "." && part !== "") { result.push(part); } } // Preserve leading slash if (path.startsWith("/")) { return "/" + result.join("/"); } return result.join("/") || "."; } /** * Validate and expand environment variable references in a command string * Only allows whitelisted environment variables * * @param command - The command string with potential $VAR or ${VAR} references * @param projectPath - The project path to use for $PWD substitution * @returns The command with only allowed env vars expanded * @throws Error if a non-whitelisted env var is referenced */ export function expandEnvVarsSafe( command: string, projectPath: string ): { command: string; warnings: string[] } { const warnings: string[] = []; const expandedCommand = command.replace(/\$\{?(\w+)\}?/g, (match, varName) => { // Special case: $PWD should be the project path, not the current process's PWD if (varName === "PWD") { return projectPath; } // Check if the variable is in the whitelist if (!ALLOWED_ENV_VARS.has(varName)) { warnings.push( `Environment variable $${varName} is not in the allowed list and will not be expanded` ); return match; // Leave unexpanded } // Get the value from process.env or leave unchanged if not found const value = process.env[varName]; if (value === undefined) { return match; } return value; }); return { command: expandedCommand, warnings }; } /** * Sanitize a command string for safe execution * - Validates paths * - Expands only whitelisted env vars * - Logs warnings for suspicious patterns */ export function sanitizeCommand( command: string, projectPath: string ): { sanitized: string; warnings: string[]; errors: string[] } { const warnings: string[] = []; const errors: string[] = []; // Validate the project path const pathValidation = validatePathForShell(projectPath); if (!pathValidation.valid) { errors.push(`Invalid project path: ${pathValidation.error}`); } // Expand environment variables safely const { command: expandedCommand, warnings: expandWarnings } = expandEnvVarsSafe( command, projectPath ); warnings.push(...expandWarnings); // Check for common injection patterns const injectionPatterns = [ { pattern: /\$\([^)]+\)/, description: "command substitution $()" }, { pattern: /`[^`]+`/, description: "backtick command substitution" }, { pattern: /\|\s*\w+/, description: "pipe to another command" }, { pattern: /&&\s*\w+/, description: "command chaining with &&" }, { pattern: /\|\|\s*\w+/, description: "command chaining with ||" }, { pattern: /;\s*\w+/, description: "command chaining with ;" }, ]; for (const { pattern, description } of injectionPatterns) { if (pattern.test(command)) { warnings.push(`Command contains ${description} - ensure this is intentional`); } } return { sanitized: expandedCommand, warnings, errors, }; } /** * Check if a command appears to be trying to escape its intended scope * This is a heuristic check for obviously malicious patterns */ export function detectCommandEscapeAttempt(command: string): string[] { const issues: string[] = []; // Check for attempts to access sensitive files const sensitivePatterns = [ /\/etc\/passwd/, /\/etc\/shadow/, /\.ssh\//, /\.gnupg\//, /\/root\//, /\.env/, /\.credentials/, /id_rsa/, /id_ed25519/, ]; for (const pattern of sensitivePatterns) { if (pattern.test(command)) { issues.push(`Command may attempt to access sensitive files matching ${pattern}`); } } // Check for network-related commands that might exfiltrate data const networkPatterns = [ /\bcurl\b/, /\bwget\b/, /\bnc\b/, /\bnetcat\b/, /\bssh\b.*-R/, /\bssh\b.*-L/, ]; for (const pattern of networkPatterns) { if (pattern.test(command)) { issues.push(`Command contains network operation that may require review`); } } return issues; }