fix(security): use icacls /sid for locale-independent Windows ACL audit (#38900)

* fix(security): use icacls /sid for locale-independent Windows ACL audit

On non-English Windows editions (Russian, Chinese, etc.) icacls prints
account names in the system locale.  When Node.js reads the output in a
different code page the strings are garbled (e.g. "NT AUTHORITY\???????"
for "NT AUTHORITY\СИСТЕМА"), causing summarizeWindowsAcl to classify SYSTEM
and Administrators as untrusted and flag the config files as "others
writable" — a false-positive security alert.

Fix:
1. Pass /sid to icacls so it outputs security identifiers (*S-1-5-X-...)
   instead of locale-dependent account names.
2. Extend SID_RE to accept the leading * that icacls prepends to SIDs in
   /sid mode: /^\*?s-\d+-\d+(-\d+)+$/i
3. Strip the * before looking up the bare SID in TRUSTED_SIDS / the
   per-user USERSID set so *S-1-5-18 is correctly classified as SYSTEM
   (trusted) and *S-1-5-32-544 as Administrators (trusted).

Tests:
- Update the inspectWindowsAcl "returns parsed ACL entries" assertion to
  expect the /sid flag in the icacls call.
- Add "classifies *S-1-5-18 (icacls /sid prefix form of SYSTEM) as trusted"
  SID classification test.
- Add "classifies *S-1-5-32-544 (icacls /sid Administrators) as trusted".
- Add inspectWindowsAcl end-to-end test with /sid-format mock output
  (*S-1-5-18, *S-1-5-32-544, user SID) — all three classified as trusted.

Fixes #35834

* fix(security): classify world-equivalent SIDs as 'world' when using icacls /sid

When icacls is invoked with /sid, world-equivalent principals like
Everyone, Authenticated Users, and BUILTIN\Users are emitted as raw
SIDs (*S-1-1-0, *S-1-5-11, *S-1-5-32-545). classifyPrincipal() had
no SID-based mapping for these, so they fell through to the generic
'group' category instead of 'world', silently downgrading security
findings that should trigger world-write/world-readable alerts.

Fix: add a WORLD_SIDS constant and check it before falling back to
'group'. Add three regression tests to lock in the behaviour.

* Security: resolve owner SID fallback for Windows ACL audit

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Byungsker
2026-03-08 02:49:33 +09:00
committed by GitHub
parent 4de697f8fa
commit 7735a0b85c
2 changed files with 182 additions and 6 deletions

View File

@@ -244,6 +244,20 @@ Successfully processed 1 files`;
expectTrustedOnly([aclEntry({ principal: "S-1-5-18" })]);
});
it("classifies *S-1-5-18 (icacls /sid prefix form of SYSTEM) as trusted (refs #35834)", () => {
// icacls /sid output prefixes SIDs with *, e.g. *S-1-5-18 instead of
// S-1-5-18. Without this fix the asterisk caused SID_RE to not match
// and the SYSTEM entry was misclassified as "group" (untrusted).
expectTrustedOnly([aclEntry({ principal: "*S-1-5-18" })]);
});
it("classifies *S-1-5-32-544 (icacls /sid Administrators) as trusted", () => {
const entries: WindowsAclEntry[] = [aclEntry({ principal: "*S-1-5-32-544" })];
const summary = summarizeWindowsAcl(entries);
expect(summary.trusted).toHaveLength(1);
expect(summary.untrustedGroup).toHaveLength(0);
});
it("classifies BUILTIN\\Administrators SID (S-1-5-32-544) as trusted", () => {
const entries: WindowsAclEntry[] = [aclEntry({ principal: "S-1-5-32-544" })];
const summary = summarizeWindowsAcl(entries);
@@ -265,6 +279,21 @@ Successfully processed 1 files`;
);
});
it("does not trust *-prefixed Everyone via USERSID", () => {
const entries: WindowsAclEntry[] = [
{
principal: "*S-1-1-0",
rights: ["R"],
rawRights: "(R)",
canRead: true,
canWrite: false,
},
];
const summary = summarizeWindowsAcl(entries, { USERSID: "*S-1-1-0" });
expect(summary.untrustedWorld).toHaveLength(1);
expect(summary.trusted).toHaveLength(0);
});
it("classifies unknown SID as group (not world)", () => {
const entries: WindowsAclEntry[] = [
{
@@ -281,6 +310,53 @@ Successfully processed 1 files`;
expect(summary.trusted).toHaveLength(0);
});
it("classifies Everyone SID (S-1-1-0) as world, not group", () => {
// When icacls is run with /sid, "Everyone" becomes *S-1-1-0.
// It must be classified as "world" to preserve security-audit severity.
const entries: WindowsAclEntry[] = [
{
principal: "*S-1-1-0",
rights: ["R"],
rawRights: "(R)",
canRead: true,
canWrite: false,
},
];
const summary = summarizeWindowsAcl(entries);
expect(summary.untrustedWorld).toHaveLength(1);
expect(summary.untrustedGroup).toHaveLength(0);
});
it("classifies Authenticated Users SID (S-1-5-11) as world, not group", () => {
const entries: WindowsAclEntry[] = [
{
principal: "*S-1-5-11",
rights: ["R"],
rawRights: "(R)",
canRead: true,
canWrite: false,
},
];
const summary = summarizeWindowsAcl(entries);
expect(summary.untrustedWorld).toHaveLength(1);
expect(summary.untrustedGroup).toHaveLength(0);
});
it("classifies BUILTIN\\Users SID (S-1-5-32-545) as world, not group", () => {
const entries: WindowsAclEntry[] = [
{
principal: "*S-1-5-32-545",
rights: ["R"],
rawRights: "(R)",
canRead: true,
canWrite: false,
},
];
const summary = summarizeWindowsAcl(entries);
expect(summary.untrustedWorld).toHaveLength(1);
expect(summary.untrustedGroup).toHaveLength(0);
});
it("full scenario: SYSTEM SID + owner SID only → no findings", () => {
const ownerSid = "S-1-5-21-1824257776-4070701511-781240313-1001";
const entries: WindowsAclEntry[] = [
@@ -319,7 +395,55 @@ Successfully processed 1 files`;
exec: mockExec,
});
expectInspectSuccess(result, 2);
expect(mockExec).toHaveBeenCalledWith("icacls", ["C:\\test\\file.txt"]);
// /sid is passed so that account names are printed as SIDs, making the
// audit locale-independent (fixes #35834).
expect(mockExec).toHaveBeenCalledWith("icacls", ["C:\\test\\file.txt", "/sid"]);
});
it("classifies *S-1-5-18 (SID form of SYSTEM from /sid) as trusted", async () => {
// When icacls is called with /sid it outputs *S-X-X-X instead of
// locale-dependent names like "NT AUTHORITY\\SYSTEM" or the Russian
// garbled equivalent.
const mockExec = vi.fn().mockResolvedValue({
stdout:
"C:\\test\\file.txt *S-1-5-21-111-222-333-1001:(F)\n *S-1-5-18:(F)\n *S-1-5-32-544:(F)",
stderr: "",
});
const result = await inspectWindowsAcl("C:\\test\\file.txt", {
exec: mockExec,
env: { USERSID: "S-1-5-21-111-222-333-1001" },
});
expectInspectSuccess(result, 3);
// All three entries (current user, SYSTEM, Administrators) must be trusted.
expect(result.trusted).toHaveLength(3);
expect(result.untrustedGroup).toHaveLength(0);
expect(result.untrustedWorld).toHaveLength(0);
});
it("resolves current user SID via whoami when USERSID is missing", async () => {
const mockExec = vi
.fn()
.mockResolvedValueOnce({
stdout:
"C:\\test\\file.txt *S-1-5-21-111-222-333-1001:(F)\n *S-1-5-18:(F)",
stderr: "",
})
.mockResolvedValueOnce({
stdout: '"mock-host\\\\MockUser","S-1-5-21-111-222-333-1001"\r\n',
stderr: "",
});
const result = await inspectWindowsAcl("C:\\test\\file.txt", {
exec: mockExec,
env: { USERNAME: "MockUser", USERDOMAIN: "mock-host" },
});
expectInspectSuccess(result, 2);
expect(result.trusted).toHaveLength(2);
expect(result.untrustedGroup).toHaveLength(0);
expect(mockExec).toHaveBeenNthCalledWith(1, "icacls", ["C:\\test\\file.txt", "/sid"]);
expect(mockExec).toHaveBeenNthCalledWith(2, "whoami", ["/user", "/fo", "csv", "/nh"]);
});
it("returns error state on exec failure", async () => {