diff --git a/docs/recorded-crashes.md b/docs/recorded-crashes.md index 836aa82677..7af17e7bbe 100644 --- a/docs/recorded-crashes.md +++ b/docs/recorded-crashes.md @@ -105,6 +105,27 @@ GDB/Python/pwndbg/strace. Fixture generation explicitly launches its owned test program; subsequent inspection only reads the resulting core. Real acceptance checks recorded threads, high registers, missing/malformed notes, opaque owners, CLI/MCP parity, optional debugger context and historical PID collision with an -owned live sentinel. The syscall trace also rejects access to the still-present +owned live sentinel. An observed Node thread group must launch the exact ownership +`ps` inventory before it can read that live process's `stat` or `status` for native +identity/UID facts. Only traced `CLONE_THREAD` members share this allowance; +exec-replaced threads, ordinary children, debugger and decoder processes cannot +use it. The purpose of stat/status reads within the same Node thread group +cannot be distinguished by strace. A second recording uses a valid +historical PID above the host's assignable PID range and rejects resolved lookups, +including Node `stat`/`status` reads, to detect historical-PID-driven access separately +from live inventory. Descriptor paths come from strace's `-yy` decoding; exact +PID-directory opens and relative accesses through those handles are rejected. +Failed FD-relative pathname attempts are checked across syscall variants. +Legacy relative pathname calls without dirfd/cwd evidence and symlink aliases +remain unverified; the verification report declares these gaps rather than +establishing their absence. +One syscall/selector contract supplies trace selection, target interpretation +and the report's explicit coverage catalog. Unlisted PID-directed syscalls remain +unverified. The catalog includes PID-directed resource +limits, scheduling, priority, process metadata, signals and pidfd controls. +Self, group and UID selectors retain their syscall-defined meanings; unresolved +targets cannot qualify. Coverage is reported once for all CLI/MCP traces and +does not establish containment of 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/lib/recorded-core-fixtures.py b/scripts/lib/recorded-core-fixtures.py index cc8d654439..ba65bb3cab 100644 --- a/scripts/lib/recorded-core-fixtures.py +++ b/scripts/lib/recorded-core-fixtures.py @@ -8,7 +8,7 @@ from elftools.elf.elffile import ELFFile from elftools.construct import Container from pwnlib.elf.datatypes import elf_prstatus_amd64 -source, directory, sentinel = Path(sys.argv[1]), Path(sys.argv[2]), int(sys.argv[3]) +source, directory, sentinel, unlisted = Path(sys.argv[1]), Path(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]) original = source.read_bytes() image = ELFFile(io.BytesIO(original)) segments = list(image.iter_segments()) @@ -81,14 +81,15 @@ content = bytearray(original) start = descriptor_start(status) + elf_prstatus_amd64.pr_pid.offset content[start:start + ctypes.sizeof(ctypes.c_uint32)] = (0xfffffffe).to_bytes(4, "little") save("negative-pid", content) -content = bytearray(original) -content[start:start + ctypes.sizeof(ctypes.c_uint32)] = sentinel.to_bytes(4, "little") -for note in notes: - if note.n_name == "CORE" and note.n_type == "NT_PRPSINFO": - # Build with unchanged pyelftools; no hard-coded PID offset or host file resolution. - raw = dict(note.n_desc, pr_pid=sentinel) - encoded = image.structs.Elf_Prpsinfo.build(Container(**raw)) - offset = descriptor_start(note) - content[offset:offset + len(encoded)] = encoded -save("historical-pid-collision", content) +for name, pid in [("historical-pid-collision", sentinel), ("historical-pid-unlisted", unlisted)]: + content = bytearray(original) + content[start:start + ctypes.sizeof(ctypes.c_uint32)] = pid.to_bytes(4, "little") + for note in notes: + if note.n_name == "CORE" and note.n_type == "NT_PRPSINFO": + # Build with unchanged pyelftools; no hard-coded PID offset or host file resolution. + raw = dict(note.n_desc, pr_pid=pid) + encoded = image.structs.Elf_Prpsinfo.build(Container(**raw)) + offset = descriptor_start(note) + content[offset:offset + len(encoded)] = encoded + save(name, content) print(json.dumps(files)) diff --git a/scripts/verify-recorded-crash.mjs b/scripts/verify-recorded-crash.mjs index eb59749c73..c8eb29f1df 100644 --- a/scripts/verify-recorded-crash.mjs +++ b/scripts/verify-recorded-crash.mjs @@ -13,6 +13,7 @@ import { startCoreSentinel, } from "./verify/recorded-crash/fixtures.mjs"; import { verifyCoreInspectionTrace } from "./verify/recorded-crash/trace.mjs"; +import { historicalPidTraceContract } from "./verify/recorded-crash/trace-policy.mjs"; const environment = Object.fromEntries( Object.entries(process.env).filter(([, value]) => typeof value === "string"), @@ -61,6 +62,10 @@ try { const core = await createRecordedCore(options); sentinel = await startCoreSentinel(options); sentinel.assertAlive(); + // Valid signed ELF PID, outside this Linux host's assignable PID range. + const unlistedPid = 2 ** 31 - 1; + const pidMaximum = Number(await readFile("/proc/sys/kernel/pid_max", "utf8")); + assert.ok(Number.isSafeInteger(pidMaximum) && pidMaximum < unlistedPid); const fixtures = JSON.parse( await fixtureCommand( environment.REA_PWNTOOLS_PYTHON, @@ -72,6 +77,7 @@ try { core, root.path, String(sentinel.pid), + String(unlistedPid), ], options, ), @@ -276,6 +282,15 @@ try { core: fixtures["historical-pid-collision"], sentinel, }); + trace.push( + ...(await verifyCoreInspectionTrace({ + ...options, + entrypoint, + core: fixtures["historical-pid-unlisted"], + historicalPid: unlistedPid, + sentinel, + })), + ); cases += trace.length; assert.ok((await readFile(core)).length > 0); } catch (cause) { @@ -308,6 +323,7 @@ const result = { "Linux x64; pwntools4.15.0/pyelftools0.33/Unicorn2.1.2; core-only BYO GDB/pwndbg2026.09.15", fixture_generation: "owned test program executed", inspection_trace: trace, + historical_pid_access_coverage: historicalPidTraceContract, verifier, }; console.log(JSON.stringify(result, null, 2)); diff --git a/scripts/verify/recorded-crash/trace-client.mjs b/scripts/verify/recorded-crash/trace-client.mjs index f38f1d6cff..43ae3b8f7a 100644 --- a/scripts/verify/recorded-crash/trace-client.mjs +++ b/scripts/verify/recorded-crash/trace-client.mjs @@ -1,11 +1,19 @@ import { connectRecordedCrash } from "./public.mjs"; +import assert from "node:assert/strict"; const session = await connectRecordedCrash({ entrypoint: process.argv[2], environment: process.env, }); try { - await session.inspect("mcp", process.argv[3], { debuggerContext: true }); + const report = await session.inspect("mcp", process.argv[3], { + debuggerContext: true, + }); + const historicalPid = Number(process.argv[4]); + assert.ok( + report.threads.some((thread) => thread.historical_pid === historicalPid), + "MCP must decode the selected historical-PID control before its trace qualifies", + ); } finally { await session.close(); } diff --git a/scripts/verify/recorded-crash/trace-ownership.d.mts b/scripts/verify/recorded-crash/trace-ownership.d.mts index 0daeba3737..c265488fac 100644 --- a/scripts/verify/recorded-crash/trace-ownership.d.mts +++ b/scripts/verify/recorded-crash/trace-ownership.d.mts @@ -1,6 +1,17 @@ -/** Assert the documented procfs allowance over strace -ff process/thread files. */ -export function assertHistoricalPidReadsAreOwnershipOnly(options: { +export interface OwnershipTraceExecution { + readonly pid: number; + readonly path: string; + readonly line: string; + readonly succeeded: boolean; + readonly ownershipInventory: boolean; +} +/** Parsed topology and execution facts for one complete strace observation. */ +export function collectOwnershipTraceFacts(options: { readonly traces: readonly { readonly pid: number; readonly text: string }[]; - readonly sentinelPid: number; + readonly nodeExecutable: string; readonly psExecutables: ReadonlySet; -}): string[]; +}): { + readonly ownershipThreads: ReadonlySet; + readonly psProcesses: ReadonlySet; + readonly executions: readonly OwnershipTraceExecution[]; +}; diff --git a/scripts/verify/recorded-crash/trace-ownership.mjs b/scripts/verify/recorded-crash/trace-ownership.mjs index 0267dcdaa8..2787ebe41d 100644 --- a/scripts/verify/recorded-crash/trace-ownership.mjs +++ b/scripts/verify/recorded-crash/trace-ownership.mjs @@ -3,18 +3,10 @@ import assert from "node:assert/strict"; const ownershipPsArguments = '["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]'; -/** - * Allow only the procfs paths used by the process-ownership scan: ps may - * inspect its process-table fields, and its caller's thread group may read - * native identity and UID records. With strace -ff, libuv worker threads get - * separate files; follow CLONE_THREAD edges, never ordinary child-process - * edges. strace cannot distinguish the purpose of a stat/status read inside - * the same Node thread group, so this is deliberately limited to those two - * files; provider Python/GDB children remain outside the permit. - */ -export const assertHistoricalPidReadsAreOwnershipOnly = ({ +/** Parse execution/thread facts once; only an observed Node ps caller owns inventory reads. */ +export const collectOwnershipTraceFacts = ({ traces, - sentinelPid, + nodeExecutable, psExecutables, }) => { const processGroups = new Map(); @@ -71,57 +63,65 @@ export const assertHistoricalPidReadsAreOwnershipOnly = ({ const childPid = event.childPid ?? /\) = (\d+)(?:<[^>]*>)?$/u.exec(event.text)?.[1]; if (childPid === undefined) continue; - event.childPid = Number(childPid); + const observedChild = Number(childPid); + // A child-side clone return of zero is not a parent-to-child edge. + if (!Number.isSafeInteger(observedChild) || observedChild <= 0) continue; + event.childPid = observedChild; if (/\bCLONE_THREAD\b/u.test(event.text)) joinThreadGroup(event.parentPid, event.childPid); } - const psPids = new Set(); + const executions = []; for (const trace of traces) { - const successfulPsExec = trace.text.split("\n").some((line) => { - const executable = /^execve\("([^"]+)"/u.exec(line)?.[1]; - return ( - executable !== undefined && - psExecutables.has(executable) && - line.includes(ownershipPsArguments) && - line.endsWith(" = 0") - ); - }); - if (successfulPsExec) psPids.add(trace.pid); - } - - const psParentGroups = new Set( - cloneEvents - .filter(({ childPid }) => childPid !== undefined && psPids.has(childPid)) - .map(({ parentPid }) => findGroup(parentPid)), - ); - - const ownershipReads = []; - const pidPrefix = `/proc/${sentinelPid}/`; - for (const trace of traces) { - const isPsProcess = psPids.has(trace.pid); - const isOwnershipProcessThread = psParentGroups.has(findGroup(trace.pid)); - const pidReads = trace.text - .split("\n") - .filter((line) => line.includes(pidPrefix)); - for (const line of pidReads) { - const psAllowedPath = new RegExp( - `/proc/${sentinelPid}/(?:stat|status|cmdline|wchan|environ)"`, - "u", - ).test(line); - const ownershipThreadPath = new RegExp( - `/proc/${sentinelPid}/(?:stat|status)"`, - "u", - ).test(line); - const psRead = isPsProcess && psAllowedPath; - const ownershipThreadRead = - isOwnershipProcessThread && ownershipThreadPath; + for (const line of trace.text.split("\n")) { + if (!/execve(?:at)?\(/u.test(line)) continue; + const match = /^execve\("([^"]+)"/u.exec(line); + assert.notEqual(match, null, `Unresolved executable identity: ${line}`); assert.ok( - psRead || ownershipThreadRead, - `Historical PID caused a provider lookup in trace.${trace.pid}: ${line}`, + / = (?:0|-1 [A-Z]+)/u.test(line), + `Incomplete execution observation: ${line}`, ); - ownershipReads.push(line); + executions.push({ + pid: trace.pid, + path: match[1], + line, + succeeded: line.endsWith(" = 0"), + ownershipInventory: + psExecutables.has(match[1]) && line.includes(ownershipPsArguments), + }); } } - return ownershipReads; + const psProcesses = new Set( + executions + .filter( + ({ succeeded, ownershipInventory }) => succeeded && ownershipInventory, + ) + .map(({ pid }) => pid), + ); + const psParentGroups = new Set( + cloneEvents + .filter(({ childPid }) => psProcesses.has(childPid)) + .map(({ parentPid }) => findGroup(parentPid)), + ); + const nodeGroups = new Set( + executions + .filter(({ path, succeeded }) => succeeded && path === nodeExecutable) + .map(({ pid }) => findGroup(pid)), + ); + const replaced = new Set( + executions + .filter(({ path, succeeded }) => succeeded && path !== nodeExecutable) + .map(({ pid }) => pid), + ); + const ownershipThreads = new Set( + traces + .filter( + ({ pid }) => + nodeGroups.has(findGroup(pid)) && + psParentGroups.has(findGroup(pid)) && + !replaced.has(pid), + ) + .map(({ pid }) => pid), + ); + return { ownershipThreads, psProcesses, executions }; }; diff --git a/scripts/verify/recorded-crash/trace-policy.d.mts b/scripts/verify/recorded-crash/trace-policy.d.mts new file mode 100644 index 0000000000..1f39deb219 --- /dev/null +++ b/scripts/verify/recorded-crash/trace-policy.d.mts @@ -0,0 +1,16 @@ +export interface SyscallTrace { + readonly pid: number; + readonly text: string; +} +export const historicalPidTraceContract: { + readonly checked_paths: readonly string[]; + readonly pid_syscalls: readonly string[]; + readonly unverified: readonly string[]; +}; +export function assertHistoricalPidAccess(input: { + readonly trace: SyscallTrace; + readonly historicalPid: number; + readonly ownershipThreads: ReadonlySet; + readonly liveCollision: boolean; + readonly psInventory: boolean; +}): readonly string[]; diff --git a/scripts/verify/recorded-crash/trace-policy.mjs b/scripts/verify/recorded-crash/trace-policy.mjs new file mode 100644 index 0000000000..faf4c71f28 --- /dev/null +++ b/scripts/verify/recorded-crash/trace-policy.mjs @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { posix } from "node:path"; + +// This owner supplies both tracer selection and target interpretation. Kinds +// describe syscall argument semantics, never catalog positions or output PIDs. +const pidTargetKinds = new Map([ + ...[ + "prlimit64", + "sched_getaffinity", + "sched_setaffinity", + "sched_getparam", + "sched_setparam", + "sched_getscheduler", + "sched_setscheduler", + "sched_getattr", + "sched_setattr", + "sched_rr_get_interval", + "getpgid", + "getsid", + "get_robust_list", + "migrate_pages", + "move_pages", + "pidfd_open", + "tkill", + "rt_sigqueueinfo", + ].map((name) => [name, "pid"]), + ...["tgkill", "rt_tgsigqueueinfo", "setpgid", "kcmp"].map((name) => [ + name, + "pair", + ]), + ...["getpriority", "setpriority"].map((name) => [name, "priority"]), + ...["ioprio_get", "ioprio_set"].map((name) => [name, "ioprio"]), + ...[ + "pidfd_send_signal", + "pidfd_getfd", + "process_madvise", + "process_mrelease", + ].map((name) => [name, "pidfd"]), + ...["capget", "capset"].map((name) => [name, "capability"]), + ["kill", "signal-group"], + ["wait4", "pid-group"], + ["waitid", "wait-selector"], +]); + +export const historicalPidTraceContract = Object.freeze({ + checked_paths: Object.freeze(["absolute-proc-paths", "decoded-fd-paths"]), + pid_syscalls: Object.freeze([...pidTargetKinds.keys()]), + unverified: Object.freeze([ + "legacy-cwd-relative-paths", + "symlink-path-aliases", + "same-node-thread-stat-status-purpose", + "other-pid-directed-syscalls", + ]), +}); + +const readPid = (value) => { + const match = /^(-?\d+)(?=[),]|$)/u.exec(value ?? ""); + const pid = match === null ? NaN : Number(match[1]); + return Number.isSafeInteger(pid) ? pid : null; +}; +const readPidfd = (value) => { + const match = /^\d+$/u.exec(value ?? ""); + return match === null ? null : readPid(match[1]); +}; + +const historicalPidTarget = (line, pid) => { + const call = /^(\w+)\((.*)$/u.exec(line); + const kind = call === null ? undefined : pidTargetKinds.get(call[1]); + if (kind === undefined) return "unselected"; + const args = call[2].split(", ", 3); + const match = (...targets) => + targets.includes(pid) + ? "historical" + : targets.includes(null) + ? "unresolved" + : "other"; + switch (kind) { + case "pid": + return match(readPid(args[0])); + case "pair": + return match(readPid(args[0]), readPid(args[1])); + case "pidfd": + return match(readPidfd(args[0])); + case "pid-group": + case "signal-group": { + const target = readPid(args[0]); + if (target === null) return "unresolved"; + return target === pid || + target === -pid || + (kind === "signal-group" && target === -1) + ? "historical" + : "other"; + } + case "priority": + case "ioprio": { + const prefix = kind === "priority" ? "PRIO_" : "IOPRIO_WHO_"; + if (args[0] === `${prefix}USER`) return "other"; + if (![`${prefix}PROCESS`, `${prefix}PGRP`].includes(args[0])) + return "unresolved"; + return match(readPid(args[1])); + } + case "wait-selector": + if (args[0] === "P_ALL") return "other"; + if (args[0] === "P_PIDFD") return match(readPidfd(args[1])); + if (!["P_PID", "P_PGID"].includes(args[0])) return "unresolved"; + return match(readPid(args[1])); + case "capability": { + const header = /^\{[^}]*\bpid=(-?\d+)(?=[,}])/u.exec(call[2]); + return match(header === null ? null : readPid(header[1])); + } + default: + return "unresolved"; + } +}; + +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) + ); +}; + +/** Check resolved PID references; cwd-relative legacy paths remain unqualified. */ +export function assertHistoricalPidAccess({ + trace, + historicalPid, + ownershipThreads, + liveCollision, + psInventory, +}) { + const root = `/proc/${historicalPid}`; + const pathReference = new RegExp(`${root}(?=[/">]|$)`, "u"); + const referencesHistoricalPath = (line) => { + if (pathReference.test(line)) 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 owns the current dirfd identity, including descriptor reuse. Resolve + // each FD/path argument pair, not just openat: failed readlinkat, statx and + // newfstatat attempts have no returned descriptor to reveal their target. + for (const relative of line.matchAll( + /(?:\(|, )(?:-?\d+|AT_FDCWD)<(\/[^>]*)>, ("(?:[^"\\]|\\.)*")/gu, + )) { + // strace prints ASCII separators, dot segments, /proc and decimal PIDs + // literally. Other C-escaped bytes remain opaque path components; JSON + // decoding would incorrectly reject valid non-ASCII caller paths. + const pathname = relative[2].slice(1, -1); + const path = posix.resolve(relative[1], pathname); + if (path === root || path.startsWith(`${root}/`)) return true; + } + return false; + }; + const accesses = []; + for (const line of trace.text.split("\n")) { + const target = historicalPidTarget(line, historicalPid); + if ( + target !== "historical" && + target !== "unresolved" && + !referencesHistoricalPath(line) + ) + continue; + const ownershipMetadata = + ownershipThreads.has(trace.pid) && + readsMetadata(line, root, ["stat", "status"]); + const psMetadata = + psInventory && + (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 && + target !== "historical" && + target !== "unresolved" && + (ownershipMetadata || psMetadata), + `${target === "unresolved" ? "Unresolved process target in" : "Historical PID caused a"} non-inventory lookup in trace ${trace.pid}: ${line}`, + ); + accesses.push(line); + } + return accesses; +} diff --git a/scripts/verify/recorded-crash/trace.mjs b/scripts/verify/recorded-crash/trace.mjs index 44ef4f9632..34735737b6 100644 --- a/scripts/verify/recorded-crash/trace.mjs +++ b/scripts/verify/recorded-crash/trace.mjs @@ -3,7 +3,12 @@ import { readdir, readFile, realpath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { isAbsolute, join, resolve } from "node:path"; import { runOwnedCommand } from "../../../dist/process/OwnedCommand.js"; -import { assertHistoricalPidReadsAreOwnershipOnly } from "./trace-ownership.mjs"; +import { parseEvidence } from "../../../dist/domain/evidence.js"; +import { + assertHistoricalPidAccess, + historicalPidTraceContract, +} from "./trace-policy.mjs"; +import { collectOwnershipTraceFacts } from "./trace-ownership.mjs"; /** Check attempted syscalls, including failed attempts, on a recorded PID that collides with an owned live sentinel. */ export async function verifyCoreInspectionTrace({ @@ -13,6 +18,7 @@ export async function verifyCoreInspectionTrace({ core, sentinel, runId, + historicalPid = sentinel.pid, }) { const gdb = await realpath(environment.REA_PWNDBG_GDB); const python = await realpath(environment.REA_PWNTOOLS_PYTHON); @@ -31,7 +37,8 @@ export async function verifyCoreInspectionTrace({ const observations = []; for (const mode of ["cli", "mcp"]) { sentinel.assertAlive(); - const prefix = join(root, `inspection-${mode}.trace`); + const traceName = `inspection-${historicalPid}-${mode}.trace.`; + const prefix = join(root, traceName.slice(0, -1)); const invocation = mode === "cli" ? [ @@ -45,17 +52,19 @@ export async function verifyCoreInspectionTrace({ fileURLToPath(new URL("./trace-client.mjs", import.meta.url)), entrypoint, core, + String(historicalPid), ]; - await runOwnedCommand( + const result = await runOwnedCommand( { command: environment.REA_VERIFY_STRACE_COMMAND, 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,${historicalPidTraceContract.pid_syscalls.join(",")}`, "-o", prefix, process.execPath, @@ -68,23 +77,45 @@ export async function verifyCoreInspectionTrace({ }, { timeoutMs: 90_000, diagnosticBytes: 1024 * 1024 }, ); + if (mode === "cli") { + const report = parseEvidence( + JSON.parse(result.stdout.text), + ).normalized_result; + assert.ok( + report.threads.some( + (thread) => thread.historical_pid === historicalPid, + ), + "CLI must decode the selected historical-PID control before its trace qualifies", + ); + } const executions = []; + const ownershipReads = []; let gdbObserved = false; - const traces = await Promise.all( - (await readdir(root)) - .filter((file) => file.startsWith(`inspection-${mode}.trace.`)) - .map(async (file) => ({ - file, - pid: Number(file.slice(`inspection-${mode}.trace.`.length)), - text: await readFile(join(root, file), "utf8"), - })), - ); - const ownershipReads = assertHistoricalPidReadsAreOwnershipOnly({ + const traces = []; + for (const file of await readdir(root)) { + if (!file.startsWith(traceName)) continue; + const pid = Number(file.slice(traceName.length)); + assert.ok(Number.isSafeInteger(pid) && pid > 0); + const text = await readFile(join(root, file), "utf8"); + // Retain only execution/thread evidence across files, not all raw traces. + const inventory = text + .split("\n") + .filter((line) => + /^(?:execve|clone|fork|vfork|<\.\.\. (?:clone|fork|vfork))/u.test( + line, + ), + ) + .join("\n"); + traces.push({ pid, text: inventory, file }); + } + const ownershipFacts = collectOwnershipTraceFacts({ traces, - sentinelPid: sentinel.pid, + nodeExecutable: process.execPath, psExecutables: psPaths, }); - for (const { file, text } of traces) { + for (const trace of traces) { + const { file } = trace; + const text = await readFile(join(root, file), "utf8"); // Keep the source executable present: GDB 16 otherwise silently hides a // core-only violation when its automatic executable lookup finds nothing. const recordedExecutable = JSON.stringify(join(root, "fixture-program")); @@ -92,6 +123,15 @@ export async function verifyCoreInspectionTrace({ !text.split("\n").some((line) => line.includes(recordedExecutable)), `Inspection attempted access to the recorded executable: ${file}`, ); + ownershipReads.push( + ...assertHistoricalPidAccess({ + trace: { pid: trace.pid, text }, + historicalPid, + ownershipThreads: ownershipFacts.ownershipThreads, + liveCollision: historicalPid === sentinel.pid, + psInventory: ownershipFacts.psProcesses.has(trace.pid), + }), + ); assert.ok( !/\bptrace\(/u.test(text), `Inspection attempted ptrace: ${file}`, @@ -106,48 +146,40 @@ export async function verifyCoreInspectionTrace({ ), `Inspection attempted an Internet socket: ${file}`, ); - for (const line of text.split("\n")) { - if (!/execve(?:at)?\(/u.test(line)) continue; - const match = /^execve\("([^"]+)"/u.exec(line); - assert.notEqual(match, null, `Unresolved executable identity: ${line}`); - assert.ok( - / = (?:0|-1 [A-Z]+)/u.test(line), - `Incomplete execution observation: ${line}`, + } + for (const execution of ownershipFacts.executions) { + const { line } = execution; + const executable = await realpath(execution.path).catch( + () => execution.path, + ); + const ownership = execution.ownershipInventory; + const iconv = + iconvPaths.has(execution.path) && line.includes('["iconv", "-l"]'); + const version = + gitPaths.has(execution.path) && + line.includes( + "[" + + [ + "git", + "--git-dir", + join(plugin, ".git"), + "rev-parse", + "--short", + "HEAD", + ] + .map((argument) => JSON.stringify(argument)) + .join(", ") + + "]", ); - const executable = await realpath(match[1]).catch(() => match[1]); - const ownership = - psPaths.has(match[1]) && - line.includes( - '["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]', - ); - const iconv = - iconvPaths.has(match[1]) && line.includes('["iconv", "-l"]'); - const version = - gitPaths.has(match[1]) && - line.includes( - "[" + - [ - "git", - "--git-dir", - join(plugin, ".git"), - "rev-parse", - "--short", - "HEAD", - ] - .map((argument) => JSON.stringify(argument)) - .join(", ") + - "]", - ); - assert.ok( - [process.execPath, python, gdb].includes(executable) || - ownership || - iconv || - version, - `Unexpected attempted host execution: ${line}`, - ); - if (executable === gdb) gdbObserved = true; - executions.push(executable); - } + assert.ok( + [process.execPath, python, gdb].includes(executable) || + ownership || + iconv || + version, + `Unexpected attempted host execution: ${line}`, + ); + if (executable === gdb) gdbObserved = true; + executions.push(executable); } assert.ok(gdbObserved, "Trace must include the actual core-only GDB stage"); assert.ok(executions.includes(python)); @@ -155,7 +187,7 @@ export async function verifyCoreInspectionTrace({ observations.push({ mode, executions, - historical_pid: sentinel.pid, + historical_pid: historicalPid, sentinel: "still-alive", ownership_process_table_reads: ownershipReads, }); diff --git a/src/domain/stringPatterns.test.ts b/src/domain/stringPatterns.test.ts index 4410ad7b95..4dd634d7a7 100644 --- a/src/domain/stringPatterns.test.ts +++ b/src/domain/stringPatterns.test.ts @@ -1,3 +1,4 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it } from "vitest"; import { z } from "zod"; @@ -136,3 +137,20 @@ it("advertises every portable base64 constraint and keeps large inputs stack-saf } expect(isCanonicalBase64(encoded)).toBe(true); }); + +it("validates complete projected canonical schemas at the encoded-input budget", () => { + const projected = z.toJSONSchema(canonicalBase64Schema, { + target: "draft-2020-12", + }); + const validate = new Ajv2020({ strict: true }).compile(projected); + const encoded = Buffer.alloc(24 * 1024 * 1024 - 3, 0x81).toString("base64"); + for (const { value, expected } of [ + { value: encoded, expected: true }, + { value: `${encoded.slice(0, -1)}!`, expected: false }, + { value: `${encoded.slice(0, -3)}R==`, expected: false }, + { value: `${encoded}A`, expected: false }, + ]) { + expect(canonicalBase64Schema.safeParse(value).success).toBe(expected); + expect(validate(value)).toBe(expected); + } +}); diff --git a/src/process/LinuxProcessStat.test.ts b/src/process/LinuxProcessStat.test.ts index d8544528bb..d922714e1c 100644 --- a/src/process/LinuxProcessStat.test.ts +++ b/src/process/LinuxProcessStat.test.ts @@ -7,6 +7,7 @@ const statRecord = ( readonly pid?: string; readonly command?: string; readonly state?: string; + readonly flags?: string; readonly parentPid?: string; readonly processGroupId?: string; readonly sessionId?: string; @@ -20,6 +21,7 @@ const statRecord = ( options.sessionId ?? "19", ]; while (fields.length < 19) fields.push("0"); + fields[6] = options.flags ?? "0"; fields.push(options.startTime ?? "987654"); return `${options.pid ?? "42"} (${options.command ?? "worker"}) ${fields.join(" ")}`; }; @@ -56,3 +58,38 @@ it("distinguishes zombie lifecycle and rejects malformed or missing identity fie undefined, ); }); + +it.each(["R", "S", "D"])( + "retains PF_EXITING separately from terminal state %s", + (state) => { + expect( + parseLinuxProcessStat(statRecord({ state, flags: "4194316" }))?.lifecycle, + ).toBe("exiting"); + expect( + parseLinuxProcessStat(statRecord({ state, flags: "4194312" }))?.lifecycle, + ).toBe("non-zombie"); + }, +); + +it.each(["Z", "X", "x"])( + "recognizes terminal native state %s independently of flags", + (state) => { + expect( + parseLinuxProcessStat(statRecord({ state, flags: "invalid" }))?.lifecycle, + ).toBe(state === "Z" ? "zombie" : "dead"); + }, +); + +it.each(["invalid", "-1", "4.1", "9007199254740992"])( + "preserves identity and relations with unknown flags %s", + (flags) => { + expect(parseLinuxProcessStat(statRecord({ flags }))).toEqual({ + pid: 42, + startTime: "987654", + lifecycle: undefined, + parentPid: 17, + processGroupId: 18, + sessionId: 19, + }); + }, +); diff --git a/src/process/LinuxProcessStat.ts b/src/process/LinuxProcessStat.ts index 93e56647c1..d255b74936 100644 --- a/src/process/LinuxProcessStat.ts +++ b/src/process/LinuxProcessStat.ts @@ -1,8 +1,10 @@ +import type { ProcessLifecycle } from "./ProcessLifecycle.js"; + /** Native process facts parsed from one Linux `/proc//stat` record. */ export interface LinuxProcessStat { readonly pid: number; readonly startTime: string; - readonly lifecycle: "non-zombie" | "zombie"; + readonly lifecycle: ProcessLifecycle | undefined; readonly parentPid: number; readonly processGroupId: number; readonly sessionId: number; @@ -58,10 +60,28 @@ export const parseLinuxProcessStat = ( ) return undefined; + // Field 9 is task flags. PF_EXITING can precede state Z and procfs token + // access can already fail during that interval. Neither proves terminal exit. + const flagsText = fields[6]; + const flags = + flagsText !== undefined && /^\d+$/u.test(flagsText) + ? Number(flagsText) + : Number.NaN; + const lifecycle: ProcessLifecycle | undefined = + state === "Z" + ? "zombie" + : state === "X" || state === "x" + ? "dead" + : !Number.isSafeInteger(flags) + ? undefined + : (flags & 0x00000004) !== 0 + ? "exiting" + : "non-zombie"; + return { pid, startTime, - lifecycle: state === "Z" ? "zombie" : "non-zombie", + lifecycle, parentPid, processGroupId, sessionId, diff --git a/src/process/ProcessLifecycle.ts b/src/process/ProcessLifecycle.ts new file mode 100644 index 0000000000..9c0d0f52e1 --- /dev/null +++ b/src/process/ProcessLifecycle.ts @@ -0,0 +1,27 @@ +import type { ProcessIdentityObservation } from "./ProcessOwnership.js"; + +/** Native lifecycle is independent of ownership, token readability and ps text. */ +export type ProcessLifecycle = "non-zombie" | "exiting" | "zombie" | "dead"; + +export type ProcessLifecycleObservation = + | { readonly state: "observed"; readonly lifecycle: ProcessLifecycle } + | { readonly state: "unavailable"; readonly reason: string }; + +export const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000; +export const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25; + +export const processIdentityHasExited = ( + identity: ProcessIdentityObservation | undefined, +): boolean => + identity?.state === "absent" || + (identity?.state === "readable" && + identity.lifecycle?.state === "observed" && + (identity.lifecycle.lifecycle === "zombie" || + identity.lifecycle.lifecycle === "dead")); + +export const processIdentityIsExiting = ( + identity: ProcessIdentityObservation | undefined, +): boolean => + identity?.state === "readable" && + identity.lifecycle?.state === "observed" && + identity.lifecycle.lifecycle === "exiting"; diff --git a/src/process/ProcessOwnership.exiting.test.ts b/src/process/ProcessOwnership.exiting.test.ts new file mode 100644 index 0000000000..c6db1232b4 --- /dev/null +++ b/src/process/ProcessOwnership.exiting.test.ts @@ -0,0 +1,179 @@ +import { expect, it, vi } from "vitest"; +import { + cleanupOwnedProcessGroup, + type ProcessIdentityObservation, + type ProcessOwnershipHost, + type ProcessTableEntry, +} from "./ProcessOwnership.js"; +import { + readProcessOwnershipFacts, + settleExitingProcessIdentities, +} from "./ProcessOwnershipObservation.js"; +import { processIdentityHasExited } from "./ProcessLifecycle.js"; + +const row: ProcessTableEntry = { + pid: 910, + parentPid: 17, + processGroupId: 910, + state: "R", + command: "[MainThread]", +}; +const identity = ( + lifecycle: "non-zombie" | "exiting" | "zombie" | "dead", + generation = "start:1", +): ProcessIdentityObservation => ({ + state: "readable", + identity: generation, + lifecycle: { state: "observed", lifecycle }, + relation: { + state: "observed", + parentPid: row.parentPid, + processGroupId: row.processGroupId, + }, +}); +const hostWith = ( + observe: () => ProcessIdentityObservation, +): ProcessOwnershipHost => ({ + listProcesses: vi.fn(async () => [row]), + environment: async () => ({}), + processIdentities: vi.fn( + async (rows: readonly ProcessTableEntry[]) => + new Map(rows.map(({ pid }) => [pid, observe()])), + ), + runTokens: vi.fn( + async (rows: readonly ProcessTableEntry[]) => + new Map( + rows.map(({ pid }) => [ + pid, + { state: "unavailable" as const, reason: "EACCES" }, + ]), + ), + ), + signalGroup: vi.fn(), +}); + +it.each(["zombie", "dead", "absent"] as const)( + "settles exiting token reads only after a fresh native %s observation", + async (terminal) => { + let reads = 0; + const host = hostWith(() => + ++reads < 3 + ? identity("exiting") + : terminal === "absent" + ? { state: "absent", reason: "not-in-snapshot" } + : identity(terminal), + ); + const facts = await readProcessOwnershipFacts([row], host); + expect(processIdentityHasExited(facts[0]?.identityAfter)).toBe(true); + expect(facts[0]?.token).toEqual({ state: "unavailable", reason: "EACCES" }); + expect(host.processIdentities).toHaveBeenCalledTimes(3); + expect(host.runTokens).toHaveBeenCalledTimes(1); + expect(host.listProcesses).not.toHaveBeenCalled(); + expect(host.signalGroup).not.toHaveBeenCalled(); + }, +); + +it("keeps unconfirmed exit explicit at the bounded deadline", async () => { + const host = hostWith(() => identity("exiting")); + const result = await cleanupOwnedProcessGroup( + { + runId: "run", + leaderPid: row.pid, + processGroupId: row.processGroupId, + expectedCommand: "/exact/node", + }, + host, + ); + expect(result).toMatchObject({ + cleaned: false, + reason: "owned launcher exit could not be confirmed", + }); + expect(host.signalGroup).not.toHaveBeenCalled(); +}); + +it("settles launcher exit before comparing a stale command display", async () => { + let reads = 0; + const host = hostWith(() => + ++reads === 1 ? identity("exiting") : identity("zombie"), + ); + await expect( + cleanupOwnedProcessGroup( + { + runId: "run", + leaderPid: row.pid, + processGroupId: row.processGroupId, + expectedCommand: "/exact/node", + }, + host, + ), + ).resolves.toMatchObject({ cleaned: true, signaled: false }); + expect(host.signalGroup).not.toHaveBeenCalled(); +}); + +it("stops at PID reuse and preserves the changed generation", async () => { + let reads = 0; + const host = hostWith(() => + ++reads < 3 ? identity("exiting") : identity("exiting", "start:2"), + ); + const facts = await readProcessOwnershipFacts([row], host); + expect(facts[0]).toMatchObject({ + state: "replaced", + identityBefore: { identity: "start:1" }, + identityAfter: { identity: "start:2" }, + }); + expect(host.processIdentities).toHaveBeenCalledTimes(3); + expect(host.signalGroup).not.toHaveBeenCalled(); +}); + +it("preserves unknown exit evidence without inferring terminal absence", async () => { + const host = hostWith(() => ({ + state: "unavailable", + reason: "native identity denied", + })); + const result = await settleExitingProcessIdentities( + [row], + host, + new Map([[row.pid, identity("exiting")]]), + ); + expect(result.get(row.pid)).toEqual({ + state: "unavailable", + reason: "native identity denied", + }); + expect(processIdentityHasExited(result.get(row.pid))).toBe(false); +}); + +it("refreshes only selected exiting PIDs and preserves other native observations", async () => { + const other = { ...row, pid: 911 }; + const host = hostWith(() => identity("zombie")); + const initial = new Map([ + [row.pid, identity("exiting")], + [other.pid, identity("non-zombie")], + ]); + const result = await settleExitingProcessIdentities( + [row, other], + host, + initial, + ); + expect(host.processIdentities).toHaveBeenCalledExactlyOnceWith( + [row], + undefined, + undefined, + ); + expect(result.get(other.pid)).toBe(initial.get(other.pid)); + expect(host.listProcesses).not.toHaveBeenCalled(); +}); + +it("preserves caller cancellation during native exit settlement", async () => { + const abort = new AbortController(); + const reason = new Error("caller cancelled exit observation"); + const host = hostWith(() => identity("exiting")); + const pending = settleExitingProcessIdentities( + [row], + host, + new Map([[row.pid, identity("exiting")]]), + abort.signal, + ); + abort.abort(reason); + await expect(pending).rejects.toBe(reason); + expect(host.processIdentities).not.toHaveBeenCalled(); +}); diff --git a/src/process/ProcessOwnership.finalSweep.test.ts b/src/process/ProcessOwnership.finalSweep.test.ts index 6ed2491fc4..2f3d5036a1 100644 --- a/src/process/ProcessOwnership.finalSweep.test.ts +++ b/src/process/ProcessOwnership.finalSweep.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { cleanupOwnedProcessGroup, + verifyNoTokenOwnedProcesses, type ProcessOwnershipHost, type ProcessRunTokenObservation, type ProcessTableEntry, @@ -353,3 +354,41 @@ it("retries a moved token-owned helper group without signaling a released peer a expect(signalGroups).toEqual([100, 200, 300]); expect(owner.evidenceOwner.candidates).toEqual([]); }); + +it("settles transient token reads without borrowing the application timer", async () => { + const fixture = makeHost(() => undefined); + let reads = 0; + const host: ProcessOwnershipHost = { + ...fixture.host, + runTokens: async (entries) => { + reads += 1; + return new Map( + entries.map(({ pid }) => [ + pid, + reads === 1 + ? { + state: "unavailable" as const, + reason: "environment_errno_EACCES", + } + : { state: "readable" as const, runId: undefined }, + ]), + ); + }, + }; + const applicationTimer = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(() => { + throw new Error( + "application timer must not mediate native ownership retries", + ); + }); + try { + await expect( + verifyNoTokenOwnedProcesses("sweep-run", host), + ).resolves.toEqual({ cleaned: true, signaled: false }); + expect(reads).toBe(2); + expect(applicationTimer).not.toHaveBeenCalled(); + } finally { + applicationTimer.mockRestore(); + } +}); diff --git a/src/process/ProcessOwnership.identity.test.ts b/src/process/ProcessOwnership.identity.test.ts index 5b5c69f988..28d4ce9e4f 100644 --- a/src/process/ProcessOwnership.identity.test.ts +++ b/src/process/ProcessOwnership.identity.test.ts @@ -20,6 +20,52 @@ const observedIdentity = (entry: ProcessTableEntry, identity: string) => ({ }, }); +it.each([undefined, "run-token"])( + "validates a current generation with token %s when its baseline identity was unknown", + async (runId) => { + const row: ProcessTableEntry = { + pid: 909, + parentPid: 1, + processGroupId: 909, + state: "S", + command: "foreign-process", + }; + const environment = vi.fn(() => + Promise.resolve(runId === undefined ? {} : { REA_PROCESS_RUN_ID: runId }), + ); + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve([row]), + environment, + processIdentities: (entries) => + Promise.resolve( + new Map( + entries.map((entry) => [ + entry.pid, + observedIdentity(entry, "current-start"), + ]), + ), + ), + signalGroup, + }; + + await expect( + verifyNoTokenOwnedProcesses("run-token", host, [ + { pid: row.pid, identity: null }, + ]), + ).resolves.toEqual( + runId === undefined + ? { cleaned: true, signaled: false } + : { + cleaned: false, + reason: "token-owned process remained after cleanup", + }, + ); + expect(environment).toHaveBeenCalledWith(row.pid); + expect(signalGroup).not.toHaveBeenCalled(); + }, +); + describe("Linux process start identities", () => { itOnLinux("reads Linux process start identities from proc stat", async () => { const host = createSystemProcessOwnershipHost("linux", {}); diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index a0c45fb571..95a80b22dc 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -1,12 +1,13 @@ import { execFile } from "node:child_process"; import { performance } from "node:perf_hooks"; -import { setTimeout as delay } from "node:timers/promises"; import { promisify } from "node:util"; +import { setTimeout as delay } from "node:timers/promises"; import { errorMessage, readNativeProcessIdentityMap, readProcessOwnershipFacts, + settleExitingProcessIdentities, systemProcessOwnershipHost as systemHost, } from "./ProcessOwnershipObservation.js"; import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js"; @@ -15,10 +16,15 @@ import { readProcessRunTokens } from "./ProcessRunTokenObservations.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; -const execFileAsync = promisify(execFile); +import { + PROCESS_CLEANUP_VERIFICATION_GRACE_MS, + PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, + processIdentityHasExited, + processIdentityIsExiting, + type ProcessLifecycleObservation, +} from "./ProcessLifecycle.js"; -const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000; -const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25; +const execFileAsync = promisify(execFile); /** Shared fields for a process group acquired by a resource owner. */ interface OwnedProcessGroupFacts { @@ -99,13 +105,7 @@ export type ProcessIdentityObservation = } | { readonly state: "unavailable"; readonly reason: string }; -/** Native lifecycle fact observed alongside (but independent of) start identity. */ -export type ProcessLifecycleObservation = - | { - readonly state: "observed"; - readonly lifecycle: "non-zombie" | "zombie"; - } - | { readonly state: "unavailable"; readonly reason: string }; +export type { ProcessLifecycleObservation } from "./ProcessLifecycle.js"; /** Parent and process-group membership from the same native identity read. */ export type ProcessNativeRelationObservation = @@ -526,12 +526,7 @@ const readProcessRelationSnapshot = async ( const failures: ProcessOwnershipValidationFailure[] = []; for (const process of processes) { const identity = identities.get(process.pid); - if ( - identity?.state === "readable" && - identity.lifecycle?.state === "observed" && - identity.lifecycle.lifecycle === "zombie" - ) - continue; + if (processIdentityHasExited(identity)) continue; if ( identity?.state === "readable" && identity.relation?.state === "observed" @@ -554,8 +549,8 @@ const readProcessRelationSnapshot = async ( }; // A process-table read can race exit and retain the old state with an altered -// command. Only a fresh table proving that PID is no longer live can settle -// that mismatch; surviving group members still require normal token checks. +// command. Native terminal facts can settle that mismatch; exit-in-progress +// must be confirmed first. Surviving members still require normal token checks. const settleLauncherExit = async ( ownership: OwnedProcessGroup, processes: readonly ProcessTableEntry[], @@ -568,15 +563,32 @@ const settleLauncherExit = async ( if (launcher === undefined) return processes; let identityCheckedLauncher = launcher; if (host.processIdentities !== undefined) { - const identities = await readNativeProcessIdentityMap([launcher], host); + const initialIdentities = await readNativeProcessIdentityMap( + [launcher], + host, + ); + const identities = await settleExitingProcessIdentities( + [launcher], + host, + initialIdentities, + ); const identity = identities.get(launcher.pid); - if (identity?.state === "absent") - return processes.filter(({ pid }) => pid !== launcher.pid); + const initialIdentity = initialIdentities.get(launcher.pid); if ( + initialIdentity?.state === "readable" && identity?.state === "readable" && - identity.lifecycle?.state === "observed" && - identity.lifecycle.lifecycle === "zombie" + initialIdentity.identity !== identity.identity ) + return { + cleaned: false, + reason: "owned launcher identity changed while verifying exit", + }; + if (processIdentityIsExiting(identity)) + return { + cleaned: false, + reason: "owned launcher exit could not be confirmed", + }; + if (processIdentityHasExited(identity)) return processes.filter(({ pid }) => pid !== launcher.pid); if ( identity?.state !== "readable" || @@ -965,14 +977,6 @@ const sanitizedTokenReadFailure = (diagnostic: string | undefined): string => { return "other_unavailable"; }; -const processIdentityHasExited = ( - identity: ProcessIdentityObservation, -): boolean => - identity.state === "absent" || - (identity.state === "readable" && - identity.lifecycle?.state === "observed" && - identity.lifecycle.lifecycle === "zombie"); - const processOwnershipFailures = async ( members: readonly ProcessTableEntry[], runId: string, @@ -984,6 +988,15 @@ const processOwnershipFailures = async ( const failures: ProcessOwnershipValidationFailure[] = []; for (const fact of facts) { if (processIdentityHasExited(fact.identityAfter)) continue; + if (processIdentityIsExiting(fact.identityAfter)) { + failures.push({ + pid: fact.process.pid, + reason: "process-identity-unavailable", + diagnostic: + "native process exit could not be confirmed before signal", + }); + continue; + } const relation = fact.identityAfter.state === "readable" && fact.identityAfter.relation?.state === "observed" @@ -1289,24 +1302,14 @@ const scanWithOwnershipEvidence = async ( observationRows, host, ); - const baselineIdentityFailures = new Map< - number, - ProcessOwnershipValidationFailure - >(); const candidates = observationRows.filter((process) => { if (retainedPids.has(process.pid)) return true; if (!baseline.has(process.pid)) return true; const before = baseline.get(process.pid); const now = firstIdentities.get(process.pid); if (before === null || now?.state !== "readable") { - baselineIdentityFailures.set(process.pid, { - pid: process.pid, - reason: "process-identity-unavailable", - diagnostic: "process identity was unavailable during token validation", - }); - // Keep the row in the owning fact pass. The failure is not evidence that - // the process is absent, and a related unknown generation must survive - // a later process-table omission. + // The baseline selects candidates; current native facts and retained + // ownership evidence decide identity, liveness and applicability. return true; } return now.identity !== before; @@ -1328,7 +1331,8 @@ const scanWithOwnershipEvidence = async ( .filter( ({ token, identityAfter }) => token.state === "unavailable" && - !processIdentityHasExited(identityAfter), + !processIdentityHasExited(identityAfter) && + !processIdentityIsExiting(identityAfter), ) .map(({ process }) => process); if (retryRows.length === 0) break; @@ -1365,8 +1369,6 @@ const scanWithOwnershipEvidence = async ( // token became unreadable while exiting. Unknown native state stays a // cleanup failure; table omission alone is never proof of exit. if (processIdentityHasExited(fact.identityAfter)) continue; - const baselineFailure = baselineIdentityFailures.get(fact.process.pid); - if (baselineFailure !== undefined) failures.push(baselineFailure); if (fact.state === "replaced") failures.push({ pid: fact.process.pid, diff --git a/src/process/ProcessOwnershipEvidence.ts b/src/process/ProcessOwnershipEvidence.ts index 7f35d93a02..fd93b78042 100644 --- a/src/process/ProcessOwnershipEvidence.ts +++ b/src/process/ProcessOwnershipEvidence.ts @@ -1,3 +1,4 @@ +import { processIdentityHasExited } from "./ProcessLifecycle.js"; import type { ProcessIdentityObservation, ProcessNativeRelationObservation, @@ -208,9 +209,7 @@ export class ProcessOwnershipEvidenceOwner { fact.identityAfter.state === "readable" && fact.identityAfter.identity !== previous.identity) || (fact.state === "same-generation" && - fact.identityAfter.state === "readable" && - fact.identityAfter.lifecycle?.state === "observed" && - fact.identityAfter.lifecycle.lifecycle === "zombie") + processIdentityHasExited(fact.identityAfter)) ) { this.#candidates.delete(pid); retiredPids.add(pid); @@ -274,9 +273,7 @@ export class ProcessOwnershipEvidenceOwner { (retiredPids.has(pid) && fact.state !== "same-generation") || fact.state === "absent" || (fact.state === "same-generation" && - fact.identityAfter.state === "readable" && - fact.identityAfter.lifecycle?.state === "observed" && - fact.identityAfter.lifecycle.lifecycle === "zombie") + processIdentityHasExited(fact.identityAfter)) ) continue; const identity = fact.identityAfter; diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 01439da94f..ec1ae9ddb6 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -1,4 +1,12 @@ import { readFile } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; +import { setTimeout as delay } from "node:timers/promises"; +import { + processIdentityHasExited, + processIdentityIsExiting, + PROCESS_CLEANUP_VERIFICATION_GRACE_MS, + PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, +} from "./ProcessLifecycle.js"; import { parseLinuxProcessStat } from "./LinuxProcessStat.js"; import { parseLinuxProcessEffectiveUid } from "./LinuxProcessStatus.js"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; @@ -63,11 +71,7 @@ export const observeOwnedProcessGroups = async ( if (retainedPids.has(process.pid)) return true; const identity = firstIdentities.get(process.pid); if (identity?.state === "readable") { - if ( - identity.lifecycle?.state === "observed" && - identity.lifecycle.lifecycle === "zombie" - ) - return false; + if (processIdentityHasExited(identity)) return false; if ( identity.relation?.state === "observed" && groupIds.includes(identity.relation.processGroupId) @@ -116,12 +120,7 @@ export const observeOwnedProcessGroups = async ( fact.identityAfter.relation?.state === "observed" ? fact.identityAfter.relation.processGroupId : undefined; - if ( - fact.identityAfter.state === "readable" && - fact.identityAfter.lifecycle?.state === "observed" && - fact.identityAfter.lifecycle.lifecycle === "zombie" - ) - continue; + if (processIdentityHasExited(fact.identityAfter)) continue; const candidateGroups = groupIds.filter( (groupId) => nativeGroupId === groupId || @@ -301,10 +300,7 @@ const observeNativeOwnedProcessLineage = async ( ownership, `owned launcher identity could not be read: ${launcherIdentity?.reason ?? "identity observation was omitted"}`, ); - if ( - launcherIdentity.lifecycle?.state === "observed" && - launcherIdentity.lifecycle.lifecycle === "zombie" - ) + if (processIdentityHasExited(launcherIdentity)) return unavailableLineage(ownership, "owned launcher is not live"); const nativeLauncher = processWithNativeRelation(launcher, launcherIdentity); if (nativeLauncher === undefined) @@ -318,12 +314,7 @@ const observeNativeOwnedProcessLineage = async ( const nativeProcesses = processes.flatMap((process) => { const identity = initialIdentities.get(process.pid); - if ( - identity?.state === "readable" && - identity.lifecycle?.state === "observed" && - identity.lifecycle.lifecycle === "zombie" - ) - return []; + if (processIdentityHasExited(identity)) return []; const nativeProcess = identity?.state === "readable" ? processWithNativeRelation(process, identity) @@ -371,11 +362,7 @@ const observeNativeOwnedProcessLineage = async ( ownership, `process identity changed or became unavailable during lineage validation for PID ${String(member.pid)}`, ); - if ( - fact.identityAfter.state === "readable" && - fact.identityAfter.lifecycle?.state === "observed" && - fact.identityAfter.lifecycle.lifecycle === "zombie" - ) { + if (processIdentityHasExited(fact.identityAfter)) { if (member.pid === ownership.leaderPid) return unavailableLineage( ownership, @@ -542,6 +529,55 @@ export const readNativeProcessIdentityMap = async ( ): Promise> => readIdentityFacts(processes, host, "before", signal); +/** + * Wait only for positively observed native exit-in-progress. Never infer exit + * from EACCES or ps text, enumerate the process table, or follow a reused PID. + * A deadline, unavailable read or changed generation remains explicit evidence. + */ +export const settleExitingProcessIdentities = async ( + processes: readonly ProcessTableEntry[], + host: ProcessOwnershipHost, + initial: ReadonlyMap, + signal?: AbortSignal, +): Promise> => { + signal?.throwIfAborted(); + let pending = processes.filter(({ pid }) => + processIdentityIsExiting(initial.get(pid)), + ); + if (pending.length === 0) return initial; + const identities = new Map(initial); + const deadline = performance.now() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS; + while (pending.length > 0) { + const remaining = deadline - performance.now(); + if (remaining <= 0) break; + try { + await delay( + Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining), + undefined, + signal === undefined ? undefined : { signal }, + ); + } catch (cause: unknown) { + signal?.throwIfAborted(); + throw cause; + } + const refreshed = await readNativeProcessIdentityMap(pending, host, signal); + for (const { pid } of pending) + identities.set(pid, identityFact(refreshed, pid, "after")); + pending = pending.filter(({ pid }) => { + const first = initial.get(pid); + const current = identities.get(pid); + return ( + first?.state === "readable" && + current?.state === "readable" && + processIdentityIsExiting(current) && + current.identity === first.identity + ); + }); + } + signal?.throwIfAborted(); + return identities; +}; + /** * Read identity, token, then identity for caller-provided rows. This owner * never enumerates the process table; a token is tied to a generation only @@ -591,12 +627,11 @@ export const readProcessOwnershipFacts = async ( .filter(({ pid }) => tokens.get(pid)?.state !== "readable") .map(({ pid }) => pid), ); - const identityAfter = await readIdentityFacts( + const identityAfter = await settleExitingProcessIdentities( processes, host, - "after", + await readIdentityFacts(processes, host, "after", signal, effectiveUidPids), signal, - effectiveUidPids, ); signal?.throwIfAborted(); const facts = processes.map((process): ProcessOwnershipFact => { @@ -741,10 +776,16 @@ export const createSystemProcessOwnershipHost = ( state: "readable" as const, identity: `linux-starttime:${facts.startTime}`, ...(effectiveUid === undefined ? {} : { effectiveUid }), - lifecycle: { - state: "observed" as const, - lifecycle: facts.lifecycle, - }, + lifecycle: + facts.lifecycle === undefined + ? { + state: "unavailable" as const, + reason: "malformed_proc_flags", + } + : { + state: "observed" as const, + lifecycle: facts.lifecycle, + }, relation: { state: "observed" as const, parentPid: facts.parentPid, diff --git a/tests/boundary/filesystem/recordedCrashTraceClassifier.test.ts b/tests/boundary/filesystem/recordedCrashTraceClassifier.test.ts index f03fbd5ba1..4a4e72b958 100644 --- a/tests/boundary/filesystem/recordedCrashTraceClassifier.test.ts +++ b/tests/boundary/filesystem/recordedCrashTraceClassifier.test.ts @@ -1,23 +1,43 @@ import { expect, it } from "vitest"; -import { assertHistoricalPidReadsAreOwnershipOnly } from "../../../scripts/verify/recorded-crash/trace-ownership.mjs"; +import { collectOwnershipTraceFacts } from "../../../scripts/verify/recorded-crash/trace-ownership.mjs"; +import { assertHistoricalPidAccess } from "../../../scripts/verify/recorded-crash/trace-policy.mjs"; +const node = "/fixture/node"; +const nodeExec = 'execve("/fixture/node", ["node", "rea.mjs"], 0x7fff) = 0'; const psExec = 'execve("/usr/bin/ps", ["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="], 0x7fff) = 0'; +const parent = (...events: readonly string[]) => ({ + pid: 1200, + text: [nodeExec, ...events].join("\n"), +}); +const check = ( + traces: readonly { readonly pid: number; readonly text: string }[], +) => { + const facts = collectOwnershipTraceFacts({ + traces, + nodeExecutable: node, + psExecutables: new Set(["/usr/bin/ps"]), + }); + return traces.flatMap((trace) => + assertHistoricalPidAccess({ + trace, + historicalPid: 4008, + ownershipThreads: facts.ownershipThreads, + liveCollision: true, + psInventory: facts.psProcesses.has(trace.pid), + }), + ); +}; it("correlates the ps child trace with its parent identity read", () => { - const ownershipReads = assertHistoricalPidReadsAreOwnershipOnly({ - sentinelPid: 4008, - psExecutables: new Set(["/usr/bin/ps"]), - traces: [ - { - pid: 1200, - text: [ - "vfork() = 1201", - "clone3({flags=CLONE_VM|CLONE_SIGHAND|CLONE_THREAD}, 88) = 1202", - 'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY|O_CLOEXEC) = 21', - ].join("\n"), - }, + expect( + check([ + parent( + "vfork() = 1201", + "clone3({flags=CLONE_VM|CLONE_SIGHAND|CLONE_THREAD}, 88) = 1202", + 'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY|O_CLOEXEC) = 21', + ), { pid: 1202, text: [ @@ -33,55 +53,34 @@ it("correlates the ps child trace with its parent identity read", () => { 'openat(AT_FDCWD, "/proc/4008/cmdline", O_RDONLY) = 4', ].join("\n"), }, - ], - }); - - expect(ownershipReads).toHaveLength(5); + ]), + ).toHaveLength(5); }); it("joins unfinished and resumed CLONE_THREAD records", () => { - const ownershipReads = assertHistoricalPidReadsAreOwnershipOnly({ - sentinelPid: 4008, - psExecutables: new Set(["/usr/bin/ps"]), - traces: [ - { - pid: 1200, - text: [ - "vfork() = 1201", - "clone3({flags=CLONE_VM|CLONE_SIGHAND|CLONE_THREAD, child_tid=0x3 ", - "<... clone3 resumed>) = 1202", - ].join("\n"), - }, - { - pid: 1202, - text: 'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3', - }, + expect( + check([ + parent( + "vfork() = 1201", + "clone3({flags=CLONE_VM|CLONE_SIGHAND|CLONE_THREAD, child_tid=0x3 ", + "<... clone3 resumed>) = 1202", + ), + { pid: 1202, text: 'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3' }, { pid: 1201, text: psExec }, - ], - }); - - expect(ownershipReads).toHaveLength(1); + ]), + ).toHaveLength(1); }); -it("rejects provider-style reads outside the ps child and stat-only caller", () => { - const traces = [ - { - pid: 1200, - text: [ +it("rejects provider-style reads outside the ps child and identity-only caller", () => { + expect(() => + check([ + parent( "vfork() = 1201", 'openat(AT_FDCWD, "/proc/4008/cmdline", O_RDONLY) = 21', - ].join("\n"), - }, - { pid: 1201, text: psExec }, - ]; - - expect(() => - assertHistoricalPidReadsAreOwnershipOnly({ - traces, - sentinelPid: 4008, - psExecutables: new Set(["/usr/bin/ps"]), - }), - ).toThrow("Historical PID caused a provider lookup"); + ), + { pid: 1201, text: psExec }, + ]), + ).toThrow("non-inventory lookup"); }); it("does not trust a ps command with different arguments or failed exec", () => { @@ -90,55 +89,83 @@ it("does not trust a ps command with different arguments or failed exec", () => psExec.replace(" = 0", " = -1 EACCES"), ]) expect(() => - assertHistoricalPidReadsAreOwnershipOnly({ - sentinelPid: 4008, - psExecutables: new Set(["/usr/bin/ps"]), - traces: [ - { - pid: 1200, - text: `vfork() = 1201\nopenat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3`, - }, - { pid: 1201, text }, - ], - }), - ).toThrow("Historical PID caused a provider lookup"); + check([ + parent( + "vfork() = 1201", + 'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3', + ), + { pid: 1201, text }, + ]), + ).toThrow("non-inventory lookup"); }); it("allows only known procfs fields inside the exact ps child", () => { expect(() => - assertHistoricalPidReadsAreOwnershipOnly({ - sentinelPid: 4008, - psExecutables: new Set(["/usr/bin/ps"]), - traces: [ - { pid: 1200, text: "vfork() = 1201" }, - { - pid: 1201, - text: `${psExec}\nopenat(AT_FDCWD, "/proc/4008/exe", O_RDONLY) = 3`, - }, - ], - }), - ).toThrow("Historical PID caused a provider lookup"); + check([ + parent("vfork() = 1201"), + { + pid: 1201, + text: `${psExec}\nopenat(AT_FDCWD, "/proc/4008/exe", O_RDONLY) = 3`, + }, + ]), + ).toThrow("non-inventory lookup"); }); it("does not grant the ownership process thread permit to a forked child", () => { for (const field of ["stat", "status"]) expect(() => - assertHistoricalPidReadsAreOwnershipOnly({ - sentinelPid: 4008, - psExecutables: new Set(["/usr/bin/ps"]), - traces: [ - { - pid: 1200, - text: ["vfork() = 1201", "clone3({flags=SIGCHLD}, 88) = 1203"].join( - "\n", - ), - }, - { pid: 1201, text: psExec }, - { - pid: 1203, - text: `openat(AT_FDCWD, "/proc/4008/${field}", O_RDONLY) = 3`, - }, - ], - }), - ).toThrow("Historical PID caused a provider lookup"); + check([ + parent("vfork() = 1201", "clone3({flags=SIGCHLD}, 88) = 1203"), + { pid: 1201, text: psExec }, + { + pid: 1203, + text: `openat(AT_FDCWD, "/proc/4008/${field}", O_RDONLY) = 3`, + }, + ]), + ).toThrow("non-inventory lookup"); +}); + +it("requires an observed Node execution as well as the inventory ps child", () => { + expect(() => + check([ + { + pid: 1200, + text: 'vfork() = 1201\nopenat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3', + }, + { pid: 1201, text: psExec }, + ]), + ).toThrow("non-inventory lookup"); +}); + +it("refuses identity reads in an exec-replaced ownership thread", () => { + expect(() => + check([ + parent( + "vfork() = 1201", + "clone3({flags=CLONE_VM|CLONE_THREAD}, 88) = 1202", + ), + { pid: 1201, text: psExec }, + { + pid: 1202, + text: 'execve("/fixture/gdb", ["gdb"], 0x7fff) = 0\nopenat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3', + }, + ]), + ).toThrow("non-inventory lookup"); +}); + +it("does not join unrelated groups through child-side clone returns", () => { + expect(() => + check([ + parent("vfork() = 1201", "clone3({flags=CLONE_VM|CLONE_THREAD}, 88) = 0"), + { pid: 1201, text: psExec }, + { + pid: 1300, + text: [ + nodeExec, + "clone3({flags=CLONE_VM|CLONE_THREAD}, 88) = 0", + 'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3', + ].join("\n"), + }, + ]), + ).toThrow("non-inventory lookup"); }); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index e620fb13c8..3e452f8679 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -917,9 +917,21 @@ it("retains typed memory failure through failed cleanup and restores it after re "heap-failure", () => fails, ); + const originalRootClose = PrivateRuntimeRoot.prototype.close; + const workspaceCloseCause = new Error("fixture workspace close failure"); + let workspaceCloseFails = true; + let workspaceCloseAttempts = 0; const rootClose = vi .spyOn(PrivateRuntimeRoot.prototype, "close") - .mockRejectedValueOnce(new Error("fixture workspace close failure")); + .mockImplementation(function (this: PrivateRuntimeRoot) { + // Independent native helpers own their own roots. Keep the acquired + // Android workspace's failure active until the test explicitly clears it. + if (this.path === launches[0]?.cwd) { + workspaceCloseAttempts += 1; + if (workspaceCloseFails) return Promise.reject(workspaceCloseCause); + } + return originalRootClose.call(this); + }); try { const result = await service.execute("inspect_android_package", { path: apk, @@ -935,11 +947,14 @@ it("retains typed memory failure through failed cleanup and restores it after re }); const workspace = launches[0]?.cwd; expect(failure.cleanupResources).toContain(workspace); + expect(workspaceCloseAttempts).toBe(0); fails = false; await expect(provider.close()).rejects.toMatchObject({ cleanupIncomplete: true, }); expect(failure.cleanupResources).toEqual([workspace]); + expect(workspaceCloseAttempts).toBeGreaterThan(0); + workspaceCloseFails = false; await provider.close(); expect(failure.cleanupIncomplete).toBe(false); expect(failure.cleanupResources).toEqual([]); @@ -955,6 +970,7 @@ it("retains typed memory failure through failed cleanup and restores it after re await verifyCleanup(launches); } finally { rootClose.mockRestore(); + workspaceCloseFails = false; fails = false; await provider.close(); } diff --git a/tests/boundary/process/processBaselineAbsence.test.ts b/tests/boundary/process/processBaselineAbsence.test.ts new file mode 100644 index 0000000000..31ffe38160 --- /dev/null +++ b/tests/boundary/process/processBaselineAbsence.test.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto"; + +import { expect, it } from "vitest"; + +import { verifyNoTokenOwnedProcesses } from "../../../src/process/ProcessOwnership.js"; +import { createSystemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; +import { + spawnProviderProcessFixture, + stopProviderProcessFixture, + waitForProviderProcessReady, +} from "../../fixtures/providerProcess.js"; + +it.skipIf(process.platform !== "linux")( + "accepts native absence after a baseline process exits between table and identity reads", + async () => { + const child = spawnProviderProcessFixture("graceful"); + const native = createSystemProcessOwnershipHost("linux", process.env); + try { + await waitForProviderProcessReady(child); + const baseline = await native.captureBaseline?.(); + expect(baseline).toContainEqual({ + pid: child.pid, + identity: expect.stringMatching(/^linux-starttime:\d+$/u), + }); + const rows = (await native.listProcesses()).filter( + ({ pid }) => pid === child.pid, + ); + expect(rows).toHaveLength(1); + + await expect( + verifyNoTokenOwnedProcesses( + randomUUID(), + { + ...native, + listProcesses: async () => { + // Keep one real candidate row, then force the enumeration race + // before the unmodified native identity/token observer runs. + await stopProviderProcessFixture(child); + return rows; + }, + }, + baseline, + ), + ).resolves.toEqual({ cleaned: true, signaled: false }); + } finally { + await stopProviderProcessFixture(child); + await native.close?.(); + } + }, +); diff --git a/tests/boundary/process/recordedCrashTracePolicy.test.ts b/tests/boundary/process/recordedCrashTracePolicy.test.ts new file mode 100644 index 0000000000..69db11fc48 --- /dev/null +++ b/tests/boundary/process/recordedCrashTracePolicy.test.ts @@ -0,0 +1,236 @@ +import { expect, it } from "vitest"; +import { assertHistoricalPidAccess } from "../../../scripts/verify/recorded-crash/trace-policy.mjs"; + +const stat = 'openat(AT_FDCWD, "/proc/900/stat", O_RDONLY|O_CLOEXEC) = 22'; + +const check = (pid: number, text: string, liveCollision = true) => + assertHistoricalPidAccess({ + trace: { pid, text }, + historicalPid: 900, + ownershipThreads: new Set([100, 101]), + liveCollision, + psInventory: false, + }); + +it("allows stat reads only in the supplied ownership thread set", () => { + expect(check(100, stat)).toEqual([stat]); + expect(check(101, stat)).toEqual([stat]); + 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, + ownershipThreads: 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) => { + expect(() => check(101, stat.replace("/stat", `/${name}`))).toThrow( + "non-inventory lookup", + ); + }, +); + +it("rejects an unlisted historical PID lookup even from an observed inventory thread", () => { + 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, + ownershipThreads: new Set(), + liveCollision: true, + psInventory: true, + }), + ).toThrow("non-inventory lookup"); +}); + +it.each([ + 'readlinkat(5, "900/exe", 0x1, 4096) = -1 EACCES (Permission denied)', + 'newfstatat(5, "900/mem", 0x1, 0) = -1 EACCES (Permission denied)', + 'statx(5, "900/mem", 0, STATX_ALL, 0x1) = -1 EACCES (Permission denied)', + 'openat(5, "../proc/900/mem", O_RDONLY) = -1 EACCES (Permission denied)', +])("checks failed relative pathname operations beyond openat: %s", (line) => { + for (const live of [true, false]) + expect(() => check(101, line, live)).toThrow("non-inventory lookup"); +}); + +it.each([ + "getpriority(PRIO_PROCESS, 900) = 20", + "setpriority(PRIO_PROCESS, 900, 0) = 0", + "getpriority(PRIO_PGRP, 900) = 20", + "setpriority(PRIO_PGRP, 900, 0) = 0", + "getpriority(0x3 /* unknown PRIO selector */, 900) = -1 EINVAL (Invalid argument)", +])("rejects historical PID priority access: %s", (line) => { + for (const live of [true, false]) + expect(() => check(101, line, live)).toThrow("non-inventory lookup"); +}); + +it("keeps native priority UID selectors and distinct PIDs separate", () => { + expect(check(101, "getpriority(PRIO_USER, 900) = 20")).toEqual([]); + expect(check(101, "getpriority(PRIO_PROCESS, 9000) = 20")).toEqual([]); +}); + +it.each([ + String.raw`openat(AT_FDCWD, "/fixture/\345\267\245\345\205\267", O_RDONLY) = -1 ENOENT (No such file or directory)`, + String.raw`openat(5, "\345\267\245\345\205\267", O_RDONLY) = -1 ENOENT (No such file or directory)`, +])("preserves unrelated C-quoted pathname components: %s", (line) => { + expect(check(101, line)).toEqual([]); +}); + +it.each([ + "getpgid(900) = 900", + "getsid(900) = 900", + "get_robust_list(900, [0x1], [24]) = 0", + "migrate_pages(900, 64, [1], [1]) = 0", + "move_pages(900, 0, NULL, NULL, NULL, 0) = 0", + "setpgid(0, 900) = 0", + "kcmp(901, 900, KCMP_FILE, 0, 0) = 0", + "wait4(900, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)", + "wait4(-900, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)", + "waitid(P_PID, 900, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)", + "waitid(P_PGID, 900, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)", + "waitid(P_PIDFD, 7, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)", + "capget({version=_LINUX_CAPABILITY_VERSION_3, pid=900}, NULL) = 0", + "capset({version=_LINUX_CAPABILITY_VERSION_3, pid=900}, NULL) = 0", + "process_madvise(7, [], 0, MADV_NORMAL, 0) = -1 EPERM (Operation not permitted)", + "process_mrelease(7, 0) = -1 EPERM (Operation not permitted)", +])( + "rejects resolved PID resource, scheduler and metadata targets: %s", + (line) => { + for (const live of [true, false]) + expect(() => check(101, line, live)).toThrow("non-inventory lookup"); + }, +); + +it.each([ + "prlimit64(0, RLIMIT_NOFILE, NULL, {rlim_cur=1024, rlim_max=4096}) = 0", + "sched_getaffinity(0, 8, [0]) = 8", + "sched_setscheduler(901, 900, {sched_priority=0}) = -1 EINVAL (Invalid argument)", + "sched_get_priority_max(900) = -1 EINVAL (Invalid argument)", + "getpgid(901) = 900", + "wait4(-1, NULL, WNOHANG, NULL) = 0", + "waitid(P_ALL, 900, NULL, WNOHANG, NULL) = 0", + "capget({version=_LINUX_CAPABILITY_VERSION_3, pid=0}, NULL) = 0", + "kcmp(901, 902, KCMP_FILE, 900, 0) = 0", +])( + "keeps self, UID, policy and output values distinct from PID targets: %s", + (line) => { + expect(check(101, line)).toEqual([]); + }, +); + +it.each([ + "sched_getaffinity(unknown, 8, [0]) = -1 EFAULT (Bad address)", + "capget(NULL, NULL) = -1 EFAULT (Bad address)", + "waitid(P_PIDFD, 7, NULL, WNOHANG, NULL) = -1 EBADF (Bad file descriptor)", + "ioprio_get(0x4 /* unknown selector */, 900) = -1 EINVAL (Invalid argument)", +])("retains unresolved target facts: %s", (line) => { + for (const live of [true, false]) + expect(() => check(101, line, live)).toThrow("Unresolved process target"); +}); + +it.each([ + "prlimit64(900, RLIMIT_NOFILE, NULL, {rlim_cur=1024, rlim_max=1024}) = 0", + "prlimit64(900, RLIMIT_NOFILE, {rlim_cur=1024, rlim_max=1024}, NULL) = 0", + "sched_setaffinity(900, 128, [0 1]) = 0", + "sched_getaffinity(900, 128, [0 1]) = 8", + "sched_setscheduler(900, SCHED_OTHER, {sched_priority=0}) = 0", + "sched_getscheduler(900) = SCHED_OTHER", + "sched_setparam(900, {sched_priority=0}) = 0", + "sched_getparam(900, {sched_priority=0}) = 0", + "sched_setattr(900, {size=56, sched_policy=SCHED_OTHER}, 0) = 0", + "sched_getattr(900, {size=56, sched_policy=SCHED_OTHER}, 56, 0) = 0", + "sched_rr_get_interval(900, {tv_sec=0, tv_nsec=0}) = 0", +])("rejects historical PID resource and scheduler access: %s", (line) => { + for (const live of [true, false]) { + expect(() => check(101, line, live)).toThrow("non-inventory lookup"); + expect(() => + check(101, line.replace(/ = .*/u, " = -1 ESRCH (No such process)"), live), + ).toThrow("non-inventory lookup"); + } + expect(check(101, line.replace("(900", "(9000"))).toEqual([]); + expect(check(101, line.replace("(900", "(0"))).toEqual([]); +}); + +it.each([ + "ioprio_get(IOPRIO_WHO_PROCESS, 900) = IOPRIO_PRIO_VALUE(IOPRIO_CLASS_NONE, 0)", + "ioprio_set(IOPRIO_WHO_PROCESS, 900, IOPRIO_PRIO_VALUE(IOPRIO_CLASS_NONE, 0)) = 0", + "ioprio_get(IOPRIO_WHO_PGRP, 900) = 0", + "ioprio_set(IOPRIO_WHO_PGRP, 900, 0) = -1 ESRCH (No such process)", + "ioprio_get(PRIO_USER, 900) = -1 EINVAL (Invalid argument)", + "getpriority(IOPRIO_WHO_USER, 900) = -1 EINVAL (Invalid argument)", +])("rejects historical PID I/O priority access: %s", (line) => { + for (const live of [true, false]) + expect(() => check(101, line, live)).toThrow("non-inventory lookup"); +}); + +it("keeps I/O priority UID selectors and distinct PID/self selectors separate", () => { + expect(check(101, "ioprio_get(IOPRIO_WHO_USER, 900) = 0")).toEqual([]); + expect(check(101, "ioprio_get(IOPRIO_WHO_PROCESS, 9000) = 0")).toEqual([]); + expect(check(101, "ioprio_get(IOPRIO_WHO_PROCESS, 0) = 0")).toEqual([]); +});