import os from "node:os"; import { runExec } from "../process/exec.js"; 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; }; 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", ]); const WORLD_SUFFIXES = ["\\users", "\\authenticated users"]; const TRUSTED_SUFFIXES = ["\\administrators", "\\system"]; const normalize = (value: string) => value.trim().toLowerCase(); export function resolveWindowsUserPrincipal(env?: NodeJS.ProcessEnv): string | null { const username = env?.USERNAME?.trim() || os.userInfo().username?.trim(); if (!username) { return null; } const domain = env?.USERDOMAIN?.trim(); return domain ? `${domain}\\${username}` : username; } function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set { const trusted = new Set(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)); } } return trusted; } function classifyPrincipal( principal: string, env?: NodeJS.ProcessEnv, ): "trusted" | "world" | "group" { const normalized = normalize(principal); const trusted = buildTrustedPrincipals(env); if (trusted.has(normalized) || TRUSTED_SUFFIXES.some((s) => normalized.endsWith(s))) { return "trusted"; } if (WORLD_PRINCIPALS.has(normalized) || WORLD_SUFFIXES.some((s) => normalized.endsWith(s))) { return "world"; } 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 }; } 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 ( lower.startsWith("successfully processed") || lower.startsWith("processed") || lower.startsWith("failed processing") || lower.startsWith("no mapping between account names") ) { continue; } let entry = trimmed; if (lower.startsWith(lowerTarget)) { entry = trimmed.slice(normalizedTarget.length).trim(); } else if (lower.startsWith(quotedLower)) { entry = trimmed.slice(quotedTarget.length).trim(); } if (!entry) { continue; } const idx = entry.indexOf(":"); if (idx === -1) { continue; } 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")) { continue; } const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase())); if (rights.length === 0) { continue; } const { canRead, canWrite } = rightsFromTokens(rights); entries.push({ principal, rights, rawRights, canRead, canWrite }); } return entries; } export function summarizeWindowsAcl( entries: WindowsAclEntry[], env?: NodeJS.ProcessEnv, ): Pick { const trusted: WindowsAclEntry[] = []; const untrustedWorld: WindowsAclEntry[] = []; const untrustedGroup: WindowsAclEntry[] = []; for (const entry of entries) { const classification = classifyPrincipal(entry.principal, env); if (classification === "trusted") { trusted.push(entry); } else if (classification === "world") { untrustedWorld.push(entry); } else { untrustedGroup.push(entry); } } return { trusted, untrustedWorld, untrustedGroup }; } export async function inspectWindowsAcl( targetPath: string, opts?: { env?: NodeJS.ProcessEnv; exec?: ExecFn }, ): Promise { const exec = opts?.exec ?? runExec; try { const { stdout, stderr } = await exec("icacls", [targetPath]); const output = `${stdout}\n${stderr}`.trim(); const entries = parseIcaclsOutput(output, targetPath); const { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, opts?.env); 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: { isDir: boolean; env?: NodeJS.ProcessEnv }, ): string { const user = resolveWindowsUserPrincipal(opts.env) ?? "%USERNAME%"; const grant = opts.isDir ? "(OI)(CI)F" : "F"; return `icacls "${targetPath}" /inheritance:r /grant:r "${user}:${grant}" /grant:r "SYSTEM:${grant}"`; } export function createIcaclsResetCommand( targetPath: string, opts: { isDir: boolean; env?: NodeJS.ProcessEnv }, ): { command: string; args: string[]; display: string } | null { const user = resolveWindowsUserPrincipal(opts.env); if (!user) { return null; } const grant = opts.isDir ? "(OI)(CI)F" : "F"; const args = [ targetPath, "/inheritance:r", "/grant:r", `${user}:${grant}`, "/grant:r", `SYSTEM:${grant}`, ]; return { command: "icacls", args, display: formatIcaclsResetCommand(targetPath, opts) }; }