* fix(process): classify baseline cleanup with current ownership facts Use the pre-launch baseline only to select candidates for current native identity/token validation. Preserve confirmed absence and verified foreign generations instead of carrying a premature baseline identity failure. Exercise the real Linux enumeration/exit race and retain countercontrols for unknown identities and live token-owned processes. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * fix(ci): distinguish process inventory from historical PID lookups * test(ci): verify decoded historical PID controls before tracing * fix(ci): trace historical PID handles and signal probes * fix(ci): resolve traced directory paths across pathname syscalls Classify producer-decoded FD/path pairs once instead of limiting failed relative lookup detection to openat on /proc. This preserves attempted readlinkat, statx and newfstatat accesses with no returned descriptor. Three regression cases fail against the previous policy and all 33 controls pass with the shared pathname interpretation. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * test(ci): declare unresolved legacy pathname coverage Report the historical-PID access surfaces actually checked and explicitly mark legacy cwd-relative pathname calls unverified. The existing tracer does not provide their cwd identity, and a blanket rejection also rejects legitimate GDB build-ID metadata queries. Retain every committed access guard and document the preexisting limit without claiming absence from missing path evidence. The complete real 44-case CLI/MCP recorded-crash qualification passes with the coverage declaration. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * fix(ci): reject priority access to historical PIDs * fix(ci): preserve C-quoted components in traced path checks strace renders non-ASCII pathname bytes as C octal escapes, not JSON strings. Keep those components opaque when resolving literal ASCII path separators and proc PID selectors, matching the existing absolute-path interpretation. Two controls fail before the correction and all 35 pass afterward. An actual strace observation independently reproduces the rejection of an unrelated Chinese filename and verifies the corrected classification. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * fix(ci): reject historical PID resource and scheduler calls * fix(schema): preserve portable large base64 validation * fix(ci): share explicit PID syscall contracts with tracing Derive tracer selection and reported coverage from the same named target contracts used by the access checker. Cover resource limits, scheduler calls, process metadata, PID groups, capabilities and pidfd operations without mixing UIDs, policy enums, self selectors or output values with target PIDs. Interpret each target once and retain unresolved target facts as a distinct failure. Emit the coverage catalog once per verifier result. The previous oracle misses 28 new controls. All 83 controls pass after the change; real native resource/scheduler probes capture 14 previously missed calls with resource limits and affinity unchanged. All 35 selected syscall names are accepted by the pinned local strace. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * fix(ci): classify I/O priority and qualify PID trace coverage * fix(process): keep ownership retries on the native clock * fix(ci): retain process edges in ownership trace inventory Keep fork/vfork and resumed edges along with clone events so the shared attribution owner receives the complete process relation facts it consumes. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * fix(process): type native exit predicates and ownership fixtures precisely * fix(ci): retain trace coverage limits after integration Restore explicit unverified symlink aliases and same-Node stat/status purpose in the shared coverage contract and guide. The native-exit and trace-attribution integration does not establish those properties. Preserve both limitations rather than silently broadening the reported qualification. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
203 lines
6.5 KiB
JavaScript
203 lines
6.5 KiB
JavaScript
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;
|
|
}
|