[codex] Extract filesystem safety primitives (#77918)

* refactor: extract filesystem safety primitives

* refactor: use fs-safe for file access helpers

* refactor: reuse fs-safe for media reads

* refactor: use fs-safe for image reads

* refactor: reuse fs-safe in qqbot media opener

* refactor: reuse fs-safe for local media checks

* refactor: consume cleaner fs-safe api

* refactor: align fs-safe json option names

* fix: preserve fs-safe migration contracts

* refactor: use fs-safe primitive subpaths

* refactor: use grouped fs-safe subpaths

* refactor: align fs-safe api usage

* refactor: adapt private state store api

* chore: refresh proof gate

* refactor: follow fs-safe json api split

* refactor: follow reduced fs-safe surface

* build: default fs-safe python helper off

* fix: preserve fs-safe plugin sdk aliases

* refactor: consolidate fs-safe usage

* refactor: unify fs-safe store usage

* refactor: trim fs-safe temp workspace usage

* refactor: hide low-level fs-safe primitives

* build: use published fs-safe package

* fix: preserve outbound recovery durability after rebase

* chore: refresh pr checks
This commit is contained in:
Peter Steinberger
2026-05-06 02:15:17 +01:00
committed by GitHub
parent 61481eb34f
commit 538605ff44
356 changed files with 4918 additions and 11913 deletions

View File

@@ -1,206 +1,8 @@
import fs from "node:fs/promises";
import {
formatIcaclsResetCommand,
formatWindowsAclSummary,
inspectWindowsAcl,
type ExecFn,
} from "./windows-acl.js";
export type PermissionCheck = {
ok: boolean;
isSymlink: boolean;
isDir: boolean;
mode: number | null;
bits: number | null;
source: "posix" | "windows-acl" | "unknown";
worldWritable: boolean;
groupWritable: boolean;
worldReadable: boolean;
groupReadable: boolean;
aclSummary?: string;
error?: string;
};
export type PermissionCheckOptions = {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
exec?: ExecFn;
};
export async function safeStat(targetPath: string): Promise<{
ok: boolean;
isSymlink: boolean;
isDir: boolean;
mode: number | null;
uid: number | null;
gid: number | null;
error?: string;
}> {
try {
const lst = await fs.lstat(targetPath);
return {
ok: true,
isSymlink: lst.isSymbolicLink(),
isDir: lst.isDirectory(),
mode: typeof lst.mode === "number" ? lst.mode : null,
uid: typeof lst.uid === "number" ? lst.uid : null,
gid: typeof lst.gid === "number" ? lst.gid : null,
};
} catch (err) {
return {
ok: false,
isSymlink: false,
isDir: false,
mode: null,
uid: null,
gid: null,
error: String(err),
};
}
}
export async function inspectPathPermissions(
targetPath: string,
opts?: PermissionCheckOptions,
): Promise<PermissionCheck> {
const st = await safeStat(targetPath);
if (!st.ok) {
return {
ok: false,
isSymlink: false,
isDir: false,
mode: null,
bits: null,
source: "unknown",
worldWritable: false,
groupWritable: false,
worldReadable: false,
groupReadable: false,
error: st.error,
};
}
let effectiveMode = st.mode;
let effectiveIsDir = st.isDir;
if (st.isSymlink) {
try {
const target = await fs.stat(targetPath);
effectiveMode = typeof target.mode === "number" ? target.mode : st.mode;
effectiveIsDir = target.isDirectory();
} catch {
// Keep lstat-derived metadata when target lookup fails.
}
}
const bits = modeBits(effectiveMode);
const platform = opts?.platform ?? process.platform;
if (platform === "win32") {
const acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec });
if (!acl.ok) {
return {
ok: true,
isSymlink: st.isSymlink,
isDir: effectiveIsDir,
mode: effectiveMode,
bits,
source: "unknown",
worldWritable: false,
groupWritable: false,
worldReadable: false,
groupReadable: false,
error: acl.error,
};
}
return {
ok: true,
isSymlink: st.isSymlink,
isDir: effectiveIsDir,
mode: effectiveMode,
bits,
source: "windows-acl",
worldWritable: acl.untrustedWorld.some((entry) => entry.canWrite),
groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite),
worldReadable: acl.untrustedWorld.some((entry) => entry.canRead),
groupReadable: acl.untrustedGroup.some((entry) => entry.canRead),
aclSummary: formatWindowsAclSummary(acl),
};
}
return {
ok: true,
isSymlink: st.isSymlink,
isDir: effectiveIsDir,
mode: effectiveMode,
bits,
source: "posix",
worldWritable: isWorldWritable(bits),
groupWritable: isGroupWritable(bits),
worldReadable: isWorldReadable(bits),
groupReadable: isGroupReadable(bits),
};
}
export function formatPermissionDetail(targetPath: string, perms: PermissionCheck): string {
if (perms.source === "windows-acl") {
const summary = perms.aclSummary ?? "unknown";
return `${targetPath} acl=${summary}`;
}
return `${targetPath} mode=${formatOctal(perms.bits)}`;
}
export function formatPermissionRemediation(params: {
targetPath: string;
perms: PermissionCheck;
isDir: boolean;
posixMode: number;
env?: NodeJS.ProcessEnv;
}): string {
if (params.perms.source === "windows-acl") {
return formatIcaclsResetCommand(params.targetPath, { isDir: params.isDir, env: params.env });
}
const mode = params.posixMode.toString(8).padStart(3, "0");
return `chmod ${mode} ${params.targetPath}`;
}
export function modeBits(mode: number | null): number | null {
if (mode == null) {
return null;
}
return mode & 0o777;
}
export function formatOctal(bits: number | null): string {
if (bits == null) {
return "unknown";
}
return bits.toString(8).padStart(3, "0");
}
export function isWorldWritable(bits: number | null): boolean {
if (bits == null) {
return false;
}
return (bits & 0o002) !== 0;
}
export function isGroupWritable(bits: number | null): boolean {
if (bits == null) {
return false;
}
return (bits & 0o020) !== 0;
}
export function isWorldReadable(bits: number | null): boolean {
if (bits == null) {
return false;
}
return (bits & 0o004) !== 0;
}
export function isGroupReadable(bits: number | null): boolean {
if (bits == null) {
return false;
}
return (bits & 0o040) !== 0;
}
export {
formatPermissionDetail,
formatPermissionRemediation,
inspectPathPermissions,
safeStat,
type PermissionCheck,
type PermissionCheckOptions,
} from "../infra/permissions.js";

View File

@@ -1,39 +1,4 @@
import fs from "node:fs";
import path from "node:path";
export function isPathInside(basePath: string, candidatePath: string): boolean {
const base = path.resolve(basePath);
const candidate = path.resolve(candidatePath);
const rel = path.relative(base, candidate);
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
}
function safeRealpathSync(filePath: string): string | null {
try {
return fs.realpathSync(filePath);
} catch {
return null;
}
}
export function isPathInsideWithRealpath(
basePath: string,
candidatePath: string,
opts?: { requireRealpath?: boolean },
): boolean {
if (!isPathInside(basePath, candidatePath)) {
return false;
}
const baseReal = safeRealpathSync(basePath);
const candidateReal = safeRealpathSync(candidatePath);
if (!baseReal || !candidateReal) {
// Default to false (safe): only bypass the realpath check when the caller
// explicitly opts out with requireRealpath: false. All production callers
// already pass requireRealpath: true; this change makes the default secure.
return opts?.requireRealpath === false;
}
return isPathInside(baseReal, candidateReal);
}
export { isPathInside, isPathInsideWithRealpath } from "../infra/path-safety.js";
export function extensionUsesSkippedScannerPath(entry: string): boolean {
const segments = entry.split(/[\\/]+/).filter(Boolean);

View File

@@ -1,415 +1,12 @@
import os from "node:os";
import path from "node:path";
import { getWindowsInstallRoots } from "../infra/windows-install-roots.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { runExec } from "../process/exec.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
const log = createSubsystemLogger("security/windows-acl");
export type ExecFn = typeof runExec;
export type WindowsAclEntry = {
principal: string;
rights: string[];
rawRights: string;
canRead: boolean;
canWrite: boolean;
};
export type WindowsAclSummary = {
ok: boolean;
entries: WindowsAclEntry[];
untrustedWorld: WindowsAclEntry[];
untrustedGroup: WindowsAclEntry[];
trusted: WindowsAclEntry[];
error?: string;
};
export type WindowsUserInfoProvider = () => { username?: string | null };
export type IcaclsResetCommandOptions = {
isDir: boolean;
env?: NodeJS.ProcessEnv;
userInfo?: WindowsUserInfoProvider;
};
const INHERIT_FLAGS = new Set(["I", "OI", "CI", "IO", "NP"]);
const WORLD_PRINCIPALS = new Set([
"everyone",
"users",
"builtin\\users",
"authenticated users",
"nt authority\\authenticated users",
]);
const TRUSTED_BASE = new Set([
"nt authority\\system",
"system",
"builtin\\administrators",
"creator owner",
// Localized SYSTEM account names (French, German, Spanish, Portuguese)
"autorite nt\\système",
"nt-autorität\\system",
"autoridad nt\\system",
"autoridade nt\\system",
]);
const WORLD_SUFFIXES = ["\\users", "\\authenticated users"];
const TRUSTED_SUFFIXES = ["\\administrators", "\\system", "\\système"];
// Accept an optional leading * which icacls prefixes to SIDs when invoked with /sid
// (e.g. *S-1-5-18 instead of S-1-5-18).
const SID_RE = /^\*?s-\d+-\d+(-\d+)+$/i;
const TRUSTED_SIDS = new Set([
"s-1-5-18",
"s-1-5-32-544",
"s-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
]);
// SIDs for world-equivalent principals that icacls /sid emits as raw SIDs.
// Without this list these would be classified as "group" instead of "world".
// S-1-1-0 Everyone
// S-1-5-11 Authenticated Users
// S-1-5-32-545 BUILTIN\Users
const WORLD_SIDS = new Set(["s-1-1-0", "s-1-5-11", "s-1-5-32-545"]);
const STATUS_PREFIXES = [
"successfully processed",
"processed",
"failed processing",
"no mapping between account names",
];
const normalize = (value: string) => normalizeLowercaseStringOrEmpty(value);
const defaultWindowsUserInfo: WindowsUserInfoProvider = () => os.userInfo();
function normalizeSid(value: string): string {
const normalized = normalize(value);
return normalized.startsWith("*") ? normalized.slice(1) : normalized;
}
export function resolveWindowsUserPrincipal(
env?: NodeJS.ProcessEnv,
userInfo: WindowsUserInfoProvider = defaultWindowsUserInfo,
): string | null {
const username = env?.USERNAME?.trim() || userInfo().username?.trim();
if (!username) {
return null;
}
const domain = env?.USERDOMAIN?.trim();
return domain ? `${domain}\\${username}` : username;
}
function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set<string> {
const trusted = new Set<string>(TRUSTED_BASE);
const principal = resolveWindowsUserPrincipal(env);
if (principal) {
trusted.add(normalize(principal));
const parts = principal.split("\\");
const userOnly = parts.at(-1);
if (userOnly) {
trusted.add(normalize(userOnly));
}
}
const userSid = normalizeSid(env?.USERSID ?? "");
// Guard: never add world-equivalent SIDs (Everyone, Authenticated Users, BUILTIN\\Users)
// to the trusted set, even if USERSID is set to one of them by a malicious process.
if (userSid && SID_RE.test(userSid) && !WORLD_SIDS.has(userSid)) {
trusted.add(userSid);
}
return trusted;
}
function resolveWindowsSystemCommand(command: string, env?: NodeJS.ProcessEnv): string {
// Never fall back to a bare helper name here; Windows command search can
// consult the current directory and PATH before the real System32 helper.
const root = getWindowsInstallRoots(env ?? process.env).systemRoot;
return path.win32.join(root, "System32", command);
}
function classifyPrincipal(
principal: string,
trustedPrincipals: Set<string>,
): "trusted" | "world" | "group" {
const normalized = normalize(principal);
if (SID_RE.test(normalized)) {
// Strip the leading * that icacls /sid prefixes to SIDs before lookup.
const sid = normalizeSid(normalized);
// World-equivalent SIDs must be classified as "world", not "group", so
// that callers applying world-write policies catch everyone/authenticated-
// users entries the same way they would catch the human-readable names.
if (WORLD_SIDS.has(sid)) {
return "world";
}
if (TRUSTED_SIDS.has(sid) || trustedPrincipals.has(sid)) {
return "trusted";
}
return "group";
}
if (
trustedPrincipals.has(normalized) ||
TRUSTED_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
) {
return "trusted";
}
if (
WORLD_PRINCIPALS.has(normalized) ||
WORLD_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
) {
return "world";
}
// Fallback: strip diacritics and re-check for localized SYSTEM variants
const stripped = normalized.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (
stripped !== normalized &&
(TRUSTED_BASE.has(stripped) ||
TRUSTED_SUFFIXES.some((suffix) => {
const strippedSuffix = suffix.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
return stripped.endsWith(strippedSuffix);
}))
) {
return "trusted";
}
return "group";
}
function rightsFromTokens(tokens: string[]): {
canRead: boolean;
canWrite: boolean;
} {
const upper = tokens.join("").toUpperCase();
const canWrite =
upper.includes("F") || upper.includes("M") || upper.includes("W") || upper.includes("D");
const canRead = upper.includes("F") || upper.includes("M") || upper.includes("R");
return { canRead, canWrite };
}
function isStatusLine(lowerLine: string): boolean {
return STATUS_PREFIXES.some((prefix) => lowerLine.startsWith(prefix));
}
function stripTargetPrefix(params: {
trimmedLine: string;
lowerLine: string;
normalizedTarget: string;
lowerTarget: string;
quotedTarget: string;
quotedLower: string;
}): string {
if (params.lowerLine.startsWith(params.lowerTarget)) {
return params.trimmedLine.slice(params.normalizedTarget.length).trim();
}
if (params.lowerLine.startsWith(params.quotedLower)) {
return params.trimmedLine.slice(params.quotedTarget.length).trim();
}
return params.trimmedLine;
}
function parseAceEntry(entry: string): WindowsAclEntry | null {
if (!entry || !entry.includes("(")) {
return null;
}
const idx = entry.indexOf(":");
if (idx === -1) {
return null;
}
const principal = entry.slice(0, idx).trim();
const rawRights = entry.slice(idx + 1).trim();
const tokens =
rawRights
.match(/\(([^)]+)\)/g)
?.map((token) => token.slice(1, -1).trim())
.filter(Boolean) ?? [];
if (tokens.some((token) => token.toUpperCase() === "DENY")) {
return null;
}
const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase()));
if (rights.length === 0) {
return null;
}
const { canRead, canWrite } = rightsFromTokens(rights);
return { principal, rights, rawRights, canRead, canWrite };
}
export function parseIcaclsOutput(output: string, targetPath: string): WindowsAclEntry[] {
const entries: WindowsAclEntry[] = [];
const normalizedTarget = targetPath.trim();
const lowerTarget = normalizedTarget.toLowerCase();
const quotedTarget = `"${normalizedTarget}"`;
const quotedLower = quotedTarget.toLowerCase();
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line.trim()) {
continue;
}
const trimmed = line.trim();
const lower = trimmed.toLowerCase();
if (isStatusLine(lower)) {
continue;
}
const entry = stripTargetPrefix({
trimmedLine: trimmed,
lowerLine: lower,
normalizedTarget,
lowerTarget,
quotedTarget,
quotedLower,
});
const parsed = parseAceEntry(entry);
if (!parsed) {
continue;
}
entries.push(parsed);
}
return entries;
}
export function summarizeWindowsAcl(
entries: WindowsAclEntry[],
env?: NodeJS.ProcessEnv,
): Pick<WindowsAclSummary, "trusted" | "untrustedWorld" | "untrustedGroup"> {
const trustedPrincipals = buildTrustedPrincipals(env);
const trusted: WindowsAclEntry[] = [];
const untrustedWorld: WindowsAclEntry[] = [];
const untrustedGroup: WindowsAclEntry[] = [];
for (const entry of entries) {
const classification = classifyPrincipal(entry.principal, trustedPrincipals);
if (classification === "trusted") {
trusted.push(entry);
} else if (classification === "world") {
untrustedWorld.push(entry);
} else {
untrustedGroup.push(entry);
}
}
return { trusted, untrustedWorld, untrustedGroup };
}
async function resolveCurrentUserSid(
exec: ExecFn,
env?: NodeJS.ProcessEnv,
): Promise<string | null> {
try {
const { stdout, stderr } = await exec(resolveWindowsSystemCommand("whoami.exe", env), [
"/user",
"/fo",
"csv",
"/nh",
]);
const match = `${stdout}\n${stderr}`.match(/\*?S-\d+-\d+(?:-\d+)+/i);
return match ? normalizeSid(match[0]) : null;
} catch (err) {
// Log but do not propagate — SID resolution is best-effort.
// Callers fall back to env-based resolution when this returns null.
log.warn("resolveCurrentUserSid failed", { error: String(err) });
return null;
}
}
export async function inspectWindowsAcl(
targetPath: string,
opts?: { env?: NodeJS.ProcessEnv; exec?: ExecFn },
): Promise<WindowsAclSummary> {
const exec = opts?.exec ?? runExec;
try {
// /sid outputs security identifiers (e.g. *S-1-5-18) instead of locale-
// dependent account names so the audit works correctly on non-English
// Windows (Russian, Chinese, etc.) where icacls prints Cyrillic / CJK
// characters that may be garbled when Node reads them in the wrong code
// page. Fixes #35834.
const { stdout, stderr } = await exec(resolveWindowsSystemCommand("icacls.exe", opts?.env), [
targetPath,
"/sid",
]);
const output = `${stdout}\n${stderr}`.trim();
const entries = parseIcaclsOutput(output, targetPath);
let effectiveEnv = opts?.env;
let { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv);
const needsUserSidResolution =
!effectiveEnv?.USERSID &&
untrustedGroup.some((entry) => SID_RE.test(normalize(entry.principal)));
if (needsUserSidResolution) {
const currentUserSid = await resolveCurrentUserSid(exec, effectiveEnv);
if (currentUserSid) {
effectiveEnv = { ...effectiveEnv, USERSID: currentUserSid };
({ trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv));
}
}
return { ok: true, entries, trusted, untrustedWorld, untrustedGroup };
} catch (err) {
return {
ok: false,
entries: [],
trusted: [],
untrustedWorld: [],
untrustedGroup: [],
error: String(err),
};
}
}
export function formatWindowsAclSummary(summary: WindowsAclSummary): string {
if (!summary.ok) {
return "unknown";
}
const untrusted = [...summary.untrustedWorld, ...summary.untrustedGroup];
if (untrusted.length === 0) {
return "trusted-only";
}
return untrusted.map((entry) => `${entry.principal}:${entry.rawRights}`).join(", ");
}
export function formatIcaclsResetCommand(
targetPath: string,
opts: IcaclsResetCommandOptions,
): string {
const command = resolveWindowsSystemCommand("icacls.exe", opts.env);
const user = resolveWindowsUserPrincipal(opts.env, opts.userInfo) ?? "%USERNAME%";
const grant = opts.isDir ? "(OI)(CI)F" : "F";
// Quoted executable paths need shell-specific handling in PowerShell; keep
// the resolved System32 helper as the command token and quote only arguments.
return [
command,
`"${targetPath}"`,
"/inheritance:r",
"/grant:r",
`"${user}:${grant}"`,
"/grant:r",
`"*S-1-5-18:${grant}"`,
].join(" ");
}
export function createIcaclsResetCommand(
targetPath: string,
opts: IcaclsResetCommandOptions,
): { command: string; args: string[]; display: string } | null {
const user = resolveWindowsUserPrincipal(opts.env, opts.userInfo);
if (!user) {
return null;
}
const grant = opts.isDir ? "(OI)(CI)F" : "F";
const args = [
targetPath,
"/inheritance:r",
"/grant:r",
`${user}:${grant}`,
"/grant:r",
`*S-1-5-18:${grant}`,
];
return {
command: resolveWindowsSystemCommand("icacls.exe", opts.env),
args,
display: formatIcaclsResetCommand(targetPath, opts),
};
}
export {
createIcaclsResetCommand,
formatIcaclsResetCommand,
formatWindowsAclSummary,
inspectWindowsAcl,
parseIcaclsOutput,
resolveWindowsUserPrincipal,
summarizeWindowsAcl,
type ExecFn,
type WindowsAclEntry,
type WindowsAclSummary,
} from "../infra/permissions.js";