forked from sanbuphy/learn-coding-agent
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathshellQuote.ts
More file actions
304 lines (272 loc) · 10.6 KB
/
shellQuote.ts
File metadata and controls
304 lines (272 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
/**
* 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<string, string | undefined>
| ((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(/(?<!\\)"/g) || []
if (doubleQuotes.length % 2 !== 0) return true
// Check for unbalanced single quotes
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above
const singleQuotes = entry.match(/(?<!\\)'/g) || []
if (singleQuotes.length % 2 !== 0) return true
}
return false
}
/**
* Detects commands containing '\' patterns that exploit the shell-quote library's
* incorrect handling of backslashes inside single quotes.
*
* In bash, single quotes preserve ALL characters literally - backslash has no
* special meaning. So '\' is just the string \ (the quote opens, contains \,
* and the next ' closes it). But shell-quote incorrectly treats \ as an escape
* character inside single quotes, causing '\' to NOT close the quoted string.
*
* This means the pattern '\' <payload> '\' hides <payload> 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<unknown>): 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')
}
}