diff --git a/docs/recorded-crashes.md b/docs/recorded-crashes.md index 4f04639ef2..3a7bbfcde7 100644 --- a/docs/recorded-crashes.md +++ b/docs/recorded-crashes.md @@ -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. diff --git a/scripts/verify/recorded-crash/trace-policy.d.mts b/scripts/verify/recorded-crash/trace-policy.d.mts index ad73ad47ce..e58d98aa07 100644 --- a/scripts/verify/recorded-crash/trace-policy.d.mts +++ b/scripts/verify/recorded-crash/trace-policy.d.mts @@ -6,7 +6,7 @@ export function nodeInventoryThreads( traces: readonly SyscallTrace[], executable: string, ): ReadonlySet; -export function assertHistoricalPidReads(input: { +export function assertHistoricalPidAccess(input: { readonly trace: SyscallTrace; readonly historicalPid: number; readonly nodeThreads: ReadonlySet; diff --git a/scripts/verify/recorded-crash/trace-policy.mjs b/scripts/verify/recorded-crash/trace-policy.mjs index 9a5e2f32fa..85498a3785 100644 --- a/scripts/verify/recorded-crash/trace-policy.mjs +++ b/scripts/verify/recorded-crash/trace-policy.mjs @@ -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) && + (!//u.test(line) || line.includes(``)) + ); +}; + +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; } diff --git a/scripts/verify/recorded-crash/trace.mjs b/scripts/verify/recorded-crash/trace.mjs index de777c3d6f..d37fb99942 100644 --- a/scripts/verify/recorded-crash/trace.mjs +++ b/scripts/verify/recorded-crash/trace.mjs @@ -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, diff --git a/tests/boundary/process/recordedCrashTracePolicy.test.ts b/tests/boundary/process/recordedCrashTracePolicy.test.ts index fbf9c78159..bcfb921243 100644 --- a/tests/boundary/process/recordedCrashTracePolicy.test.ts +++ b/tests/boundary/process/recordedCrashTracePolicy.test.ts @@ -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, "", 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, "/proc/900", {st_mode=S_IFDIR|0555}, 0) = 0'; + const input = { + trace: { pid: 102, text: metadata }, + historicalPid: 900, + nodeThreads: new Set(), + 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', + }, + }), + ).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', + 'openat(5, "mem", O_RDONLY|O_CLOEXEC) = 6', + 'openat(5, "stat", O_RDONLY|O_CLOEXEC) = -1 EACCES (Permission denied)', + 'openat(5, "900/mem", O_RDONLY) = -1 ENOENT (No such file or directory)', + 'openat(5, "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", + "pidfd_send_signal(7, 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, "mem", O_RDONLY) = 6')).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 = [ {