* Checks if a command is a "safe" heredoc-in-substitution pattern that can * bypass the generic $() validator. * * This is an EARLY-ALLOW path: returning `true` causes bashCommandIsSafe to * return `passthrough`, bypassing ALL subsequent validators. Given this * authority, the check must be PRO
(command: string)
| 315 | * precisely replicate bash's heredoc-closing behavior. |
| 316 | */ |
| 317 | function isSafeHeredoc(command: string): boolean { |
| 318 | if (!HEREDOC_IN_SUBSTITUTION.test(command)) return false |
| 319 | |
| 320 | // SECURITY: Use [ \t] (not \s) between << and the delimiter. \s matches |
| 321 | // newlines, but bash requires the delimiter word on the same line as <<. |
| 322 | // Matching across newlines could accept malformed syntax that bash rejects. |
| 323 | // Handle quote variations: 'EOF', ''EOF'' (splitCommand may mangle quotes). |
| 324 | const heredocPattern = |
| 325 | /\$\(cat[ \t]*<<(-?)[ \t]*(?:'+([A-Za-z_]\w*)'+|\\([A-Za-z_]\w*))/g |
| 326 | let match |
| 327 | type HeredocMatch = { |
| 328 | start: number |
| 329 | operatorEnd: number |
| 330 | delimiter: string |
| 331 | isDash: boolean |
| 332 | } |
| 333 | const safeHeredocs: HeredocMatch[] = [] |
| 334 | |
| 335 | while ((match = heredocPattern.exec(command)) !== null) { |
| 336 | const delimiter = match[2] || match[3] |
| 337 | if (delimiter) { |
| 338 | safeHeredocs.push({ |
| 339 | start: match.index, |
| 340 | operatorEnd: match.index + match[0].length, |
| 341 | delimiter, |
| 342 | isDash: match[1] === '-', |
| 343 | }) |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | // If no safe heredoc patterns found, it's not safe |
| 348 | if (safeHeredocs.length === 0) return false |
| 349 | |
| 350 | // SECURITY: For each heredoc, find the closing delimiter using LINE-BASED |
| 351 | // matching that exactly replicates bash's behavior. Bash closes a heredoc |
| 352 | // at the FIRST line that exactly matches the delimiter. Any subsequent |
| 353 | // occurrence of the delimiter is just content (or a new command). Regex |
| 354 | // [\s\S]*? can skip past the first delimiter to find a later `DELIM)` |
| 355 | // pattern, hiding injected commands between the two delimiters. |
| 356 | type VerifiedHeredoc = { start: number; end: number } |
| 357 | const verified: VerifiedHeredoc[] = [] |
| 358 | |
| 359 | for (const { start, operatorEnd, delimiter, isDash } of safeHeredocs) { |
| 360 | // The opening line must end immediately after the delimiter (only |
| 361 | // horizontal whitespace allowed before the newline). If there's other |
| 362 | // content (like `; rm -rf /`), this is not a simple safe heredoc. |
| 363 | const afterOperator = command.slice(operatorEnd) |
| 364 | const openLineEnd = afterOperator.indexOf('\n') |
| 365 | if (openLineEnd === -1) return false // No content at all |
| 366 | const openLineTail = afterOperator.slice(0, openLineEnd) |
| 367 | if (!/^[ \t]*$/.test(openLineTail)) return false // Extra content on open line |
| 368 | |
| 369 | // Body starts after the newline |
| 370 | const bodyStart = operatorEnd + openLineEnd + 1 |
| 371 | const body = command.slice(bodyStart) |
| 372 | const bodyLines = body.split('\n') |
| 373 | |
| 374 | // Find the FIRST line that closes the heredoc. There are two valid forms: |
no test coverage detected