mirror of
https://github.com/morluto/rea
synced 2026-10-27 22:45:19 -04:00
fix(ci): trace historical PID handles and signal probes
This commit is contained in:
@@ -110,6 +110,11 @@ that live process's `stat` for the shared ownership inventory; debugger and
|
||||
decoder processes cannot use this exception. A second recording uses a valid
|
||||
historical PID above the host's assignable PID range and rejects every lookup,
|
||||
including Node `stat` reads, to detect historical-PID-driven access separately
|
||||
from live inventory. The syscall trace also rejects access to the still-present
|
||||
from live inventory. Descriptor paths come from strace's `-yy` decoding; exact
|
||||
PID-directory opens and relative accesses through those handles are rejected.
|
||||
The trace includes signal calls and rejects PID-targeted signals/probes and
|
||||
pidfd controls, including nonfatal signals and signal zero. These checks cover
|
||||
the recorded file/process/signal syscall surfaces, not every possible kernel
|
||||
interaction. The syscall trace also rejects access to the still-present
|
||||
source executable. The CI workflow defines Ubuntu 24.04 and Debian 13 (GDB 16)
|
||||
container lanes. Original inputs and host configuration remain unchanged.
|
||||
|
||||
@@ -6,7 +6,7 @@ export function nodeInventoryThreads(
|
||||
traces: readonly SyscallTrace[],
|
||||
executable: string,
|
||||
): ReadonlySet<number>;
|
||||
export function assertHistoricalPidReads(input: {
|
||||
export function assertHistoricalPidAccess(input: {
|
||||
readonly trace: SyscallTrace;
|
||||
readonly historicalPid: number;
|
||||
readonly nodeThreads: ReadonlySet<number>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { posix } from "node:path";
|
||||
|
||||
/** Identify REA's Node processes and their observed CLONE_THREAD children. */
|
||||
export function nodeInventoryThreads(traces, executable) {
|
||||
@@ -46,34 +47,92 @@ export function nodeInventoryThreads(traces, executable) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Permit only observed inventory metadata; an unlisted historical PID permits no lookup. */
|
||||
export function assertHistoricalPidReads({
|
||||
const targetsHistoricalPid = (line, pid) => {
|
||||
const first = /^(kill|tkill|rt_sigqueueinfo|pidfd_open)\((-?\d+),/u.exec(
|
||||
line,
|
||||
);
|
||||
if (first !== null) {
|
||||
const target = Number(first[2]);
|
||||
if (target === pid) return true;
|
||||
if (first[1] === "kill" && (target === -pid || target === -1)) return true;
|
||||
}
|
||||
const pair = /^(?:tgkill|rt_tgsigqueueinfo)\((-?\d+), (-?\d+),/u.exec(line);
|
||||
if (pair !== null && (Number(pair[1]) === pid || Number(pair[2]) === pid))
|
||||
return true;
|
||||
// With -yy the producer supplies the actual pidfd target. An undecoded
|
||||
// pidfd control cannot establish that it excludes the historical process.
|
||||
return (
|
||||
/^(?:pidfd_send_signal|pidfd_getfd)\(/u.test(line) &&
|
||||
(!/<pid:\d+>/u.test(line) || line.includes(`<pid:${pid}>`))
|
||||
);
|
||||
};
|
||||
|
||||
const readsMetadata = (line, root, names) => {
|
||||
const path = `${root}/(?:${names.join("|")})`;
|
||||
return (
|
||||
new RegExp(
|
||||
`^openat\\([^,]+, "${path}", O_RDONLY(?:\\|O_CLOEXEC)?\\)`,
|
||||
"u",
|
||||
).test(line) ||
|
||||
new RegExp(
|
||||
`^statx\\(\\d+<${path}>, "", [^,]*\\bAT_EMPTY_PATH\\b`,
|
||||
"u",
|
||||
).test(line) ||
|
||||
new RegExp(`^fstat(?:64)?\\(\\d+<${path}>,`, "u").test(line)
|
||||
);
|
||||
};
|
||||
|
||||
/** Permit only observed inventory reads; PID-directory handles and controls never qualify. */
|
||||
export function assertHistoricalPidAccess({
|
||||
trace,
|
||||
historicalPid,
|
||||
nodeThreads,
|
||||
liveCollision,
|
||||
psInventory,
|
||||
}) {
|
||||
const reads = trace.text
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(`/proc/${historicalPid}/`));
|
||||
for (const line of reads) {
|
||||
const root = `/proc/${historicalPid}`;
|
||||
const pathReference = new RegExp(`${root}(?=[/">]|$)`, "u");
|
||||
const accesses = trace.text.split("\n").filter((line) => {
|
||||
if (pathReference.test(line) || targetsHistoricalPid(line, historicalPid))
|
||||
return true;
|
||||
// strace prints the ASCII /proc, PID and dot segments literally. Keep the
|
||||
// original line as evidence while recognizing lexical path aliases too.
|
||||
for (const quoted of line.matchAll(/"((?:[^"\\]|\\.)*)"/gu))
|
||||
if (
|
||||
quoted[1].startsWith("/proc/") &&
|
||||
pathReference.test(posix.normalize(quoted[1]))
|
||||
)
|
||||
return true;
|
||||
// -yy resolves dirfds at the syscall boundary, including descriptor reuse.
|
||||
// Cover failed relative lookups from /proc, which have no returned FD path.
|
||||
const relative = /^openat\(\d+<\/proc>, "([^"]*)",/u.exec(line);
|
||||
if (relative === null) return false;
|
||||
const path = posix.resolve("/proc", relative[1]);
|
||||
return path === root || path.startsWith(`${root}/`);
|
||||
});
|
||||
for (const line of accesses) {
|
||||
const nodeStat =
|
||||
nodeThreads.has(trace.pid) &&
|
||||
new RegExp(
|
||||
`^openat\\([^,]+, "/proc/${historicalPid}/stat", O_RDONLY(?:\\|O_CLOEXEC)?\\)`,
|
||||
"u",
|
||||
).test(line);
|
||||
nodeThreads.has(trace.pid) && readsMetadata(line, root, ["stat"]);
|
||||
const psMetadata =
|
||||
psInventory &&
|
||||
new RegExp(
|
||||
`/proc/${historicalPid}/(?:stat|status|cmdline|wchan|environ)"`,
|
||||
"u",
|
||||
).test(line);
|
||||
(readsMetadata(line, root, [
|
||||
"stat",
|
||||
"status",
|
||||
"cmdline",
|
||||
"wchan",
|
||||
"environ",
|
||||
]) ||
|
||||
// procps reads the PID directory's UID without opening a directory FD.
|
||||
new RegExp(
|
||||
`^newfstatat\\([^,]+, "${root}", .*?, (?:0|AT_SYMLINK_NOFOLLOW)\\)`,
|
||||
"u",
|
||||
).test(line));
|
||||
assert.ok(
|
||||
liveCollision && (nodeStat || psMetadata),
|
||||
liveCollision &&
|
||||
!targetsHistoricalPid(line, historicalPid) &&
|
||||
(nodeStat || psMetadata),
|
||||
`Historical PID caused a non-inventory lookup in trace ${trace.pid}: ${line}`,
|
||||
);
|
||||
}
|
||||
return reads;
|
||||
return accesses;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isAbsolute, join, resolve } from "node:path";
|
||||
import { runOwnedCommand } from "../../../dist/process/OwnedCommand.js";
|
||||
import { parseEvidence } from "../../../dist/domain/evidence.js";
|
||||
import {
|
||||
assertHistoricalPidReads,
|
||||
assertHistoricalPidAccess,
|
||||
nodeInventoryThreads,
|
||||
} from "./trace-policy.mjs";
|
||||
|
||||
@@ -59,10 +59,11 @@ export async function verifyCoreInspectionTrace({
|
||||
arguments: [
|
||||
"-ff",
|
||||
"-qq",
|
||||
"-yy",
|
||||
"-s",
|
||||
"8192",
|
||||
"-e",
|
||||
"trace=%process,%network,%file,ptrace,process_vm_readv,process_vm_writev",
|
||||
"trace=%process,%signal,%network,%file,ptrace,process_vm_readv,process_vm_writev,pidfd_open,pidfd_send_signal,pidfd_getfd",
|
||||
"-o",
|
||||
prefix,
|
||||
process.execPath,
|
||||
@@ -124,7 +125,7 @@ export async function verifyCoreInspectionTrace({
|
||||
line.endsWith(" = 0"),
|
||||
);
|
||||
ownershipReads.push(
|
||||
...assertHistoricalPidReads({
|
||||
...assertHistoricalPidAccess({
|
||||
trace: { pid: trace.pid, text },
|
||||
historicalPid,
|
||||
nodeThreads,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
assertHistoricalPidReads,
|
||||
assertHistoricalPidAccess,
|
||||
nodeInventoryThreads,
|
||||
} from "../../../scripts/verify/recorded-crash/trace-policy.mjs";
|
||||
|
||||
@@ -20,7 +20,7 @@ const traces = [
|
||||
];
|
||||
|
||||
const check = (pid: number, text: string, liveCollision = true) =>
|
||||
assertHistoricalPidReads({
|
||||
assertHistoricalPidAccess({
|
||||
trace: { pid, text },
|
||||
historicalPid: 900,
|
||||
nodeThreads: nodeInventoryThreads(traces, node),
|
||||
@@ -35,6 +35,44 @@ it("allows a live collision's stat read only in an observed Node process or thre
|
||||
expect(() => check(102, stat)).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it("permits the native descriptor metadata query used by Node's readFile", () => {
|
||||
const metadata =
|
||||
'statx(20</proc/900/stat>, "", AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {}) = 0';
|
||||
expect(check(101, metadata)).toEqual([metadata]);
|
||||
expect(() => check(101, metadata, false)).toThrow("non-inventory lookup");
|
||||
expect(() => check(101, metadata.replace("/stat>", "/mem>"))).toThrow(
|
||||
"non-inventory lookup",
|
||||
);
|
||||
});
|
||||
|
||||
it("permits procps UID metadata without granting a PID directory handle", () => {
|
||||
const metadata =
|
||||
'newfstatat(AT_FDCWD</tmp>, "/proc/900", {st_mode=S_IFDIR|0555}, 0) = 0';
|
||||
const input = {
|
||||
trace: { pid: 102, text: metadata },
|
||||
historicalPid: 900,
|
||||
nodeThreads: new Set<number>(),
|
||||
liveCollision: true,
|
||||
psInventory: true,
|
||||
};
|
||||
expect(assertHistoricalPidAccess(input)).toEqual([metadata]);
|
||||
expect(() =>
|
||||
assertHistoricalPidAccess({ ...input, liveCollision: false }),
|
||||
).toThrow("non-inventory lookup");
|
||||
expect(() =>
|
||||
assertHistoricalPidAccess({ ...input, psInventory: false }),
|
||||
).toThrow("non-inventory lookup");
|
||||
expect(() =>
|
||||
assertHistoricalPidAccess({
|
||||
...input,
|
||||
trace: {
|
||||
pid: 102,
|
||||
text: 'openat(AT_FDCWD, "/proc/900", O_RDONLY|O_DIRECTORY) = 5</proc/900>',
|
||||
},
|
||||
}),
|
||||
).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it.each(["mem", "maps", "environ", "exe", "root"])(
|
||||
"rejects Node access to the live historical PID's %s",
|
||||
(name) => {
|
||||
@@ -48,6 +86,46 @@ it("rejects an unlisted historical PID lookup even from an observed inventory th
|
||||
expect(() => check(101, stat, false)).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it.each([
|
||||
'openat(AT_FDCWD, "/proc/900", O_RDONLY|O_DIRECTORY|O_CLOEXEC) = 5</proc/900>',
|
||||
'openat(5</proc/900>, "mem", O_RDONLY|O_CLOEXEC) = 6</proc/900/mem>',
|
||||
'openat(5</proc/900>, "stat", O_RDONLY|O_CLOEXEC) = -1 EACCES (Permission denied)',
|
||||
'openat(5</proc>, "900/mem", O_RDONLY) = -1 ENOENT (No such file or directory)',
|
||||
'openat(5</proc>, "self/../900/mem", O_RDONLY) = -1 ENOENT (No such file or directory)',
|
||||
'openat(AT_FDCWD, "/proc/./900", O_RDONLY|O_DIRECTORY) = -1 ENOENT (No such file or directory)',
|
||||
'openat(AT_FDCWD, "/proc/self/../900/mem", O_RDONLY) = -1 ENOENT (No such file or directory)',
|
||||
"kill(900, 0) = 0",
|
||||
"kill(-900, SIGSTOP) = 0",
|
||||
"kill(-1, SIGSTOP) = 0",
|
||||
"tkill(900, SIGSTOP) = 0",
|
||||
"tgkill(900, 901, SIGSTOP) = 0",
|
||||
"tgkill(901, 900, SIGSTOP) = 0",
|
||||
"rt_sigqueueinfo(900, SIGUSR1, {}) = 0",
|
||||
"rt_tgsigqueueinfo(901, 900, SIGUSR1, {}) = 0",
|
||||
"pidfd_open(900, 0) = 7<pid:900>",
|
||||
"pidfd_send_signal(7<pid:900>, SIGSTOP, NULL, 0) = 0",
|
||||
"pidfd_send_signal(7, SIGSTOP, NULL, 0) = 0",
|
||||
])("rejects historical PID directory/handle/control access: %s", (line) => {
|
||||
for (const live of [true, false])
|
||||
expect(() => check(101, line, live)).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it("keeps descriptor reuse and distinct PIDs separate and refuses ps writes", () => {
|
||||
expect(check(101, 'openat(5</tmp>, "mem", O_RDONLY) = 6</tmp/mem>')).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(check(101, stat.replaceAll("900", "9000"))).toEqual([]);
|
||||
expect(() =>
|
||||
assertHistoricalPidAccess({
|
||||
trace: { pid: 102, text: stat.replace("O_RDONLY", "O_WRONLY") },
|
||||
historicalPid: 900,
|
||||
nodeThreads: new Set(),
|
||||
liveCollision: true,
|
||||
psInventory: true,
|
||||
}),
|
||||
).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it("keeps exec-replaced threads foreign and follows acknowledged unfinished thread clones", () => {
|
||||
const observed = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user