Files
reverse-engineering-anything/src/process/ProcessOwnership.identity.test.ts
T
muhammad-luayandN0zoM1z0 5547c1e697 fix(process): stop failing captures on tokens macOS cannot expose (#1057)
* fix(process): stop failing captures on tokens macOS cannot expose

On a busy macOS 26 host the residual process sweep fails every capture:
KERN_PROCARGS2 omits the environment of Apple platform binaries for any
caller and fails with EINVAL for processes owned by another account, so an
unrelated process created after the capture baseline escalates to a hard
cleanup failure with cleanup_incomplete.

Report environment withholding distinctly from the Darwin token reader by
reading the platform-binary flag through csops, carry the effective uid in
the process table, and re-read unresolved tokens with a bounded retry plus a
final liveness confirmation so transient exec and exit states settle.
Candidates whose token the OS provably withholds, foreign-uid candidates,
and candidates that exited during validation are now recorded as unverified
processes instead of failing an otherwise clean capture or being signaled.
Related candidates and every other unreadable candidate stay fail-closed.

* test(process): align ownership lanes with the uid-aware process table

The Darwin sweep reads the effective uid through ps, so the offline
verification lanes' syscall allowlists must accept the new argv in
verify-binary-layout and verify-evm-interface. The foreign-uid ownership
test requires POSIX process uids; skip it where the platform has none
(the curated Windows lane).

* fix(verify): recognize UID-bearing ownership process tables

---------

Co-authored-by: N0zoM1z0 <1705004398@qq.com>
2026-10-08 22:01:05 +08:00

492 lines
14 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
verifyNoTokenOwnedProcesses,
type ProcessOwnershipHost,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
import {
createSystemProcessOwnershipHost,
parseLinuxProcessStartTime,
} from "./ProcessOwnershipObservation.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Linux process start identities", () => {
itOnLinux("reads Linux process start identities from proc stat", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
expect(host.processIdentities).toBeDefined();
expect(host.captureBaseline).toBeDefined();
const observedProcess = {
pid: process.pid,
parentPid: process.ppid,
processGroupId: process.pid,
state: "S",
command: process.execPath,
} satisfies ProcessTableEntry;
const identities = await host.processIdentities?.([observedProcess]);
expect(identities?.get(observedProcess.pid)).toMatchObject({
state: "readable",
identity: expect.stringMatching(/^linux-starttime:\d+$/u),
});
const baseline = await host.captureBaseline?.();
expect(baseline).toContainEqual({
pid: observedProcess.pid,
identity: expect.stringMatching(/^linux-starttime:\d+$/u),
});
});
it("parses stat start time after a command name containing parentheses", () => {
const fields = Array.from({ length: 20 }, (_, index) =>
index === 0 ? "S" : index === 19 ? "987654" : "0",
);
expect(
parseLinuxProcessStartTime(
`42 (worker ) old ) name) ${fields.join(" ")}`,
),
).toBe("987654");
expect(
parseLinuxProcessStartTime("42 (worker) S too-short"),
).toBeUndefined();
});
});
describe("capture-local process ownership", () => {
it("validates and cleans an exact-token owner without a capture baseline", async () => {
const process: ProcessTableEntry = {
pid: 904,
parentPid: 1,
processGroupId: 904,
state: "S",
command: "generic-darwin-owner",
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", runId: "run-token" } as const,
]),
),
),
processIdentities: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", identity: "owner-start" } as const,
]),
),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: process.pid,
processGroupId: process.processGroupId,
sweepTokenOwnedProcesses: true,
},
host,
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL");
});
it.skipIf(typeof process.getuid !== "function")(
"cleans verified owners and records unrelated opaque candidates as unverified",
async () => {
const owned: ProcessTableEntry = {
pid: 905,
parentPid: 1,
processGroupId: 905,
state: "S",
command: "owned-detached-child",
};
const opaque: ProcessTableEntry = {
pid: 906,
parentPid: 1,
processGroupId: 906,
state: "S",
command: "/usr/bin/unrelated-process",
};
const malformed: ProcessTableEntry = {
...opaque,
pid: 907,
processGroupId: 907,
uid: process.getuid?.() === 0 ? 1 : 0,
command: "/usr/bin/malformed-procargs-process",
};
const sysctlDenied: ProcessTableEntry = {
...opaque,
pid: 908,
processGroupId: 908,
uid: process.getuid?.() === 0 ? 1 : 0,
command: "/usr/bin/sysctl-denied-process",
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
platform: "darwin",
listProcesses: () =>
Promise.resolve([owned, opaque, malformed, sysctlDenied]),
environment: (pid) =>
pid === owned.pid
? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" })
: Promise.reject(new Error("permission denied")),
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
pid === owned.pid
? { state: "readable", runId: "run-token" as const }
: {
state: "unavailable",
reason:
pid === malformed.pid
? ("malformed_procargs" as const)
: pid === sysctlDenied.pid
? ("sysctl_failed_1" as const)
: ("platform_binary_environment_withheld" as const),
},
]),
),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
},
host,
),
).resolves.toMatchObject({
cleaned: true,
signaled: true,
unverified: [
{ pid: 906, diagnostic: "platform_binary_environment_withheld" },
{ pid: 907, diagnostic: "malformed_procargs" },
{ pid: 908, diagnostic: "sysctl_failed_1" },
],
});
expect(signalGroup).toHaveBeenCalledWith(owned.processGroupId, "SIGKILL");
expect(signalGroup).not.toHaveBeenCalledWith(
opaque.processGroupId,
"SIGKILL",
);
expect(signalGroup).not.toHaveBeenCalledWith(
malformed.processGroupId,
"SIGKILL",
);
expect(signalGroup).not.toHaveBeenCalledWith(
sysctlDenied.processGroupId,
"SIGKILL",
);
},
);
});
describe("capture-local process identity baselines", () => {
it("checks reused and unknown PIDs while skipping unchanged identities", async () => {
const tokenReads: number[][] = [];
const processes: readonly ProcessTableEntry[] = [
{
pid: 899,
parentPid: 1,
processGroupId: 899,
state: "S",
command: "stable",
},
{
pid: 900,
parentPid: 1,
processGroupId: 900,
state: "S",
command: "reused",
},
{
pid: 902,
parentPid: 1,
processGroupId: 902,
state: "S",
command: "unknown",
},
];
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: () => Promise.resolve({}),
processIdentities: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
pid === 902
? { state: "unavailable", reason: "fixture" }
: {
state: "readable",
identity: pid === 899 ? "stable-start" : "reused-start",
},
]),
),
),
runTokens: (entries) => {
tokenReads.push(entries.map(({ pid }) => pid));
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{
state: "readable",
runId: pid === 900 ? "run-token" : undefined,
},
]),
),
);
},
signalGroup: vi.fn(),
};
await expect(
verifyNoTokenOwnedProcesses("run-token", host, [
{ pid: 899, identity: "stable-start" },
{ pid: 900, identity: "old-start" },
{ pid: 902, identity: null },
]),
).resolves.toEqual({
cleaned: false,
reason: "process identity could not be revalidated",
failures: [
{
pid: 902,
reason: "process-identity-unavailable",
diagnostic: "process identity was unavailable during token scan",
},
],
});
expect(tokenReads).toEqual([[900]]);
});
it("revalidates fallback environment owners before signaling after PID reuse", async () => {
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 910,
parentPid: 1,
processGroupId: 910,
state: "S",
command: "fallback-environment-owner",
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
processIdentities: (entries) => {
identityReads += 1;
const identity = identityReads < 3 ? "owned-start" : "reused-start";
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", identity } as const,
]),
),
);
},
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason: "process identity could not be revalidated",
});
expect(signalGroup).not.toHaveBeenCalled();
expect(identityReads).toBeGreaterThanOrEqual(3);
});
});
describe("process identity during token validation", () => {
it("revalidates identity after the final environment token read", async () => {
let environmentReads = 0;
let reusedDuringFinalRead = false;
const process: ProcessTableEntry = {
pid: 912,
parentPid: 1,
processGroupId: 912,
state: "S",
command: "reused-during-final-token-read",
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => {
environmentReads += 1;
if (environmentReads === 3) reusedDuringFinalRead = true;
return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
},
processIdentities: (entries) => {
const identity = reusedDuringFinalRead ? "reused-start" : "owned-start";
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", identity } as const,
]),
),
);
},
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason: "process identity could not be revalidated",
});
expect(signalGroup).not.toHaveBeenCalled();
expect(reusedDuringFinalRead).toBe(true);
});
it("fails closed when a candidate PID changes identity during token reading", async () => {
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 911,
parentPid: 1,
processGroupId: 911,
state: "S",
command: "reused-during-token-read",
};
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({}),
processIdentities: (entries) => {
identityReads += 1;
const identity = identityReads === 1 ? "new-start" : "reused-start";
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", identity } as const,
]),
),
);
},
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", runId: undefined } as const,
]),
),
),
signalGroup: vi.fn(),
};
await expect(
verifyNoTokenOwnedProcesses("run-token", host, [
{ pid: process.pid, identity: "old-start" },
]),
).resolves.toEqual({
cleaned: false,
reason: "process identity could not be revalidated",
failures: [
{
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
},
],
});
});
});
describe("process identity before signaling", () => {
it("refuses to signal when PID identity changes after token validation", async () => {
const signalGroup = vi.fn();
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 903,
parentPid: 1,
processGroupId: 903,
state: "S",
command: "reused-before-signal",
};
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
processIdentities: (entries) => {
identityReads += 1;
const identity = identityReads < 3 ? "owned-start" : "reused-start";
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", identity } as const,
]),
),
);
},
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", runId: "run-token" } as const,
]),
),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason: "process identity could not be revalidated",
});
expect(signalGroup).not.toHaveBeenCalled();
expect(identityReads).toBeGreaterThanOrEqual(3);
});
});