fix(process): settle native exit and repair ownership verification (#5249)

* fix(process): classify baseline cleanup with current ownership facts

Use the pre-launch baseline only to select candidates for current native
identity/token validation. Preserve confirmed absence and verified foreign
generations instead of carrying a premature baseline identity failure.

Exercise the real Linux enumeration/exit race and retain countercontrols for
unknown identities and live token-owned processes.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)

* fix(ci): distinguish process inventory from historical PID lookups

* test(ci): verify decoded historical PID controls before tracing

* fix(ci): trace historical PID handles and signal probes

* fix(ci): resolve traced directory paths across pathname syscalls

Classify producer-decoded FD/path pairs once instead of limiting failed relative
lookup detection to openat on /proc. This preserves attempted readlinkat, statx
and newfstatat accesses with no returned descriptor.

Three regression cases fail against the previous policy and all 33 controls
pass with the shared pathname interpretation.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)

* test(ci): declare unresolved legacy pathname coverage

Report the historical-PID access surfaces actually checked and explicitly mark
legacy cwd-relative pathname calls unverified. The existing tracer does not
provide their cwd identity, and a blanket rejection also rejects legitimate
GDB build-ID metadata queries.

Retain every committed access guard and document the preexisting limit without
claiming absence from missing path evidence. The complete real 44-case CLI/MCP
recorded-crash qualification passes with the coverage declaration.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)

* fix(ci): reject priority access to historical PIDs

* fix(ci): preserve C-quoted components in traced path checks

strace renders non-ASCII pathname bytes as C octal escapes, not JSON strings.
Keep those components opaque when resolving literal ASCII path separators and
proc PID selectors, matching the existing absolute-path interpretation.

Two controls fail before the correction and all 35 pass afterward. An actual
strace observation independently reproduces the rejection of an unrelated
Chinese filename and verifies the corrected classification.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)

* fix(ci): reject historical PID resource and scheduler calls

* fix(schema): preserve portable large base64 validation

* fix(ci): share explicit PID syscall contracts with tracing

Derive tracer selection and reported coverage from the same named target
contracts used by the access checker. Cover resource limits, scheduler calls,
process metadata, PID groups, capabilities and pidfd operations without mixing
UIDs, policy enums, self selectors or output values with target PIDs.

Interpret each target once and retain unresolved target facts as a distinct
failure. Emit the coverage catalog once per verifier result.

The previous oracle misses 28 new controls. All 83 controls pass after the
change; real native resource/scheduler probes capture 14 previously missed
calls with resource limits and affinity unchanged. All 35 selected syscall
names are accepted by the pinned local strace.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)

* fix(ci): classify I/O priority and qualify PID trace coverage

* fix(process): keep ownership retries on the native clock

* fix(ci): retain process edges in ownership trace inventory

Keep fork/vfork and resumed edges along with clone events so the shared attribution owner receives the complete process relation facts it consumes.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)

* fix(process): type native exit predicates and ownership fixtures precisely

* fix(ci): retain trace coverage limits after integration

Restore explicit unverified symlink aliases and same-Node stat/status purpose in the shared coverage contract and guide. The native-exit and trace-attribution integration does not establish those properties. Preserve both limitations rather than silently broadening the reported qualification.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-10-20 02:01:00 +08:00
1 parent 1b3ac94242
commit 1c2c2f1e78
23 files changed
+1357 -315

No files matched your search

+22 -1
View File
@@ -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.
+12 -11
View File
@@ -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))
+16
View File
@@ -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));
@@ -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();
}
@@ -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>;
}): string[];
}): {
readonly ownershipThreads: ReadonlySet<number>;
readonly psProcesses: ReadonlySet<number>;
readonly executions: readonly OwnershipTraceExecution[];
};
@@ -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 };
};
@@ -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<number>;
readonly liveCollision: boolean;
readonly psInventory: boolean;
}): readonly string[];
@@ -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+<pid:(\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;
}
+90 -58
View File
@@ -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,
});
+18
View File
@@ -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);
}
});
+37
View File
@@ -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,
});
},
);
+22 -2
View File
@@ -1,8 +1,10 @@
import type { ProcessLifecycle } from "./ProcessLifecycle.js";
/** Native process facts parsed from one Linux `/proc/<pid>/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,
+27
View File
@@ -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";
@@ -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();
});
@@ -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();
}
});
@@ -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", {});
+49 -47
View File
@@ -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,
+3 -6
View File
@@ -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;
+74 -33
View File
@@ -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<ReadonlyMap<number, ProcessIdentityObservation>> =>
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<number, ProcessIdentityObservation>,
signal?: AbortSignal,
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
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,
@@ -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 <unfinished ...>",
"<... 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 <unfinished ...>",
"<... 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");
});
+17 -1
View File
@@ -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();
}
@@ -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?.();
}
},
);
@@ -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</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,
ownershipThreads: 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) => {
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</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,
ownershipThreads: new Set(),
liveCollision: true,
psInventory: true,
}),
).toThrow("non-inventory lookup");
});
it.each([
'readlinkat(5</proc>, "900/exe", 0x1, 4096) = -1 EACCES (Permission denied)',
'newfstatat(5</proc>, "900/mem", 0x1, 0) = -1 EACCES (Permission denied)',
'statx(5</proc>, "900/mem", 0, STATX_ALL, 0x1) = -1 EACCES (Permission denied)',
'openat(5</tmp>, "../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>, "/fixture/\345\267\245\345\205\267", O_RDONLY) = -1 ENOENT (No such file or directory)`,
String.raw`openat(5</fixture>, "\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<pid:900>, 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<pid:900>, [], 0, MADV_NORMAL, 0) = -1 EPERM (Operation not permitted)",
"process_mrelease(7<pid:900>, 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([]);
});