From 26356dc34a74e38b68eb2acb4befb126176ed978 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 21:18:47 -0700 Subject: [PATCH] fix(process): record withheld unrelated Linux processes as unverified (#6220) * fix(process): record withheld unrelated Linux processes as unverified The token sweep after an owned command (for example the Ghidra Java probe run by setup and doctor) failed cleanup when an unrelated same-UID process started during the sweep and Linux denied its /proc//environ. Setgid or setuid programs (mismatched real/saved IDs) and non-dumpable processes are withheld by the kernel exactly like another account's processes, which were already recorded as unverified. Derive the kernel restriction from the same stat-bracketed procfs read that already supplies the effective UID: all Uid/Gid IDs from status, and the root ownership procfs reports for a non-dumpable process's environ. Keep that fact through exit settlement. Related candidates still fail closed. Fixes #6204 Co-Authored-By: Claude Opus 5.5 * test(process): define caller credentials on hosts without geteuid Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/process/LinuxProcessStatus.test.ts | 69 +++++++-- src/process/LinuxProcessStatus.ts | 67 +++++++-- src/process/ProcessOwnership.identity.test.ts | 70 ++++++++++ .../ProcessOwnership.procIdentity.test.ts | 132 +++++++++++++++++- src/process/ProcessOwnership.ts | 29 ++-- src/process/ProcessOwnershipObservation.ts | 61 ++++++-- 6 files changed, 381 insertions(+), 47 deletions(-) diff --git a/src/process/LinuxProcessStatus.test.ts b/src/process/LinuxProcessStatus.test.ts index 46f7688447..5854e68225 100644 --- a/src/process/LinuxProcessStatus.test.ts +++ b/src/process/LinuxProcessStatus.test.ts @@ -1,13 +1,15 @@ import { expect, it } from "vitest"; -import { parseLinuxProcessEffectiveUid } from "./LinuxProcessStatus.js"; +import { + linuxEnvironmentRestriction, + parseLinuxProcessIds, +} from "./LinuxProcessStatus.js"; -it("parses the effective UID from the single native status Uid row", () => { - expect( - parseLinuxProcessEffectiveUid( - "Name:\tworker\nUid:\t1000\t1001\t1002\t1003\nGid:\t20\t20\t20\t20\n", - ), - ).toBe(1001); +it("parses the single native status Uid and Gid rows", () => { + const status = + "Name:\tworker\nUid:\t1000\t1001\t1002\t1003\nGid:\t20\t21\t22\t23\n"; + expect(parseLinuxProcessIds(status, "Uid")).toEqual([1000, 1001, 1002, 1003]); + expect(parseLinuxProcessIds(status, "Gid")).toEqual([20, 21, 22, 23]); }); it.each([ @@ -16,6 +18,55 @@ it.each([ "Uid:\t1000\t1001\t1002\t1003\nUid:\t1000\t1001\t1002\t1003\n", "Uid:\t1000\t-1\t1002\t1003\n", "Uid:\t1000\t9007199254740992\t1002\t1003\n", -])("does not infer an effective UID from malformed status: %s", (status) => { - expect(parseLinuxProcessEffectiveUid(status)).toBeUndefined(); + "Uid:\t1000\t1001\t1002\t1003 extra\n", +])("does not infer IDs from malformed status: %s", (status) => { + expect(parseLinuxProcessIds(status, "Uid")).toBeUndefined(); +}); + +const caller = { callerUid: 1000, callerGid: 1000 } as const; +const matching = [1000, 1000, 1000, 1000] as const; + +it.each([ + [ + "a setgid program", + { uids: matching, gids: [1000, 2, 2, 2], procfsOwnerUid: 1000 }, + "credentials", + ], + [ + "a saved UID from another account", + { uids: [1000, 1000, 0, 1000], gids: matching, procfsOwnerUid: 1000 }, + "credentials", + ], + [ + "a non-dumpable process", + { uids: matching, gids: matching, procfsOwnerUid: 0 }, + "non-dumpable", + ], + [ + "an inspectable process", + { uids: matching, gids: matching, procfsOwnerUid: 1000 }, + undefined, + ], + [ + "a process with unknown procfs ownership and GIDs", + { uids: matching, gids: undefined, procfsOwnerUid: undefined }, + undefined, + ], +] as const)( + "derives the kernel environment restriction for %s", + (_name, facts, expected) => { + expect(linuxEnvironmentRestriction({ ...caller, ...facts })).toBe(expected); + }, +); + +it("does not derive a credential restriction without caller credentials", () => { + expect( + linuxEnvironmentRestriction({ + uids: [0, 0, 0, 0], + gids: [0, 0, 0, 0], + procfsOwnerUid: 0, + callerUid: undefined, + callerGid: undefined, + }), + ).toBeUndefined(); }); diff --git a/src/process/LinuxProcessStatus.ts b/src/process/LinuxProcessStatus.ts index 60d8f2809e..33d52dbceb 100644 --- a/src/process/LinuxProcessStatus.ts +++ b/src/process/LinuxProcessStatus.ts @@ -1,19 +1,60 @@ -/** Effective UID parsed from one Linux `/proc//status` record. */ -export const parseLinuxProcessEffectiveUid = ( +/** Real, effective, saved and filesystem IDs from one Linux status row. */ +export type LinuxProcessIds = readonly [number, number, number, number]; + +/** Parse the single `Uid:` or `Gid:` row of one Linux `/proc//status` record. */ +export const parseLinuxProcessIds = ( value: string, -): number | undefined => { - let effectiveUid: number | undefined; + row: "Uid" | "Gid", +): LinuxProcessIds | undefined => { + let ids: LinuxProcessIds | undefined; for (const line of value.split("\n")) { - if (!line.startsWith("Uid:")) continue; - if (effectiveUid !== undefined) return undefined; - const match = /^Uid:\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*$/u.exec( - line, - ); + if (!line.startsWith(`${row}:`)) continue; + if (ids !== undefined) return undefined; + const match = + /^[UG]id:\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*$/u.exec(line); if (match === null) return undefined; - const values = match.slice(1).map(Number); - if (values.some((uid) => !Number.isSafeInteger(uid) || uid < 0)) + const [real, effective, saved, filesystem] = match.slice(1).map(Number); + if ( + real === undefined || + effective === undefined || + saved === undefined || + filesystem === undefined || + [real, effective, saved, filesystem].some( + (id) => !Number.isSafeInteger(id) || id < 0, + ) + ) return undefined; - effectiveUid = values[1]; + ids = [real, effective, saved, filesystem]; } - return effectiveUid; + return ids; +}; + +/** + * Why Linux withholds `/proc//environ` from a caller without + * CAP_SYS_PTRACE (PTRACE_MODE_READ_FSCREDS): the target's real, effective or + * saved UID/GID differs from the caller's, or the target is non-dumpable, which + * procfs reports as root ownership of `/proc//environ`. + */ +export type LinuxEnvironmentRestriction = "credentials" | "non-dumpable"; + +/** Derive the kernel's environment restriction from one generation's procfs facts. */ +export const linuxEnvironmentRestriction = (facts: { + readonly uids: LinuxProcessIds; + readonly gids: LinuxProcessIds | undefined; + readonly procfsOwnerUid: number | undefined; + readonly callerUid: number | undefined; + readonly callerGid: number | undefined; +}): LinuxEnvironmentRestriction | undefined => { + const { uids, gids, procfsOwnerUid, callerUid, callerGid } = facts; + if ( + (callerUid !== undefined && + uids.slice(0, 3).some((uid) => uid !== callerUid)) || + (callerGid !== undefined && + gids !== undefined && + gids.slice(0, 3).some((gid) => gid !== callerGid)) + ) + return "credentials"; + return procfsOwnerUid !== undefined && procfsOwnerUid !== uids[1] + ? "non-dumpable" + : undefined; }; diff --git a/src/process/ProcessOwnership.identity.test.ts b/src/process/ProcessOwnership.identity.test.ts index 28d4ce9e4f..8076515f16 100644 --- a/src/process/ProcessOwnership.identity.test.ts +++ b/src/process/ProcessOwnership.identity.test.ts @@ -219,6 +219,76 @@ it }, ); +it + .skipIf(typeof process.getuid !== "function") + .each(["credentials", "non-dumpable"] as const)( + "records an unrelated same-UID Linux process whose environment is withheld for %s", + async (restriction) => { + const diagnostic = "EACCES: permission denied, open '/proc/908/environ'"; + const verify = async ( + processGroupId: number, + environmentRestriction: typeof restriction | undefined, + ) => { + const row: ProcessTableEntry = { + pid: 908, + parentPid: 1, + processGroupId, + uid: process.getuid!(), + state: "S", + command: "unrelated-process", + }; + const host: ProcessOwnershipHost = { + platform: "linux", + signalGroup: vi.fn(), + listProcesses: () => Promise.resolve([row]), + environment: () => Promise.resolve({}), + runTokens: () => + Promise.resolve( + new Map([ + [908, { state: "unavailable", reason: diagnostic } as const], + ]), + ), + processIdentities: (entries) => + Promise.resolve( + new Map( + entries.map((entry) => [ + entry.pid, + { + ...observedIdentity(entry, "current-generation"), + effectiveUid: process.getuid!(), + ...(environmentRestriction === undefined + ? {} + : { environmentRestriction }), + }, + ]), + ), + ), + }; + return verifyNoTokenOwnedProcesses("run-token", host, undefined, { + leaderPid: 100, + processGroupId: 100, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + }); + }; + + await expect(verify(908, restriction)).resolves.toMatchObject({ + cleaned: true, + unverified: [{ pid: 908, diagnostic }], + }); + // EACCES alone and a withheld member of the owned group stay fail-closed. + for (const [processGroupId, environmentRestriction] of [ + [908, undefined], + [100, restriction], + ] as const) + await expect( + verify(processGroupId, environmentRestriction), + ).resolves.toMatchObject({ + cleaned: false, + failures: [{ pid: 908, reason: "environment-unreadable" }], + }); + }, +); + it.each(["omitted", "throws"] as const)( "keeps a matching token unresolved when the first native identity is %s", async (firstRead) => { diff --git a/src/process/ProcessOwnership.procIdentity.test.ts b/src/process/ProcessOwnership.procIdentity.test.ts index fff53db724..fd519081d6 100644 --- a/src/process/ProcessOwnership.procIdentity.test.ts +++ b/src/process/ProcessOwnership.procIdentity.test.ts @@ -1,12 +1,18 @@ +import { performance } from "node:perf_hooks"; + import { expect, it, vi } from "vitest"; const filesystem = vi.hoisted(() => ({ readFile: vi.fn(), + // Host procfs ownership must never leak into fixture PIDs. + stat: vi.fn(async () => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }), })); vi.mock("node:fs/promises", async (importOriginal) => { const original = await importOriginal(); - return { ...original, readFile: filesystem.readFile }; + return { ...original, readFile: filesystem.readFile, stat: filesystem.stat }; }); import { @@ -34,22 +40,66 @@ const procStat = ( parentPid = 1, processGroupId = 42, pid = 42, + flags = 0, ) => { - const fields = ["S", String(parentPid), String(processGroupId), "42"]; + const fields = [ + "S", + String(parentPid), + String(processGroupId), + "42", + "0", + "0", + String(flags), + ]; while (fields.length < 19) fields.push("0"); fields.push(String(startTime)); return `${String(pid)} (fixture) ${fields.join(" ")}`; }; -it("binds Linux effective UID to the same native process generation", async () => { +/** + * Select REA's caller credentials for the simulated Linux host. Windows has no + * `process.geteuid`/`getegid`, so define them for the call and restore the + * original properties (or their absence) afterwards. + */ +const withCaller = async ( + uid: number, + gid: number, + run: () => Promise, +): Promise => { + const names = ["geteuid", "getegid"] as const; + const originals = names.map((name) => + Object.getOwnPropertyDescriptor(process, name), + ); + names.forEach((name, index) => + Object.defineProperty(process, name, { + configurable: true, + writable: true, + value: () => (index === 0 ? uid : gid), + }), + ); + try { + return await run(); + } finally { + names.forEach((name, index) => { + const original = originals[index]; + if (original === undefined) Reflect.deleteProperty(process, name); + else Object.defineProperty(process, name, original); + }); + } +}; + +it("binds Linux credentials to the same native process generation", async () => { const host = createSystemProcessOwnershipHost("linux", {}); filesystem.readFile .mockResolvedValueOnce(procStat(100, 10, 42)) .mockResolvedValueOnce("Name:\tfixture\nUid:\t1000\t1001\t1002\t1003\n") .mockResolvedValueOnce(procStat(100, 11, 43)); + filesystem.stat.mockResolvedValueOnce({ uid: 1001 } as never); await expect( - host.processIdentities?.([entry], undefined, new Set([entry.pid])), + withCaller(1001, 20, async () => + host.processIdentities?.([entry], undefined, new Set([entry.pid])), + ), ).resolves.toEqual( new Map([ [ @@ -58,6 +108,8 @@ it("binds Linux effective UID to the same native process generation", async () = state: "readable", identity: "linux-starttime:100", effectiveUid: 1001, + // Kernel ptrace checks also compare the real and saved UIDs. + environmentRestriction: "credentials", lifecycle: { state: "observed", lifecycle: "non-zombie" }, relation: { state: "observed", @@ -73,8 +125,41 @@ it("binds Linux effective UID to the same native process generation", async () = "/proc/42/status", "/proc/42/stat", ]); + expect(filesystem.stat).toHaveBeenLastCalledWith("/proc/42/environ"); }); +it.each([ + [ + "a non-dumpable process", + 0, + "Gid:\t1000\t1000\t1000\t1000\n", + "non-dumpable", + ], + ["a setgid process", 1000, "Gid:\t1000\t5\t5\t5\n", "credentials"], + ["an inspectable process", 1000, "Gid:\t1000\t1000\t1000\t1000\n", undefined], +] as const)( + "reports the Linux environment restriction for %s", + async (_name, procfsOwnerUid, gidRow, expected) => { + const host = createSystemProcessOwnershipHost("linux", {}); + filesystem.readFile + .mockResolvedValueOnce(procStat(100)) + .mockResolvedValueOnce(`Uid:\t1000\t1000\t1000\t1000\n${gidRow}`) + .mockResolvedValueOnce(procStat(100)); + filesystem.stat.mockResolvedValueOnce({ uid: procfsOwnerUid } as never); + + const identity = ( + await withCaller(1000, 1000, async () => + host.processIdentities?.([entry], undefined, new Set([entry.pid])), + ) + )?.get(entry.pid); + + expect(identity).toMatchObject({ state: "readable", effectiveUid: 1000 }); + if (expected === undefined) + expect(identity).not.toHaveProperty("environmentRestriction"); + else expect(identity).toHaveProperty("environmentRestriction", expected); + }, +); + it("keeps native identity when status UID is unavailable", async () => { const host = createSystemProcessOwnershipHost("linux", {}); filesystem.readFile @@ -167,6 +252,45 @@ it("reads Linux effective UID only for token-unavailable rows", async () => { ); }); +it("keeps the Linux environment restriction while an exiting candidate settles", async () => { + let identityReads = 0; + filesystem.readFile.mockImplementation(async (path) => { + if (String(path) === "/proc/42/status") + return "Uid:\t1000\t1000\t1000\t1000\nGid:\t1000\t1000\t1000\t1000\n"; + if (String(path) !== "/proc/42/stat") + throw new Error(`unexpected path: ${String(path)}`); + identityReads += 1; + // PF_EXITING is set after the first identity read. + return procStat(100, 1, 42, 42, identityReads === 1 ? 0 : 4); + }); + filesystem.stat.mockResolvedValue({ uid: 0 } as never); + const systemHost = createSystemProcessOwnershipHost("linux", {}); + const host: ProcessOwnershipHost = { + ...systemHost, + runTokens: async () => + new Map([[entry.pid, { state: "unavailable", reason: "EACCES" }]]), + }; + + try { + const [fact] = await withCaller(1000, 1000, async () => + readProcessOwnershipFacts([entry], host, undefined, { + exitSettlement: { deadline: performance.now() + 30 }, + }), + ); + + expect(fact?.beforeExitSettlement).toBeDefined(); + expect(fact?.identityAfter).toMatchObject({ + state: "readable", + lifecycle: { state: "observed", lifecycle: "exiting" }, + environmentRestriction: "non-dumpable", + }); + expect(identityReads).toBeGreaterThan(3); + } finally { + filesystem.stat.mockReset(); + filesystem.stat.mockRejectedValue(filesystemError("ENOENT")); + } +}); + it("reports absence only when /proc lookup and signal-zero both report ESRCH", async () => { const host = createSystemProcessOwnershipHost("linux", {}); const signalProbe = vi.spyOn(process, "kill").mockImplementation(() => { diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 98dc2480c6..2038c92971 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -14,6 +14,7 @@ import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js"; import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js"; import { readProcessRunTokens } from "./ProcessRunTokenObservations.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import type { LinuxEnvironmentRestriction } from "./LinuxProcessStatus.js"; import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; import { @@ -90,6 +91,8 @@ export type ProcessIdentityObservation = readonly identity: string; /** Effective UID from the same native observation, when that boundary reports it. */ readonly effectiveUid?: number; + /** Linux procfs reason the kernel withholds this generation's environment from REA. */ + readonly environmentRestriction?: LinuxEnvironmentRestriction; /** Kept separate from the start identity; optional for older/custom hosts. */ readonly lifecycle?: ProcessLifecycleObservation; /** Native relation facts read with this identity, never copied from a process-table row. */ @@ -184,8 +187,8 @@ export interface ProcessOwnershipHost { processIdentities?( processes: readonly ProcessTableEntry[], signal?: AbortSignal, - /** Linux UID enrichment is selective; omitted means UID remains unknown. */ - effectiveUidPids?: ReadonlySet, + /** Linux credential enrichment is selective; omitted means UID and environment restriction remain unknown. */ + credentialPids?: ReadonlySet, ): Promise>; /** Snapshot identities before launch, honoring startup cancellation. */ captureBaseline?(signal?: AbortSignal): Promise; @@ -1422,15 +1425,19 @@ const currentProcessUid = (): number | undefined => * Whether the operating system provably withheld this candidate's run token: * current macOS omits the environment of Apple platform binaries and * CS_RESTRICT processes from KERN_PROCARGS2, and fails with EINVAL for - * processes owned by another account. These classes cannot be token-verified - * by this caller. An unrelated candidate is recorded as unverified instead - * of failing an otherwise clean capture; every related or otherwise - * unreadable candidate stays fail-closed. + * processes owned by another account. Linux denies `/proc//environ` for + * another account, mismatched real/saved IDs (setgid and setuid programs) and + * non-dumpable processes. These classes cannot be token-verified by this + * caller. An unrelated candidate is recorded as unverified instead of failing + * an otherwise clean capture; every related or otherwise unreadable candidate + * stays fail-closed. */ const environmentWithheldFromInspection = ( diagnostic: string, observedUid: number | undefined, + restriction?: LinuxEnvironmentRestriction, ): boolean => { + if (restriction !== undefined) return true; const uid = currentProcessUid(); if (uid !== undefined && observedUid !== undefined && observedUid !== uid) return true; @@ -1692,14 +1699,18 @@ const scanWithOwnershipEvidence = async ( relationAfter?.state === "observed" && relationBefore.parentPid === relationAfter.parentPid && relationBefore.processGroupId === relationAfter.processGroupId; - const observedUid = + const observed = fact.state === "same-generation" && fact.identityAfter.state === "readable" - ? fact.identityAfter.effectiveUid + ? fact.identityAfter : undefined; if ( knownUnrelated && - environmentWithheldFromInspection(fact.token.reason, observedUid) + 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 24608c2e3f..17fbe6de37 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { readFile, stat as statPath } from "node:fs/promises"; import { performance } from "node:perf_hooks"; import { setTimeout as delay } from "node:timers/promises"; import { @@ -9,7 +9,11 @@ import { type ProcessExitSettlementBudget, } from "./ProcessLifecycle.js"; import { parseLinuxProcessStat } from "./LinuxProcessStat.js"; -import { parseLinuxProcessEffectiveUid } from "./LinuxProcessStatus.js"; +import { + linuxEnvironmentRestriction, + parseLinuxProcessIds, + type LinuxEnvironmentRestriction, +} from "./LinuxProcessStatus.js"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js"; @@ -679,7 +683,7 @@ const readIdentityFacts = async ( host: ProcessOwnershipHost, phase: "before" | "after", signal?: AbortSignal, - effectiveUidPids?: ReadonlySet, + credentialPids?: ReadonlySet, ): Promise> => { signal?.throwIfAborted(); if (host.processIdentities === undefined) @@ -696,7 +700,7 @@ const readIdentityFacts = async ( const observations = await host.processIdentities( processes, signal, - effectiveUidPids, + credentialPids, ); signal?.throwIfAborted(); return new Map( @@ -733,6 +737,8 @@ export const settleExitingProcessIdentities = async ( initial: ReadonlyMap, signal?: AbortSignal, budget: ProcessExitSettlementBudget = { deadline: undefined }, + /** Keep the initial read's selective Linux credential facts on refresh. */ + credentialPids?: ReadonlySet, ): Promise> => { signal?.throwIfAborted(); let pending = processes.filter(({ pid }) => @@ -755,7 +761,13 @@ export const settleExitingProcessIdentities = async ( signal?.throwIfAborted(); throw cause; } - const refreshed = await readNativeProcessIdentityMap(pending, host, signal); + const refreshed = await readIdentityFacts( + pending, + host, + "before", + signal, + credentialPids, + ); for (const { pid } of pending) identities.set(pid, identityFact(refreshed, pid, "after")); pending = pending.filter(({ pid }) => { @@ -818,7 +830,7 @@ export const readProcessOwnershipFacts = async ( } signal?.throwIfAborted(); - const effectiveUidPids = new Set( + const credentialPids = new Set( processes .filter(({ pid }) => tokens.get(pid)?.state !== "readable") .map(({ pid }) => pid), @@ -828,7 +840,7 @@ export const readProcessOwnershipFacts = async ( host, "after", signal, - effectiveUidPids, + credentialPids, ); const identityAfter = await settleExitingProcessIdentities( processes, @@ -836,6 +848,7 @@ export const readProcessOwnershipFacts = async ( observedAfter, signal, options.exitSettlement, + credentialPids, ); signal?.throwIfAborted(); const deriveFact = ( @@ -958,7 +971,7 @@ export const createSystemProcessOwnershipHost = ( }; const processIdentities: NonNullable< ProcessOwnershipHost["processIdentities"] - > = async (processes, signal, effectiveUidPids) => { + > = async (processes, signal, credentialPids) => { if (processes.length === 0) return new Map(); if (platform === "linux") return new Map( @@ -987,10 +1000,14 @@ export const createSystemProcessOwnershipHost = ( const readableIdentity = ( facts: typeof parsed, effectiveUid?: number, + environmentRestriction?: LinuxEnvironmentRestriction, ) => ({ state: "readable" as const, identity: `linux-starttime:${facts.startTime}`, ...(effectiveUid === undefined ? {} : { effectiveUid }), + ...(environmentRestriction === undefined + ? {} + : { environmentRestriction }), lifecycle: facts.lifecycle === undefined ? { @@ -1008,7 +1025,7 @@ export const createSystemProcessOwnershipHost = ( }, }); - if (!effectiveUidPids?.has(pid)) + if (!credentialPids?.has(pid)) return [pid, readableIdentity(parsed)] as const; let status: string; @@ -1022,9 +1039,26 @@ export const createSystemProcessOwnershipHost = ( return [pid, readableIdentity(parsed)] as const; } signal?.throwIfAborted(); - const effectiveUid = parseLinuxProcessEffectiveUid(status); - if (effectiveUid === undefined) + const uids = parseLinuxProcessIds(status, "Uid"); + if (uids === undefined) return [pid, readableIdentity(parsed)] as const; + // procfs reports root ownership of environ while a process is non-dumpable. + let procfsOwnerUid: number | undefined; + try { + procfsOwnerUid = ( + await statPath(`/proc/${String(pid)}/environ`) + ).uid; + } catch (cause: unknown) { + void cause; + } + signal?.throwIfAborted(); + const environmentRestriction = linuxEnvironmentRestriction({ + uids, + gids: parseLinuxProcessIds(status, "Gid"), + procfsOwnerUid, + callerUid: process.geteuid?.(), + callerGid: process.getegid?.(), + }); let afterStatus: string; try { @@ -1052,7 +1086,10 @@ export const createSystemProcessOwnershipHost = ( ] as const; if (verified === undefined || verified.pid !== pid) return [pid, readableIdentity(parsed)] as const; - return [pid, readableIdentity(verified, effectiveUid)] as const; + return [ + pid, + readableIdentity(verified, uids[1], environmentRestriction), + ] as const; } catch (cause: unknown) { if (isExpectedAbort(cause, signal)) throw cause; if (