ssh-mcp 2.3.3 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,453 @@
1
+ /**
2
+ * The SDDL half of the Windows ACL check: descriptor text in, verdict out.
3
+ *
4
+ * Split from windows-acl.ts so the parser's platform independence is legible in the module
5
+ * graph — this file imports nothing at all, and `sddl-import-boundary.test.ts` holds it to
6
+ * that. What it owns is the grammar *and* the allowlist the grammar is judged against
7
+ * (`ALWAYS_ALLOWED_SIDS`, the RID-500 rule in `aclIdentity`): the second is authorization
8
+ * policy rather than syntax, but it is policy that needs no machine to evaluate, which is
9
+ * the line this cut actually draws.
10
+ *
11
+ * An earlier version of this header claimed the split prevents a *silent* failure — that an
12
+ * edit reaching for `icacls` inside `parseDacl` would leave CI green. That was wrong, and a
13
+ * review round disproved it by doing it: such an edit fails about 69 parser tests loudly on
14
+ * Linux, because none of those tests carries a platform guard. The real hazard is the step
15
+ * after. Faced with 69 red tests on a change that "obviously only affects Windows", the
16
+ * cheapest repair is `describe.runIf(win32)` — and *then* the 2000-run property suite stops
17
+ * exercising the decision off Windows and the green is real. Asserting the boundary here
18
+ * means the first step fails somewhere whose only sane repair is to undo the import.
19
+ *
20
+ * The type split follows the same line: the verdict vocabulary is what a descriptor says, so
21
+ * `Grant`, `UnknownReason`, `AclVerdict` and `AclIdentity` live here. `AclFinding` and
22
+ * `AclOptions` are what we tell the operator and what the operator asked for, so they stay
23
+ * with the platform half.
24
+ *
25
+ * `PRINCIPAL_NAMES`/`nameFor` are here against the plan that queued this split, which put
26
+ * them on the message side. They cannot go there while `parseDacl` populates `Grant.name`:
27
+ * honouring it would mean passing the parser a name resolver, a signature change the same
28
+ * plan ruled out by asking for no behaviour change. The table is keyed by SDDL alias and
29
+ * SID, which is this file's vocabulary anyway.
30
+ */
31
+ /**
32
+ * The principals a private config may name, by SID.
33
+ *
34
+ * An allowlist, because the POSIX branch is one — it refuses any bit outside
35
+ * 0700 — and the refusal message promises "the owning user only". The first
36
+ * version of this file was a denylist of six well-known groups, which let
37
+ * through `Domain Users` (whose SID is domain-relative, so no static list can
38
+ * name it), `Power Users`, and any second local account or custom group. On a
39
+ * domain-joined host that is every employee, reported as owner-only.
40
+ *
41
+ * SYSTEM and Administrators stay allowed: they are what a file under %APPDATA%
42
+ * actually carries (measured), and an administrator can take ownership
43
+ * regardless of what any DACL says, so refusing them would buy nothing.
44
+ */
45
+ const ALWAYS_ALLOWED_SIDS = new Set([
46
+ 'S-1-5-18', // NT AUTHORITY\SYSTEM
47
+ 'S-1-5-32-544', // BUILTIN\Administrators
48
+ ]);
49
+ /**
50
+ * SDDL alias to SID.
51
+ *
52
+ * Three entries carry the correctness load — `SY`, `BA`, and `LA` via
53
+ * DOMAIN_RELATIVE_RIDS — because those are the only principals that can appear in the
54
+ * allowlist, so those are the only comparisons where two spellings of one principal
55
+ * have to come out equal. That mattered: on a GitHub `windows-latest` runner the user is
56
+ * the built-in Administrator, whose own ACE is written `LA`, and comparing raw SIDs made
57
+ * the operator a stranger to their own config — 60 tests failed with "access is granted
58
+ * to LA".
59
+ *
60
+ * The other fourteen are verdict-neutral: an unresolved alias falls through unchanged and
61
+ * is refused either way. They exist so a refusal reports one entry per principal and
62
+ * prints a SID a remediation can name — which means the length of this list is not a
63
+ * security property.
64
+ */
65
+ const ALIAS_SIDS = new Map([
66
+ ['SY', 'S-1-5-18'],
67
+ ['BA', 'S-1-5-32-544'],
68
+ ['WD', 'S-1-1-0'],
69
+ ['BU', 'S-1-5-32-545'],
70
+ ['AU', 'S-1-5-11'],
71
+ ['IU', 'S-1-5-4'],
72
+ ['BG', 'S-1-5-32-546'],
73
+ ['AN', 'S-1-5-7'],
74
+ ['PU', 'S-1-5-32-547'],
75
+ ['NU', 'S-1-5-2'],
76
+ ['SU', 'S-1-5-6'],
77
+ ['RU', 'S-1-5-32-554'],
78
+ ['WR', 'S-1-5-33'],
79
+ ['CO', 'S-1-3-0'],
80
+ ['LS', 'S-1-5-19'],
81
+ ['NS', 'S-1-5-20'],
82
+ ['RC', 'S-1-5-12'],
83
+ ]);
84
+ /**
85
+ * Aliases whose SID is relative to an account domain, expanded against the
86
+ * domain of the account we run as. `LA` is the reason this exists; the group ones
87
+ * are here so a descriptor that spells one principal both ways is reported once
88
+ * rather than twice — the display name still comes from the raw trustee, so `DU`
89
+ * reads as "Domain Users" while the SID spelling reads as the SID.
90
+ */
91
+ const DOMAIN_RELATIVE_RIDS = new Map([
92
+ ['LA', 500], // built-in Administrator
93
+ ['LG', 501], // built-in Guest
94
+ ['DA', 512], // Domain Admins
95
+ ['DU', 513], // Domain Users
96
+ ['DG', 514], // Domain Guests
97
+ ['DC', 515], // Domain Computers
98
+ ['DD', 516], // Domain Controllers
99
+ ['CA', 517], // Cert Publishers
100
+ // These two live only in the forest root domain, so expanding them against *our*
101
+ // account domain names a SID that does not exist in a child domain. Harmless for
102
+ // the verdict — a SID resolving to nothing is not in the allowlist, so the ACE is
103
+ // refused either way — but the remediation would print a /remove:g icacls cannot
104
+ // map, which is the defect Grant.sid exists to prevent. Kept so a refusal names
105
+ // the principal; treat the SID as best effort.
106
+ ['SA', 518], // Schema Admins (forest root)
107
+ ['EA', 519], // Enterprise Admins (forest root)
108
+ ['PA', 520], // Group Policy Creator Owners
109
+ ]);
110
+ /**
111
+ * Display names for principals we expect to have to name in a refusal. Only
112
+ * cosmetic — an unrecognised trustee is reported by its SID or alias, and is
113
+ * refused either way, so nothing security-relevant depends on this table being
114
+ * complete.
115
+ */
116
+ const PRINCIPAL_NAMES = new Map([
117
+ ['WD', 'Everyone'], ['S-1-1-0', 'Everyone'],
118
+ ['BU', 'BUILTIN\\Users'], ['S-1-5-32-545', 'BUILTIN\\Users'],
119
+ ['AU', 'Authenticated Users'], ['S-1-5-11', 'Authenticated Users'],
120
+ ['IU', 'Interactive'], ['S-1-5-4', 'Interactive'],
121
+ ['BG', 'BUILTIN\\Guests'], ['S-1-5-32-546', 'BUILTIN\\Guests'],
122
+ ['AN', 'Anonymous'], ['S-1-5-7', 'Anonymous'],
123
+ ['PU', 'BUILTIN\\Power Users'], ['S-1-5-32-547', 'BUILTIN\\Power Users'],
124
+ ['DU', 'Domain Users'],
125
+ ['NU', 'NETWORK'], ['S-1-5-2', 'NETWORK'],
126
+ ['SU', 'SERVICE'], ['S-1-5-6', 'SERVICE'],
127
+ ['RU', 'Pre-Windows 2000 Compatible Access'], ['S-1-5-32-554', 'Pre-Windows 2000 Compatible Access'],
128
+ ['WR', 'WRITE RESTRICTED'], ['S-1-5-33', 'WRITE RESTRICTED'],
129
+ ['CO', 'CREATOR OWNER'], ['S-1-3-0', 'CREATOR OWNER'],
130
+ ]);
131
+ /**
132
+ * ACE types that grant nothing to their trustee: denials, and the audit, alarm
133
+ * and non-ACL entry types.
134
+ *
135
+ * A denylist here, and an allowlist for principals — each in the direction that
136
+ * fails safe. The first version tested `type === 'A' || type === 'OA'`, which
137
+ * silently skipped `XA` and `ZA`: conditional allow ACEs, exactly what Dynamic
138
+ * Access Control puts on managed files. An ACE type this does not recognise is
139
+ * now treated as granting, so a future addition to SDDL cannot open a hole by
140
+ * being unfamiliar.
141
+ */
142
+ const NON_GRANTING_ACE_TYPES = new Set([
143
+ 'D', 'OD', 'XD', // access denied
144
+ 'AU', 'AL', 'OU', 'OL', 'XU', // audit and alarm
145
+ 'ML', 'RA', 'SP', 'TL', 'FL', // label, attribute, policy, trust, filter
146
+ ]);
147
+ /**
148
+ * Two different flag vocabularies, which is easy to conflate — the first draft
149
+ * did, and rejected every real descriptor as unparseable because `D:AI(...)`
150
+ * carries a *control* flag where an ACE flag was expected.
151
+ *
152
+ * DACL control flags qualify the ACL as a whole: `P` protected (inheritance
153
+ * blocked), `AI` auto-inherited, `AR` auto-inherit-required. `P` is one
154
+ * character, so these cannot be read in fixed-width chunks.
155
+ */
156
+ const DACL_CONTROL_FLAGS = /^(?:P|AI|AR)*$/;
157
+ /** ACE flags, which are all two characters. */
158
+ const ACE_FLAG_CODES = new Set([
159
+ 'CI', 'OI', 'IO', 'NP', 'ID', 'SA', 'FA', 'TP', 'CR',
160
+ ]);
161
+ /**
162
+ * Rights that let a trustee change the object, as SDDL letter codes. `WD` here is
163
+ * WRITE_DAC, not Everyone — the rights field and the trustee field have separate
164
+ * vocabularies that happen to share spellings.
165
+ */
166
+ const WRITE_RIGHT_CODES = new Set([
167
+ 'FA', 'FW', 'KA', 'KW', 'GA', 'GW', 'SD', 'WD', 'WO', 'CC', 'DC', 'SW', 'WP', 'DT',
168
+ ]);
169
+ /** Rights that only let a trustee look. Anything unrecognised counts as write. */
170
+ const READ_RIGHT_CODES = new Set([
171
+ 'FR', 'FX', 'GR', 'GX', 'KR', 'RC', 'RP', 'LC', 'LO',
172
+ ]);
173
+ /**
174
+ * FILE_WRITE_DATA, FILE_APPEND_DATA, FILE_WRITE_EA, FILE_WRITE_ATTRIBUTES, DELETE,
175
+ * WRITE_DAC, WRITE_OWNER, GENERIC_WRITE, GENERIC_ALL.
176
+ */
177
+ const WRITE_MASK = 0x2 | 0x4 | 0x10 | 0x100 | 0x10000 | 0x40000 | 0x80000 | 0x40000000 | 0x10000000;
178
+ /**
179
+ * Whether a rights field lets its trustee *change* the object rather than only read it.
180
+ *
181
+ * This distinction is the basis of the default posture, and the field used to be parsed
182
+ * and discarded. Read exposure on Windows is genuinely muddier than under POSIX — a
183
+ * shared volume grants `BUILTIN\Users` read, and refusing over that stranded the reporter
184
+ * of #138 — but write exposure is not muddy at all: "an ACE grants a non-owner
185
+ * FILE_WRITE_DATA or WRITE_DAC" is exactly as unambiguous as `0o022`, and a config another
186
+ * account can rewrite decides which hosts, roles and approval policy this server honours.
187
+ * That is an authorization bypass, not a disclosure, and it was being reported as one.
188
+ *
189
+ * Measured from real inheritance: `0x1200a9` is read-and-execute, `0x1301bf` is modify.
190
+ * Anything this cannot classify counts as write, because refusing is the safe direction.
191
+ */
192
+ function grantsWrite(rights) {
193
+ const field = rights.trim().toUpperCase();
194
+ if (field === '')
195
+ return true;
196
+ if (/^0X[0-9A-F]+$/.test(field))
197
+ return (Number.parseInt(field.slice(2), 16) & WRITE_MASK) !== 0;
198
+ if (/^[0-9]+$/.test(field))
199
+ return (Number.parseInt(field, 10) & WRITE_MASK) !== 0;
200
+ if (field.length % 2 !== 0)
201
+ return true;
202
+ for (let i = 0; i < field.length; i += 2) {
203
+ const code = field.slice(i, i + 2);
204
+ if (WRITE_RIGHT_CODES.has(code))
205
+ return true;
206
+ if (!READ_RIGHT_CODES.has(code))
207
+ return true;
208
+ }
209
+ return false;
210
+ }
211
+ /**
212
+ * Whether a group's parentheses balance when quoting is ignored.
213
+ *
214
+ * The quote handling closed one merge and opened another: two ACEs whose conditional
215
+ * literals each carry a single unmatched `"` put the scanner into `inQuotes` across the
216
+ * intervening `)(`, collapsing every ACE between them into one group whose type, flags
217
+ * and trustee the attacker chooses. Five shapes were executed against the previous
218
+ * version and all five came back `restricted` while hiding real Everyone, Users and
219
+ * Authenticated Users grants.
220
+ *
221
+ * Where the quote-aware and quote-blind scans disagree, the quotes were load-bearing
222
+ * across an ACE boundary and the descriptor is not something to draw a conclusion from.
223
+ */
224
+ function quoteBlindBalanced(group) {
225
+ let depth = 0;
226
+ for (const ch of group) {
227
+ if (ch === '(')
228
+ depth++;
229
+ else if (ch === ')') {
230
+ depth--;
231
+ if (depth < 0)
232
+ return false;
233
+ }
234
+ }
235
+ return depth === 0;
236
+ }
237
+ /**
238
+ * ACE groups from a DACL body, respecting nesting *and quoting*.
239
+ *
240
+ * The quote handling is not decoration. A conditional ACE (`XA`/`ZA`) carries an
241
+ * expression whose string literals may contain parentheses, and SDDL gives them
242
+ * no escape. A quote-blind scan can therefore be fed two conditional ACEs whose
243
+ * literals hold `"("` and `")"`, which merges every ACE between them into a
244
+ * single group — and field 5 of the merged blob is the *first* ACE's trustee,
245
+ * which the attacker sets to the owner. Verified before the fix: a descriptor
246
+ * bracketing the real `BUILTIN\Users` and `Authenticated Users` grants that way
247
+ * came back `restricted`.
248
+ *
249
+ * That is the previous version's truncating regex returning as a merging scan,
250
+ * and it fails in the same unsafe direction, so the counting has to know about
251
+ * quotes. SDDL string literals have no `"` escape, which makes a plain toggle
252
+ * exact rather than approximate.
253
+ */
254
+ function aceGroups(body) {
255
+ const groups = [];
256
+ let depth = 0;
257
+ let start = -1;
258
+ let inQuotes = false;
259
+ for (let i = 0; i < body.length; i++) {
260
+ const ch = body[i];
261
+ if (ch === '"') {
262
+ inQuotes = !inQuotes;
263
+ continue;
264
+ }
265
+ if (inQuotes)
266
+ continue;
267
+ if (ch === '(') {
268
+ if (depth === 0)
269
+ start = i + 1;
270
+ depth++;
271
+ }
272
+ else if (ch === ')') {
273
+ depth--;
274
+ // A stray ')' means this is not a descriptor we understand.
275
+ if (depth < 0)
276
+ return null;
277
+ if (depth === 0) {
278
+ const group = body.slice(start, i);
279
+ if (!quoteBlindBalanced(group))
280
+ return null;
281
+ groups.push(group);
282
+ }
283
+ }
284
+ }
285
+ // An unterminated quote means the same thing as unbalanced parentheses: we do
286
+ // not know where this ACE ends, so we do not know what it grants.
287
+ return depth === 0 && !inQuotes ? groups : null;
288
+ }
289
+ /** Whether a DACL's own flag string is one we recognise. */
290
+ function daclFlagsAreValid(flags) {
291
+ return flags === 'NO_ACCESS_CONTROL' || DACL_CONTROL_FLAGS.test(flags);
292
+ }
293
+ /**
294
+ * An ACE flag string as the set of two-character codes it is made of, or null if
295
+ * it is not made of them.
296
+ *
297
+ * Returning the set rather than a boolean is what makes the inherit-only test
298
+ * safe. `flags.includes('IO')` matched across a code boundary: `CIOI` — accepted
299
+ * by this order-agnostic validator — contains the substring `IO` spanning CI and
300
+ * OI, so a grant that does apply to the object was discarded as inherit-only.
301
+ * Measured before the fix: `D:(A;CIOI;FA;;;WD)` came back restricted while
302
+ * `D:(A;OICI;FA;;;WD)` correctly came back broad.
303
+ */
304
+ function aceFlagCodes(flags) {
305
+ if (flags.length % 2 !== 0)
306
+ return null;
307
+ const codes = new Set();
308
+ for (let i = 0; i < flags.length; i += 2) {
309
+ const code = flags.slice(i, i + 2);
310
+ if (!ACE_FLAG_CODES.has(code))
311
+ return null;
312
+ codes.add(code);
313
+ }
314
+ return codes;
315
+ }
316
+ /**
317
+ * Trustees this parser is willing to act on: a SID, or an SDDL alias.
318
+ *
319
+ * Anything else is `unparsable` rather than "a principal named oddly". Two
320
+ * reasons. It stops a trustee field from being trusted purely for being
321
+ * non-empty, which is what the merged-group attack above relied on; and the
322
+ * value is interpolated into an `icacls` command the operator is told to paste,
323
+ * where `FOO"BAR` produced an unbalanced-quote command line.
324
+ */
325
+ const TRUSTEE = /^(?:S-1-[0-9]+(?:-[0-9]+)*|[A-Z]{2})$/;
326
+ function nameFor(trustee) {
327
+ return PRINCIPAL_NAMES.get(trustee) ?? trustee;
328
+ }
329
+ /** The SID a trustee names, resolving both kinds of alias. */
330
+ function resolveTrustee(trustee, accountDomain) {
331
+ const fixed = ALIAS_SIDS.get(trustee);
332
+ if (fixed)
333
+ return fixed;
334
+ const rid = DOMAIN_RELATIVE_RIDS.get(trustee);
335
+ if (rid !== undefined && accountDomain)
336
+ return `${accountDomain}-${rid}`;
337
+ // A trustee that already *is* a SID resolves to itself; only an alias this cannot
338
+ // expand comes back null.
339
+ if (trustee.startsWith('S-1-'))
340
+ return trustee;
341
+ // Null, not the trustee. Returning the raw alias let it travel on as `Grant.sid` and
342
+ // be printed as `/remove:g *OW` — and `*` declares a numeric SID to icacls, so that
343
+ // command removes nothing. Reproduced for `AC` (on `C:\Program Files`), `OW` (on
344
+ // redirected profiles), `RD`, `ED`, `PS`, `LW`, and for every domain-relative alias on
345
+ // a host with no derivable account domain: a service identity or an Entra-joined
346
+ // account. The operator restarted into the identical refusal, and
347
+ // --allowUncheckedConfigAcl deliberately does not cover a `broad` verdict, so the
348
+ // message was a dead end. Fifth instance of this defect in this change.
349
+ return null;
350
+ }
351
+ /**
352
+ * What a security descriptor's DACL grants, judged against the SIDs allowed to
353
+ * hold access.
354
+ *
355
+ * Returns `unknown` for anything it cannot fully account for. That is the whole
356
+ * point of the rewrite: the first version had one output — an empty list — for
357
+ * "restricted to the owner" and for "I found nothing I recognise", which are
358
+ * opposite security conclusions. It reported `D:NO_ACCESS_CONTROL` (full
359
+ * control for everyone), a descriptor with no `D:` at all, a conditional ACE,
360
+ * and the string `D:\` as safe.
361
+ *
362
+ * Exported so the parsing runs on every CI platform rather than only on the
363
+ * Windows job. The fixtures are real `icacls /save` output.
364
+ */
365
+ export function parseDacl(descriptor, identity) {
366
+ const { allowed: allowedSids, accountDomain } = identity;
367
+ const unknown = (detail) => ({ status: 'unknown', reason: 'unparsable', detail });
368
+ const marker = descriptor.indexOf('D:');
369
+ if (marker === -1)
370
+ return unknown('no DACL component');
371
+ let body = descriptor.slice(marker + 2);
372
+ // A SACL may follow. Cut at the boundary, which is the ')' closing the last
373
+ // DACL ACE — or immediately, for a DACL with no ACEs at all.
374
+ const saclAfterAce = body.search(/\)S:/);
375
+ if (saclAfterAce !== -1)
376
+ body = body.slice(0, saclAfterAce + 1);
377
+ else if (body.startsWith('S:'))
378
+ body = '';
379
+ else {
380
+ const bareSacl = body.indexOf('S:');
381
+ if (bareSacl !== -1 && !body.slice(0, bareSacl).includes('('))
382
+ body = body.slice(0, bareSacl);
383
+ }
384
+ const firstAce = body.indexOf('(');
385
+ const flags = (firstAce === -1 ? body : body.slice(0, firstAce)).trim();
386
+ if (!daclFlagsAreValid(flags))
387
+ return unknown(`unrecognised DACL flags ${JSON.stringify(flags)}`);
388
+ // A NULL DACL grants full control to every principal. It carries no ACEs, so
389
+ // an ACE scan finds nothing and would call it restricted.
390
+ if (flags === 'NO_ACCESS_CONTROL')
391
+ return { status: 'no-dacl' };
392
+ const groups = aceGroups(firstAce === -1 ? '' : body.slice(firstAce));
393
+ if (groups === null)
394
+ return unknown('unbalanced parentheses in the DACL');
395
+ // An empty DACL is not a missing one: it denies everyone, which is private.
396
+ if (groups.length === 0)
397
+ return { status: 'restricted' };
398
+ const grants = new Map();
399
+ for (const ace of groups) {
400
+ const fields = ace.split(';');
401
+ if (fields.length < 6)
402
+ return unknown(`ACE with ${fields.length} fields: ${JSON.stringify(ace)}`);
403
+ const type = fields[0].trim().toUpperCase();
404
+ if (NON_GRANTING_ACE_TYPES.has(type))
405
+ continue;
406
+ const aceFlags = fields[1].trim().toUpperCase();
407
+ const flagCodes = aceFlagCodes(aceFlags);
408
+ if (flagCodes === null) {
409
+ return unknown(`unrecognised ACE flags ${JSON.stringify(aceFlags)}`);
410
+ }
411
+ // Inherit-only grants nothing on this object; it applies to children, whose
412
+ // own descriptors are read separately.
413
+ if (flagCodes.has('IO'))
414
+ continue;
415
+ const trustee = fields[5].trim().toUpperCase();
416
+ if (!TRUSTEE.test(trustee)) {
417
+ return unknown(`ACE with an unrecognised trustee ${JSON.stringify(fields[5])}`);
418
+ }
419
+ const sid = resolveTrustee(trustee, accountDomain);
420
+ if (sid !== null && allowedSids.has(sid))
421
+ continue;
422
+ grants.set(sid ?? trustee, {
423
+ name: nameFor(trustee), trustee, sid, writes: grantsWrite(fields[2]),
424
+ });
425
+ }
426
+ return grants.size === 0 ? { status: 'restricted' } : { status: 'broad', grants: [...grants.values()] };
427
+ }
428
+ /**
429
+ * Which SIDs a descriptor may name, given the account we run as.
430
+ *
431
+ * Separate from `inspectAcl` so the rule is asserted rather than mirrored. While
432
+ * it was inline, both test suites built a replica of this set by hand — so adding
433
+ * `BUILTIN\Users` to the allowlist, or dropping the RID-500 entry, left every
434
+ * off-Windows test green and failed only on the advisory Windows job. Two
435
+ * mutations survived the required check that way.
436
+ */
437
+ export function aclIdentity(sid) {
438
+ // Classic account domains only: `S-1-5-21-A-B-C-1001` -> `S-1-5-21-A-B-C`. An
439
+ // Entra-joined account is `S-1-12-1-…` and has no account domain, so the
440
+ // domain-relative aliases stay unresolved there and fail closed.
441
+ const accountDomain = /^(S-1-5-21-[0-9-]+)-\d+$/.exec(sid)?.[1];
442
+ const allowed = new Set([...ALWAYS_ALLOWED_SIDS, sid]);
443
+ // RID 500 is the built-in Administrator. In a default configuration it belongs
444
+ // to the Administrators group, which is already allowed, so naming the account
445
+ // directly grants nobody new access — while refusing it is a false positive,
446
+ // and here that means refusing, or at best warning, about a config that is fine. (On a
447
+ // domain-joined host the account domain is the domain, so this authorises
448
+ // DOMAIN\Administrator, whose local-admin status comes from Domain Admins
449
+ // nesting — a GPO default rather than a law.)
450
+ if (accountDomain)
451
+ allowed.add(`${accountDomain}-500`);
452
+ return { allowed, accountDomain };
453
+ }