Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5610fe5f | ||
|
|
33450d02ea | ||
|
|
3de261cc5e | ||
|
|
2026d0099b | ||
|
|
0ac49fbb1f | ||
|
|
6892c8bc52 | ||
|
|
8ea965261e | ||
|
|
54e971a148 | ||
|
|
2a42096e15 | ||
|
|
40ab4048bf | ||
|
|
2761410d44 | ||
|
|
6768d4ab52 | ||
|
|
818a9c6dfe | ||
|
|
1c73ea7e4e | ||
|
|
1130d1b222 | ||
|
|
128610df12 | ||
|
|
99cec9a56e | ||
|
|
73c88afb87 | ||
|
|
9c49e7a24c | ||
|
|
26b870b1b2 | ||
|
|
8dd8ca0804 | ||
|
|
1e84699018 | ||
|
|
5c656717ef | ||
|
|
804caa2cb2 | ||
|
|
5d2895d2b2 | ||
|
|
d34c8514a1 | ||
|
|
b82ae1962f | ||
|
|
b32ce58c3a | ||
|
|
5f94077b3c |
No files matched your search
@@ -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.
|
||||
@@ -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))
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
|
||||
/** One operation owns a lazily started native-exit waiting window. */
|
||||
export interface ProcessExitSettlementBudget {
|
||||
deadline: number | undefined;
|
||||
}
|
||||
|
||||
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,126 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
verifyNoTokenOwnedProcesses,
|
||||
type ProcessIdentityObservation,
|
||||
type ProcessOwnershipHost,
|
||||
type ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
|
||||
|
||||
const row = (pid: number, parentPid: number): ProcessTableEntry => ({
|
||||
pid,
|
||||
parentPid,
|
||||
processGroupId: pid,
|
||||
state: "R",
|
||||
command: "/fixture/node",
|
||||
});
|
||||
const identity = (
|
||||
process: ProcessTableEntry,
|
||||
exiting: boolean,
|
||||
): ProcessIdentityObservation => ({
|
||||
state: "readable",
|
||||
identity: `start:${process.pid}`,
|
||||
lifecycle: {
|
||||
state: "observed",
|
||||
lifecycle: exiting ? "exiting" : "non-zombie",
|
||||
},
|
||||
relation: {
|
||||
state: "observed",
|
||||
parentPid: process.parentPid,
|
||||
processGroupId: process.processGroupId,
|
||||
},
|
||||
});
|
||||
|
||||
it("retains a live token-cleared child when its verified parent exits during observation", async () => {
|
||||
const parent = row(200, 1);
|
||||
const child = row(300, parent.pid);
|
||||
const owner = new ProcessOwnershipEvidenceOwner();
|
||||
let parentReads = 0;
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: async () => [parent, child],
|
||||
environment: async (pid) =>
|
||||
pid === parent.pid ? { REA_PROCESS_RUN_ID: "run" } : {},
|
||||
processIdentities: async (rows) =>
|
||||
new Map<number, ProcessIdentityObservation>(
|
||||
rows.map((process) => [
|
||||
process.pid,
|
||||
process.pid === parent.pid && ++parentReads > 2
|
||||
? { state: "absent" as const, reason: "not-in-snapshot" }
|
||||
: identity(
|
||||
process,
|
||||
process.pid === parent.pid && parentReads === 2,
|
||||
),
|
||||
]),
|
||||
),
|
||||
signalGroup: vi.fn(),
|
||||
};
|
||||
const result = await verifyNoTokenOwnedProcesses("run", host, undefined, {
|
||||
leaderPid: 100,
|
||||
processGroupId: 100,
|
||||
evidenceOwner: owner,
|
||||
});
|
||||
// The fixture's child never exited or changed generation. Its previously
|
||||
// observed parent edge must survive the parent's later terminal observation.
|
||||
expect(result.cleaned).toBe(false);
|
||||
expect(owner.candidates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
pid: child.pid,
|
||||
identity: "start:300",
|
||||
applicability: "related-ownership-unresolved",
|
||||
}),
|
||||
);
|
||||
expect(host.signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shares native exit waiting before signaling healthy groups in one cleanup attempt", async () => {
|
||||
const root = row(100, 1);
|
||||
const rows = [
|
||||
root,
|
||||
row(200, root.pid),
|
||||
row(300, root.pid),
|
||||
row(400, root.pid),
|
||||
];
|
||||
let clock = 0;
|
||||
let firstSignalAt: number | undefined;
|
||||
const nativeClock = vi
|
||||
.spyOn(performance, "now")
|
||||
.mockImplementation(() => clock);
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: async () => rows,
|
||||
environment: async () => ({ REA_PROCESS_RUN_ID: "run" }),
|
||||
processIdentities: async (selected) => {
|
||||
clock += 25;
|
||||
return new Map<number, ProcessIdentityObservation>(
|
||||
selected.map((process) => [
|
||||
process.pid,
|
||||
identity(process, process.pid !== root.pid),
|
||||
]),
|
||||
);
|
||||
},
|
||||
signalGroup: vi.fn((group) => {
|
||||
expect(group).toBe(root.pid);
|
||||
firstSignalAt ??= clock;
|
||||
}),
|
||||
};
|
||||
try {
|
||||
const result = await cleanupOwnedProcessGroup(
|
||||
{
|
||||
runId: "run",
|
||||
leaderPid: root.pid,
|
||||
processGroupId: root.pid,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
|
||||
},
|
||||
host,
|
||||
);
|
||||
expect(result.cleaned).toBe(false);
|
||||
expect(firstSignalAt).toBeDefined();
|
||||
// Controlled native-observation time, not a wall-clock timeout: three
|
||||
// stuck helpers must not each add another full second before root signal.
|
||||
expect(firstSignalAt).toBeLessThan(2_000);
|
||||
} finally {
|
||||
nativeClock.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -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", {});
|
||||
|
||||
@@ -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,16 @@ 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,
|
||||
type ProcessExitSettlementBudget,
|
||||
} 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 +106,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 =
|
||||
@@ -117,7 +118,7 @@ export type ProcessNativeRelationObservation =
|
||||
| { readonly state: "unavailable"; readonly reason: string };
|
||||
|
||||
/** One caller-supplied process row validated across identity/token/identity reads. */
|
||||
export interface ProcessOwnershipFact {
|
||||
interface ProcessOwnershipFactSnapshot {
|
||||
/** Caller-supplied candidate row; its parent/group may predate this read. */
|
||||
readonly process: ProcessTableEntry;
|
||||
/** Identity relation only; token ownership still requires matching readable runId. */
|
||||
@@ -129,6 +130,11 @@ export interface ProcessOwnershipFact {
|
||||
readonly identityAfter: ProcessIdentityObservation;
|
||||
}
|
||||
|
||||
export interface ProcessOwnershipFact extends ProcessOwnershipFactSnapshot {
|
||||
/** Bracketed ownership proof before a later native exit observation. */
|
||||
readonly beforeExitSettlement?: ProcessOwnershipFactSnapshot;
|
||||
}
|
||||
|
||||
/** Token-verified process lineage retained for one owned provider run. */
|
||||
export interface OwnedProcessLineage {
|
||||
readonly runId: string;
|
||||
@@ -327,6 +333,7 @@ export const cleanupOwnedProcessGroup = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
): Promise<ProcessCleanupResult> => {
|
||||
const exitSettlement: ProcessExitSettlementBudget = { deadline: undefined };
|
||||
const processTable = await readLiveProcessTable(host);
|
||||
if (!processTable.available)
|
||||
return {
|
||||
@@ -337,6 +344,7 @@ export const cleanupOwnedProcessGroup = async (
|
||||
ownership,
|
||||
processTable.processes,
|
||||
host,
|
||||
exitSettlement,
|
||||
);
|
||||
if ("cleaned" in plan) return plan;
|
||||
let signaled = false;
|
||||
@@ -346,6 +354,7 @@ export const cleanupOwnedProcessGroup = async (
|
||||
ownership,
|
||||
host,
|
||||
plan.tokenOwnedIdentities,
|
||||
exitSettlement,
|
||||
);
|
||||
if ("cleaned" in revalidation)
|
||||
return mergeUnverifiedObservations(revalidation, plan.unverified);
|
||||
@@ -386,6 +395,7 @@ export const cleanupOwnedProcessGroup = async (
|
||||
host,
|
||||
ownership.captureBaseline,
|
||||
relation,
|
||||
exitSettlement,
|
||||
);
|
||||
while (!verification.cleaned) {
|
||||
const remaining = deadline - performance.now();
|
||||
@@ -398,6 +408,7 @@ export const cleanupOwnedProcessGroup = async (
|
||||
host,
|
||||
ownership.captureBaseline,
|
||||
relation,
|
||||
exitSettlement,
|
||||
);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
@@ -429,6 +440,7 @@ export const verifyNoTokenOwnedProcesses = async (
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
captureBaseline?: ProcessOwnershipBaseline,
|
||||
relation?: OwnershipSweepRelation,
|
||||
exitSettlement: ProcessExitSettlementBudget = { deadline: undefined },
|
||||
): Promise<ProcessCleanupResult> => {
|
||||
const processTable = await readLiveProcessTable(host);
|
||||
if (!processTable.available)
|
||||
@@ -442,6 +454,7 @@ export const verifyNoTokenOwnedProcesses = async (
|
||||
host,
|
||||
captureBaseline,
|
||||
relation,
|
||||
exitSettlement,
|
||||
);
|
||||
if (scan.failures.length > 0)
|
||||
return cleanupValidationFailure(scan.failures, scan.unverified);
|
||||
@@ -526,12 +539,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,12 +562,13 @@ 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[],
|
||||
host: ProcessOwnershipHost,
|
||||
exitSettlement: ProcessExitSettlementBudget,
|
||||
): Promise<
|
||||
| readonly ProcessTableEntry[]
|
||||
| Extract<ProcessCleanupResult, { readonly cleaned: false }>
|
||||
@@ -568,15 +577,34 @@ 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,
|
||||
undefined,
|
||||
exitSettlement,
|
||||
);
|
||||
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" ||
|
||||
@@ -635,8 +663,14 @@ const createOwnedCleanupPlan = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
initialProcesses: readonly ProcessTableEntry[],
|
||||
host: ProcessOwnershipHost,
|
||||
exitSettlement: ProcessExitSettlementBudget,
|
||||
): Promise<OwnedCleanupPlan | ProcessCleanupResult> => {
|
||||
const processes = await settleLauncherExit(ownership, initialProcesses, host);
|
||||
const processes = await settleLauncherExit(
|
||||
ownership,
|
||||
initialProcesses,
|
||||
host,
|
||||
exitSettlement,
|
||||
);
|
||||
if ("cleaned" in processes) return processes;
|
||||
const relationSnapshot = await readProcessRelationSnapshot(
|
||||
ownership,
|
||||
@@ -664,6 +698,7 @@ const createOwnedCleanupPlan = async (
|
||||
? {}
|
||||
: { sampledProcessGroupIds: [...sampledProcessGroupIds] }),
|
||||
},
|
||||
exitSettlement,
|
||||
)
|
||||
: { owned: [], failures: [], unverified: [], identities: new Map() };
|
||||
const tokenOwnedGroupIds = new Set(
|
||||
@@ -752,6 +787,7 @@ const createOwnedCleanupPlan = async (
|
||||
ownership.runId,
|
||||
host,
|
||||
tokenOwned.identities,
|
||||
exitSettlement,
|
||||
);
|
||||
if (failures.length === 0 && unverifiableSampledGroups.has(processGroupId))
|
||||
unresolved.push(
|
||||
@@ -827,6 +863,7 @@ const revalidateOwnedProcessGroup = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
host: ProcessOwnershipHost,
|
||||
expectedIdentities: ReadonlyMap<number, string | null>,
|
||||
exitSettlement: ProcessExitSettlementBudget,
|
||||
): Promise<{ readonly empty: boolean } | ProcessCleanupResult> => {
|
||||
let members: readonly ProcessTableEntry[];
|
||||
try {
|
||||
@@ -834,6 +871,7 @@ const revalidateOwnedProcessGroup = async (
|
||||
ownership,
|
||||
liveProcesses(await host.listProcesses()),
|
||||
host,
|
||||
exitSettlement,
|
||||
);
|
||||
if ("cleaned" in processes) return processes;
|
||||
const relationSnapshot = await readProcessRelationSnapshot(
|
||||
@@ -858,6 +896,7 @@ const revalidateOwnedProcessGroup = async (
|
||||
ownership.runId,
|
||||
host,
|
||||
expectedIdentities,
|
||||
exitSettlement,
|
||||
);
|
||||
if (failures.length > 0) return cleanupValidationFailure(failures);
|
||||
if (
|
||||
@@ -965,25 +1004,29 @@ 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,
|
||||
host: ProcessOwnershipHost,
|
||||
expectedIdentities: ReadonlyMap<number, string | null> = new Map(),
|
||||
exitSettlement: ProcessExitSettlementBudget = { deadline: undefined },
|
||||
): Promise<readonly ProcessOwnershipValidationFailure[]> => {
|
||||
if (host.processIdentities !== undefined) {
|
||||
const facts = await readProcessOwnershipFacts(members, host);
|
||||
const facts = await readProcessOwnershipFacts(members, host, undefined, {
|
||||
exitSettlement,
|
||||
});
|
||||
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"
|
||||
@@ -1160,6 +1203,7 @@ const scanTokenOwnedProcesses = async (
|
||||
host: ProcessOwnershipHost,
|
||||
captureBaseline?: ProcessOwnershipBaseline,
|
||||
relation?: OwnershipSweepRelation,
|
||||
exitSettlement: ProcessExitSettlementBudget = { deadline: undefined },
|
||||
): Promise<TokenOwnedProcessScan> => {
|
||||
if (host.processIdentities !== undefined)
|
||||
return scanWithOwnershipEvidence(
|
||||
@@ -1168,6 +1212,7 @@ const scanTokenOwnedProcesses = async (
|
||||
host,
|
||||
captureBaseline,
|
||||
relation,
|
||||
exitSettlement,
|
||||
);
|
||||
|
||||
const candidates = processes;
|
||||
@@ -1260,6 +1305,7 @@ const scanWithOwnershipEvidence = async (
|
||||
host: ProcessOwnershipHost,
|
||||
captureBaseline: ProcessOwnershipBaseline | undefined,
|
||||
relation: OwnershipSweepRelation | undefined,
|
||||
exitSettlement: ProcessExitSettlementBudget,
|
||||
): Promise<TokenOwnedProcessScan> => {
|
||||
const evidenceOwner =
|
||||
relation === undefined
|
||||
@@ -1289,24 +1335,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;
|
||||
@@ -1320,6 +1356,7 @@ const scanWithOwnershipEvidence = async (
|
||||
undefined,
|
||||
{
|
||||
identityBefore: firstIdentities,
|
||||
exitSettlement,
|
||||
},
|
||||
);
|
||||
let facts = initialFacts;
|
||||
@@ -1328,12 +1365,18 @@ const scanWithOwnershipEvidence = async (
|
||||
.filter(
|
||||
({ token, identityAfter }) =>
|
||||
token.state === "unavailable" &&
|
||||
!processIdentityHasExited(identityAfter),
|
||||
!processIdentityHasExited(identityAfter) &&
|
||||
!processIdentityIsExiting(identityAfter),
|
||||
)
|
||||
.map(({ process }) => process);
|
||||
if (retryRows.length === 0) break;
|
||||
await delay(TOKEN_READ_RETRY_DELAY_MS);
|
||||
const retries = await readProcessOwnershipFacts(retryRows, host);
|
||||
const retries = await readProcessOwnershipFacts(
|
||||
retryRows,
|
||||
host,
|
||||
undefined,
|
||||
{ exitSettlement },
|
||||
);
|
||||
const retryByPid = new Map(retries.map((fact) => [fact.process.pid, fact]));
|
||||
facts = facts.map((fact) => retryByPid.get(fact.process.pid) ?? fact);
|
||||
}
|
||||
@@ -1365,8 +1408,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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { processIdentityHasExited } from "./ProcessLifecycle.js";
|
||||
import type {
|
||||
ProcessIdentityObservation,
|
||||
ProcessNativeRelationObservation,
|
||||
@@ -82,6 +83,14 @@ export class ProcessOwnershipEvidenceOwner {
|
||||
readonly scopeFacts?: readonly ProcessOwnershipFact[];
|
||||
},
|
||||
): readonly RetainedProcessOwnershipCandidate[] {
|
||||
// Acquire parent/child scope while its bracketed token proof still exists.
|
||||
// A later terminal observation may retire that parent, never its child's
|
||||
// already observed relationship to the run.
|
||||
if (facts.some((fact) => fact.beforeExitSettlement !== undefined))
|
||||
this.reconcile(
|
||||
facts.map((fact) => fact.beforeExitSettlement ?? fact),
|
||||
options,
|
||||
);
|
||||
this.#snapshotCount += 1;
|
||||
const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact]));
|
||||
const sameGeneration = new Map<
|
||||
@@ -208,9 +217,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 +281,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;
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
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,
|
||||
type ProcessExitSettlementBudget,
|
||||
} from "./ProcessLifecycle.js";
|
||||
import { parseLinuxProcessStat } from "./LinuxProcessStat.js";
|
||||
import { parseLinuxProcessEffectiveUid } from "./LinuxProcessStatus.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
@@ -63,11 +72,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 +121,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 +301,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 +315,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 +363,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 +530,57 @@ 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,
|
||||
budget: ProcessExitSettlementBudget = { deadline: undefined },
|
||||
): 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 = (budget.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
|
||||
@@ -553,6 +592,7 @@ export const readProcessOwnershipFacts = async (
|
||||
signal?: AbortSignal,
|
||||
options: {
|
||||
readonly identityBefore?: ReadonlyMap<number, ProcessIdentityObservation>;
|
||||
readonly exitSettlement?: ProcessExitSettlementBudget;
|
||||
} = {},
|
||||
): Promise<readonly ProcessOwnershipFact[]> => {
|
||||
signal?.throwIfAborted();
|
||||
@@ -591,20 +631,30 @@ export const readProcessOwnershipFacts = async (
|
||||
.filter(({ pid }) => tokens.get(pid)?.state !== "readable")
|
||||
.map(({ pid }) => pid),
|
||||
);
|
||||
const identityAfter = await readIdentityFacts(
|
||||
const observedAfter = await readIdentityFacts(
|
||||
processes,
|
||||
host,
|
||||
"after",
|
||||
signal,
|
||||
effectiveUidPids,
|
||||
);
|
||||
const identityAfter = await settleExitingProcessIdentities(
|
||||
processes,
|
||||
host,
|
||||
observedAfter,
|
||||
signal,
|
||||
options.exitSettlement,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const facts = processes.map((process): ProcessOwnershipFact => {
|
||||
const deriveFact = (
|
||||
process: ProcessTableEntry,
|
||||
observation: ProcessIdentityObservation | undefined,
|
||||
): ProcessOwnershipFact => {
|
||||
const before = identityBefore.get(process.pid) ?? {
|
||||
state: "unavailable" as const,
|
||||
reason: "process identity was not returned during before observation",
|
||||
};
|
||||
const after = identityAfter.get(process.pid) ?? {
|
||||
const after = observation ?? {
|
||||
state: "unavailable" as const,
|
||||
reason: "process identity was not returned during after observation",
|
||||
};
|
||||
@@ -629,6 +679,14 @@ export const readProcessOwnershipFacts = async (
|
||||
token,
|
||||
identityAfter: after,
|
||||
};
|
||||
};
|
||||
const facts = processes.map((process): ProcessOwnershipFact => {
|
||||
const after = identityAfter.get(process.pid);
|
||||
const original = observedAfter.get(process.pid);
|
||||
const fact = deriveFact(process, after);
|
||||
return after === original
|
||||
? fact
|
||||
: { ...fact, beforeExitSettlement: deriveFact(process, original) };
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
return facts;
|
||||
@@ -741,10 +799,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");
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
Reference in new issue
Block a user