Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions lib/token.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(":");
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions scripts/test-darwin-sandbox.mjs
Original file line number Diff line number Diff line change
@@ -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");
9 changes: 5 additions & 4 deletions src/darwin.c
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
23 changes: 23 additions & 0 deletions test/darwin-sandbox.fixture.mjs
Original file line number Diff line number Diff line change
@@ -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));
}
4 changes: 4 additions & 0 deletions test/identity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
Loading