Files
software-mansion__argent/packages/tool-server/test/native-profiler-ios-shell-injection.test.ts
filip131311 05f53502ab feat(tool-server): drive iOS simulators from additional CoreSimulator device sets (#600)
> Recreates #543 — GitHub auto-closed it when the base branch of the
stacked PR (#542) was deleted on merge, and a closed PR cannot be
reopened after its head is force-pushed. Same commit, rebased onto main.

## Summary

**Was stacked on #542** (now merged). Consumes the
`ios.additionalDeviceSets` configuration across the iOS tool surface, so
simulators living in extra CoreSimulator device sets — e.g. **Radon
IDE's** `~/Library/Caches/com.swmansion.radon-ide/Devices/iOS` — are
discovered by `list-devices` and drivable by every tool, by UDID,
exactly like default-set devices.

### The core piece: a UDID → device-set map (`utils/ios-device-sets.ts`)

simctl scopes every operation to one device set, so each per-device call
must know the target's owning set. The new module owns that mapping and
the single argv choke point:

- **Learned from discovery**: `list-devices` and the simulator watcher
enumerate the default set + every configured set and `rememberDeviceSet`
each sighting — any tool used after a list hits a warm cache.
- **Lazy fallback**: an unknown UDID triggers one `simctl list` probe
per configured set, cached on hit. An unknown-everywhere UDID resolves
to the default set **without** caching (a device that appears later is
found on the next call) — preserving pre-feature behavior and error
messages byte-for-byte.
- **`simctlArgsForUdid(udid, args)`** builds the full argv (`--set`
injected only for additional-set devices); `simctlPrefix` covers
multi-call sites that resolve once; sync variants
(`simctlArgsForUdidSync`, `cachedDeviceSetForUdid`) cover the
`ChildProcess`-returning and `execFileSync` call sites, each of which
runs after an async step that warmed the cache.
- Config is **read off disk per call** (the #534 design): editing
`ios.additionalDeviceSets` applies without a tool-server restart.
- **Guard**: a configured set whose directory doesn't exist is never
queried — simctl would materialize the dir, turning a config typo into a
stray directory.

### Wired consumers

- **`list-devices`** — enumerates every set, tags additional-set devices
with `deviceSet`, documents the field.
- **`simulator-watcher`** — polls every set (a set failing to list
throws → the tick is skipped, so a transient error still can't
mass-dispose tracked services); warms the map for the injection path.
- **simulator-server blueprint** — passes `--device-set` (the same flag
Radon IDE passes to this shared binary); default-set spawns unchanged.
- **`ax-prefs`** — the pre-boot AX plist now lands under the owning
set's device dir (previously hardcoded to the default
`CoreSimulator/Devices` root, so the write would land nowhere for these
devices and describe would degrade).
- **`boot-device`** — set-aware shutdown/boot/bootstatus; skips the
Simulator.app attach + `CurrentDeviceUDID` write for additional-set
devices (one Simulator.app instance displays one device set — these run
headless and are streamed, exactly how Radon IDE presents them).
- **Every remaining per-device simctl call site**: launch-app /
restart-app / reinstall-app (both the direct impls and the
`SimctlBackend` shared handlers), open-url, settings-permissions
(privacy + install probe), the tvOS screenshot fallback, status-bar
pin/restore, device-shutdown, the ios-host `simctl spawn` family behind
describe/native-devtools, tv-control's AX daemon, and the native
profiler's sync helpers (cache warmed at `startNativeProfilerIos`).

**Out of scope**: `sim-remote` — the remote orchestrator host has its
own device-set story.

## Testing

- **Unit**: new `ios-device-sets.test.ts` (mapping, lazy probe + caching
semantics, no-probe fast path with no sets configured, discovery
enumeration/tagging/dedup, missing-dir guard); blueprint test asserts
the `--device-set` argv; the profiler shell-injection mock extended for
the new import. Full tool-server suite green: **276 files / 2752
tests**; `tsc --build`, eslint, prettier clean.
- **Live, end-to-end on a real Radon IDE device** (iPhone Air, iOS 26.4,
in `com.swmansion.radon-ide/Devices/iOS`), driving this branch's
tool-server over HTTP with the config set at project scope:
- `list-devices` → the Radon device appears, tagged with its `deviceSet`
(invisible to argent 0.16.1 on the same machine);
  - `boot-device` → boots headless in its set, no Simulator.app window;
- `screenshot` → real home-screen PNG via simulator-server spawned with
`--device-set`;
  - `launch-app` (Settings) → launched via `simctl --set launch`;
- `describe` → full accessibility tree from the Settings screen, i.e.
the AX bootstrap (set-aware prefs + in-sim ax-service daemon via `simctl
--set spawn`) works;
- clean shutdown; default-set devices meanwhile listed and behaved
exactly as before.
2026-07-31 15:14:03 +02:00

153 lines
6.5 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
// Records every child_process invocation the profiler makes so the test can
// assert how the (attacker-influenced) udid is passed and how stdout is buffered.
const rec = vi.hoisted(() => ({
calls: [] as {
fn: string;
file: string;
args: string[];
options?: { maxBuffer?: number; input?: string };
}[],
}));
vi.mock("child_process", () => ({
execFileSync: (
file: string,
args: string[],
options?: { maxBuffer?: number; input?: string }
) => {
rec.calls.push({ fn: "execFileSync", file, args, options });
if (args.includes("launchctl")) {
// `launchctl list` output: PID, status, label.
return "123\t0\tUIKitApplication:com.example.app[abc]\n";
}
if (args.includes("listapps")) {
return "(opaque simctl plist)";
}
if (args[0] === "-convert") {
// plutil JSON of installed apps, keyed arbitrarily.
return JSON.stringify({
"com.example.app": {
CFBundleExecutable: "Example",
CFBundleIdentifier: "com.example.app",
ApplicationType: "User",
},
});
}
return "";
},
// `promisify(execFile)`d at module load by ios-device-sets (imported through
// the profiler platform module). The sync helpers under test use the cached
// device-set view, so this async path must exist but is never called here.
execFile: (
_file: string,
_args: string[],
_options: unknown,
callback: (err: Error | null, result: { stdout: string; stderr: string }) => void
) => {
callback(null, { stdout: "", stderr: "" });
},
// A regression back to shell interpolation would route the command (with the
// udid baked into the string) through execSync — make that fail loudly.
execSync: (cmd: string) => {
rec.calls.push({ fn: "execSync", file: String(cmd), args: [] });
throw new Error(`execSync must not be used here (shell-injection risk): ${cmd}`);
},
spawn: () => {
throw new Error("spawn not expected in this test");
},
}));
import { enumerateRunningUserApps } from "../src/tools/profiler/native-profiler/platforms/ios";
import { DEFAULT_EXEC_MAX_BUFFER } from "../src/utils/ios-profiler/run-with-timeout";
import { deviceStrategy } from "../src/utils/ios-profiler/capture-strategy/device";
const VALID_UDID = "6DBF83B4-1A2B-4C3D-9E4F-0123456789AB";
describe("native iOS profiler: device_id shell-injection guard", () => {
it("passes a hostile udid only as a discrete argv element (never via a shell)", () => {
rec.calls.length = 0;
const hostileUdid = 'booted"; touch /tmp/argent-pwned #';
const apps = enumerateRunningUserApps(hostileUdid);
expect(apps).toHaveLength(1);
// No shell-interpreted execSync was used at all.
expect(rec.calls.every((c) => c.fn === "execFileSync")).toBe(true);
// simctl was invoked with the udid as an exact, standalone argv element —
// not concatenated into a command string a shell could parse.
const simctlCalls = rec.calls.filter((c) => c.args.includes("simctl"));
expect(simctlCalls.length).toBeGreaterThanOrEqual(2);
for (const c of simctlCalls) {
expect(c.file).toBe("xcrun");
expect(c.args).toContain(hostileUdid);
}
});
it("captures each stage's stdout with a raised maxBuffer (no ENOBUFS on large output)", () => {
// Splitting the old `simctl listapps | plutil` shell pipe into two
// in-process execFileSync stages means Node — not the OS — buffers each
// stdout. Without an explicit maxBuffer each stage inherits Node's 1 MiB
// default and a large `listapps` (many installed apps) throws ENOBUFS,
// surfaced as NATIVE_PROFILER_APP_LIST_FAILED. Every stage must lift the
// cap to run-with-timeout.ts's 256 MiB.
rec.calls.length = 0;
enumerateRunningUserApps(VALID_UDID);
const captures = rec.calls.filter((c) => c.fn === "execFileSync");
expect(captures.length).toBeGreaterThanOrEqual(3); // launchctl, listapps, plutil
for (const c of captures) {
expect(c.options?.maxBuffer).toBe(DEFAULT_EXEC_MAX_BUFFER);
}
});
it("feeds simctl listapps output to plutil over stdin (in-code pipe, not /bin/sh)", () => {
// The old `simctl listapps | plutil` relied on /bin/sh to wire the pipe.
// The replacement pipes in code: stage 1's stdout is handed to stage 2 via
// execFileSync's `input`, and `--` stops plutil option parsing so the
// trailing `-` (read-stdin) can never be taken for a flag. Guard both so a
// refactor can't quietly drop the stdin feed or the `--` and re-open a gap.
rec.calls.length = 0;
enumerateRunningUserApps(VALID_UDID);
const listapps = rec.calls.find((c) => c.args.includes("listapps"));
const plutil = rec.calls.find((c) => c.file === "plutil");
expect(listapps).toBeDefined();
expect(plutil).toBeDefined();
// Stage 2 receives stage 1's stdout via stdin (the mocked listapps returns
// "(opaque simctl plist)"), never on the argv or via a shell.
expect(plutil?.options?.input).toBe("(opaque simctl plist)");
const dashIdx = plutil?.args.indexOf("--") ?? -1;
expect(dashIdx).toBeGreaterThanOrEqual(0);
expect(plutil?.args[dashIdx + 1]).toBe("-");
});
});
describe("native iOS profiler: record-args device_id argv safety", () => {
it("places a hostile device_id as an exact standalone argv element after --device", () => {
// The record path (`xctrace record --device <deviceId> …`, spawned as argv)
// is where device_id — the input this fix is named for — reaches a
// subprocess. It is argv-safe by construction today (no shell), and the
// anchored UDID classifier keeps a metacharacter payload off the iOS path
// entirely; assert the token discipline anyway so a future {shell:true} or
// arg-fusion regression on the primary attacker input lights up.
const hostileDeviceId = 'booted"; touch /tmp/argent-pwned #';
const args = deviceStrategy.buildRecordArgs({
templatePath: "/tmp/Argent.tracetemplate",
deviceId: hostileDeviceId,
target: { executable: "Example", pid: 1234 },
outputFile: "/tmp/out.trace",
});
const deviceIdx = args.indexOf("--device");
expect(deviceIdx).toBeGreaterThanOrEqual(0);
expect(args[deviceIdx + 1]).toBe(hostileDeviceId);
// Exactly one element equals the value, and no other element embeds it —
// i.e. it was never fused with its flag or a neighbouring token.
expect(args.filter((a) => a === hostileDeviceId)).toHaveLength(1);
expect(args.some((a) => a !== hostileDeviceId && a.includes(hostileDeviceId))).toBe(false);
});
});