mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 18:35:53 +00:00
* 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>
433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
cleanupOwnedProcessGroup,
|
|
type ProcessOwnershipHost,
|
|
} from "./ProcessOwnership.js";
|
|
import { parseProcessEnvironment } from "./ProcessOwnershipObservation.js";
|
|
import { selectCapturedProcessGroupIds } from "./ProcessOwnershipProcessTree.js";
|
|
import { matchesOwnedProcessCommand } from "./ProcessCommandIdentity.js";
|
|
import { host, ownership } from "./ProcessOwnership.fixture.js";
|
|
describe("owned process-group cleanup discovery", () => {
|
|
it("excludes sampled groups whose leader was outside the captured tree", () => {
|
|
expect(
|
|
selectCapturedProcessGroupIds(100, [
|
|
{ pid: 100, process_group_id: 42 },
|
|
{ pid: 100, process_group_id: 100 },
|
|
{ pid: 101, process_group_id: 42 },
|
|
{ pid: 102, process_group_id: 102 },
|
|
{ pid: 103, process_group_id: 102 },
|
|
]),
|
|
).toEqual([100, 102]);
|
|
});
|
|
it("drops nameless Linux environment entries", () => {
|
|
expect(
|
|
parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"),
|
|
).toEqual({ REA_PROCESS_RUN_ID: "owned", EMPTY: "" });
|
|
});
|
|
it("matches macOS executable names after process-table normalization", () => {
|
|
expect(
|
|
matchesOwnedProcessCommand(
|
|
"node /tmp/fake-launcher.mjs",
|
|
"/opt/node/bin/node",
|
|
"darwin",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
matchesOwnedProcessCommand("(node)", "/opt/node/bin/node", "darwin"),
|
|
).toBe(true);
|
|
expect(
|
|
matchesOwnedProcessCommand(
|
|
"node /tmp/fake-launcher.mjs",
|
|
"/opt/python/bin/python3",
|
|
"darwin",
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
matchesOwnedProcessCommand(
|
|
"node /tmp/fake-launcher.mjs",
|
|
"/opt/node/bin/node",
|
|
"linux",
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
matchesOwnedProcessCommand("(node)", "/opt/node/bin/node", "linux"),
|
|
).toBe(false);
|
|
});
|
|
it("signals only a group whose every member carries the run token", async () => {
|
|
const { adapter, signalGroup } = host({
|
|
100: { REA_PROCESS_RUN_ID: "run-token" },
|
|
101: { REA_PROCESS_RUN_ID: "run-token" },
|
|
});
|
|
expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({
|
|
cleaned: true,
|
|
signaled: true,
|
|
});
|
|
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
|
|
});
|
|
});
|
|
|
|
describe("opaque process neighbors during cleanup", () => {
|
|
it("cleans verified run-owned groups and records a withheld unrelated neighbor", async () => {
|
|
const processes = [
|
|
{ pid: 100, parentPid: 1, processGroupId: 100, command: "capture" },
|
|
{
|
|
pid: 200,
|
|
parentPid: 1,
|
|
processGroupId: 200,
|
|
command: "detached-child",
|
|
},
|
|
{
|
|
pid: 900,
|
|
parentPid: 1,
|
|
processGroupId: 900,
|
|
command: "/usr/bin/opaque-neighbor",
|
|
},
|
|
].map((process) => ({ ...process, state: "S" }));
|
|
const signalGroup = vi.fn();
|
|
const identities = new Map(
|
|
processes.map(({ pid }) => [
|
|
pid,
|
|
{ state: "readable" as const, identity: `start-${String(pid)}` },
|
|
]),
|
|
);
|
|
const adapter: ProcessOwnershipHost = {
|
|
platform: "darwin",
|
|
listProcesses: () => Promise.resolve(processes),
|
|
environment: (pid) =>
|
|
Promise.resolve({
|
|
REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token",
|
|
}),
|
|
processIdentities: () => Promise.resolve(identities),
|
|
runTokens: (members) =>
|
|
Promise.resolve(
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
pid === 900
|
|
? {
|
|
state: "unavailable" as const,
|
|
reason: "platform_binary_environment_withheld",
|
|
}
|
|
: { state: "readable" as const, runId: "run-token" },
|
|
]),
|
|
),
|
|
),
|
|
signalGroup,
|
|
};
|
|
|
|
const result = await cleanupOwnedProcessGroup(
|
|
{
|
|
...ownership,
|
|
sweepTokenOwnedProcesses: true,
|
|
captureBaseline: [],
|
|
},
|
|
adapter,
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
cleaned: true,
|
|
signaled: true,
|
|
unverified: [
|
|
{ pid: 900, diagnostic: "platform_binary_environment_withheld" },
|
|
],
|
|
});
|
|
expect(signalGroup.mock.calls).toEqual([
|
|
[100, "SIGKILL"],
|
|
[200, "SIGKILL"],
|
|
]);
|
|
expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL");
|
|
});
|
|
|
|
it("fails closed for an unrelated unreadable candidate the OS should expose", async () => {
|
|
const processes = [
|
|
{ pid: 100, parentPid: 1, processGroupId: 100, command: "capture" },
|
|
{
|
|
pid: 900,
|
|
parentPid: 1,
|
|
processGroupId: 900,
|
|
command: "opaque-neighbor",
|
|
},
|
|
].map((process) => ({ ...process, state: "S" }));
|
|
const signalGroup = vi.fn();
|
|
const identities = new Map(
|
|
processes.map(({ pid }) => [
|
|
pid,
|
|
{ state: "readable" as const, identity: `start-${String(pid)}` },
|
|
]),
|
|
);
|
|
const adapter: ProcessOwnershipHost = {
|
|
platform: "darwin",
|
|
listProcesses: () => Promise.resolve(processes),
|
|
environment: (pid) =>
|
|
Promise.resolve({
|
|
REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token",
|
|
}),
|
|
processIdentities: () => Promise.resolve(identities),
|
|
runTokens: (members) =>
|
|
Promise.resolve(
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
pid === 900
|
|
? {
|
|
state: "unavailable" as const,
|
|
reason: "environment_unavailable",
|
|
}
|
|
: { state: "readable" as const, runId: "run-token" },
|
|
]),
|
|
),
|
|
),
|
|
signalGroup,
|
|
};
|
|
|
|
const result = await cleanupOwnedProcessGroup(
|
|
{
|
|
...ownership,
|
|
sweepTokenOwnedProcesses: true,
|
|
captureBaseline: [],
|
|
},
|
|
adapter,
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
cleaned: false,
|
|
reason: expect.stringContaining("environment_unavailable=1"),
|
|
});
|
|
expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]);
|
|
expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL");
|
|
});
|
|
});
|
|
|
|
describe("related sweep candidates during cleanup", () => {
|
|
it.each(["environment_unavailable", "platform_binary_environment_withheld"])(
|
|
"fails closed for a related candidate with %s",
|
|
async (diagnostic) => {
|
|
const processes = [
|
|
{
|
|
pid: 100,
|
|
parentPid: 1,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "capture",
|
|
},
|
|
{
|
|
pid: 101,
|
|
parentPid: 100,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
uid: process.getuid?.() === 0 ? 1 : 0,
|
|
command: "ready-hang",
|
|
},
|
|
];
|
|
const signalGroup = vi.fn();
|
|
const identities = new Map(
|
|
processes.map(({ pid }) => [
|
|
pid,
|
|
{ state: "readable" as const, identity: `start-${String(pid)}` },
|
|
]),
|
|
);
|
|
const adapter: ProcessOwnershipHost = {
|
|
listProcesses: () => Promise.resolve(processes),
|
|
environment: (pid) =>
|
|
pid === 100
|
|
? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" })
|
|
: Promise.reject(new Error("EACCES: permission denied")),
|
|
processIdentities: () => Promise.resolve(identities),
|
|
runTokens: (members) =>
|
|
Promise.resolve(
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
pid === 100
|
|
? { state: "readable" as const, runId: "run-token" }
|
|
: {
|
|
state: "unavailable" as const,
|
|
reason: diagnostic,
|
|
},
|
|
]),
|
|
),
|
|
),
|
|
signalGroup,
|
|
};
|
|
|
|
const result = await cleanupOwnedProcessGroup(
|
|
{
|
|
...ownership,
|
|
sweepTokenOwnedProcesses: true,
|
|
captureBaseline: [],
|
|
},
|
|
adapter,
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
cleaned: false,
|
|
reason: expect.stringContaining(
|
|
"process ownership token could not be read",
|
|
),
|
|
});
|
|
expect(signalGroup).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("sampled detached process groups during cleanup", () => {
|
|
it("signals only token-authenticated sampled groups and reports an unowned sample", async () => {
|
|
const processes = [
|
|
{
|
|
pid: 100,
|
|
parentPid: 1,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "capture",
|
|
},
|
|
{
|
|
pid: 200,
|
|
parentPid: 1,
|
|
processGroupId: 200,
|
|
state: "S",
|
|
command: "sanitized-detached-child",
|
|
},
|
|
];
|
|
const signalGroup = vi.fn();
|
|
const identities = new Map([
|
|
[100, { state: "readable" as const, identity: "start-100" }],
|
|
[200, { state: "readable" as const, identity: "start-200" }],
|
|
]);
|
|
const adapter: ProcessOwnershipHost = {
|
|
listProcesses: () => Promise.resolve(processes),
|
|
environment: (pid) =>
|
|
Promise.resolve(pid === 100 ? { REA_PROCESS_RUN_ID: "run-token" } : {}),
|
|
processIdentities: () => Promise.resolve(identities),
|
|
runTokens: (members) =>
|
|
Promise.resolve(
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
{
|
|
state: "readable" as const,
|
|
runId: pid === 100 ? "run-token" : undefined,
|
|
},
|
|
]),
|
|
),
|
|
),
|
|
signalGroup,
|
|
};
|
|
|
|
const result = await cleanupOwnedProcessGroup(
|
|
{
|
|
...ownership,
|
|
sweepTokenOwnedProcesses: true,
|
|
sampledProcessGroupIds: [200],
|
|
},
|
|
adapter,
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
cleaned: false,
|
|
reason: "process tree contains an unowned or PID-reused process",
|
|
failures: [{ pid: 200, reason: "run-token-mismatch" }],
|
|
});
|
|
expect(signalGroup).toHaveBeenCalledTimes(1);
|
|
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
|
|
expect(signalGroup).not.toHaveBeenCalledWith(200, "SIGKILL");
|
|
});
|
|
|
|
it("reports a live sampled group after the launcher has exited without signaling it", async () => {
|
|
const process = {
|
|
pid: 200,
|
|
parentPid: 1,
|
|
processGroupId: 200,
|
|
state: "S",
|
|
command: "sanitized-detached-child",
|
|
};
|
|
const signalGroup = vi.fn();
|
|
const adapter: ProcessOwnershipHost = {
|
|
listProcesses: () => Promise.resolve([process]),
|
|
environment: () => Promise.resolve({}),
|
|
runTokens: () =>
|
|
Promise.resolve(
|
|
new Map([[200, { state: "readable" as const, runId: undefined }]]),
|
|
),
|
|
signalGroup,
|
|
};
|
|
|
|
const result = await cleanupOwnedProcessGroup(
|
|
{
|
|
...ownership,
|
|
sweepTokenOwnedProcesses: true,
|
|
sampledProcessGroupIds: [200],
|
|
},
|
|
adapter,
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
cleaned: false,
|
|
reason: "process tree contains an unowned or PID-reused process",
|
|
failures: [{ pid: 200, reason: "run-token-mismatch" }],
|
|
});
|
|
expect(signalGroup).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("rooted process-group cleanup", () => {
|
|
it("validates and signals rooted descendant groups once, root first", async () => {
|
|
const processes = [
|
|
{ pid: 100, parentPid: 1, processGroupId: 100 },
|
|
{ pid: 110, parentPid: 100, processGroupId: 100 },
|
|
{ pid: 101, parentPid: 100, processGroupId: 101 },
|
|
{ pid: 102, parentPid: 101, processGroupId: 102 },
|
|
{ pid: 103, parentPid: 102, processGroupId: 102 },
|
|
].map((process) => ({
|
|
...process,
|
|
state: "S",
|
|
command: "fixture",
|
|
}));
|
|
const signalGroup = vi.fn();
|
|
const adapter: ProcessOwnershipHost = {
|
|
listProcesses: () => Promise.resolve(processes),
|
|
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
|
|
signalGroup,
|
|
};
|
|
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
|
|
{ cleaned: true, signaled: true },
|
|
);
|
|
expect(signalGroup.mock.calls).toEqual([
|
|
[100, "SIGKILL"],
|
|
[101, "SIGKILL"],
|
|
[102, "SIGKILL"],
|
|
]);
|
|
});
|
|
it("leaves an unrelated Hopper process group outside the rooted tree untouched", async () => {
|
|
const processes = [
|
|
{ pid: 100, parentPid: 1, processGroupId: 100, command: "rea-hopper" },
|
|
{ pid: 101, parentPid: 100, processGroupId: 101, command: "owned-child" },
|
|
{
|
|
pid: 900,
|
|
parentPid: 1,
|
|
processGroupId: 900,
|
|
command:
|
|
"/Applications/Hopper Disassembler.app/Contents/MacOS/Hopper Disassembler",
|
|
},
|
|
].map((process) => ({ ...process, state: "S" }));
|
|
const environment = vi.fn((pid: number) =>
|
|
Promise.resolve({
|
|
REA_PROCESS_RUN_ID: pid === 900 ? "unrelated-run" : "run-token",
|
|
}),
|
|
);
|
|
const signalGroup = vi.fn();
|
|
const adapter: ProcessOwnershipHost = {
|
|
listProcesses: () => Promise.resolve(processes),
|
|
environment,
|
|
signalGroup,
|
|
};
|
|
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
|
|
{ cleaned: true, signaled: true },
|
|
);
|
|
expect(signalGroup.mock.calls).toEqual([
|
|
[100, "SIGKILL"],
|
|
[101, "SIGKILL"],
|
|
]);
|
|
expect(environment.mock.calls.some(([pid]) => pid === 900)).toBe(false);
|
|
});
|
|
});
|