mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
4af71a45f9
Settle absent and zombie candidates before retaining baseline or token-read failures. Preserve fail-closed behavior for live and unknown generations, and stop retrying exited candidates. Bind Linux effective UID to stat-bracketed status reads only for unreadable-token candidates, preserving partial identity facts and avoiding full-table UID enrichment.
293 lines
8.9 KiB
TypeScript
293 lines
8.9 KiB
TypeScript
import { expect, it, vi } from "vitest";
|
|
|
|
const filesystem = vi.hoisted(() => ({
|
|
readFile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const original = await importOriginal<typeof import("node:fs/promises")>();
|
|
return { ...original, readFile: filesystem.readFile };
|
|
});
|
|
|
|
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,
|
|
) => {
|
|
const fields = ["S", String(parentPid), String(processGroupId), "42"];
|
|
while (fields.length < 19) fields.push("0");
|
|
fields.push(String(startTime));
|
|
return `${String(pid)} (fixture) ${fields.join(" ")}`;
|
|
};
|
|
|
|
it("binds Linux effective UID 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));
|
|
|
|
await expect(
|
|
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
|
|
).resolves.toEqual(
|
|
new Map([
|
|
[
|
|
42,
|
|
{
|
|
state: "readable",
|
|
identity: "linux-starttime:100",
|
|
effectiveUid: 1001,
|
|
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",
|
|
]);
|
|
});
|
|
|
|
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("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("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();
|
|
}
|
|
});
|