Skip to content

Commit e9780db

Browse files
fix(security): close remaining /bin/sh -c shell-injection sites in bundle ID flows (#390)
* fix(security): close remaining /bin/sh -c shell-injection sites in bundle ID flows Follow-up to #289. PR #289 hardened the `useShell=true` path through `shellEscapeArg`, but four call sites still hand-built `/bin/sh -c` strings interpolating user-controlled paths and passed them to the executor with `useShell=false`: - `src/utils/bundle-id.ts` - `src/mcp/tools/project-discovery/get_mac_bundle_id.ts` - `src/mcp/tools/macos/build_macos.ts` - `src/utils/macos-steps.ts` All four now invoke `defaults` and `PlistBuddy` directly with an argv array, removing shell parsing entirely (option 1 from the issue). The malicious appPath becomes a single positional argument to `defaults` / `PlistBuddy` and is never interpreted as a shell expression. Tests: - The four `UNFIXED:` regression cases in `bundle-id-injection.test.ts` and `mac-bundle-id-injection.test.ts` are flipped to safe assertions (no `/bin/sh`, exact argv shape, `useShell=false`). - New PlistBuddy-fallback cases assert the same shape on the second branch. - Existing fixtures in `get_app_bundle_id.test.ts`, `get_mac_bundle_id.test.ts`, and `build_run_device.test.ts` updated to the new argv-shape command keys. - Pre-fix verification: stashed the four source edits and re-ran the injection suites — 8/8 fail. Restored the fix — 8/8 pass. Local gates: typecheck, lint, prettier --check, full vitest run (1772 passed / 0 failed). Closes #367 * test: Remove stale shell stub from macOS smoke test --------- Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com> Co-authored-by: Cameron Cooke <web@cameroncooke.com>
1 parent 38b57a7 commit e9780db

11 files changed

Lines changed: 157 additions & 85 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixed
66

7+
- Fixed remaining `/bin/sh -c` shell-injection sites in bundle ID extraction and macOS launch flows by invoking `defaults` and `PlistBuddy` directly with argv arrays so user-supplied app paths are no longer interpreted by a shell ([#367](https://github.com/getsentry/XcodeBuildMCP/issues/367)).
78
- Fixed simulator test JSONL accuracy by keeping preflight discovery observational, preserving only user-supplied test selectors, discovering multiline parameterized Swift Testing tests, and parsing destination-suffixed xcodebuild test result lines.
89
- Removed stale physical-device log session status and shutdown cleanup for deprecated standalone device log capture, and corrected the device build-and-run tool description.
910
- Fixed mixed Swift Testing and XCTest summaries so simulator test text output no longer overcounts parameterized Swift Testing results or issue lines.

src/mcp/tools/device/__tests__/build_run_device.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ describe('build_run_device tool', () => {
109109
});
110110
}
111111

112-
if (command[0] === '/bin/sh') {
112+
if (command[0] === 'defaults' || command[0] === '/usr/libexec/PlistBuddy') {
113113
return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
114114
}
115115

@@ -143,7 +143,7 @@ describe('build_run_device tool', () => {
143143
});
144144
}
145145

146-
if (command[0] === '/bin/sh') {
146+
if (command[0] === 'defaults' || command[0] === '/usr/libexec/PlistBuddy') {
147147
return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
148148
}
149149

@@ -177,7 +177,7 @@ describe('build_run_device tool', () => {
177177
});
178178
}
179179

180-
if (command[0] === '/bin/sh') {
180+
if (command[0] === 'defaults' || command[0] === '/usr/libexec/PlistBuddy') {
181181
return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
182182
}
183183

@@ -218,7 +218,7 @@ describe('build_run_device tool', () => {
218218
});
219219
}
220220

221-
if (command[0] === '/bin/sh') {
221+
if (command[0] === 'defaults' || command[0] === '/usr/libexec/PlistBuddy') {
222222
return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
223223
}
224224

@@ -258,7 +258,7 @@ describe('build_run_device tool', () => {
258258
});
259259
}
260260

261-
if (command[0] === '/bin/sh') {
261+
if (command[0] === 'defaults' || command[0] === '/usr/libexec/PlistBuddy') {
262262
return createMockCommandResponse({ success: true, output: 'io.sentry.MyWatchApp' });
263263
}
264264

src/mcp/tools/macos/build_macos.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export function createBuildMacOSExecutor(
107107
);
108108

109109
const plistResult = await executor(
110-
['/bin/sh', '-c', `defaults read "${appPath}/Contents/Info" CFBundleIdentifier`],
110+
['defaults', 'read', `${appPath}/Contents/Info`, 'CFBundleIdentifier'],
111111
'Extract Bundle ID',
112112
false,
113113
);

src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe('get_app_bundle_id plugin', () => {
9191

9292
it('should return success with bundle ID using defaults read', async () => {
9393
const mockExecutor = createMockExecutorForCommands({
94-
'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': 'io.sentry.MyApp',
94+
'defaults read /path/to/MyApp.app/Info CFBundleIdentifier': 'io.sentry.MyApp',
9595
});
9696
const mockFileSystemExecutor = createMockFileSystemExecutor({
9797
existsSync: () => true,
@@ -116,10 +116,10 @@ describe('get_app_bundle_id plugin', () => {
116116

117117
it('should fallback to PlistBuddy when defaults read fails', async () => {
118118
const mockExecutor = createMockExecutorForCommands({
119-
'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
119+
'defaults read /path/to/MyApp.app/Info CFBundleIdentifier': new Error(
120120
'defaults read failed',
121121
),
122-
'/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
122+
'/usr/libexec/PlistBuddy -c Print :CFBundleIdentifier /path/to/MyApp.app/Info.plist':
123123
'io.sentry.MyApp',
124124
});
125125
const mockFileSystemExecutor = createMockFileSystemExecutor({
@@ -145,10 +145,10 @@ describe('get_app_bundle_id plugin', () => {
145145

146146
it('should return error when both extraction methods fail', async () => {
147147
const mockExecutor = createMockExecutorForCommands({
148-
'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
148+
'defaults read /path/to/MyApp.app/Info CFBundleIdentifier': new Error(
149149
'defaults read failed',
150150
),
151-
'/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
151+
'/usr/libexec/PlistBuddy -c Print :CFBundleIdentifier /path/to/MyApp.app/Info.plist':
152152
new Error('Command failed'),
153153
});
154154
const mockFileSystemExecutor = createMockFileSystemExecutor({
@@ -169,10 +169,10 @@ describe('get_app_bundle_id plugin', () => {
169169

170170
it('keeps extraction errors short and preserves diagnostics', async () => {
171171
const mockExecutor = createMockExecutorForCommands({
172-
'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
172+
'defaults read /path/to/MyApp.app/Info CFBundleIdentifier': new Error(
173173
'defaults read failed',
174174
),
175-
'/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
175+
'/usr/libexec/PlistBuddy -c Print :CFBundleIdentifier /path/to/MyApp.app/Info.plist':
176176
new Error('Command failed'),
177177
});
178178
const mockFileSystemExecutor = createMockFileSystemExecutor({

src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe('get_mac_bundle_id plugin', () => {
7272

7373
it('should return success with bundle ID using defaults read', async () => {
7474
const mockExecutor = createMockExecutorForCommands({
75-
'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier':
75+
'defaults read /Applications/MyApp.app/Contents/Info CFBundleIdentifier':
7676
'io.sentry.MyMacApp',
7777
});
7878
const mockFileSystemExecutor = createMockFileSystemExecutor({
@@ -96,10 +96,10 @@ describe('get_mac_bundle_id plugin', () => {
9696

9797
it('should fallback to PlistBuddy when defaults read fails', async () => {
9898
const mockExecutor = createMockExecutorForCommands({
99-
'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
99+
'defaults read /Applications/MyApp.app/Contents/Info CFBundleIdentifier': new Error(
100100
'defaults read failed',
101101
),
102-
'/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
102+
'/usr/libexec/PlistBuddy -c Print :CFBundleIdentifier /Applications/MyApp.app/Contents/Info.plist':
103103
'io.sentry.MyMacApp',
104104
});
105105
const mockFileSystemExecutor = createMockFileSystemExecutor({
@@ -123,10 +123,10 @@ describe('get_mac_bundle_id plugin', () => {
123123

124124
it('should return error when both extraction methods fail', async () => {
125125
const mockExecutor = createMockExecutorForCommands({
126-
'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
126+
'defaults read /Applications/MyApp.app/Contents/Info CFBundleIdentifier': new Error(
127127
'Command failed',
128128
),
129-
'/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
129+
'/usr/libexec/PlistBuddy -c Print :CFBundleIdentifier /Applications/MyApp.app/Contents/Info.plist':
130130
new Error('Command failed'),
131131
});
132132
const mockFileSystemExecutor = createMockFileSystemExecutor({
@@ -147,10 +147,10 @@ describe('get_mac_bundle_id plugin', () => {
147147

148148
it('keeps extraction errors short and preserves diagnostics', async () => {
149149
const mockExecutor = createMockExecutorForCommands({
150-
'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
150+
'defaults read /Applications/MyApp.app/Contents/Info CFBundleIdentifier': new Error(
151151
'Command failed',
152152
),
153-
'/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
153+
'/usr/libexec/PlistBuddy -c Print :CFBundleIdentifier /Applications/MyApp.app/Contents/Info.plist':
154154
new Error('Command failed'),
155155
});
156156
const mockFileSystemExecutor = createMockFileSystemExecutor({

src/mcp/tools/project-discovery/get_mac_bundle_id.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
import { toErrorMessage } from '../../../utils/errors.ts';
1414
import { createBasicDiagnostics } from '../../../utils/diagnostics.ts';
1515

16-
async function executeSyncCommand(command: string, executor: CommandExecutor): Promise<string> {
17-
const result = await executor(['/bin/sh', '-c', command], 'macOS Bundle ID Extraction');
16+
async function runSpawn(command: string[], executor: CommandExecutor): Promise<string> {
17+
const result = await executor(command, 'macOS Bundle ID Extraction', false);
1818
if (!result.success) {
1919
throw new Error(result.error ?? 'Command failed');
2020
}
@@ -49,14 +49,19 @@ export function createGetMacBundleIdExecutor(
4949
let bundleId: string;
5050

5151
try {
52-
bundleId = await executeSyncCommand(
53-
`defaults read "${appPath}/Contents/Info" CFBundleIdentifier`,
52+
bundleId = await runSpawn(
53+
['defaults', 'read', `${appPath}/Contents/Info`, 'CFBundleIdentifier'],
5454
executor,
5555
);
5656
} catch {
5757
try {
58-
bundleId = await executeSyncCommand(
59-
`/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Contents/Info.plist"`,
58+
bundleId = await runSpawn(
59+
[
60+
'/usr/libexec/PlistBuddy',
61+
'-c',
62+
'Print :CFBundleIdentifier',
63+
`${appPath}/Contents/Info.plist`,
64+
],
6065
executor,
6166
);
6267
} catch (innerError) {

src/smoke-tests/__tests__/e2e-mcp-device-macos.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ beforeAll(async () => {
2121
open: { success: true, output: '' },
2222
kill: { success: true, output: '' },
2323
pkill: { success: true, output: '' },
24-
'/bin/sh': { success: true, output: 'io.sentry.MyApp' },
2524
'defaults read': { success: true, output: 'io.sentry.MyApp' },
2625
PlistBuddy: { success: true, output: 'io.sentry.MyApp' },
2726
xcresulttool: { success: true, output: '{}' },

src/utils/__tests__/bundle-id-injection.test.ts

Lines changed: 64 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,86 +4,103 @@ import { extractBundleIdFromAppPath } from '../bundle-id.ts';
44
import type { CommandExecutor } from '../CommandExecutor.ts';
55

66
/**
7-
* CWE-78 regression tests for bundle-id.ts
7+
* CWE-78 regression tests for bundle-id.ts.
88
*
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.
9+
* The implementation now invokes `defaults` and `PlistBuddy` directly with
10+
* an argv array (no `/bin/sh -c`), so user-controlled `appPath` values
11+
* containing shell metacharacters are passed as opaque positional
12+
* arguments and never reach a shell parser.
1613
*/
1714

1815
type CapturedCall = {
1916
command: string[];
2017
logPrefix?: string;
18+
useShell?: boolean;
2119
};
2220

2321
const stubProcess = { pid: 1, on: () => stubProcess } as unknown as ChildProcess;
2422

2523
function createCapturingExecutor(calls: CapturedCall[]): CommandExecutor {
26-
return async (command, logPrefix) => {
27-
calls.push({ command: [...command], logPrefix });
28-
// Simulate 'defaults' returning a fake bundle ID
24+
return async (command, logPrefix, useShell) => {
25+
calls.push({ command: [...command], logPrefix, useShell });
2926
return { success: true, output: 'com.example.app', process: stubProcess };
3027
};
3128
}
3229

3330
describe('bundle-id.ts — CWE-78 shell injection vectors', () => {
34-
it('UNFIXED: double-quote breakout in appPath reaches /bin/sh -c unescaped', async () => {
31+
it('does not invoke /bin/sh and passes a metacharacter-laden path as an argv element', async () => {
3532
const calls: CapturedCall[] = [];
3633
const executor = createCapturingExecutor(calls);
3734

38-
// Malicious appPath that breaks out of the double-quoted context
3935
const maliciousPath = '/tmp/evil" $(id) "bar';
4036
await extractBundleIdFromAppPath(maliciousPath, executor);
4137

4238
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');
39+
const call = calls[0];
40+
41+
expect(call.command[0]).toBe('defaults');
42+
expect(call.command[0]).not.toBe('/bin/sh');
43+
expect(call.useShell).toBe(false);
44+
45+
expect(call.command).toEqual([
46+
'defaults',
47+
'read',
48+
`${maliciousPath}/Info`,
49+
'CFBundleIdentifier',
50+
]);
6351
});
6452

65-
it('UNFIXED: semicolon injection in appPath allows command chaining', async () => {
53+
it('isolates semicolon-injection attempts as a single argv element', async () => {
6654
const calls: CapturedCall[] = [];
6755
const executor = createCapturingExecutor(calls);
6856

6957
const maliciousPath = '/tmp/foo"; rm -rf / ; echo "';
7058
await extractBundleIdFromAppPath(maliciousPath, executor);
7159

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');
60+
const call = calls[0];
61+
expect(call.command[0]).toBe('defaults');
62+
expect(call.command[2]).toBe(`${maliciousPath}/Info`);
63+
expect(call.command).not.toContain('/bin/sh');
7664
});
7765

78-
it('UNFIXED: backtick injection in appPath', async () => {
66+
it('isolates backtick-injection attempts as a single argv element', async () => {
7967
const calls: CapturedCall[] = [];
8068
const executor = createCapturingExecutor(calls);
8169

8270
const maliciousPath = '/tmp/`touch /tmp/pwned`';
8371
await extractBundleIdFromAppPath(maliciousPath, executor);
8472

85-
const cmdString = calls[0].command[2];
86-
expect(cmdString).toContain('`touch /tmp/pwned`');
73+
const call = calls[0];
74+
expect(call.command[0]).toBe('defaults');
75+
expect(call.command[2]).toBe(`${maliciousPath}/Info`);
76+
expect(call.command).not.toContain('/bin/sh');
77+
});
78+
79+
it('falls back to PlistBuddy without invoking a shell', async () => {
80+
const calls: CapturedCall[] = [];
81+
const failingExecutor: CommandExecutor = async (command, logPrefix, useShell) => {
82+
calls.push({ command: [...command], logPrefix, useShell });
83+
if (command[0] === 'defaults') {
84+
return { success: false, output: '', error: 'defaults read failed', process: stubProcess };
85+
}
86+
return { success: true, output: 'com.example.app', process: stubProcess };
87+
};
88+
89+
const maliciousPath = '/tmp/evil" $(id) "bar';
90+
const result = await extractBundleIdFromAppPath(maliciousPath, failingExecutor);
91+
92+
expect(result).toBe('com.example.app');
93+
expect(calls).toHaveLength(2);
94+
95+
const fallback = calls[1];
96+
expect(fallback.useShell).toBe(false);
97+
expect(fallback.command).toEqual([
98+
'/usr/libexec/PlistBuddy',
99+
'-c',
100+
'Print :CFBundleIdentifier',
101+
`${maliciousPath}/Info.plist`,
102+
]);
103+
expect(fallback.command).not.toContain('/bin/sh');
87104
});
88105

89106
it('safe appPath without metacharacters works normally', async () => {
@@ -95,6 +112,11 @@ describe('bundle-id.ts — CWE-78 shell injection vectors', () => {
95112

96113
expect(result).toBe('com.example.app');
97114
expect(calls).toHaveLength(1);
98-
expect(calls[0].command[2]).toContain(safePath);
115+
expect(calls[0].command).toEqual([
116+
'defaults',
117+
'read',
118+
`${safePath}/Info`,
119+
'CFBundleIdentifier',
120+
]);
99121
});
100122
});

0 commit comments

Comments
 (0)