Skip to content

Commit ef86ca4

Browse files
Merge pull request #7448 from Shopify/sentinel-fix-execcommand-safety-17378133700905160634
[Security] Add command safety check to execCommand
2 parents 38a5800 + d2178be commit ef86ca4

2 files changed

Lines changed: 56 additions & 14 deletions

File tree

packages/cli-kit/src/public/node/system.test.ts

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as system from './system.js'
2-
import {execa, execaCommand} from 'execa'
2+
import {execa} from 'execa'
33
import {describe, expect, test, vi} from 'vitest'
44
import which from 'which'
55
import {Readable} from 'stream'
@@ -206,16 +206,19 @@ describe('captureCommandWithExitCode', () => {
206206
describe('execCommand', () => {
207207
test('runs command successfully without throwing', async () => {
208208
// Given
209-
vi.mocked(execaCommand).mockResolvedValueOnce({} as any)
209+
vi.mocked(which.sync).mockReturnValueOnce('/system/echo')
210+
vi.mocked(execa).mockResolvedValueOnce({} as any)
210211

211212
// When/Then
212213
await expect(system.execCommand('echo hello')).resolves.toBeUndefined()
214+
expect(execa).toHaveBeenCalledWith('echo', ['hello'], expect.anything())
213215
})
214216

215217
test('throws ExternalError on command failure', async () => {
216218
// Given
217219
const error = new Error('command not found')
218-
vi.mocked(execaCommand).mockRejectedValueOnce(error)
220+
vi.mocked(which.sync).mockReturnValueOnce('/system/nonexistent')
221+
vi.mocked(execa).mockRejectedValueOnce(error)
219222

220223
// When/Then
221224
await expect(system.execCommand('nonexistent')).rejects.toThrow('command not found')
@@ -224,7 +227,8 @@ describe('execCommand', () => {
224227
test('calls custom error handler when provided', async () => {
225228
// Given
226229
const error = new Error('custom error')
227-
vi.mocked(execaCommand).mockRejectedValueOnce(error)
230+
vi.mocked(which.sync).mockReturnValueOnce('/system/failing')
231+
vi.mocked(execa).mockRejectedValueOnce(error)
228232
const customHandler = vi.fn()
229233

230234
// When
@@ -234,37 +238,70 @@ describe('execCommand', () => {
234238
expect(customHandler).toHaveBeenCalledWith(error)
235239
})
236240

237-
test('handles command with spaces in arguments', async () => {
241+
test('handles command with spaces in arguments (quoted strings)', async () => {
238242
// Given
239-
vi.mocked(execaCommand).mockResolvedValueOnce({} as any)
243+
vi.mocked(which.sync).mockReturnValueOnce('/system/touch')
244+
vi.mocked(execa).mockResolvedValueOnce({} as any)
240245

241246
// When
242247
await system.execCommand('touch "my file.txt"')
243248

244249
// Then
245-
expect(execaCommand).toHaveBeenCalledWith('touch "my file.txt"', expect.anything())
250+
// The quoted argument is parsed into a single argument without quotes,
251+
// and the executable launched matches the executable that was safety-checked.
252+
expect(execa).toHaveBeenCalledWith('touch', ['my file.txt'], expect.anything())
246253
})
247254

248255
test('uses provided cwd option', async () => {
249256
// Given
250-
vi.mocked(execaCommand).mockResolvedValueOnce({} as any)
257+
vi.mocked(which.sync).mockReturnValueOnce('/system/pwd')
258+
vi.mocked(execa).mockResolvedValueOnce({} as any)
251259

252260
// When
253261
await system.execCommand('pwd', {cwd: '/some/dir'})
254262

255263
// Then
256-
expect(execaCommand).toHaveBeenCalledWith('pwd', expect.objectContaining({cwd: '/some/dir'}))
264+
expect(execa).toHaveBeenCalledWith('pwd', [], expect.objectContaining({cwd: '/some/dir'}))
257265
})
258266

259-
test('passes stdin option to execaCommand', async () => {
267+
test('passes stdin option to execa', async () => {
260268
// Given
261-
vi.mocked(execaCommand).mockResolvedValueOnce({} as any)
269+
vi.mocked(which.sync).mockReturnValueOnce('/system/cat')
270+
vi.mocked(execa).mockResolvedValueOnce({} as any)
262271

263272
// When
264273
await system.execCommand('cat', {stdin: 'inherit'})
265274

266275
// Then
267-
expect(execaCommand).toHaveBeenCalledWith('cat', expect.objectContaining({stdin: 'inherit'}))
276+
expect(execa).toHaveBeenCalledWith('cat', [], expect.objectContaining({stdin: 'inherit'}))
277+
})
278+
279+
test('raises an error if the command to run is found in the current directory', async () => {
280+
// Given
281+
vi.mocked(which.sync).mockReturnValueOnce('/currentDirectory/command')
282+
283+
// When
284+
const got = system.execCommand('command', {cwd: '/currentDirectory'})
285+
286+
// Then
287+
await expect(got).rejects.toThrowError('Skipped run of unsecure binary command found in the current directory.')
288+
})
289+
290+
test('safety check and execution agree on the binary (no parser mismatch bypass)', async () => {
291+
// Given
292+
// Whatever token the safety check approves must be exactly what execa launches.
293+
// Previously, parseCommand() could approve one token while execaCommand() launched
294+
// a different one (e.g. via backslash-escaped spaces), bypassing checkCommandSafety.
295+
vi.mocked(which.sync).mockReturnValueOnce('/system/some-binary')
296+
vi.mocked(execa).mockResolvedValueOnce({} as any)
297+
298+
// When
299+
await system.execCommand('some-binary arg1 arg2')
300+
301+
// Then
302+
const checkedCommand = vi.mocked(which.sync).mock.calls[0]?.[0]
303+
const launchedCommand = vi.mocked(execa).mock.calls[0]?.[0]
304+
expect(launchedCommand).toBe(checkedCommand)
268305
})
269306
})
270307

packages/cli-kit/src/public/node/system.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {isTruthy} from './context/utilities.js'
66
import {renderWarning} from './ui.js'
77
import {platformAndArch} from './os.js'
88
import {shouldDisplayColors, outputDebug} from './output.js'
9-
import {execa, execaCommand, ExecaChildProcess} from 'execa'
9+
import {execa, ExecaChildProcess} from 'execa'
1010
import supportsHyperlinks from 'supports-hyperlinks'
1111
import which from 'which'
1212
import {delimiter} from 'pathe'
@@ -206,8 +206,13 @@ export async function execCommand(command: string, options?: ExecOptions): Promi
206206
env.FORCE_COLOR = '1'
207207
}
208208
const executionCwd = options?.cwd ?? cwd()
209+
const [cmd, ...args] = parseCommand(command)
210+
if (!cmd) {
211+
throw new AbortError('Empty command')
212+
}
213+
checkCommandSafety(cmd, {cwd: executionCwd})
209214
try {
210-
await execaCommand(command, {
215+
await execa(cmd, args, {
211216
env,
212217
cwd: executionCwd,
213218
stdin: options?.stdin,

0 commit comments

Comments
 (0)