diff --git a/src/process/LinuxProcessStatus.test.ts b/src/process/LinuxProcessStatus.test.ts index 5854e68225..278a8df73c 100644 --- a/src/process/LinuxProcessStatus.test.ts +++ b/src/process/LinuxProcessStatus.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "vitest"; import { linuxEnvironmentRestriction, + linuxUidMapsDiffer, parseLinuxProcessIds, } from "./LinuxProcessStatus.js"; @@ -70,3 +71,16 @@ it("does not derive a credential restriction without caller credentials", () => }), ).toBeUndefined(); }); + +it.each([ + ["0 1000 1\n", "0 0 1\n", true], + [" 0\t0 1\n", "0 0 1\n", false], + ["", "0 0 1\n", undefined], + ["0 0 1\n", undefined, undefined], + ["0 0 1 extra\n", "0 0 1\n", undefined], +] as const)( + "compares readable UID maps conservatively", + (target, caller, expected) => { + expect(linuxUidMapsDiffer(target, caller)).toBe(expected); + }, +); diff --git a/src/process/LinuxProcessStatus.ts b/src/process/LinuxProcessStatus.ts index 33d52dbceb..fdeb362f79 100644 --- a/src/process/LinuxProcessStatus.ts +++ b/src/process/LinuxProcessStatus.ts @@ -32,10 +32,39 @@ export const parseLinuxProcessIds = ( /** * 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`. + * saved UID/GID differs from the caller's, the target is non-dumpable (which + * procfs reports as root ownership of `/proc//environ`), or the target + * occupies another user namespace. */ -export type LinuxEnvironmentRestriction = "credentials" | "non-dumpable"; +export type LinuxEnvironmentRestriction = + | "credentials" + | "non-dumpable" + | "user-namespace"; + +/** Different readable UID maps prove distinct namespaces; equal maps do not prove sameness. */ +export const linuxUidMapsDiffer = ( + target: string | undefined, + caller: string | undefined, +): boolean | undefined => { + const normalized = (value: string | undefined): string | undefined => { + if (value === undefined || value.trim() === "") return undefined; + const rows: string[] = []; + for (const line of value.trim().split("\n")) { + const match = /^\s*([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*$/u.exec(line); + if (match === null) return undefined; + const numbers = match.slice(1).map(Number); + if (numbers.some((number) => !Number.isSafeInteger(number))) + return undefined; + rows.push(numbers.join(" ")); + } + return rows.join("\n"); + }; + const targetMap = normalized(target); + const callerMap = normalized(caller); + return targetMap === undefined || callerMap === undefined + ? undefined + : targetMap !== callerMap; +}; /** Derive the kernel's environment restriction from one generation's procfs facts. */ export const linuxEnvironmentRestriction = (facts: { @@ -44,8 +73,16 @@ export const linuxEnvironmentRestriction = (facts: { readonly procfsOwnerUid: number | undefined; readonly callerUid: number | undefined; readonly callerGid: number | undefined; + readonly userNamespaceDiffers?: boolean | undefined; }): LinuxEnvironmentRestriction | undefined => { - const { uids, gids, procfsOwnerUid, callerUid, callerGid } = facts; + const { + uids, + gids, + procfsOwnerUid, + callerUid, + callerGid, + userNamespaceDiffers, + } = facts; if ( (callerUid !== undefined && uids.slice(0, 3).some((uid) => uid !== callerUid)) || @@ -54,7 +91,7 @@ export const linuxEnvironmentRestriction = (facts: { gids.slice(0, 3).some((gid) => gid !== callerGid)) ) return "credentials"; - return procfsOwnerUid !== undefined && procfsOwnerUid !== uids[1] - ? "non-dumpable" - : undefined; + if (procfsOwnerUid !== undefined && procfsOwnerUid !== uids[1]) + return "non-dumpable"; + return userNamespaceDiffers === true ? "user-namespace" : undefined; }; diff --git a/src/process/ProcessOwnership.procIdentity.test.ts b/src/process/ProcessOwnership.procIdentity.test.ts index c433fd1035..5e2fb08186 100644 --- a/src/process/ProcessOwnership.procIdentity.test.ts +++ b/src/process/ProcessOwnership.procIdentity.test.ts @@ -4,6 +4,7 @@ import { expect, it, vi } from "vitest"; const filesystem = vi.hoisted(() => ({ readFile: vi.fn(), + readlink: vi.fn(async (_path: string) => "user:[1]"), // Host procfs ownership must never leak into fixture PIDs. stat: vi.fn(async () => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); @@ -12,13 +13,20 @@ const filesystem = vi.hoisted(() => ({ vi.mock("node:fs/promises", async (importOriginal) => { const original = await importOriginal(); - return { ...original, readFile: filesystem.readFile, stat: filesystem.stat }; + return { + ...original, + readFile: filesystem.readFile, + readlink: filesystem.readlink, + stat: filesystem.stat, + }; }); import { createSystemProcessOwnershipHost, readProcessOwnershipFacts, } from "./ProcessOwnershipObservation.js"; +import { verifyNoTokenOwnedProcesses } from "./ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; import type { ProcessOwnershipHost, ProcessTableEntry, @@ -161,6 +169,131 @@ it.each([ }, ); +it.each([ + [ + "different namespace", + "user:[2]", + "0 0 1\n", + 42, + "environment_errno_EACCES", + true, + ], + [ + "permission denied", + "user:[2]", + "0 0 1\n", + 42, + "environment_errno_EPERM", + true, + ], + [ + "same namespace", + "user:[1]", + "0 0 1\n", + 42, + "environment_errno_EACCES", + false, + ], + [ + "denied namespace lookup", + null, + "0 1000 1\n", + 42, + "environment_errno_EACCES", + true, + ], + [ + "equal maps after denied lookup", + null, + "0 0 1\n", + 42, + "environment_errno_EACCES", + false, + ], + [ + "unreadable namespace and map", + null, + null, + 42, + "environment_errno_EACCES", + false, + ], + [ + "owned group", + "user:[2]", + "0 0 1\n", + 100, + "environment_errno_EACCES", + false, + ], + [ + "other read failure", + "user:[2]", + "0 0 1\n", + 42, + "environment_errno_EIO", + false, + ], +] as const)( + "classifies an unreadable process with %s and %s", + async ( + _case, + targetNamespace, + targetUidMap, + processGroupId, + diagnostic, + cleaned, + ) => { + const callerUid = process.getuid?.() ?? 1000; + const candidate = { ...entry, processGroupId }; + filesystem.readFile.mockImplementation(async (path) => { + if (String(path) === "/proc/42/stat") + return procStat(100, 1, processGroupId); + if (String(path) === "/proc/42/status") + return `Uid:\t${callerUid}\t${callerUid}\t${callerUid}\t${callerUid}\nGid:\t1000\t1000\t1000\t1000\n`; + if (String(path) === "/proc/self/uid_map") return "0 0 1\n"; + if (String(path) === "/proc/42/uid_map") { + if (targetUidMap === null) throw filesystemError("EACCES"); + return targetUidMap; + } + throw new Error(`unexpected path: ${String(path)}`); + }); + filesystem.stat.mockResolvedValue({ uid: callerUid } as never); + filesystem.readlink.mockImplementation(async (path) => { + if (String(path) === "/proc/self/ns/user") return "user:[1]"; + if (targetNamespace === null) throw filesystemError("EACCES"); + return targetNamespace; + }); + const systemHost = createSystemProcessOwnershipHost("linux", {}); + const host: ProcessOwnershipHost = { + ...systemHost, + listProcesses: async () => [candidate], + runTokens: async () => + new Map([[42, { state: "unavailable", reason: diagnostic }]]), + }; + + const result = await withCaller(callerUid, 1000, () => + verifyNoTokenOwnedProcesses("owned-run", host, undefined, { + leaderPid: 100, + processGroupId: 100, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + }), + ); + + if (cleaned) + expect(result).toMatchObject({ + cleaned: true, + unverified: [{ pid: 42, diagnostic }], + }); + else + expect(result).toMatchObject({ + cleaned: false, + failures: [{ pid: 42, reason: "environment-unreadable" }], + }); + expect(filesystem.readlink).toHaveBeenCalledWith("/proc/42/ns/user"); + }, +); + it("keeps native identity when status UID is unavailable", async () => { const host = createSystemProcessOwnershipHost("linux", {}); filesystem.readFile diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 198b0dfaa6..98ced55ec9 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -1564,8 +1564,9 @@ const currentProcessUid = (): number | undefined => * CS_RESTRICT processes from KERN_PROCARGS2, and fails with EINVAL for * 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 + * non-dumpable processes, and processes in other user namespaces when the + * environment read is permission-denied. 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. */ @@ -1574,6 +1575,8 @@ const environmentWithheldFromInspection = ( observedUid: number | undefined, restriction?: LinuxEnvironmentRestriction, ): boolean => { + if (restriction === "user-namespace") + return /^(?:environment_errno_)?(?:EACCES|EPERM)(?::|\b)/u.test(diagnostic); if (restriction !== undefined) return true; const uid = currentProcessUid(); if (uid !== undefined && observedUid !== undefined && observedUid !== uid) diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 804ae93eb1..a0beeae736 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -1,5 +1,5 @@ import { ProcessAdmissionError } from "./ProcessAdmissionError.js"; -import { readFile, stat as statPath } from "node:fs/promises"; +import { readFile, readlink, stat as statPath } from "node:fs/promises"; import { performance } from "node:perf_hooks"; import { setTimeout as delay } from "node:timers/promises"; import { @@ -12,6 +12,7 @@ import { import { parseLinuxProcessStat } from "./LinuxProcessStat.js"; import { linuxEnvironmentRestriction, + linuxUidMapsDiffer, parseLinuxProcessIds, type LinuxEnvironmentRestriction, } from "./LinuxProcessStatus.js"; @@ -1254,7 +1255,23 @@ export const createSystemProcessOwnershipHost = ( ProcessOwnershipHost["processIdentities"] > = async (processes, signal, credentialPids) => { if (processes.length === 0) return new Map(); - if (platform === "linux") + if (platform === "linux") { + const callerUserNamespace = + credentialPids !== undefined && credentialPids.size > 0 + ? readlink("/proc/self/ns/user").catch(() => undefined) + : undefined; + let callerUidMap: Promise | undefined; + const readUidMap = async (path: string): Promise => { + try { + return await readFile(path, { + encoding: "utf8", + ...(signal === undefined ? {} : { signal }), + }); + } catch (cause: unknown) { + if (isExpectedAbort(cause, signal)) throw cause; + return undefined; + } + }; return new Map( await Promise.all( processes.map(async ({ pid }) => { @@ -1340,13 +1357,49 @@ export const createSystemProcessOwnershipHost = ( void cause; } signal?.throwIfAborted(); - const environmentRestriction = linuxEnvironmentRestriction({ + const restrictionFacts = { uids, gids: parseLinuxProcessIds(status, "Gid"), procfsOwnerUid, callerUid: process.geteuid?.(), callerGid: process.getegid?.(), - }); + }; + let environmentRestriction = + linuxEnvironmentRestriction(restrictionFacts); + if (environmentRestriction === undefined) { + let userNamespaceDiffers: boolean | undefined; + try { + const targetUserNamespace = await readlink( + `/proc/${String(pid)}/ns/user`, + ); + const callerNamespace = await callerUserNamespace; + if (callerNamespace !== undefined) + userNamespaceDiffers = + callerNamespace !== targetUserNamespace; + } catch (cause: unknown) { + void cause; + } + signal?.throwIfAborted(); + if (userNamespaceDiffers === undefined) { + // ns/user readlink shares environ's ptrace check. A differing + // readable UID map can still establish distinct namespaces. + const targetUidMap = await readUidMap( + `/proc/${String(pid)}/uid_map`, + ); + if (targetUidMap !== undefined) { + callerUidMap ??= readUidMap("/proc/self/uid_map"); + userNamespaceDiffers = linuxUidMapsDiffer( + targetUidMap, + await callerUidMap, + ); + } + } + environmentRestriction = linuxEnvironmentRestriction({ + ...restrictionFacts, + userNamespaceDiffers, + }); + } + signal?.throwIfAborted(); let afterStatus: string; try { @@ -1428,6 +1481,7 @@ export const createSystemProcessOwnershipHost = ( }), ), ); + } if (darwinTokens === undefined) return new Map(); try { return await darwinTokens.identities(processes, signal);