diff --git a/src/process/LinuxProcessStat.test.ts b/src/process/LinuxProcessStat.test.ts index 8f8ff7d1af..bdf8e62509 100644 --- a/src/process/LinuxProcessStat.test.ts +++ b/src/process/LinuxProcessStat.test.ts @@ -37,6 +37,7 @@ it("parses command names containing parentheses and retains native relation fact parentPid: 17, processGroupId: 18, sessionId: 19, + kernelThread: false, }); }); @@ -69,6 +70,7 @@ it("retains identity when Linux teardown reports unavailable group and session I parentPid: 0, processGroupId: -1, sessionId: -1, + kernelThread: false, }); expect( parseLinuxProcessStat(statRecord({ processGroupId: "-2" })), @@ -122,6 +124,23 @@ it.each(["invalid", "-1", "4.1", "9007199254740992"])( parentPid: 17, processGroupId: 18, sessionId: 19, + kernelThread: undefined, }); }, ); + +it("distinguishes a kernel thread from a process sharing its flags", () => { + // Field 9 task_flags: PF_KTHREAD is 0x00200000; PF_EXITING stays orthogonal. + expect(parseLinuxProcessStat(statRecord({ flags: "2097408" }))).toMatchObject({ + kernelThread: true, + lifecycle: "non-zombie", + }); + expect(parseLinuxProcessStat(statRecord({ flags: "2097156" }))).toMatchObject({ + kernelThread: true, + lifecycle: "exiting", + }); + expect(parseLinuxProcessStat(statRecord({ flags: "4" }))).toMatchObject({ + kernelThread: false, + lifecycle: "exiting", + }); +}); diff --git a/src/process/LinuxProcessStat.ts b/src/process/LinuxProcessStat.ts index 88e66e0d14..e2c10c1abd 100644 --- a/src/process/LinuxProcessStat.ts +++ b/src/process/LinuxProcessStat.ts @@ -10,6 +10,12 @@ export interface LinuxProcessStat { readonly parentPid: number; readonly processGroupId: number; readonly sessionId: number; + /** + * CF_KTHREAD|PF_KTHREAD (Linux `fs/proc/array.c` task_flags). Kernel threads + * are never forked by a provider, never inherit its environment, and cannot + * be token-owned; undefined when field 9 could not be parsed. + */ + readonly kernelThread: boolean | undefined; } /** @@ -70,6 +76,9 @@ export const parseLinuxProcessStat = ( flagsText !== undefined && /^\d+$/u.test(flagsText) ? Number(flagsText) : Number.NaN; + const kernelThread = Number.isSafeInteger(flags) + ? (flags & 0x00200000) !== 0 + : undefined; const lifecycle: ProcessLifecycle | undefined = state === "Z" ? "zombie" @@ -89,5 +98,6 @@ export const parseLinuxProcessStat = ( parentPid, processGroupId, sessionId, + kernelThread, }; }; diff --git a/src/process/ProcessOwnership.kernelThread.test.ts b/src/process/ProcessOwnership.kernelThread.test.ts new file mode 100644 index 0000000000..daad5a7097 --- /dev/null +++ b/src/process/ProcessOwnership.kernelThread.test.ts @@ -0,0 +1,141 @@ +import { expect, it } from "vitest"; +import { + verifyNoTokenOwnedProcesses, + type ProcessIdentityObservation, + type ProcessOwnershipHost, + type ProcessRunTokenObservation, + type ProcessTableEntry, +} from "./ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; + +/** + * A kernel thread that appeared while the capture ran. The kernel reports it + * with ppid 2 and pgid 0; the capture never forked it and it never inherits + * the provider's environment. + */ +const kernelThread: ProcessTableEntry = { + pid: 200, + parentPid: 2, + processGroupId: 0, + state: "R", + command: "psimon", +}; + +/** + * Rows present while the capture runs (and so in its snapshot), but released + * before the final verification below. + */ +const captureRows: readonly ProcessTableEntry[] = [ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "capture-root", + }, + { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "capture-child", + }, +]; + +const makeHost = (options: { + readonly kernelThread: boolean; +}): ProcessOwnershipHost => ({ + platform: "linux", + // Cleanup already released the capture tree; only the kernel thread remains. + listProcesses: async () => [kernelThread], + environment: async () => ({}), + runTokens: async (entries) => + new Map( + entries.map(({ pid }) => [ + pid, + pid === kernelThread.pid + ? // The thread exited between the snapshot and its environment read. + { + state: "unavailable", + code: "ESRCH", + reason: "ESRCH: no such process", + } + : { state: "readable", runId: "sweep-run" }, + ]), + ), + processIdentities: async (entries) => + new Map( + entries.map((entry) => { + if (entry.pid !== kernelThread.pid) + return [ + entry.pid, + { + state: "absent" as const, + reason: "not-found-in-caller-pid-namespace" as const, + }, + ] as const; + return [ + entry.pid, + { + state: "readable" as const, + identity: `start-${String(entry.pid)}`, + lifecycle: { + state: "observed" as const, + lifecycle: "non-zombie" as const, + }, + relation: { + state: "observed" as const, + parentPid: kernelThread.parentPid, + processGroupId: kernelThread.processGroupId, + }, + ...(options.kernelThread ? { kernelThread: true as const } : {}), + }, + ] as const; + }), + ), + captureBaseline: async () => + captureRows.map(({ pid }) => ({ + pid, + identity: `start-${String(pid)}`, + })), + signalGroup: () => undefined, +}); + +const relation = { + leaderPid: 100, + processGroupId: 100, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), +}; + +it("exonerates an unrelated kernel thread whose token read raced its exit", async () => { + await expect( + verifyNoTokenOwnedProcesses( + "sweep-run", + makeHost({ kernelThread: true }), + undefined, + relation, + ), + ).resolves.toEqual({ + cleaned: true, + signaled: false, + unverified: [{ pid: kernelThread.pid, diagnostic: "ESRCH: no such process" }], + }); +}); + +it("keeps an unrelated user process with an unreadable token fail-closed", async () => { + // The same ESRCH diagnostic without the native PF_KTHREAD fact stays a + // cleanup failure: only the kernel-thread observation exonerates it. + await expect( + verifyNoTokenOwnedProcesses( + "sweep-run", + makeHost({ kernelThread: false }), + undefined, + relation, + ), + ).resolves.toMatchObject({ + cleaned: false, + reason: expect.stringContaining( + "process ownership token could not be read for 1 live process(es)", + ), + }); +}); diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index e11b32150e..2ea5ca4225 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -110,6 +110,12 @@ export type ProcessIdentityObservation = * truncated. It names a process in diagnostics; never identity or lookup. */ readonly name?: string; + /** + * Linux PF_KTHREAD from the same `/proc//stat` record. A kernel + * thread is never forked by a provider and cannot carry its run token; + * optional because only a Linux stat read can establish it. + */ + readonly kernelThread?: true; } | { readonly state: "absent"; @@ -1911,13 +1917,19 @@ const scanWithOwnershipEvidence = async ( fact.identityAfter.state === "readable" ? fact.identityAfter : undefined; + // An unrelated kernel thread is forked by the kernel, never by a provider, + // so it cannot carry this run's token and cleanup must not act on it. Its + // token read typically fails while the thread exits (ESRCH) rather than + // because the environment was withheld, which is why environment + // withholding alone cannot exonerate it. if ( knownUnrelated && - environmentWithheldFromInspection( - fact.token.reason, - observed?.effectiveUid, - observed?.environmentRestriction, - ) + (observed?.kernelThread === true || + environmentWithheldFromInspection( + fact.token.reason, + observed?.effectiveUid, + observed?.environmentRestriction, + )) ) { unverified.push({ pid: fact.process.pid, diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 0123c1960c..2b5f09c276 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -1304,6 +1304,9 @@ export const createSystemProcessOwnershipHost = ( state: "readable" as const, identity: `linux-starttime:${facts.startTime}`, ...(facts.name === "" ? {} : { name: facts.name }), + ...(facts.kernelThread === true + ? { kernelThread: true as const } + : {}), ...(effectiveUid === undefined ? {} : { effectiveUid }), ...(environmentRestriction === undefined ? {}