ssh-mcp 2.3.2 → 2.3.4

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.
@@ -5,6 +5,7 @@ import { mkdtemp, readFile, rm } from 'fs/promises';
5
5
  import { tmpdir, userInfo } from 'os';
6
6
  import { join, resolve, relative, isAbsolute, parse as parsePath } from 'path';
7
7
  import { OperatorError } from '../errors.js';
8
+ import { parseDacl, aclIdentity, } from './sddl.js';
8
9
  const run = promisify(execFile);
9
10
  const WINDOWS_ROOT = /^[A-Za-z]:\\Windows$/;
10
11
  const CANONICAL_SYSTEM32 = 'C:\\Windows\\System32';
@@ -72,186 +73,6 @@ const CHILD_ENV = { SystemRoot: join(SYSTEM32, '..'), Path: SYSTEM32 };
72
73
  * Expiry gets its own verdict for the same reason — see `timed-out`.
73
74
  */
74
75
  const ACL_TIMEOUT_MS = 5_000;
75
- /**
76
- * The principals a private config may name, by SID.
77
- *
78
- * An allowlist, because the POSIX branch is one — it refuses any bit outside
79
- * 0700 — and the refusal message promises "the owning user only". The first
80
- * version of this file was a denylist of six well-known groups, which let
81
- * through `Domain Users` (whose SID is domain-relative, so no static list can
82
- * name it), `Power Users`, and any second local account or custom group. On a
83
- * domain-joined host that is every employee, reported as owner-only.
84
- *
85
- * SYSTEM and Administrators stay allowed: they are what a file under %APPDATA%
86
- * actually carries (measured), and an administrator can take ownership
87
- * regardless of what any DACL says, so refusing them would buy nothing.
88
- */
89
- const ALWAYS_ALLOWED_SIDS = new Set([
90
- 'S-1-5-18', // NT AUTHORITY\SYSTEM
91
- 'S-1-5-32-544', // BUILTIN\Administrators
92
- ]);
93
- /**
94
- * SDDL alias to SID.
95
- *
96
- * Three entries carry the correctness load — `SY`, `BA`, and `LA` via
97
- * DOMAIN_RELATIVE_RIDS — because those are the only principals that can appear in the
98
- * allowlist, so those are the only comparisons where two spellings of one principal
99
- * have to come out equal. That mattered: on a GitHub `windows-latest` runner the user is
100
- * the built-in Administrator, whose own ACE is written `LA`, and comparing raw SIDs made
101
- * the operator a stranger to their own config — 60 tests failed with "access is granted
102
- * to LA".
103
- *
104
- * The other fourteen are verdict-neutral: an unresolved alias falls through unchanged and
105
- * is refused either way. They exist so a refusal reports one entry per principal and
106
- * prints a SID a remediation can name — which means the length of this list is not a
107
- * security property.
108
- */
109
- const ALIAS_SIDS = new Map([
110
- ['SY', 'S-1-5-18'],
111
- ['BA', 'S-1-5-32-544'],
112
- ['WD', 'S-1-1-0'],
113
- ['BU', 'S-1-5-32-545'],
114
- ['AU', 'S-1-5-11'],
115
- ['IU', 'S-1-5-4'],
116
- ['BG', 'S-1-5-32-546'],
117
- ['AN', 'S-1-5-7'],
118
- ['PU', 'S-1-5-32-547'],
119
- ['NU', 'S-1-5-2'],
120
- ['SU', 'S-1-5-6'],
121
- ['RU', 'S-1-5-32-554'],
122
- ['WR', 'S-1-5-33'],
123
- ['CO', 'S-1-3-0'],
124
- ['LS', 'S-1-5-19'],
125
- ['NS', 'S-1-5-20'],
126
- ['RC', 'S-1-5-12'],
127
- ]);
128
- /**
129
- * Aliases whose SID is relative to an account domain, expanded against the
130
- * domain of the account we run as. `LA` is the reason this exists; the group ones
131
- * are here so a descriptor that spells one principal both ways is reported once
132
- * rather than twice — the display name still comes from the raw trustee, so `DU`
133
- * reads as "Domain Users" while the SID spelling reads as the SID.
134
- */
135
- const DOMAIN_RELATIVE_RIDS = new Map([
136
- ['LA', 500], // built-in Administrator
137
- ['LG', 501], // built-in Guest
138
- ['DA', 512], // Domain Admins
139
- ['DU', 513], // Domain Users
140
- ['DG', 514], // Domain Guests
141
- ['DC', 515], // Domain Computers
142
- ['DD', 516], // Domain Controllers
143
- ['CA', 517], // Cert Publishers
144
- // These two live only in the forest root domain, so expanding them against *our*
145
- // account domain names a SID that does not exist in a child domain. Harmless for
146
- // the verdict — a SID resolving to nothing is not in the allowlist, so the ACE is
147
- // refused either way — but the remediation would print a /remove:g icacls cannot
148
- // map, which is the defect Grant.sid exists to prevent. Kept so a refusal names
149
- // the principal; treat the SID as best effort.
150
- ['SA', 518], // Schema Admins (forest root)
151
- ['EA', 519], // Enterprise Admins (forest root)
152
- ['PA', 520], // Group Policy Creator Owners
153
- ]);
154
- /**
155
- * Display names for principals we expect to have to name in a refusal. Only
156
- * cosmetic — an unrecognised trustee is reported by its SID or alias, and is
157
- * refused either way, so nothing security-relevant depends on this table being
158
- * complete.
159
- */
160
- const PRINCIPAL_NAMES = new Map([
161
- ['WD', 'Everyone'], ['S-1-1-0', 'Everyone'],
162
- ['BU', 'BUILTIN\\Users'], ['S-1-5-32-545', 'BUILTIN\\Users'],
163
- ['AU', 'Authenticated Users'], ['S-1-5-11', 'Authenticated Users'],
164
- ['IU', 'Interactive'], ['S-1-5-4', 'Interactive'],
165
- ['BG', 'BUILTIN\\Guests'], ['S-1-5-32-546', 'BUILTIN\\Guests'],
166
- ['AN', 'Anonymous'], ['S-1-5-7', 'Anonymous'],
167
- ['PU', 'BUILTIN\\Power Users'], ['S-1-5-32-547', 'BUILTIN\\Power Users'],
168
- ['DU', 'Domain Users'],
169
- ['NU', 'NETWORK'], ['S-1-5-2', 'NETWORK'],
170
- ['SU', 'SERVICE'], ['S-1-5-6', 'SERVICE'],
171
- ['RU', 'Pre-Windows 2000 Compatible Access'], ['S-1-5-32-554', 'Pre-Windows 2000 Compatible Access'],
172
- ['WR', 'WRITE RESTRICTED'], ['S-1-5-33', 'WRITE RESTRICTED'],
173
- ['CO', 'CREATOR OWNER'], ['S-1-3-0', 'CREATOR OWNER'],
174
- ]);
175
- /**
176
- * ACE types that grant nothing to their trustee: denials, and the audit, alarm
177
- * and non-ACL entry types.
178
- *
179
- * A denylist here, and an allowlist for principals — each in the direction that
180
- * fails safe. The first version tested `type === 'A' || type === 'OA'`, which
181
- * silently skipped `XA` and `ZA`: conditional allow ACEs, exactly what Dynamic
182
- * Access Control puts on managed files. An ACE type this does not recognise is
183
- * now treated as granting, so a future addition to SDDL cannot open a hole by
184
- * being unfamiliar.
185
- */
186
- const NON_GRANTING_ACE_TYPES = new Set([
187
- 'D', 'OD', 'XD', // access denied
188
- 'AU', 'AL', 'OU', 'OL', 'XU', // audit and alarm
189
- 'ML', 'RA', 'SP', 'TL', 'FL', // label, attribute, policy, trust, filter
190
- ]);
191
- /**
192
- * Two different flag vocabularies, which is easy to conflate — the first draft
193
- * did, and rejected every real descriptor as unparseable because `D:AI(...)`
194
- * carries a *control* flag where an ACE flag was expected.
195
- *
196
- * DACL control flags qualify the ACL as a whole: `P` protected (inheritance
197
- * blocked), `AI` auto-inherited, `AR` auto-inherit-required. `P` is one
198
- * character, so these cannot be read in fixed-width chunks.
199
- */
200
- const DACL_CONTROL_FLAGS = /^(?:P|AI|AR)*$/;
201
- /** ACE flags, which are all two characters. */
202
- const ACE_FLAG_CODES = new Set([
203
- 'CI', 'OI', 'IO', 'NP', 'ID', 'SA', 'FA', 'TP', 'CR',
204
- ]);
205
- /**
206
- * Rights that let a trustee change the object, as SDDL letter codes. `WD` here is
207
- * WRITE_DAC, not Everyone — the rights field and the trustee field have separate
208
- * vocabularies that happen to share spellings.
209
- */
210
- const WRITE_RIGHT_CODES = new Set([
211
- 'FA', 'FW', 'KA', 'KW', 'GA', 'GW', 'SD', 'WD', 'WO', 'CC', 'DC', 'SW', 'WP', 'DT',
212
- ]);
213
- /** Rights that only let a trustee look. Anything unrecognised counts as write. */
214
- const READ_RIGHT_CODES = new Set([
215
- 'FR', 'FX', 'GR', 'GX', 'KR', 'RC', 'RP', 'LC', 'LO',
216
- ]);
217
- /**
218
- * FILE_WRITE_DATA, FILE_APPEND_DATA, FILE_WRITE_EA, FILE_WRITE_ATTRIBUTES, DELETE,
219
- * WRITE_DAC, WRITE_OWNER, GENERIC_WRITE, GENERIC_ALL.
220
- */
221
- const WRITE_MASK = 0x2 | 0x4 | 0x10 | 0x100 | 0x10000 | 0x40000 | 0x80000 | 0x40000000 | 0x10000000;
222
- /**
223
- * Whether a rights field lets its trustee *change* the object rather than only read it.
224
- *
225
- * This distinction is the basis of the default posture, and the field used to be parsed
226
- * and discarded. Read exposure on Windows is genuinely muddier than under POSIX — a
227
- * shared volume grants `BUILTIN\Users` read, and refusing over that stranded the reporter
228
- * of #138 — but write exposure is not muddy at all: "an ACE grants a non-owner
229
- * FILE_WRITE_DATA or WRITE_DAC" is exactly as unambiguous as `0o022`, and a config another
230
- * account can rewrite decides which hosts, roles and approval policy this server honours.
231
- * That is an authorization bypass, not a disclosure, and it was being reported as one.
232
- *
233
- * Measured from real inheritance: `0x1200a9` is read-and-execute, `0x1301bf` is modify.
234
- * Anything this cannot classify counts as write, because refusing is the safe direction.
235
- */
236
- function grantsWrite(rights) {
237
- const field = rights.trim().toUpperCase();
238
- if (field === '')
239
- return true;
240
- if (/^0X[0-9A-F]+$/.test(field))
241
- return (Number.parseInt(field.slice(2), 16) & WRITE_MASK) !== 0;
242
- if (/^[0-9]+$/.test(field))
243
- return (Number.parseInt(field, 10) & WRITE_MASK) !== 0;
244
- if (field.length % 2 !== 0)
245
- return true;
246
- for (let i = 0; i < field.length; i += 2) {
247
- const code = field.slice(i, i + 2);
248
- if (WRITE_RIGHT_CODES.has(code))
249
- return true;
250
- if (!READ_RIGHT_CODES.has(code))
251
- return true;
252
- }
253
- return false;
254
- }
255
76
  /**
256
77
  * The SID of the account this process runs as.
257
78
  *
@@ -360,223 +181,6 @@ async function readDescriptor(path, signal) {
360
181
  void rm(dir, { recursive: true, force: true }).catch(() => { });
361
182
  }
362
183
  }
363
- /**
364
- * ACE groups from a DACL body, respecting nesting *and quoting*.
365
- *
366
- * The quote handling is not decoration. A conditional ACE (`XA`/`ZA`) carries an
367
- * expression whose string literals may contain parentheses, and SDDL gives them
368
- * no escape. A quote-blind scan can therefore be fed two conditional ACEs whose
369
- * literals hold `"("` and `")"`, which merges every ACE between them into a
370
- * single group — and field 5 of the merged blob is the *first* ACE's trustee,
371
- * which the attacker sets to the owner. Verified before the fix: a descriptor
372
- * bracketing the real `BUILTIN\Users` and `Authenticated Users` grants that way
373
- * came back `restricted`.
374
- *
375
- * That is the previous version's truncating regex returning as a merging scan,
376
- * and it fails in the same unsafe direction, so the counting has to know about
377
- * quotes. SDDL string literals have no `"` escape, which makes a plain toggle
378
- * exact rather than approximate.
379
- */
380
- /**
381
- * Whether a group's parentheses balance when quoting is ignored.
382
- *
383
- * The quote handling closed one merge and opened another: two ACEs whose conditional
384
- * literals each carry a single unmatched `"` put the scanner into `inQuotes` across the
385
- * intervening `)(`, collapsing every ACE between them into one group whose type, flags
386
- * and trustee the attacker chooses. Five shapes were executed against the previous
387
- * version and all five came back `restricted` while hiding real Everyone, Users and
388
- * Authenticated Users grants.
389
- *
390
- * Where the quote-aware and quote-blind scans disagree, the quotes were load-bearing
391
- * across an ACE boundary and the descriptor is not something to draw a conclusion from.
392
- */
393
- function quoteBlindBalanced(group) {
394
- let depth = 0;
395
- for (const ch of group) {
396
- if (ch === '(')
397
- depth++;
398
- else if (ch === ')') {
399
- depth--;
400
- if (depth < 0)
401
- return false;
402
- }
403
- }
404
- return depth === 0;
405
- }
406
- function aceGroups(body) {
407
- const groups = [];
408
- let depth = 0;
409
- let start = -1;
410
- let inQuotes = false;
411
- for (let i = 0; i < body.length; i++) {
412
- const ch = body[i];
413
- if (ch === '"') {
414
- inQuotes = !inQuotes;
415
- continue;
416
- }
417
- if (inQuotes)
418
- continue;
419
- if (ch === '(') {
420
- if (depth === 0)
421
- start = i + 1;
422
- depth++;
423
- }
424
- else if (ch === ')') {
425
- depth--;
426
- // A stray ')' means this is not a descriptor we understand.
427
- if (depth < 0)
428
- return null;
429
- if (depth === 0) {
430
- const group = body.slice(start, i);
431
- if (!quoteBlindBalanced(group))
432
- return null;
433
- groups.push(group);
434
- }
435
- }
436
- }
437
- // An unterminated quote means the same thing as unbalanced parentheses: we do
438
- // not know where this ACE ends, so we do not know what it grants.
439
- return depth === 0 && !inQuotes ? groups : null;
440
- }
441
- /** Whether a DACL's own flag string is one we recognise. */
442
- function daclFlagsAreValid(flags) {
443
- return flags === 'NO_ACCESS_CONTROL' || DACL_CONTROL_FLAGS.test(flags);
444
- }
445
- /**
446
- * An ACE flag string as the set of two-character codes it is made of, or null if
447
- * it is not made of them.
448
- *
449
- * Returning the set rather than a boolean is what makes the inherit-only test
450
- * safe. `flags.includes('IO')` matched across a code boundary: `CIOI` — accepted
451
- * by this order-agnostic validator — contains the substring `IO` spanning CI and
452
- * OI, so a grant that does apply to the object was discarded as inherit-only.
453
- * Measured before the fix: `D:(A;CIOI;FA;;;WD)` came back restricted while
454
- * `D:(A;OICI;FA;;;WD)` correctly came back broad.
455
- */
456
- function aceFlagCodes(flags) {
457
- if (flags.length % 2 !== 0)
458
- return null;
459
- const codes = new Set();
460
- for (let i = 0; i < flags.length; i += 2) {
461
- const code = flags.slice(i, i + 2);
462
- if (!ACE_FLAG_CODES.has(code))
463
- return null;
464
- codes.add(code);
465
- }
466
- return codes;
467
- }
468
- /**
469
- * Trustees this parser is willing to act on: a SID, or an SDDL alias.
470
- *
471
- * Anything else is `unparsable` rather than "a principal named oddly". Two
472
- * reasons. It stops a trustee field from being trusted purely for being
473
- * non-empty, which is what the merged-group attack above relied on; and the
474
- * value is interpolated into an `icacls` command the operator is told to paste,
475
- * where `FOO"BAR` produced an unbalanced-quote command line.
476
- */
477
- const TRUSTEE = /^(?:S-1-[0-9]+(?:-[0-9]+)*|[A-Z]{2})$/;
478
- function nameFor(trustee) {
479
- return PRINCIPAL_NAMES.get(trustee) ?? trustee;
480
- }
481
- /** The SID a trustee names, resolving both kinds of alias. */
482
- function resolveTrustee(trustee, accountDomain) {
483
- const fixed = ALIAS_SIDS.get(trustee);
484
- if (fixed)
485
- return fixed;
486
- const rid = DOMAIN_RELATIVE_RIDS.get(trustee);
487
- if (rid !== undefined && accountDomain)
488
- return `${accountDomain}-${rid}`;
489
- // A trustee that already *is* a SID resolves to itself; only an alias this cannot
490
- // expand comes back null.
491
- if (trustee.startsWith('S-1-'))
492
- return trustee;
493
- // Null, not the trustee. Returning the raw alias let it travel on as `Grant.sid` and
494
- // be printed as `/remove:g *OW` — and `*` declares a numeric SID to icacls, so that
495
- // command removes nothing. Reproduced for `AC` (on `C:\Program Files`), `OW` (on
496
- // redirected profiles), `RD`, `ED`, `PS`, `LW`, and for every domain-relative alias on
497
- // a host with no derivable account domain: a service identity or an Entra-joined
498
- // account. The operator restarted into the identical refusal, and
499
- // --allowUncheckedConfigAcl deliberately does not cover a `broad` verdict, so the
500
- // message was a dead end. Fifth instance of this defect in this change.
501
- return null;
502
- }
503
- /**
504
- * What a security descriptor's DACL grants, judged against the SIDs allowed to
505
- * hold access.
506
- *
507
- * Returns `unknown` for anything it cannot fully account for. That is the whole
508
- * point of the rewrite: the first version had one output — an empty list — for
509
- * "restricted to the owner" and for "I found nothing I recognise", which are
510
- * opposite security conclusions. It reported `D:NO_ACCESS_CONTROL` (full
511
- * control for everyone), a descriptor with no `D:` at all, a conditional ACE,
512
- * and the string `D:\` as safe.
513
- *
514
- * Exported so the parsing runs on every CI platform rather than only on the
515
- * Windows job. The fixtures are real `icacls /save` output.
516
- */
517
- export function parseDacl(descriptor, identity) {
518
- const { allowed: allowedSids, accountDomain } = identity;
519
- const unknown = (detail) => ({ status: 'unknown', reason: 'unparsable', detail });
520
- const marker = descriptor.indexOf('D:');
521
- if (marker === -1)
522
- return unknown('no DACL component');
523
- let body = descriptor.slice(marker + 2);
524
- // A SACL may follow. Cut at the boundary, which is the ')' closing the last
525
- // DACL ACE — or immediately, for a DACL with no ACEs at all.
526
- const saclAfterAce = body.search(/\)S:/);
527
- if (saclAfterAce !== -1)
528
- body = body.slice(0, saclAfterAce + 1);
529
- else if (body.startsWith('S:'))
530
- body = '';
531
- else {
532
- const bareSacl = body.indexOf('S:');
533
- if (bareSacl !== -1 && !body.slice(0, bareSacl).includes('('))
534
- body = body.slice(0, bareSacl);
535
- }
536
- const firstAce = body.indexOf('(');
537
- const flags = (firstAce === -1 ? body : body.slice(0, firstAce)).trim();
538
- if (!daclFlagsAreValid(flags))
539
- return unknown(`unrecognised DACL flags ${JSON.stringify(flags)}`);
540
- // A NULL DACL grants full control to every principal. It carries no ACEs, so
541
- // an ACE scan finds nothing and would call it restricted.
542
- if (flags === 'NO_ACCESS_CONTROL')
543
- return { status: 'no-dacl' };
544
- const groups = aceGroups(firstAce === -1 ? '' : body.slice(firstAce));
545
- if (groups === null)
546
- return unknown('unbalanced parentheses in the DACL');
547
- // An empty DACL is not a missing one: it denies everyone, which is private.
548
- if (groups.length === 0)
549
- return { status: 'restricted' };
550
- const grants = new Map();
551
- for (const ace of groups) {
552
- const fields = ace.split(';');
553
- if (fields.length < 6)
554
- return unknown(`ACE with ${fields.length} fields: ${JSON.stringify(ace)}`);
555
- const type = fields[0].trim().toUpperCase();
556
- if (NON_GRANTING_ACE_TYPES.has(type))
557
- continue;
558
- const aceFlags = fields[1].trim().toUpperCase();
559
- const flagCodes = aceFlagCodes(aceFlags);
560
- if (flagCodes === null) {
561
- return unknown(`unrecognised ACE flags ${JSON.stringify(aceFlags)}`);
562
- }
563
- // Inherit-only grants nothing on this object; it applies to children, whose
564
- // own descriptors are read separately.
565
- if (flagCodes.has('IO'))
566
- continue;
567
- const trustee = fields[5].trim().toUpperCase();
568
- if (!TRUSTEE.test(trustee)) {
569
- return unknown(`ACE with an unrecognised trustee ${JSON.stringify(fields[5])}`);
570
- }
571
- const sid = resolveTrustee(trustee, accountDomain);
572
- if (sid !== null && allowedSids.has(sid))
573
- continue;
574
- grants.set(sid ?? trustee, {
575
- name: nameFor(trustee), trustee, sid, writes: grantsWrite(fields[2]),
576
- });
577
- }
578
- return grants.size === 0 ? { status: 'restricted' } : { status: 'broad', grants: [...grants.values()] };
579
- }
580
184
  /**
581
185
  * Whichever comes first: the verdict, or the deadline.
582
186
  *
@@ -602,32 +206,6 @@ const TIMED_OUT = {
602
206
  detail: `the check exceeded its ${ACL_TIMEOUT_MS}ms budget`,
603
207
  };
604
208
  /** Read one path's DACL and judge it. */
605
- /**
606
- * Which SIDs a descriptor may name, given the account we run as.
607
- *
608
- * Separate from `inspectAcl` so the rule is asserted rather than mirrored. While
609
- * it was inline, both test suites built a replica of this set by hand — so adding
610
- * `BUILTIN\Users` to the allowlist, or dropping the RID-500 entry, left every
611
- * off-Windows test green and failed only on the advisory Windows job. Two
612
- * mutations survived the required check that way.
613
- */
614
- export function aclIdentity(sid) {
615
- // Classic account domains only: `S-1-5-21-A-B-C-1001` -> `S-1-5-21-A-B-C`. An
616
- // Entra-joined account is `S-1-12-1-…` and has no account domain, so the
617
- // domain-relative aliases stay unresolved there and fail closed.
618
- const accountDomain = /^(S-1-5-21-[0-9-]+)-\d+$/.exec(sid)?.[1];
619
- const allowed = new Set([...ALWAYS_ALLOWED_SIDS, sid]);
620
- // RID 500 is the built-in Administrator. In a default configuration it belongs
621
- // to the Administrators group, which is already allowed, so naming the account
622
- // directly grants nobody new access — while refusing it is a false positive,
623
- // and here that means refusing, or at best warning, about a config that is fine. (On a
624
- // domain-joined host the account domain is the domain, so this authorises
625
- // DOMAIN\Administrator, whose local-admin status comes from Domain Admins
626
- // nesting — a GPO default rather than a law.)
627
- if (accountDomain)
628
- allowed.add(`${accountDomain}-500`);
629
- return { allowed, accountDomain };
630
- }
631
209
  export async function inspectAcl(path, signal) {
632
210
  const sid = await currentUserSid(signal);
633
211
  // The budget expiring has to mean the same thing wherever it lands. `whoami` runs