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

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