|
| 1 | +import { describe, it, expect } from 'vitest'; |
| 2 | +import type { ChildProcess } from 'node:child_process'; |
| 3 | +import { extractBundleIdFromAppPath } from '../bundle-id.ts'; |
| 4 | +import type { CommandExecutor } from '../CommandExecutor.ts'; |
| 5 | + |
| 6 | +/** |
| 7 | + * CWE-78 regression tests for bundle-id.ts |
| 8 | + * |
| 9 | + * These tests verify that user-supplied appPath values containing shell |
| 10 | + * metacharacters do NOT result in shell injection when passed through |
| 11 | + * the executeSyncCommand → /bin/sh -c pipeline. |
| 12 | + * |
| 13 | + * CURRENT STATUS: These tests demonstrate the UNFIXED injection vectors |
| 14 | + * identified in the review. The command string passed to /bin/sh -c |
| 15 | + * contains unescaped user input, which would allow command injection. |
| 16 | + */ |
| 17 | + |
| 18 | +type CapturedCall = { |
| 19 | + command: string[]; |
| 20 | + logPrefix?: string; |
| 21 | +}; |
| 22 | + |
| 23 | +const stubProcess = { pid: 1, on: () => stubProcess } as unknown as ChildProcess; |
| 24 | + |
| 25 | +function createCapturingExecutor(calls: CapturedCall[]): CommandExecutor { |
| 26 | + return async (command, logPrefix) => { |
| 27 | + calls.push({ command: [...command], logPrefix }); |
| 28 | + // Simulate 'defaults' returning a fake bundle ID |
| 29 | + return { success: true, output: 'com.example.app', process: stubProcess }; |
| 30 | + }; |
| 31 | +} |
| 32 | + |
| 33 | +describe('bundle-id.ts — CWE-78 shell injection vectors', () => { |
| 34 | + it('UNFIXED: double-quote breakout in appPath reaches /bin/sh -c unescaped', async () => { |
| 35 | + const calls: CapturedCall[] = []; |
| 36 | + const executor = createCapturingExecutor(calls); |
| 37 | + |
| 38 | + // Malicious appPath that breaks out of the double-quoted context |
| 39 | + const maliciousPath = '/tmp/evil" $(id) "bar'; |
| 40 | + await extractBundleIdFromAppPath(maliciousPath, executor); |
| 41 | + |
| 42 | + expect(calls).toHaveLength(1); |
| 43 | + const shellCommand = calls[0].command; |
| 44 | + |
| 45 | + // The command is ['/bin/sh', '-c', '...'] |
| 46 | + expect(shellCommand[0]).toBe('/bin/sh'); |
| 47 | + expect(shellCommand[1]).toBe('-c'); |
| 48 | + |
| 49 | + const cmdString = shellCommand[2]; |
| 50 | + |
| 51 | + // VULNERABILITY: The raw user input is interpolated directly into the |
| 52 | + // shell command string. The $(id) is NOT escaped and would execute. |
| 53 | + // A safe implementation would either: |
| 54 | + // 1. Not use shell at all (pass args array to spawn directly), or |
| 55 | + // 2. Properly escape the appPath with shellEscapeArg |
| 56 | + // |
| 57 | + // This test documents the current vulnerable behavior. |
| 58 | + // When the fix is applied, update the assertion to verify safety. |
| 59 | + expect(cmdString).toContain('$(id)'); |
| 60 | + |
| 61 | + // Verify the command reaches shell — it's using /bin/sh -c |
| 62 | + expect(shellCommand[0]).toBe('/bin/sh'); |
| 63 | + }); |
| 64 | + |
| 65 | + it('UNFIXED: semicolon injection in appPath allows command chaining', async () => { |
| 66 | + const calls: CapturedCall[] = []; |
| 67 | + const executor = createCapturingExecutor(calls); |
| 68 | + |
| 69 | + const maliciousPath = '/tmp/foo"; rm -rf / ; echo "'; |
| 70 | + await extractBundleIdFromAppPath(maliciousPath, executor); |
| 71 | + |
| 72 | + const cmdString = calls[0].command[2]; |
| 73 | + |
| 74 | + // The rm -rf command is embedded in the shell string unescaped |
| 75 | + expect(cmdString).toContain('rm -rf'); |
| 76 | + }); |
| 77 | + |
| 78 | + it('UNFIXED: backtick injection in appPath', async () => { |
| 79 | + const calls: CapturedCall[] = []; |
| 80 | + const executor = createCapturingExecutor(calls); |
| 81 | + |
| 82 | + const maliciousPath = '/tmp/`touch /tmp/pwned`'; |
| 83 | + await extractBundleIdFromAppPath(maliciousPath, executor); |
| 84 | + |
| 85 | + const cmdString = calls[0].command[2]; |
| 86 | + expect(cmdString).toContain('`touch /tmp/pwned`'); |
| 87 | + }); |
| 88 | + |
| 89 | + it('safe appPath without metacharacters works normally', async () => { |
| 90 | + const calls: CapturedCall[] = []; |
| 91 | + const executor = createCapturingExecutor(calls); |
| 92 | + |
| 93 | + const safePath = '/Users/dev/Build/Products/Debug/MyApp.app'; |
| 94 | + const result = await extractBundleIdFromAppPath(safePath, executor); |
| 95 | + |
| 96 | + expect(result).toBe('com.example.app'); |
| 97 | + expect(calls).toHaveLength(1); |
| 98 | + expect(calls[0].command[2]).toContain(safePath); |
| 99 | + }); |
| 100 | +}); |
0 commit comments