/ utils / bash / shellQuote.ts
shellQuote.ts
  1  /**
  2   * Safe wrappers for shell-quote library functions that handle errors gracefully
  3   * These are drop-in replacements for the original functions
  4   */
  5  
  6  import {
  7    type ParseEntry,
  8    parse as shellQuoteParse,
  9    quote as shellQuoteQuote,
 10  } from 'shell-quote'
 11  import { logError } from '../log.js'
 12  import { jsonStringify } from '../slowOperations.js'
 13  
 14  export type { ParseEntry } from 'shell-quote'
 15  
 16  export type ShellParseResult =
 17    | { success: true; tokens: ParseEntry[] }
 18    | { success: false; error: string }
 19  
 20  export type ShellQuoteResult =
 21    | { success: true; quoted: string }
 22    | { success: false; error: string }
 23  
 24  export function tryParseShellCommand(
 25    cmd: string,
 26    env?:
 27      | Record<string, string | undefined>
 28      | ((key: string) => string | undefined),
 29  ): ShellParseResult {
 30    try {
 31      const tokens =
 32        typeof env === 'function'
 33          ? shellQuoteParse(cmd, env)
 34          : shellQuoteParse(cmd, env)
 35      return { success: true, tokens }
 36    } catch (error) {
 37      if (error instanceof Error) {
 38        logError(error)
 39      }
 40      return {
 41        success: false,
 42        error: error instanceof Error ? error.message : 'Unknown parse error',
 43      }
 44    }
 45  }
 46  
 47  export function tryQuoteShellArgs(args: unknown[]): ShellQuoteResult {
 48    try {
 49      const validated: string[] = args.map((arg, index) => {
 50        if (arg === null || arg === undefined) {
 51          return String(arg)
 52        }
 53  
 54        const type = typeof arg
 55  
 56        if (type === 'string') {
 57          return arg as string
 58        }
 59        if (type === 'number' || type === 'boolean') {
 60          return String(arg)
 61        }
 62  
 63        if (type === 'object') {
 64          throw new Error(
 65            `Cannot quote argument at index ${index}: object values are not supported`,
 66          )
 67        }
 68        if (type === 'symbol') {
 69          throw new Error(
 70            `Cannot quote argument at index ${index}: symbol values are not supported`,
 71          )
 72        }
 73        if (type === 'function') {
 74          throw new Error(
 75            `Cannot quote argument at index ${index}: function values are not supported`,
 76          )
 77        }
 78  
 79        throw new Error(
 80          `Cannot quote argument at index ${index}: unsupported type ${type}`,
 81        )
 82      })
 83  
 84      const quoted = shellQuoteQuote(validated)
 85      return { success: true, quoted }
 86    } catch (error) {
 87      if (error instanceof Error) {
 88        logError(error)
 89      }
 90      return {
 91        success: false,
 92        error: error instanceof Error ? error.message : 'Unknown quote error',
 93      }
 94    }
 95  }
 96  
 97  /**
 98   * Checks if parsed tokens contain malformed entries that suggest shell-quote
 99   * misinterpreted the command. This happens when input contains ambiguous
100   * patterns (like JSON-like strings with semicolons) that shell-quote parses
101   * according to shell rules, producing token fragments.
102   *
103   * For example, `echo {"hi":"hi;evil"}` gets parsed with `;` as an operator,
104   * producing tokens like `{hi:"hi` (unbalanced brace). Legitimate commands
105   * produce complete, balanced tokens.
106   *
107   * Also detects unterminated quotes in the original command: shell-quote
108   * silently drops an unmatched `"` or `'` and parses the rest as unquoted,
109   * leaving no trace in the tokens. `echo "hi;evil | cat` (one unmatched `"`)
110   * is a bash syntax error, but shell-quote yields clean tokens with `;` as
111   * an operator. The token-level checks below can't catch this, so we walk
112   * the original command with bash quote semantics and flag odd parity.
113   *
114   * Security: This prevents command injection via HackerOne #3482049 where
115   * shell-quote's correct parsing of ambiguous input can be exploited.
116   */
117  export function hasMalformedTokens(
118    command: string,
119    parsed: ParseEntry[],
120  ): boolean {
121    // Check for unterminated quotes in the original command. shell-quote drops
122    // an unmatched quote without leaving any trace in the tokens, so this must
123    // inspect the raw string. Walk with bash semantics: backslash escapes the
124    // next char outside single-quotes; no escapes inside single-quotes.
125    let inSingle = false
126    let inDouble = false
127    let doubleCount = 0
128    let singleCount = 0
129    for (let i = 0; i < command.length; i++) {
130      const c = command[i]
131      if (c === '\\' && !inSingle) {
132        i++
133        continue
134      }
135      if (c === '"' && !inSingle) {
136        doubleCount++
137        inDouble = !inDouble
138      } else if (c === "'" && !inDouble) {
139        singleCount++
140        inSingle = !inSingle
141      }
142    }
143    if (doubleCount % 2 !== 0 || singleCount % 2 !== 0) return true
144  
145    for (const entry of parsed) {
146      if (typeof entry !== 'string') continue
147  
148      // Check for unbalanced curly braces
149      const openBraces = (entry.match(/{/g) || []).length
150      const closeBraces = (entry.match(/}/g) || []).length
151      if (openBraces !== closeBraces) return true
152  
153      // Check for unbalanced parentheses
154      const openParens = (entry.match(/\(/g) || []).length
155      const closeParens = (entry.match(/\)/g) || []).length
156      if (openParens !== closeParens) return true
157  
158      // Check for unbalanced square brackets
159      const openBrackets = (entry.match(/\[/g) || []).length
160      const closeBrackets = (entry.match(/\]/g) || []).length
161      if (openBrackets !== closeBrackets) return true
162  
163      // Check for unbalanced double quotes
164      // Count quotes that aren't escaped (preceded by backslash)
165      // A token with an odd number of unescaped quotes is malformed
166      // eslint-disable-next-line custom-rules/no-lookbehind-regex -- gated by hasCommandSeparator check at caller, runs on short per-token strings
167      const doubleQuotes = entry.match(/(?<!\\)"/g) || []
168      if (doubleQuotes.length % 2 !== 0) return true
169  
170      // Check for unbalanced single quotes
171      // eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above
172      const singleQuotes = entry.match(/(?<!\\)'/g) || []
173      if (singleQuotes.length % 2 !== 0) return true
174    }
175    return false
176  }
177  
178  /**
179   * Detects commands containing '\' patterns that exploit the shell-quote library's
180   * incorrect handling of backslashes inside single quotes.
181   *
182   * In bash, single quotes preserve ALL characters literally - backslash has no
183   * special meaning. So '\' is just the string \ (the quote opens, contains \,
184   * and the next ' closes it). But shell-quote incorrectly treats \ as an escape
185   * character inside single quotes, causing '\' to NOT close the quoted string.
186   *
187   * This means the pattern '\' <payload> '\' hides <payload> from security checks
188   * because shell-quote thinks it's all one single-quoted string.
189   */
190  export function hasShellQuoteSingleQuoteBug(command: string): boolean {
191    // Walk the command with correct bash single-quote semantics
192    let inSingleQuote = false
193    let inDoubleQuote = false
194  
195    for (let i = 0; i < command.length; i++) {
196      const char = command[i]
197  
198      // Handle backslash escaping outside of single quotes
199      if (char === '\\' && !inSingleQuote) {
200        // Skip the next character (it's escaped)
201        i++
202        continue
203      }
204  
205      if (char === '"' && !inSingleQuote) {
206        inDoubleQuote = !inDoubleQuote
207        continue
208      }
209  
210      if (char === "'" && !inDoubleQuote) {
211        inSingleQuote = !inSingleQuote
212  
213        // Check if we just closed a single quote and the content ends with
214        // trailing backslashes. shell-quote's chunker regex '((\\'|[^'])*?)'
215        // incorrectly treats \' as an escape sequence inside single quotes,
216        // while bash treats backslash as literal. This creates a differential
217        // where shell-quote merges tokens that bash treats as separate.
218        //
219        // Odd trailing \'s = always a bug:
220        //   '\' -> shell-quote: \' = literal ', still open. bash: \, closed.
221        //   'abc\' -> shell-quote: abc then \' = literal ', still open. bash: abc\, closed.
222        //   '\\\'  -> shell-quote: \\ + \', still open. bash: \\\, closed.
223        //
224        // Even trailing \'s = bug ONLY when a later ' exists in the command:
225        //   '\\' alone -> shell-quote backtracks, both parsers agree string closes. OK.
226        //   '\\' 'next' -> shell-quote: \' consumes the closing ', finds next ' as
227        //                   false close, merges tokens. bash: two separate tokens.
228        //
229        //   Detail: the regex alternation tries \' before [^']. For '\\', it matches
230        //   the first \ via [^'] (next char is \, not '), then the second \ via \'
231        //   (next char IS '). This consumes the closing '. The regex continues reading
232        //   until it finds another ' to close the match. If none exists, it backtracks
233        //   to [^'] for the second \ and closes correctly. If a later ' exists (e.g.,
234        //   the opener of the next single-quoted arg), no backtracking occurs and
235        //   tokens merge. See H1 report: git ls-remote 'safe\\' '--upload-pack=evil' 'repo'
236        //   shell-quote: ["git","ls-remote","safe\\\\ --upload-pack=evil repo"]
237        //   bash:        ["git","ls-remote","safe\\\\","--upload-pack=evil","repo"]
238        if (!inSingleQuote) {
239          let backslashCount = 0
240          let j = i - 1
241          while (j >= 0 && command[j] === '\\') {
242            backslashCount++
243            j--
244          }
245          if (backslashCount > 0 && backslashCount % 2 === 1) {
246            return true
247          }
248          // Even trailing backslashes: only a bug when a later ' exists that
249          // the chunker regex can use as a false closing quote. We check for
250          // ANY later ' because the regex doesn't respect bash quote state
251          // (e.g., a ' inside double quotes is also consumable).
252          if (
253            backslashCount > 0 &&
254            backslashCount % 2 === 0 &&
255            command.indexOf("'", i + 1) !== -1
256          ) {
257            return true
258          }
259        }
260        continue
261      }
262    }
263  
264    return false
265  }
266  
267  export function quote(args: ReadonlyArray<unknown>): string {
268    // First try the strict validation
269    const result = tryQuoteShellArgs([...args])
270  
271    if (result.success) {
272      return result.quoted
273    }
274  
275    // If strict validation failed, use lenient fallback
276    // This handles objects, symbols, functions, etc. by converting them to strings
277    try {
278      const stringArgs = args.map(arg => {
279        if (arg === null || arg === undefined) {
280          return String(arg)
281        }
282  
283        const type = typeof arg
284  
285        if (type === 'string' || type === 'number' || type === 'boolean') {
286          return String(arg)
287        }
288  
289        // For unsupported types, use JSON.stringify as a safe fallback
290        // This ensures we don't crash but still get a meaningful representation
291        return jsonStringify(arg)
292      })
293  
294      return shellQuoteQuote(stringArgs)
295    } catch (error) {
296      // SECURITY: Never use JSON.stringify as a fallback for shell quoting.
297      // JSON.stringify uses double quotes which don't prevent shell command execution.
298      // For example, jsonStringify(['echo', '$(whoami)']) produces "echo" "$(whoami)"
299      if (error instanceof Error) {
300        logError(error)
301      }
302      throw new Error('Failed to quote shell arguments safely')
303    }
304  }