/** * Safe wrappers for shell-quote library functions that handle errors gracefully * These are drop-in replacements for the original functions */ import { type ParseEntry, parse as shellQuoteParse, quote as shellQuoteQuote, } from 'shell-quote' import { logError } from '../log.js' import { jsonStringify } from '../slowOperations.js' export type { ParseEntry } from 'shell-quote' export type ShellParseResult = | { success: true; tokens: ParseEntry[] } | { success: false; error: string } export type ShellQuoteResult = | { success: true; quoted: string } | { success: false; error: string } export function tryParseShellCommand( cmd: string, env?: | Record | ((key: string) => string | undefined), ): ShellParseResult { try { const tokens = typeof env === 'function' ? shellQuoteParse(cmd, env) : shellQuoteParse(cmd, env) return { success: true, tokens } } catch (error) { if (error instanceof Error) { logError(error) } return { success: false, error: error instanceof Error ? error.message : 'Unknown parse error', } } } export function tryQuoteShellArgs(args: unknown[]): ShellQuoteResult { try { const validated: string[] = args.map((arg, index) => { if (arg === null || arg === undefined) { return String(arg) } const type = typeof arg if (type === 'string') { return arg as string } if (type === 'number' || type === 'boolean') { return String(arg) } if (type === 'object') { throw new Error( `Cannot quote argument at index ${index}: object values are not supported`, ) } if (type === 'symbol') { throw new Error( `Cannot quote argument at index ${index}: symbol values are not supported`, ) } if (type === 'function') { throw new Error( `Cannot quote argument at index ${index}: function values are not supported`, ) } throw new Error( `Cannot quote argument at index ${index}: unsupported type ${type}`, ) }) const quoted = shellQuoteQuote(validated) return { success: true, quoted } } catch (error) { if (error instanceof Error) { logError(error) } return { success: false, error: error instanceof Error ? error.message : 'Unknown quote error', } } } /** * Checks if parsed tokens contain malformed entries that suggest shell-quote * misinterpreted the command. This happens when input contains ambiguous * patterns (like JSON-like strings with semicolons) that shell-quote parses * according to shell rules, producing token fragments. * * For example, `echo {"hi":"hi;evil"}` gets parsed with `;` as an operator, * producing tokens like `{hi:"hi` (unbalanced brace). Legitimate commands * produce complete, balanced tokens. * * Also detects unterminated quotes in the original command: shell-quote * silently drops an unmatched `"` or `'` and parses the rest as unquoted, * leaving no trace in the tokens. `echo "hi;evil | cat` (one unmatched `"`) * is a bash syntax error, but shell-quote yields clean tokens with `;` as * an operator. The token-level checks below can't catch this, so we walk * the original command with bash quote semantics and flag odd parity. * * Security: This prevents command injection via HackerOne #3482049 where * shell-quote's correct parsing of ambiguous input can be exploited. */ export function hasMalformedTokens( command: string, parsed: ParseEntry[], ): boolean { // Check for unterminated quotes in the original command. shell-quote drops // an unmatched quote without leaving any trace in the tokens, so this must // inspect the raw string. Walk with bash semantics: backslash escapes the // next char outside single-quotes; no escapes inside single-quotes. let inSingle = false let inDouble = false let doubleCount = 0 let singleCount = 0 for (let i = 0; i < command.length; i++) { const c = command[i] if (c === '\\' && !inSingle) { i++ continue } if (c === '"' && !inSingle) { doubleCount++ inDouble = !inDouble } else if (c === "'" && !inDouble) { singleCount++ inSingle = !inSingle } } if (doubleCount % 2 !== 0 || singleCount % 2 !== 0) return true for (const entry of parsed) { if (typeof entry !== 'string') continue // Check for unbalanced curly braces const openBraces = (entry.match(/{/g) || []).length const closeBraces = (entry.match(/}/g) || []).length if (openBraces !== closeBraces) return true // Check for unbalanced parentheses const openParens = (entry.match(/\(/g) || []).length const closeParens = (entry.match(/\)/g) || []).length if (openParens !== closeParens) return true // Check for unbalanced square brackets const openBrackets = (entry.match(/\[/g) || []).length const closeBrackets = (entry.match(/\]/g) || []).length if (openBrackets !== closeBrackets) return true // Check for unbalanced double quotes // Count quotes that aren't escaped (preceded by backslash) // A token with an odd number of unescaped quotes is malformed // eslint-disable-next-line custom-rules/no-lookbehind-regex -- gated by hasCommandSeparator check at caller, runs on short per-token strings const doubleQuotes = entry.match(/(? '\' hides from security checks * because shell-quote thinks it's all one single-quoted string. */ export function hasShellQuoteSingleQuoteBug(command: string): boolean { // Walk the command with correct bash single-quote semantics let inSingleQuote = false let inDoubleQuote = false for (let i = 0; i < command.length; i++) { const char = command[i] // Handle backslash escaping outside of single quotes if (char === '\\' && !inSingleQuote) { // Skip the next character (it's escaped) i++ continue } if (char === '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote continue } if (char === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote // Check if we just closed a single quote and the content ends with // trailing backslashes. shell-quote's chunker regex '((\\'|[^'])*?)' // incorrectly treats \' as an escape sequence inside single quotes, // while bash treats backslash as literal. This creates a differential // where shell-quote merges tokens that bash treats as separate. // // Odd trailing \'s = always a bug: // '\' -> shell-quote: \' = literal ', still open. bash: \, closed. // 'abc\' -> shell-quote: abc then \' = literal ', still open. bash: abc\, closed. // '\\\' -> shell-quote: \\ + \', still open. bash: \\\, closed. // // Even trailing \'s = bug ONLY when a later ' exists in the command: // '\\' alone -> shell-quote backtracks, both parsers agree string closes. OK. // '\\' 'next' -> shell-quote: \' consumes the closing ', finds next ' as // false close, merges tokens. bash: two separate tokens. // // Detail: the regex alternation tries \' before [^']. For '\\', it matches // the first \ via [^'] (next char is \, not '), then the second \ via \' // (next char IS '). This consumes the closing '. The regex continues reading // until it finds another ' to close the match. If none exists, it backtracks // to [^'] for the second \ and closes correctly. If a later ' exists (e.g., // the opener of the next single-quoted arg), no backtracking occurs and // tokens merge. See H1 report: git ls-remote 'safe\\' '--upload-pack=evil' 'repo' // shell-quote: ["git","ls-remote","safe\\\\ --upload-pack=evil repo"] // bash: ["git","ls-remote","safe\\\\","--upload-pack=evil","repo"] if (!inSingleQuote) { let backslashCount = 0 let j = i - 1 while (j >= 0 && command[j] === '\\') { backslashCount++ j-- } if (backslashCount > 0 && backslashCount % 2 === 1) { return true } // Even trailing backslashes: only a bug when a later ' exists that // the chunker regex can use as a false closing quote. We check for // ANY later ' because the regex doesn't respect bash quote state // (e.g., a ' inside double quotes is also consumable). if ( backslashCount > 0 && backslashCount % 2 === 0 && command.indexOf("'", i + 1) !== -1 ) { return true } } continue } } return false } export function quote(args: ReadonlyArray): string { // First try the strict validation const result = tryQuoteShellArgs([...args]) if (result.success) { return result.quoted } // If strict validation failed, use lenient fallback // This handles objects, symbols, functions, etc. by converting them to strings try { const stringArgs = args.map(arg => { if (arg === null || arg === undefined) { return String(arg) } const type = typeof arg if (type === 'string' || type === 'number' || type === 'boolean') { return String(arg) } // For unsupported types, use JSON.stringify as a safe fallback // This ensures we don't crash but still get a meaningful representation return jsonStringify(arg) }) return shellQuoteQuote(stringArgs) } catch (error) { // SECURITY: Never use JSON.stringify as a fallback for shell quoting. // JSON.stringify uses double quotes which don't prevent shell command execution. // For example, jsonStringify(['echo', '$(whoami)']) produces "echo" "$(whoami)" if (error instanceof Error) { logError(error) } throw new Error('Failed to quote shell arguments safely') } }