mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:01:24 +00:00
fix: use Windows ACLs for security audit
This commit is contained in:
203
src/security/windows-acl.ts
Normal file
203
src/security/windows-acl.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
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) };
|
||||
}
|
||||
Reference in New Issue
Block a user