From 0398667a761f288616b9c5803b34695dc997e6de Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 7 Sep 2026 16:28:43 -0400 Subject: [PATCH] fix: capture process identity in macOS sandboxes --- .github/workflows/ci.yml | 3 +++ README.md | 7 +++-- index.mjs | 20 ++++++++------ lib/token.mjs | 8 +++--- package-lock.json | 4 +-- package.json | 3 ++- scripts/test-darwin-sandbox.mjs | 47 +++++++++++++++++++++++++++++++++ src/darwin.c | 9 ++++--- test/darwin-sandbox.fixture.mjs | 23 ++++++++++++++++ test/identity.test.mjs | 4 +++ 10 files changed, 108 insertions(+), 20 deletions(-) create mode 100644 scripts/test-darwin-sandbox.mjs create mode 100644 test/darwin-sandbox.fixture.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44d129b..2ff958d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: - run: npm run typecheck - run: npm run build - run: npm test + - name: macOS sandbox process identity + if: runner.os == 'macOS' + run: npm run test:sandbox - name: Hidden live processes fail closed if: runner.os == 'Linux' run: | diff --git a/README.md b/README.md index a95966d..ef43f46 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,11 @@ binaries, and permission failures are results, not thrown exceptions. An inspection error is not proof that a process is gone. Tokens use exact OS start values, not approximate dates. Linux also includes boot and PID -namespace identity; macOS includes boot identity. Windows uses the process's -64-bit creation time. +namespace identity. macOS uses the process's microsecond start timestamp and +includes boot identity when permitted. Windows uses the process's 64-bit +creation time. Without boot identity, a repeated PID and exact timestamp +after a reboot or clock reset cannot be distinguished. A saved macOS token +with boot identity still requires permission to verify that identity. Tokens belong in trusted state on the originating machine. They are not credentials. Identity checks do not prove ownership or make a later PID-based diff --git a/index.mjs b/index.mjs index 4c87c75..e2d1cfc 100644 --- a/index.mjs +++ b/index.mjs @@ -84,14 +84,18 @@ export function check(token) { "INSPECTION_FAILED", "The OS returned an invalid identity", ); - return { - ok: true, - value: - observed.value.startTime === decoded.value.startTime && - observed.value.bootId === decoded.value.bootId - ? "same" - : "different", - }; + if (observed.value.startTime !== decoded.value.startTime) + return { ok: true, value: "different" }; + if (decoded.value.bootId !== null) { + if (observed.value.bootId === null) + return failure( + "ACCESS_DENIED", + "The saved boot identity could not be verified", + ); + if (observed.value.bootId !== decoded.value.bootId) + return { ok: true, value: "different" }; + } + return { ok: true, value: "same" }; } catch { return failure( "INSPECTION_FAILED", diff --git a/lib/token.mjs b/lib/token.mjs index 10d91f1..769d6eb 100644 --- a/lib/token.mjs +++ b/lib/token.mjs @@ -21,16 +21,18 @@ function valid(value) { return false; if (value.platform === "win32") return value.bootId === null && integer(value.startTime); - if (typeof value.bootId !== "string" || typeof value.startTime !== "string") - return false; + if (typeof value.startTime !== "string") return false; if (value.platform === "linux") return ( + typeof value.bootId === "string" && new RegExp(`^${uuid}:[0-9]{1,20}:[0-9]{1,20}$`).test(value.bootId) && integer(value.startTime) ); if ( value.platform !== "darwin" || - !new RegExp(`^${uuid}$`).test(value.bootId) + (value.bootId !== null && + (typeof value.bootId !== "string" || + !new RegExp(`^${uuid}$`).test(value.bootId))) ) return false; const parts = value.startTime.split(":"); diff --git a/package-lock.json b/package-lock.json index 5875e64..88aeba3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "unique-pid", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "unique-pid", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "os": [ "darwin", diff --git a/package.json b/package.json index b42db70..697b365 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unique-pid", - "version": "0.1.0", + "version": "0.1.1", "description": "Unique, serializable process IDs for Node.js", "type": "module", "gypfile": false, @@ -34,6 +34,7 @@ "scripts": { "build": "node scripts/build.mjs", "test": "node --test test/identity.test.mjs test/linux.test.mjs test/loading.test.mjs", + "test:sandbox": "node scripts/test-darwin-sandbox.mjs", "test:package": "node scripts/package-smoke.mjs", "pack:release": "node scripts/pack-release.mjs", "typecheck": "tsc --noEmit", diff --git a/scripts/test-darwin-sandbox.mjs b/scripts/test-darwin-sandbox.mjs new file mode 100644 index 0000000..3f9581b --- /dev/null +++ b/scripts/test-darwin-sandbox.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { capture, check, decode } from "../index.mjs"; + +if (process.platform !== "darwin") process.exit(0); + +const captured = capture(process.pid); +assert.equal(captured.ok, true, JSON.stringify(captured)); +assert.notEqual(decode(captured.value).value.bootId, null); +const bootDenied = `(version 1) (allow default) + (deny sysctl-read (sysctl-name "kern.bootsessionuuid")) + (deny process-exec (literal "/bin/ps"))`; +const fixture = fileURLToPath( + new URL("../test/darwin-sandbox.fixture.mjs", import.meta.url), +); + +function run(profile, args, env = process.env) { + const result = spawnSync( + "/usr/bin/sandbox-exec", + ["-p", profile, process.execPath, ...args], + { encoding: "utf8", env, timeout: 30000 }, + ); + assert.equal(result.error, undefined); + assert.equal(result.status, 0, result.stderr + result.stdout); + return result.stdout; +} + +const token = JSON.parse( + run(bootDenied, [fixture, "boot-denied", captured.value]), +); +assert.equal(decode(token).value.bootId, null); +assert.deepEqual(check(token), { ok: true, value: "same" }); +run("(version 1) (allow default) (deny process-info-pidinfo)", [ + fixture, + "process-denied", + captured.value, +]); +run( + bootDenied, + [ + "--test", + fileURLToPath(new URL("../test/identity.test.mjs", import.meta.url)), + ], + { ...process.env, EXPECT_PS_DENIED: "1", EXPECT_BOOT_DENIED: "1" }, +); +console.log("macOS sandbox identity and permission checks passed"); diff --git a/src/darwin.c b/src/darwin.c index 14e99b5..ba349b3 100644 --- a/src/darwin.c +++ b/src/darwin.c @@ -18,11 +18,12 @@ void observe_process(unsigned int pid, struct identity *result) { if (bsd.pbi_pid != pid || bsd.pbi_start_tvusec >= 1000000) return; size_t size = sizeof(result->boot); if (sysctlbyname("kern.bootsessionuuid", result->boot, &size, NULL, 0) != 0) { - result->status = errno == EPERM || errno == EACCES ? "denied" : "unknown"; - return; + if (errno != EPERM && errno != EACCES) return; + result->boot[0] = '\0'; + } else { + if (size < 2 || size > sizeof(result->boot) || result->boot[size - 1] != '\0') return; + for (size_t i = 0; i < size; i++) result->boot[i] = (char)tolower((unsigned char)result->boot[i]); } - if (size < 2 || size > sizeof(result->boot) || result->boot[size - 1] != '\0') return; - for (size_t i = 0; i < size; i++) result->boot[i] = (char)tolower((unsigned char)result->boot[i]); snprintf(result->start, sizeof(result->start), "%" PRIu64 ":%" PRIu64, bsd.pbi_start_tvsec, bsd.pbi_start_tvusec); result->status = "found"; diff --git a/test/darwin-sandbox.fixture.mjs b/test/darwin-sandbox.fixture.mjs new file mode 100644 index 0000000..c114ed3 --- /dev/null +++ b/test/darwin-sandbox.fixture.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { capture, check, decode } from "../index.mjs"; + +const [mode, token] = process.argv.slice(2); +if (mode === "process-denied") { + assert.equal(capture(process.pid).error.code, "ACCESS_DENIED"); + assert.equal(check(token).error.code, "ACCESS_DENIED"); +} else { + assert.equal(mode, "boot-denied"); + const saved = decode(token).value; + const captured = capture(saved.pid); + assert.equal(captured.ok, true, JSON.stringify(captured)); + assert.equal(decode(captured.value).value.bootId, null); + assert.deepEqual(check(captured.value), { ok: true, value: "same" }); + assert.equal(check(token).error.code, "ACCESS_DENIED"); + const different = + "upid1." + + Buffer.from(JSON.stringify({ ...saved, startTime: "0:0" })).toString( + "base64url", + ); + assert.deepEqual(check(different), { ok: true, value: "different" }); + console.log(JSON.stringify(captured.value)); +} diff --git a/test/identity.test.mjs b/test/identity.test.mjs index 5f4d421..c07d5c2 100644 --- a/test/identity.test.mjs +++ b/test/identity.test.mjs @@ -36,6 +36,8 @@ test("exact identity survives serialization, fresh readers, and title changes", assert.equal(other.status, 0, other.stderr); assert.deepEqual(JSON.parse(other.stdout), { ok: true, value: "same" }); const identity = decode(token).value; + if (process.env.EXPECT_BOOT_DENIED === "1") + assert.equal(identity.bootId, null); const parts = identity.startTime.split(":"); if (parts.length === 2) parts[1] = String((BigInt(parts[1]) + 1n) % 1000000n); @@ -136,6 +138,8 @@ test("invalid token schemas, encodings, and precision loss are rejected", () => { startTime: "123:1000000" }, { startTime: "18446744073709551616:0" }, { startTime: 123 }, + { bootId: 123 }, + { bootId: "" }, { surprise: true }, ]) { assert.equal(