diff --git a/packages/stim-cli/shim/macos-process.c b/packages/stim-cli/shim/macos-process.c new file mode 100644 index 000000000..9f0b5fbe0 --- /dev/null +++ b/packages/stim-cli/shim/macos-process.c @@ -0,0 +1,91 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_ARGS_BYTES (32 * 1024) +#define MAX_KERNEL_BYTES (1024 * 1024) + +static bool identity(int pid, struct proc_bsdinfo *info) { + /* XNU proc_pidinfo requires arg=1 for PROC_PIDTBSDINFO to include zombies. */ + return proc_pidinfo(pid, PROC_PIDTBSDINFO, 1, info, sizeof(*info)) == sizeof(*info) + && info->pbi_pid == (unsigned int)pid && info->pbi_uid == getuid() + && info->pbi_ruid == getuid() && info->pbi_start_tvsec > 0 + && info->pbi_start_tvusec < 1000000; +} + +static bool same_identity(const struct proc_bsdinfo *a, const struct proc_bsdinfo *b) { + return a->pbi_pid == b->pbi_pid && a->pbi_uid == b->pbi_uid && a->pbi_ruid == b->pbi_ruid + && a->pbi_start_tvsec == b->pbi_start_tvsec && a->pbi_start_tvusec == b->pbi_start_tvusec + && (a->pbi_status == SZOMB) == (b->pbi_status == SZOMB); +} + +static bool args_bounds(char *buffer, size_t length, size_t pointer_size, char **start, char **finish, int *argc) { + if (length <= sizeof(int)) return false; + memcpy(argc, buffer, sizeof(int)); + if (*argc <= 0 || *argc > MAX_ARGS_BYTES) return false; + char *cursor = buffer + sizeof(int), *end = buffer + length; + size_t size = strnlen(cursor, (size_t)(end - cursor)); + if (size == 0 || size == (size_t)(end - cursor)) return false; + /* XNU exec_extract_strings aligns executable_path= plus the path to the target pointer size. */ + const size_t prefix = sizeof("executable_path=") - 1; + size_t offset = ((prefix + size + 1 + pointer_size - 1) / pointer_size) * pointer_size - prefix; + if (offset >= (size_t)(end - cursor)) return false; + for (size_t i = size; i < offset; i++) if (cursor[i] != 0) return false; + cursor += offset; + if (*cursor == 0) return false; + *start = cursor; + for (int i = 0; i < *argc; i++) { + if (cursor >= end) return false; + size = strnlen(cursor, (size_t)(end - cursor)); + if (size == (size_t)(end - cursor)) return false; + cursor += size + 1; + if (cursor - *start > MAX_ARGS_BYTES) return false; + } + *finish = cursor; + return true; +} + +static int observe(int pid) { + struct proc_bsdinfo before = {0}, after = {0}; + if (!identity(pid, &before)) return 1; + char *buffer = NULL, *start = NULL, *finish = NULL; + int argc = 0; + if (before.pbi_status != SZOMB) { + buffer = malloc(MAX_KERNEL_BYTES); + size_t length = MAX_KERNEL_BYTES; + int mib[] = {CTL_KERN, KERN_PROCARGS2, pid}; + if (!buffer || sysctl(mib, 3, buffer, &length, NULL, 0) != 0 + || !args_bounds(buffer, length, (before.pbi_flags & PROC_FLAG_LP64) ? 8 : 4, &start, &finish, &argc)) { + free(buffer); + return 1; + } + } + if (!identity(pid, &after) || !same_identity(&before, &after)) { + free(buffer); + return 1; + } + printf("{\"pid\":%d,\"ppid\":%u,\"startSeconds\":\"%llu\",\"startMicros\":%llu,\"zombie\":%s,\"argc\":%d,\"argvHex\":\"", + pid, after.pbi_ppid, after.pbi_start_tvsec, after.pbi_start_tvusec, + after.pbi_status == SZOMB ? "true" : "false", argc); + for (char *cursor = start; cursor && cursor < finish; cursor++) printf("%02x", (unsigned char)*cursor); + puts("\"}"); + free(buffer); + return 0; +} + +int main(int argc, char **argv) { + if (argc != 2 || argv[1][0] < '1' || argv[1][0] > '9') return 2; + char *end = NULL; + errno = 0; + long pid = strtol(argv[1], &end, 10); + if (errno || *end || pid <= 0 || pid > INT_MAX) return 2; + return observe((int)pid); +} diff --git a/packages/stim-cli/src/__tests__/macos-process.test.ts b/packages/stim-cli/src/__tests__/macos-process.test.ts new file mode 100644 index 000000000..190b49b26 --- /dev/null +++ b/packages/stim-cli/src/__tests__/macos-process.test.ts @@ -0,0 +1,98 @@ +import { once } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getExecutor, resetExecutor, setExecutor } from '../exec.ts'; +import { readMacosProcess } from '../macos-process.ts'; +import { readProcessArgs, readProcessStartTime } from '../process-args.ts'; + +describe.skipIf(process.platform !== 'darwin')('native macOS process inspection', () => { + let home: string; + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'stim-native-process-')); + process.env.STIM_HOME = home; + }); + afterEach(() => { + resetExecutor(); + delete process.env.STIM_HOME; + rmSync(home, { recursive: true, force: true }); + }); + + test('keeps exact argv boundaries and start identity when ps execution is denied', async () => { + const executor = getExecutor(); + const args = ['-e', 'setInterval(() => {}, 1000)', '--', 'space and "quotes"', '', 'back\\slash', '\u2603', '', '']; + const child = executor.spawn(process.execPath, args, { stdio: 'ignore' }); + const exited = once(child, 'exit'); + await once(child, 'spawn'); + try { + const observation = readMacosProcess(child.pid!); + expect(observation?.args).toEqual([process.execPath, ...args]); + expect(observation?.zombie).toBe(false); + expect(observation?.startTime.getTime()).toBeLessThanOrEqual(Date.now()); + expect(observation?.startTime.getTime()).toBeGreaterThan(Date.now() - 10_000); + setExecutor({ + ...executor, + runFile(file, argv, options) { + if (file === 'ps') throw Object.assign(new Error('denied'), { code: 'EPERM' }); + return executor.runFile(file, argv, options); + }, + }); + expect(readProcessArgs(child.pid!)).toEqual(observation?.args); + expect(readProcessStartTime(child.pid!)).toEqual(observation?.startTime); + child.kill(); + await exited; + expect(readMacosProcess(child.pid!)).toBeNull(); + expect(readProcessArgs(child.pid!)).toBeNull(); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + await exited; + } + } + }); + + test('refuses invalid PIDs without running a compiler or process query', () => { + setExecutor({ + runFile() { + throw new Error('must not execute'); + }, + }); + for (const pid of [0, -1, 1.1, NaN, Infinity, 2_147_483_648]) expect(readMacosProcess(pid)).toBeNull(); + }); + + test('does not accept truncated, foreign-PID, oversized, or malformed native evidence', () => { + expect(readMacosProcess(process.pid)).not.toBeNull(); + const base = { + pid: process.pid, + startSeconds: '1700000000', + startMicros: 123456, + zombie: false, + argc: 1, + argvHex: Buffer.from('node\0').toString('hex'), + }; + for (const change of [ + { pid: process.pid + 1 }, + { startSeconds: 'NaN' }, + { startMicros: 1_000_000 }, + { argc: 2 }, + { argvHex: 'ff00' }, + { argvHex: 'node' }, + { argvHex: '61'.repeat(32769) }, + { zombie: true }, + { argvHex: '6e6f6465' }, + ]) { + setExecutor({ + runFile() { + return JSON.stringify({ ...base, ...change }); + }, + }); + expect(readMacosProcess(process.pid)).toBeNull(); + } + setExecutor({ + runFile() { + return '{'; + }, + }); + expect(readMacosProcess(process.pid)).toBeNull(); + }); +}); diff --git a/packages/stim-cli/src/collector/ownership.ts b/packages/stim-cli/src/collector/ownership.ts index d1cf9bcec..18f592ab3 100644 --- a/packages/stim-cli/src/collector/ownership.ts +++ b/packages/stim-cli/src/collector/ownership.ts @@ -32,7 +32,10 @@ function identifiesCollector(args: readonly string[], platform: string): boolean } function tokenize(args: readonly string[]): string[] { - const only = args.length === 1 ? args[0] : null; + const only = + args.length === 1 || (args[0]?.startsWith(TITLE_PREFIX) && args.slice(1).every((arg) => arg === '')) + ? args[0] + : null; return only && /\s/.test(only) ? only.trim().split(/\s+/) : [...args]; } diff --git a/packages/stim-cli/src/guide/cleanup.ts b/packages/stim-cli/src/guide/cleanup.ts index 1a0b63179..63a5e9f56 100644 --- a/packages/stim-cli/src/guide/cleanup.ts +++ b/packages/stim-cli/src/guide/cleanup.ts @@ -174,7 +174,14 @@ THE ONE CASE GC WILL NOT REAP genuinely stale and gets dropped, as before. A pid that started at or before that claim may still be the collector Stim registered, so the record is kept and reported for a retry, the same way a device teardown - that could not be confirmed keeps its record.`, + that could not be confirmed keeps its record. + + On macOS, if the sandbox prevents ps from executing, Stim reads same-user + process identity through native macOS APIs. The first fallback compiles a + small read-only helper using the installed Apple command-line tools and + caches it under $STIM_HOME/tools by source hash and architecture. A missing + compiler or denied native inspection still leaves ownership unverified; + it never authorizes signalling an unknown process.`, }, disk: { summary: 'disk usage, AVD and build-log sizes, the data partition, trimming the shared caches', diff --git a/packages/stim-cli/src/macos-process.ts b/packages/stim-cli/src/macos-process.ts new file mode 100644 index 000000000..34ea6d874 --- /dev/null +++ b/packages/stim-cli/src/macos-process.ts @@ -0,0 +1,83 @@ +import { createHash } from 'node:crypto'; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getConfigDir } from './config.ts'; +import { getExecutor } from './exec.ts'; + +type MacosProcess = { args: string[]; startTime: Date; zombie: boolean }; + +function helperPath(): string { + const source = ['../shim/macos-process.c', '../../shim/macos-process.c'] + .map((path) => fileURLToPath(new URL(path, import.meta.url))) + .find(existsSync); + if (!source) throw new Error('macOS process helper source is missing'); + const hash = createHash('sha256').update(readFileSync(source)).update(process.arch).digest('hex'); + const directory = join(getConfigDir(), 'tools', `macos-process-${hash}`); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const directoryStat = lstatSync(directory); + if (!directoryStat.isDirectory() || directoryStat.uid !== process.getuid?.() || (directoryStat.mode & 0o077) !== 0) { + throw new Error('macOS process helper directory is not private'); + } + const binary = join(directory, 'process'); + if (!existsSync(binary)) { + const temporary = mkdtempSync(join(directory, 'compile-')); + try { + const output = join(temporary, 'process'); + getExecutor().runFile( + '/usr/bin/clang', + ['-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', source, '-o', output], + { + timeoutMs: 5_000, + }, + ); + chmodSync(output, 0o700); + renameSync(output, binary); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + } + const stat = lstatSync(binary); + if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) { + throw new Error('macOS process helper is not a private owned executable'); + } + return binary; +} + +export function readMacosProcess(pid: number): MacosProcess | null { + if (process.platform !== 'darwin' || !Number.isInteger(pid) || pid <= 0 || pid > 2_147_483_647) return null; + try { + const output = getExecutor().runFile(helperPath(), [String(pid)], { timeoutMs: 1_000 }); + if (output.length > 66_000) return null; + const value = JSON.parse(output); + if ( + value.pid !== pid || + typeof value.zombie !== 'boolean' || + typeof value.startSeconds !== 'string' || + !/^[1-9]\d{0,10}$/.test(value.startSeconds) || + !Number.isInteger(value.startMicros) || + value.startMicros < 0 || + value.startMicros >= 1_000_000 || + !Number.isInteger(value.argc) || + value.argc < 0 || + value.argc > 32_768 || + typeof value.argvHex !== 'string' || + value.argvHex.length > 65_536 || + !/^(?:[a-f0-9]{2})*$/.test(value.argvHex) + ) + return null; + const bytes = Buffer.from(value.argvHex, 'hex'); + const decoded = bytes.toString('utf8'); + if (!Buffer.from(decoded).equals(bytes)) return null; + const args = decoded.split('\0'); + if (args.pop() !== '' || args.length !== value.argc) return null; + if (value.zombie ? args.length !== 0 : !args[0]) return null; + return { + args, + startTime: new Date(Number(value.startSeconds) * 1_000 + Math.floor(value.startMicros / 1_000)), + zombie: value.zombie, + }; + } catch { + return null; + } +} diff --git a/packages/stim-cli/src/process-args.ts b/packages/stim-cli/src/process-args.ts index 5d19b24a7..b55e693b9 100644 --- a/packages/stim-cli/src/process-args.ts +++ b/packages/stim-cli/src/process-args.ts @@ -1,5 +1,6 @@ import { closeSync, openSync, readFileSync, readSync } from 'node:fs'; import { getExecutor } from './exec.ts'; +import { readMacosProcess } from './macos-process.ts'; export const PROCESS_COMMAND_TIMEOUT_MS: number = 1_000; export const PROCESS_COMMAND_MAX_BYTES: number = 32 * 1024; @@ -110,6 +111,10 @@ export function readProcessArgs( if (platform === 'win32') return null; return parsePsCommand(runPsCommand(pid, PROCESS_COMMAND_TIMEOUT_MS)); } catch { + if (platform === 'darwin' && runPsCommand === defaultRunPsCommand) { + const observation = readMacosProcess(pid); + return observation && !observation.zombie ? observation.args : null; + } return null; } } @@ -183,6 +188,10 @@ export function readProcessStartTime( const parsed = new Date(raw); return Number.isNaN(parsed.getTime()) ? null : parsed; } catch { + if (platform === 'darwin' && runPsCommand === runPsStartCommand) { + const observation = readMacosProcess(pid); + return observation && !observation.zombie ? observation.startTime : null; + } return null; } } diff --git a/test/e2e/fixtures/macos-process-check.c b/test/e2e/fixtures/macos-process-check.c new file mode 100644 index 000000000..c6a4dead9 --- /dev/null +++ b/test/e2e/fixtures/macos-process-check.c @@ -0,0 +1,68 @@ +#define main process_helper_main +#include "../../../packages/stim-cli/shim/macos-process.c" +#undef main +#include +#include + +int main(int argc, char **argv) { + if (argc == 2 && strcmp(argv[1], "zombie") == 0) { + pid_t child = fork(); + if (child == 0) _exit(0); + assert(child > 0); + printf("%d\n", child); + fflush(stdout); + getchar(); + waitpid(child, NULL, 0); + return 0; + } + struct proc_bsdinfo a = {0}, b = {0}; + a.pbi_pid = b.pbi_pid = 123; + a.pbi_start_tvsec = b.pbi_start_tvsec = 1234; + assert(same_identity(&a, &b)); + b.pbi_start_tvusec++; + assert(!same_identity(&a, &b)); + b = a; b.pbi_start_tvsec++; + assert(!same_identity(&a, &b)); + b = a; b.pbi_uid++; + assert(!same_identity(&a, &b)); + b = a; b.pbi_ruid++; + assert(!same_identity(&a, &b)); + b = a; b.pbi_pid++; + assert(!same_identity(&a, &b)); + b = a; b.pbi_status = SZOMB; + assert(!same_identity(&a, &b)); + + char buffer[256] = {0}, *start = NULL, *finish = NULL; + int count = 3, parsed = 0; + memcpy(buffer, &count, sizeof(count)); + const char sample[] = "/bin/node\0\0\0\0\0\0\0node\0\0arg\0SECRET=not-an-argument\0"; + memcpy(buffer + sizeof(count), sample, sizeof(sample)); + size_t length = sizeof(count) + sizeof(sample); + assert(args_bounds(buffer, length, 8, &start, &finish, &parsed)); + assert(parsed == 3 && finish - start == 10); + assert(memcmp(start, "node\0\0arg\0", 10) == 0); + assert(!args_bounds(buffer, sizeof(count), 8, &start, &finish, &parsed)); + assert(!args_bounds(buffer, (size_t)(finish - buffer) - 1, 8, &start, &finish, &parsed)); + count = INT_MAX; + memcpy(buffer, &count, sizeof(count)); + assert(!args_bounds(buffer, length, 8, &start, &finish, &parsed)); + for (size_t pointer = 4; pointer <= 8; pointer += 4) { + for (size_t path_length = 1; path_length <= 24; path_length++) { + memset(buffer, 0, sizeof(buffer)); + count = 2; + memcpy(buffer, &count, sizeof(count)); + memset(buffer + sizeof(count), 'x', path_length); + size_t offset = ((16 + path_length + 1 + pointer - 1) / pointer) * pointer - 16; + char *arguments = buffer + sizeof(count) + offset; + const char ordinary[] = "sleep\00030\000SECRET=not-an-argument\0"; + memcpy(arguments, ordinary, sizeof(ordinary)); + assert(args_bounds(buffer, sizeof(buffer), pointer, &start, &finish, &parsed)); + assert(finish - start == 9 && parsed == 2); + const char empty_first[] = "\00030\000SECRET=not-an-argument\0"; + memcpy(arguments, empty_first, sizeof(empty_first)); + assert(!args_bounds(buffer, sizeof(buffer), pointer, &start, &finish, &parsed)); + } + } + puts("identity and argument bounds passed"); + return 0; +} diff --git a/test/e2e/fixtures/macos-process-smoke.mjs b/test/e2e/fixtures/macos-process-smoke.mjs new file mode 100644 index 000000000..ddb25cb5d --- /dev/null +++ b/test/e2e/fixtures/macos-process-smoke.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { readFileSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { getExecutor } from '../../../packages/stim-cli/src/exec.ts'; +import { readMacosProcess } from '../../../packages/stim-cli/src/macos-process.ts'; +import { readProcessArgs, readProcessStartTime } from '../../../packages/stim-cli/src/process-args.ts'; +import { verifyCollectorOwnership } from '../../../packages/stim-cli/src/collector/ownership.ts'; + +const executor = getExecutor(); +const [mode, sentinel, checker] = process.argv.slice(2); +if (mode === 'denied') { + assert.equal(readMacosProcess(process.pid), null); +} else { + assert.throws(() => readFileSync(sentinel)); + assert.throws(() => executor.runFile('/bin/ps', ['-p', String(process.pid), '-o', 'command='])); + const root = `${process.env.STIM_HOME}/workspace with spaces`; + const title = `stim-collector-ios --root ${root}`; + const child = executor.spawn( + process.execPath, + ['-e', `process.title = ${JSON.stringify(title)}; console.log('ready'); setInterval(() => {}, 1000)`], + { stdio: ['ignore', 'pipe', 'ignore'] }, + ); + const exited = once(child, 'exit'); + await once(child.stdout, 'data'); + try { + assert.deepEqual(readProcessArgs(child.pid).filter(Boolean), [title]); + assert(readProcessStartTime(child.pid) instanceof Date); + assert.equal(verifyCollectorOwnership({ pid: child.pid, platform: 'ios', root }).status, 'ours'); + assert.equal( + verifyCollectorOwnership({ pid: child.pid, platform: 'ios', root: `${root}/other` }).status, + 'unverified', + ); + child.kill(); + await exited; + assert.equal(verifyCollectorOwnership({ pid: child.pid, platform: 'ios', root }).status, 'gone'); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + await exited; + } + } + const emptyArgv = executor.spawn('/bin/sleep', ['30'], { + argv0: '', + env: { TEST_PROCESS_SENTINEL: 'synthetic-not-an-argument' }, + stdio: 'ignore', + }); + const emptyExit = once(emptyArgv, 'exit'); + await once(emptyArgv, 'spawn'); + try { + assert.equal(readMacosProcess(emptyArgv.pid), null); + assert.equal(readProcessArgs(emptyArgv.pid), null); + } finally { + emptyArgv.kill(); + await emptyExit; + } + const holder = executor.spawn(checker, ['zombie'], { stdio: ['pipe', 'pipe', 'inherit'] }); + const holderExit = once(holder, 'exit'); + const lines = createInterface({ input: holder.stdout }); + try { + const [line] = await once(lines, 'line'); + const pid = Number(line); + let observation; + for (let attempt = 0; attempt < 100; attempt++) { + observation = readMacosProcess(pid); + if (observation?.zombie) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(observation?.zombie, true); + assert.equal(readProcessArgs(pid), null); + assert.equal(readProcessStartTime(pid), null); + } finally { + holder.stdin.end('\n'); + await holderExit; + lines.close(); + } +} +console.log(`sandbox process ${mode} passed`); diff --git a/test/e2e/macos-process.e2e.js b/test/e2e/macos-process.e2e.js new file mode 100644 index 000000000..c91f96e6a --- /dev/null +++ b/test/e2e/macos-process.e2e.js @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +test( + 'macOS sandbox preserves collector ownership and refuses denied or zombie evidence', + { skip: process.platform !== 'darwin' }, + () => { + const temporary = realpathSync(mkdtempSync(join(tmpdir(), 'stim-process-sandbox-'))); + try { + const checker = join(temporary, 'check'); + execFileSync( + '/usr/bin/clang', + [ + '-std=c11', + '-Wall', + '-Wextra', + '-Werror', + fileURLToPath(new URL('./fixtures/macos-process-check.c', import.meta.url)), + '-o', + checker, + ], + { timeout: 10_000 }, + ); + assert.match(execFileSync(checker, { encoding: 'utf8' }), /identity and argument bounds passed/); + const sentinel = join(temporary, 'secret'); + writeFileSync(sentinel, 'must stay unreadable'); + for (const mode of ['allowed', 'denied']) { + const policy = `(version 1)(allow default)(deny file-read-data (literal ${JSON.stringify(sentinel)}))${mode === 'denied' ? '(deny process-info*)' : ''}`; + const output = execFileSync( + '/usr/bin/sandbox-exec', + [ + '-p', + policy, + process.execPath, + fileURLToPath(new URL('./fixtures/macos-process-smoke.mjs', import.meta.url)), + mode, + sentinel, + checker, + ], + { + timeout: 30_000, + encoding: 'utf8', + env: { ...process.env, STIM_HOME: join(temporary, mode) }, + }, + ); + assert.match(output, new RegExp(`sandbox process ${mode} passed`)); + } + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }, +);