Spaces:
Paused
Paused
| 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<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)); | |
| } | |
| } | |
| 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<WindowsAclSummary, "trusted" | "untrustedWorld" | "untrustedGroup"> { | |
| 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<WindowsAclSummary> { | |
| 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) }; | |
| } | |