Files
rea/src/process/ProcessOwnership.procIdentity.test.ts
T

432 lines
14 KiB
TypeScript

import { performance } from "node:perf_hooks";
import { expect, it, vi } from "vitest";
const filesystem = vi.hoisted(() => ({
readFile: vi.fn(),
// Host procfs ownership must never leak into fixture PIDs.
stat: vi.fn(async () => {
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
}),
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return { ...original, readFile: filesystem.readFile, stat: filesystem.stat };
});
import {
createSystemProcessOwnershipHost,
readProcessOwnershipFacts,
} from "./ProcessOwnershipObservation.js";
import type {
ProcessOwnershipHost,
ProcessTableEntry,
} from "./ProcessOwnership.js";
const entry: ProcessTableEntry = {
pid: 42,
parentPid: 1,
processGroupId: 42,
state: "S",
command: "fixture",
};
const filesystemError = (code: string): NodeJS.ErrnoException =>
Object.assign(new Error(code), { code });
const procStat = (
startTime: number,
parentPid = 1,
processGroupId = 42,
pid = 42,
flags = 0,
) => {
const fields = [
"S",
String(parentPid),
String(processGroupId),
"42",
"0",
"0",
String(flags),
];
while (fields.length < 19) fields.push("0");
fields.push(String(startTime));
return `${String(pid)} (fixture) ${fields.join(" ")}`;
};
/**
* Select REA's caller credentials for the simulated Linux host. Windows has no
* `process.geteuid`/`getegid`, so define them for the call and restore the
* original properties (or their absence) afterwards.
*/
const withCaller = async <T>(
uid: number,
gid: number,
run: () => Promise<T>,
): Promise<T> => {
const names = ["geteuid", "getegid"] as const;
const originals = names.map((name) =>
Object.getOwnPropertyDescriptor(process, name),
);
names.forEach((name, index) =>
Object.defineProperty(process, name, {
configurable: true,
writable: true,
value: () => (index === 0 ? uid : gid),
}),
);
try {
return await run();
} finally {
names.forEach((name, index) => {
const original = originals[index];
if (original === undefined) Reflect.deleteProperty(process, name);
else Object.defineProperty(process, name, original);
});
}
};
it("binds Linux credentials to the same native process generation", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
filesystem.readFile
.mockResolvedValueOnce(procStat(100, 10, 42))
.mockResolvedValueOnce("Name:\tfixture\nUid:\t1000\t1001\t1002\t1003\n")
.mockResolvedValueOnce(procStat(100, 11, 43));
filesystem.stat.mockResolvedValueOnce({ uid: 1001 } as never);
await expect(
withCaller(1001, 20, async () =>
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
),
).resolves.toEqual(
new Map([
[
42,
{
state: "readable",
identity: "linux-starttime:100",
effectiveUid: 1001,
// Kernel ptrace checks also compare the real and saved UIDs.
environmentRestriction: "credentials",
lifecycle: { state: "observed", lifecycle: "non-zombie" },
relation: {
state: "observed",
parentPid: 11,
processGroupId: 43,
},
},
],
]),
);
expect(filesystem.readFile.mock.calls.map(([path]) => path)).toEqual([
"/proc/42/stat",
"/proc/42/status",
"/proc/42/stat",
]);
expect(filesystem.stat).toHaveBeenLastCalledWith("/proc/42/environ");
});
it.each([
[
"a non-dumpable process",
0,
"Gid:\t1000\t1000\t1000\t1000\n",
"non-dumpable",
],
["a setgid process", 1000, "Gid:\t1000\t5\t5\t5\n", "credentials"],
["an inspectable process", 1000, "Gid:\t1000\t1000\t1000\t1000\n", undefined],
] as const)(
"reports the Linux environment restriction for %s",
async (_name, procfsOwnerUid, gidRow, expected) => {
const host = createSystemProcessOwnershipHost("linux", {});
filesystem.readFile
.mockResolvedValueOnce(procStat(100))
.mockResolvedValueOnce(`Uid:\t1000\t1000\t1000\t1000\n${gidRow}`)
.mockResolvedValueOnce(procStat(100));
filesystem.stat.mockResolvedValueOnce({ uid: procfsOwnerUid } as never);
const identity = (
await withCaller(1000, 1000, async () =>
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
)
)?.get(entry.pid);
expect(identity).toMatchObject({ state: "readable", effectiveUid: 1000 });
if (expected === undefined)
expect(identity).not.toHaveProperty("environmentRestriction");
else expect(identity).toHaveProperty("environmentRestriction", expected);
},
);
it("keeps native identity when status UID is unavailable", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
filesystem.readFile
.mockResolvedValueOnce(procStat(100))
.mockRejectedValueOnce(filesystemError("EACCES"));
await expect(
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
).resolves.toEqual(
new Map([
[
42,
{
state: "readable",
identity: "linux-starttime:100",
lifecycle: { state: "observed", lifecycle: "non-zombie" },
relation: {
state: "observed",
parentPid: 1,
processGroupId: 42,
},
},
],
]),
);
expect(filesystem.readFile).toHaveBeenCalledTimes(2);
});
it("does not attach a UID when the process generation changes around status", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
filesystem.readFile
.mockResolvedValueOnce(procStat(100))
.mockResolvedValueOnce("Uid:\t1000\t1001\t1002\t1003\n")
.mockResolvedValueOnce(procStat(101));
await expect(
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
).resolves.toEqual(
new Map([
[
42,
{
state: "unavailable",
reason: "process_identity_changed_during_uid_observation",
},
],
]),
);
});
it("reads Linux effective UID only for token-unavailable rows", async () => {
const rows = [entry, { ...entry, pid: 43, processGroupId: 43 }];
const statReads = new Map<number, number>();
const uidStatusPids: number[] = [];
filesystem.readFile.mockImplementation(async (path) => {
const match = /^\/proc\/(\d+)\/(stat|status)$/u.exec(String(path));
if (match === null) throw new Error(`unexpected path: ${String(path)}`);
const pid = Number(match[1]);
if (match[2] === "status") {
uidStatusPids.push(pid);
return "Uid:\t1000\t1001\t1002\t1003\n";
}
const reads = (statReads.get(pid) ?? 0) + 1;
statReads.set(pid, reads);
return procStat(100, 1, pid, pid);
});
const systemHost = createSystemProcessOwnershipHost("linux", {});
const host: ProcessOwnershipHost = {
...systemHost,
runTokens: async () =>
new Map([
[entry.pid, { state: "unavailable", reason: "EACCES" }],
[43, { state: "readable", runId: undefined }],
]),
};
const facts = await readProcessOwnershipFacts(rows, host);
expect(uidStatusPids).toEqual([entry.pid]);
expect(facts.map(({ identityAfter }) => identityAfter)).toMatchObject([
{ state: "readable", effectiveUid: 1001 },
{ state: "readable" },
]);
expect(facts[1]?.identityAfter).not.toHaveProperty("effectiveUid");
expect(statReads).toEqual(
new Map([
[entry.pid, 3],
[43, 2],
]),
);
});
it("keeps the Linux environment restriction while an exiting candidate settles", async () => {
let identityReads = 0;
filesystem.readFile.mockImplementation(async (path) => {
if (String(path) === "/proc/42/status")
return "Uid:\t1000\t1000\t1000\t1000\nGid:\t1000\t1000\t1000\t1000\n";
if (String(path) !== "/proc/42/stat")
throw new Error(`unexpected path: ${String(path)}`);
identityReads += 1;
// PF_EXITING is set after the first identity read.
return procStat(100, 1, 42, 42, identityReads === 1 ? 0 : 4);
});
filesystem.stat.mockResolvedValue({ uid: 0 } as never);
const systemHost = createSystemProcessOwnershipHost("linux", {});
const host: ProcessOwnershipHost = {
...systemHost,
runTokens: async () =>
new Map([[entry.pid, { state: "unavailable", reason: "EACCES" }]]),
};
try {
const [fact] = await withCaller(1000, 1000, async () =>
readProcessOwnershipFacts([entry], host, undefined, {
exitSettlement: { deadline: performance.now() + 30 },
}),
);
expect(fact?.beforeExitSettlement).toBeDefined();
expect(fact?.identityAfter).toMatchObject({
state: "readable",
lifecycle: { state: "observed", lifecycle: "exiting" },
environmentRestriction: "non-dumpable",
});
expect(identityReads).toBeGreaterThan(3);
} finally {
filesystem.stat.mockReset();
filesystem.stat.mockRejectedValue(filesystemError("ENOENT"));
}
});
it("reports absence only when /proc lookup and signal-zero both report ESRCH", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
const signalProbe = vi.spyOn(process, "kill").mockImplementation(() => {
throw filesystemError("ESRCH");
});
try {
for (const code of ["ENOENT", "ESRCH"]) {
filesystem.readFile.mockRejectedValueOnce(filesystemError(code));
await expect(host.processIdentities?.([entry])).resolves.toEqual(
new Map([
[
42,
{
state: "absent",
reason: "not-found-in-caller-pid-namespace",
},
],
]),
);
}
expect(signalProbe).toHaveBeenCalledTimes(2);
expect(signalProbe).toHaveBeenNthCalledWith(1, 42, 0);
} finally {
signalProbe.mockRestore();
}
});
it("keeps hidden or permission-denied live PIDs unavailable", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
const signalProbe = vi.spyOn(process, "kill");
try {
filesystem.readFile.mockRejectedValueOnce(filesystemError("ENOENT"));
signalProbe.mockReturnValueOnce(true);
await expect(host.processIdentities?.([entry])).resolves.toEqual(
new Map([
[
42,
{
state: "unavailable",
reason: "proc_stat_missing_but_process_signal_probe_succeeded",
},
],
]),
);
filesystem.readFile.mockRejectedValueOnce(filesystemError("ENOENT"));
signalProbe.mockImplementationOnce(() => {
throw filesystemError("EPERM");
});
const denied = await host.processIdentities?.([entry]);
const deniedIdentity = denied?.get(42);
expect(deniedIdentity).toMatchObject({ state: "unavailable" });
expect(
deniedIdentity?.state === "unavailable" ? deniedIdentity.reason : "",
).toContain("EPERM");
} finally {
signalProbe.mockRestore();
}
});
it("preserves native identity with unavailable teardown relations", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
filesystem.readFile.mockResolvedValueOnce(procStat(987654, 0, -1, 42, 4));
const identities = await host.processIdentities?.([entry]);
expect(identities?.get(42)).toMatchObject({
state: "readable",
identity: "linux-starttime:987654",
lifecycle: { state: "observed", lifecycle: "exiting" },
relation: {
state: "unavailable",
reason: "proc_stat_relation_unavailable",
},
});
});
it("keeps permission, parser, and unexpected failures unavailable", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
const signalProbe = vi.spyOn(process, "kill");
try {
for (const code of ["EACCES", "EPERM", "EIO"]) {
filesystem.readFile.mockRejectedValueOnce(filesystemError(code));
const result = await host.processIdentities?.([entry]);
expect(result?.get(42)?.state).toBe("unavailable");
}
filesystem.readFile.mockResolvedValueOnce("42 (fixture) S malformed");
await expect(host.processIdentities?.([entry])).resolves.toEqual(
new Map([[42, { state: "unavailable", reason: "malformed_proc_stat" }]]),
);
expect(signalProbe).not.toHaveBeenCalled();
} finally {
signalProbe.mockRestore();
}
});
it("rejects invalid PIDs before procfs lookup or signal probing", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
const signalProbe = vi.spyOn(process, "kill");
filesystem.readFile.mockClear();
try {
await expect(
host.processIdentities?.([{ ...entry, pid: 0 }]),
).resolves.toEqual(
new Map([[0, { state: "unavailable", reason: "invalid_process_id" }]]),
);
expect(filesystem.readFile).not.toHaveBeenCalled();
expect(signalProbe).not.toHaveBeenCalled();
} finally {
signalProbe.mockRestore();
}
});
it("preserves cancellation before and after the signal-zero probe", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
const controller = new AbortController();
const reason = new Error("cancelled");
controller.abort(reason);
filesystem.readFile.mockRejectedValueOnce(
Object.assign(new Error("aborted"), { name: "AbortError" }),
);
await expect(
host.processIdentities?.([entry], controller.signal),
).rejects.toMatchObject({ name: "AbortError" });
const afterProbe = new AbortController();
const probeAbort = vi.spyOn(process, "kill").mockImplementation(() => {
afterProbe.abort(reason);
throw filesystemError("ESRCH");
});
try {
filesystem.readFile.mockRejectedValueOnce(filesystemError("ENOENT"));
await expect(
host.processIdentities?.([entry], afterProbe.signal),
).rejects.toBe(reason);
} finally {
probeAbort.mockRestore();
}
});