mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 13:00:45 +00:00
[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:
committed by
GitHub
parent
61481eb34f
commit
538605ff44
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user