refactor: simplify windows ACL parsing and expand coverage

This commit is contained in:
Peter Steinberger
2026-02-22 10:42:53 +01:00
parent 9b9cc44a4e
commit bd4f670544
2 changed files with 126 additions and 110 deletions

View File

@@ -18,6 +18,22 @@ const {
summarizeWindowsAcl,
} = await import("./windows-acl.js");
function aclEntry(params: {
principal: string;
rights?: string[];
rawRights?: string;
canRead?: boolean;
canWrite?: boolean;
}): WindowsAclEntry {
return {
principal: params.principal,
rights: params.rights ?? ["F"],
rawRights: params.rawRights ?? "(F)",
canRead: params.canRead ?? true,
canWrite: params.canWrite ?? true,
};
}
describe("windows-acl", () => {
describe("resolveWindowsUserPrincipal", () => {
it("returns DOMAIN\\USERNAME when both are present", () => {
@@ -81,6 +97,7 @@ Successfully processed 1 files`;
it("skips status messages", () => {
const output = `Successfully processed 1 files
Processed file: C:\\test\\file.txt
Failed processing 0 files
No mapping between account names`;
const entries = parseIcaclsOutput(output, "C:\\test\\file.txt");
@@ -107,6 +124,14 @@ Successfully processed 1 files`;
expect(entries[1].principal).toBe("S-1-5-21-1824257776-4070701511-781240313-1001");
});
it("ignores malformed ACL lines that contain ':' but no rights tokens", () => {
const output = `C:\\test\\file.txt random:message
C:\\test\\file.txt BUILTIN\\Administrators:(F)`;
const entries = parseIcaclsOutput(output, "C:\\test\\file.txt");
expect(entries).toHaveLength(1);
expect(entries[0].principal).toBe("BUILTIN\\Administrators");
});
it("handles quoted target paths", () => {
const output = `"C:\\path with spaces\\file.txt" BUILTIN\\Administrators:(F)`;
const entries = parseIcaclsOutput(output, "C:\\path with spaces\\file.txt");
@@ -140,20 +165,8 @@ Successfully processed 1 files`;
describe("summarizeWindowsAcl", () => {
it("classifies trusted principals", () => {
const entries: WindowsAclEntry[] = [
{
principal: "NT AUTHORITY\\SYSTEM",
rights: ["F"],
rawRights: "(F)",
canRead: true,
canWrite: true,
},
{
principal: "BUILTIN\\Administrators",
rights: ["F"],
rawRights: "(F)",
canRead: true,
canWrite: true,
},
aclEntry({ principal: "NT AUTHORITY\\SYSTEM" }),
aclEntry({ principal: "BUILTIN\\Administrators" }),
];
const summary = summarizeWindowsAcl(entries);
expect(summary.trusted).toHaveLength(2);
@@ -163,20 +176,8 @@ Successfully processed 1 files`;
it("classifies world principals", () => {
const entries: WindowsAclEntry[] = [
{
principal: "Everyone",
rights: ["R"],
rawRights: "(R)",
canRead: true,
canWrite: false,
},
{
principal: "BUILTIN\\Users",
rights: ["R"],
rawRights: "(R)",
canRead: true,
canWrite: false,
},
aclEntry({ principal: "Everyone", rights: ["R"], rawRights: "(R)", canWrite: false }),
aclEntry({ principal: "BUILTIN\\Users", rights: ["R"], rawRights: "(R)", canWrite: false }),
];
const summary = summarizeWindowsAcl(entries);
expect(summary.trusted).toHaveLength(0);
@@ -185,15 +186,7 @@ Successfully processed 1 files`;
});
it("classifies current user as trusted", () => {
const entries: WindowsAclEntry[] = [
{
principal: "WORKGROUP\\TestUser",
rights: ["F"],
rawRights: "(F)",
canRead: true,
canWrite: true,
},
];
const entries: WindowsAclEntry[] = [aclEntry({ principal: "WORKGROUP\\TestUser" })];
const env = { USERNAME: "TestUser", USERDOMAIN: "WORKGROUP" };
const summary = summarizeWindowsAcl(entries, env);
expect(summary.trusted).toHaveLength(1);
@@ -217,15 +210,7 @@ Successfully processed 1 files`;
describe("summarizeWindowsAcl — SID-based classification", () => {
it("classifies SYSTEM SID (S-1-5-18) as trusted", () => {
const entries: WindowsAclEntry[] = [
{
principal: "S-1-5-18",
rights: ["F"],
rawRights: "(F)",
canRead: true,
canWrite: true,
},
];
const entries: WindowsAclEntry[] = [aclEntry({ principal: "S-1-5-18" })];
const summary = summarizeWindowsAcl(entries);
expect(summary.trusted).toHaveLength(1);
expect(summary.untrustedWorld).toHaveLength(0);
@@ -233,15 +218,7 @@ Successfully processed 1 files`;
});
it("classifies BUILTIN\\Administrators SID (S-1-5-32-544) as trusted", () => {
const entries: WindowsAclEntry[] = [
{
principal: "S-1-5-32-544",
rights: ["F"],
rawRights: "(F)",
canRead: true,
canWrite: true,
},
];
const entries: WindowsAclEntry[] = [aclEntry({ principal: "S-1-5-32-544" })];
const summary = summarizeWindowsAcl(entries);
expect(summary.trusted).toHaveLength(1);
expect(summary.untrustedGroup).toHaveLength(0);
@@ -249,21 +226,23 @@ Successfully processed 1 files`;
it("classifies caller SID from USERSID env var as trusted", () => {
const callerSid = "S-1-5-21-1824257776-4070701511-781240313-1001";
const entries: WindowsAclEntry[] = [
{
principal: callerSid,
rights: ["F"],
rawRights: "(F)",
canRead: true,
canWrite: true,
},
];
const entries: WindowsAclEntry[] = [aclEntry({ principal: callerSid })];
const env = { USERSID: callerSid };
const summary = summarizeWindowsAcl(entries, env);
expect(summary.trusted).toHaveLength(1);
expect(summary.untrustedGroup).toHaveLength(0);
});
it("matches SIDs case-insensitively and trims USERSID", () => {
const entries: WindowsAclEntry[] = [
aclEntry({ principal: "s-1-5-21-1824257776-4070701511-781240313-1001" }),
];
const env = { USERSID: " S-1-5-21-1824257776-4070701511-781240313-1001 " };
const summary = summarizeWindowsAcl(entries, env);
expect(summary.trusted).toHaveLength(1);
expect(summary.untrustedGroup).toHaveLength(0);
});
it("classifies unknown SID as group (not world)", () => {
const entries: WindowsAclEntry[] = [
{

View File

@@ -43,6 +43,12 @@ const TRUSTED_SIDS = new Set([
"s-1-5-32-544",
"s-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
]);
const STATUS_PREFIXES = [
"successfully processed",
"processed",
"failed processing",
"no mapping between account names",
];
const normalize = (value: string) => value.trim().toLowerCase();
@@ -66,7 +72,7 @@ function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set<string> {
trusted.add(normalize(userOnly));
}
}
const userSid = env?.USERSID?.trim().toLowerCase();
const userSid = normalize(env?.USERSID ?? "");
if (userSid && SID_RE.test(userSid)) {
trusted.add(userSid);
}
@@ -75,19 +81,24 @@ function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set<string> {
function classifyPrincipal(
principal: string,
env?: NodeJS.ProcessEnv,
trustedPrincipals: Set<string>,
): "trusted" | "world" | "group" {
const normalized = normalize(principal);
const trusted = buildTrustedPrincipals(env);
if (SID_RE.test(normalized)) {
return TRUSTED_SIDS.has(normalized) || trusted.has(normalized) ? "trusted" : "group";
return TRUSTED_SIDS.has(normalized) || trustedPrincipals.has(normalized) ? "trusted" : "group";
}
if (trusted.has(normalized) || TRUSTED_SUFFIXES.some((s) => normalized.endsWith(s))) {
if (
trustedPrincipals.has(normalized) ||
TRUSTED_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
) {
return "trusted";
}
if (WORLD_PRINCIPALS.has(normalized) || WORLD_SUFFIXES.some((s) => normalized.endsWith(s))) {
if (
WORLD_PRINCIPALS.has(normalized) ||
WORLD_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
) {
return "world";
}
return "group";
@@ -101,6 +112,58 @@ function rightsFromTokens(tokens: string[]): { canRead: boolean; canWrite: boole
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();
@@ -115,50 +178,23 @@ export function parseIcaclsOutput(output: string, targetPath: string): WindowsAc
}
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")
) {
if (isStatusLine(lower)) {
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) {
const entry = stripTargetPrefix({
trimmedLine: trimmed,
lowerLine: lower,
normalizedTarget,
lowerTarget,
quotedTarget,
quotedLower,
});
const parsed = parseAceEntry(entry);
if (!parsed) {
continue;
}
if (!entry.includes("(")) {
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 });
entries.push(parsed);
}
return entries;
@@ -168,11 +204,12 @@ 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, env);
const classification = classifyPrincipal(entry.principal, trustedPrincipals);
if (classification === "trusted") {
trusted.push(entry);
} else if (classification === "world") {