ssh-mcp 2.2.5 → 2.3.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,786 @@
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { existsSync, realpathSync } from 'fs';
4
+ import { mkdtemp, readFile, rm } from 'fs/promises';
5
+ import { tmpdir, userInfo } from 'os';
6
+ import { join, resolve, relative, isAbsolute, parse as parsePath } from 'path';
7
+ import { OperatorError } from '../errors.js';
8
+ const run = promisify(execFile);
9
+ const WINDOWS_ROOT = /^[A-Za-z]:\\Windows$/;
10
+ const CANONICAL_SYSTEM32 = 'C:\\Windows\\System32';
11
+ /**
12
+ * Absolute paths, not bare names — and not paths the caller gets to choose.
13
+ *
14
+ * These subprocesses decide whether a config file is private, so who answers must
15
+ * not be up to the ambient environment. Windows resolves a bare name through the
16
+ * application directory, the working directory and then PATH, and the working
17
+ * directory belongs to whichever MCP client spawned us; a planted `icacls.exe`
18
+ * would run as the operator *and* dictate the verdict, since all it has to emit
19
+ * is one line reading `D:(A;ID;FA;;;SY)`.
20
+ *
21
+ * The first version of this comment claimed the environment could not influence
22
+ * the verdict and then read `%SystemRoot%`, which the same parent sets — the hole
23
+ * moved one variable over rather than closing. So the value is accepted only in
24
+ * the shape a real Windows root has, and `readDescriptor` will not treat "icacls
25
+ * is missing here" as "icacls is missing on this machine" without checking the
26
+ * canonical location, because that verdict is the one that loads anyway.
27
+ *
28
+ * One thing this does *not* close: `os.tmpdir()` reads `TEMP`/`TMP` from the same
29
+ * environment, and that is where the descriptor is written and read back. A parent that
30
+ * points `TEMP` somewhere it watches has the window between `icacls` writing `acl.sddl`
31
+ * and our `readFile` to substitute one line reading `D:(A;ID;FA;;;SY)`. Taking the temp
32
+ * root from another environment variable would move the problem rather than solve it,
33
+ * and a parent with that much control already chooses our argv — it could simply pass
34
+ * `--allowUncheckedConfigAcl`. A known limit of the threat model, not something handled.
35
+ */
36
+ function systemDir() {
37
+ // Canonical first. Shape is not integrity: the previous version accepted any
38
+ // `%SystemRoot%` matching /^[A-Za-z]:\\Windows$/, and `Z:\Windows` on removable
39
+ // media, a mapped drive, a `subst` alias or a mounted VHD satisfies that — so both
40
+ // subprocesses became the attacker's, and a world-readable config was reported
41
+ // `restricted` with no warning. Reproduced before this change. On any normal host
42
+ // C:\Windows\System32\icacls.exe exists, so the environment cannot redirect at all.
43
+ if (existsSync(join(CANONICAL_SYSTEM32, 'icacls.exe')))
44
+ return CANONICAL_SYSTEM32;
45
+ const fromEnv = process.env.SystemRoot;
46
+ return fromEnv && WINDOWS_ROOT.test(fromEnv) ? join(fromEnv, 'System32') : CANONICAL_SYSTEM32;
47
+ }
48
+ const SYSTEM32 = systemDir();
49
+ const ICACLS = join(SYSTEM32, 'icacls.exe');
50
+ const WHOAMI = join(SYSTEM32, 'whoami.exe');
51
+ /**
52
+ * What these children inherit: nothing. They read no configuration of ours, and
53
+ * `Path` is System32 alone so nothing they might start can be redirected either.
54
+ */
55
+ const CHILD_ENV = { SystemRoot: join(SYSTEM32, '..'), Path: SYSTEM32 };
56
+ /**
57
+ * One budget for the whole check — and for the check, not merely its children.
58
+ *
59
+ * Per-child was the first shape, which made the ceiling the sum of however many
60
+ * children the check happens to spawn — three today, so 4.5s of dead air before
61
+ * the server reports ready, against a measured happy path of 60-150ms.
62
+ *
63
+ * The first version passed the signal to `execFile` only, which left `mkdtemp`,
64
+ * `readFile` and `rm` — six operations across the two subjects — outside the bound
65
+ * the comment claimed. A `%TEMP%` redirected to a stalled share made startup
66
+ * latency unbounded rather than five seconds. The deadline is now raced against the
67
+ * whole per-subject check, so the number means what it says.
68
+ *
69
+ * Generous, because running out is not evidence about the file: five seconds across
70
+ * three process creations plus two temp-directory round trips, so that on-access
71
+ * virus scanning or a loaded host does not turn a private config into a refusal.
72
+ * Expiry gets its own verdict for the same reason — see `timed-out`.
73
+ */
74
+ 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
+ * The SID of the account this process runs as.
207
+ *
208
+ * Needed because `icacls /save` emits the DACL alone — no `O:` owner component —
209
+ * so "the owner" has to come from somewhere else, and Node exposes no SID
210
+ * (`os.userInfo().uid` is -1 on Windows). Measured at 12-20ms, and paid once per
211
+ * process *once it succeeds* — only a success is cached, so a failure retries.
212
+ */
213
+ let cachedUserSid;
214
+ async function currentUserSid(signal) {
215
+ if (cachedUserSid !== undefined)
216
+ return cachedUserSid;
217
+ try {
218
+ const { stdout } = await run(WHOAMI, ['/user', '/fo', 'csv', '/nh'], {
219
+ signal,
220
+ windowsHide: true,
221
+ env: CHILD_ENV,
222
+ });
223
+ // `"host\user","S-1-5-21-…-1001"`
224
+ const sid = stdout.match(/"(S-1-[0-9-]+)"/)?.[1];
225
+ // Only a success is remembered. Memoising the failure made one aborted or
226
+ // transient `whoami` pin `identity-unknown` for the life of the process, so
227
+ // every later check refused — and it forced a `resetIdentityCache` export
228
+ // whose only callers were tests undoing that poisoning.
229
+ if (sid)
230
+ cachedUserSid = sid;
231
+ return sid ?? null;
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ /**
238
+ * Every line of the security descriptor file `icacls /save` writes.
239
+ *
240
+ * The saved file is UTF-16LE and holds the item's name on one line and its
241
+ * descriptor on the next. Which line is which is not guessed here — the caller
242
+ * hands each line to the parser and keeps the first that parses, so the reader
243
+ * and the parser cannot disagree about what a descriptor looks like. They did
244
+ * in the first version: the reader required a line *starting* with `D:` while
245
+ * the parser accepted an `O:`/`G:` prefix, and the disagreement resolved to
246
+ * "unchecked".
247
+ *
248
+ * SDDL via `/save` rather than PowerShell's `Get-Acl`, whose descriptor would carry an
249
+ * `O:` owner component and save the second subprocess: measured at 8-22ms against
250
+ * 740-830ms, on a check that runs at every start. And rather than plain `icacls` output,
251
+ * which prints account names that depend on the installed language. That reasoning lived
252
+ * only in the changeset, which is deleted at release.
253
+ *
254
+ * Microsoft documents `/save` against a directory; the single-file form is
255
+ * undocumented but real, and windows-acl.live.test.ts covers it by asserting a
256
+ * refusal that is only reachable if a descriptor came back parseable.
257
+ */
258
+ /**
259
+ * What a failed descriptor read means.
260
+ *
261
+ * Pure apart from the injected existence check, because the branch that matters most
262
+ * is unreachable from any platform's tests otherwise: off Windows the identity lookup
263
+ * fails first, and on the Windows job only the `read-refused` arm is exercised. The
264
+ * canonical-location check *is* the security argument for pinning System32, and it was
265
+ * executed by nothing.
266
+ *
267
+ * Two failures say nothing about the file — icacls being absent from the machine, and
268
+ * the check running out of time — and those are the two the caller loads anyway.
269
+ * Everything else (a non-zero exit, output we could not read) is about this path.
270
+ *
271
+ * Which makes "absent" worth being sure of: absent *here* is not enough, because a
272
+ * redirected %SystemRoot% would otherwise reach a fail-open branch, and pinning the
273
+ * path exists to prevent exactly that.
274
+ */
275
+ export function classifyReadFailure(err, exists = existsSync) {
276
+ const notHere = err?.code === 'ENOENT' && typeof err?.path === 'string' && err.path === ICACLS;
277
+ const missing = notHere && !exists(join(CANONICAL_SYSTEM32, 'icacls.exe'));
278
+ if (err?.name === 'AbortError') {
279
+ return { failure: 'timed-out', detail: `the check exceeded its ${ACL_TIMEOUT_MS}ms budget` };
280
+ }
281
+ if (missing)
282
+ return { failure: 'tool-missing', detail: String(err?.message ?? err).split('\n')[0] };
283
+ if (notHere) {
284
+ return {
285
+ failure: 'read-refused',
286
+ detail: `${ICACLS} is missing but ${CANONICAL_SYSTEM32}\\icacls.exe is not — %SystemRoot% looks wrong`,
287
+ };
288
+ }
289
+ return { failure: 'read-refused', detail: String(err?.message ?? err).split('\n')[0] };
290
+ }
291
+ async function readDescriptor(path, signal) {
292
+ let dir;
293
+ try {
294
+ dir = await mkdtemp(join(tmpdir(), 'ssh-mcp-acl-'));
295
+ const saved = join(dir, 'acl.sddl');
296
+ await run(ICACLS, [path, '/save', saved], { signal, windowsHide: true, env: CHILD_ENV });
297
+ const text = await readFile(saved, { encoding: 'utf16le', signal });
298
+ return { lines: text.split(/\r?\n/) };
299
+ }
300
+ catch (err) {
301
+ return classifyReadFailure(err);
302
+ }
303
+ finally {
304
+ // Not awaited: `rm` takes no signal, so awaiting it put an unbounded filesystem
305
+ // operation on the path a server start waits for. The directory holds a security
306
+ // descriptor, not a secret, and %TEMP% is per-user — so losing the race to a
307
+ // stalled disk costs a stray directory, not correctness.
308
+ if (dir)
309
+ void rm(dir, { recursive: true, force: true }).catch(() => { });
310
+ }
311
+ }
312
+ /**
313
+ * ACE groups from a DACL body, respecting nesting *and quoting*.
314
+ *
315
+ * The quote handling is not decoration. A conditional ACE (`XA`/`ZA`) carries an
316
+ * expression whose string literals may contain parentheses, and SDDL gives them
317
+ * no escape. A quote-blind scan can therefore be fed two conditional ACEs whose
318
+ * literals hold `"("` and `")"`, which merges every ACE between them into a
319
+ * single group — and field 5 of the merged blob is the *first* ACE's trustee,
320
+ * which the attacker sets to the owner. Verified before the fix: a descriptor
321
+ * bracketing the real `BUILTIN\Users` and `Authenticated Users` grants that way
322
+ * came back `restricted`.
323
+ *
324
+ * That is the previous version's truncating regex returning as a merging scan,
325
+ * and it fails in the same unsafe direction, so the counting has to know about
326
+ * quotes. SDDL string literals have no `"` escape, which makes a plain toggle
327
+ * exact rather than approximate.
328
+ */
329
+ /**
330
+ * Whether a group's parentheses balance when quoting is ignored.
331
+ *
332
+ * The quote handling closed one merge and opened another: two ACEs whose conditional
333
+ * literals each carry a single unmatched `"` put the scanner into `inQuotes` across the
334
+ * intervening `)(`, collapsing every ACE between them into one group whose type, flags
335
+ * and trustee the attacker chooses. Five shapes were executed against the previous
336
+ * version and all five came back `restricted` while hiding real Everyone, Users and
337
+ * Authenticated Users grants.
338
+ *
339
+ * Where the quote-aware and quote-blind scans disagree, the quotes were load-bearing
340
+ * across an ACE boundary and the descriptor is not something to draw a conclusion from.
341
+ */
342
+ function quoteBlindBalanced(group) {
343
+ let depth = 0;
344
+ for (const ch of group) {
345
+ if (ch === '(')
346
+ depth++;
347
+ else if (ch === ')') {
348
+ depth--;
349
+ if (depth < 0)
350
+ return false;
351
+ }
352
+ }
353
+ return depth === 0;
354
+ }
355
+ function aceGroups(body) {
356
+ const groups = [];
357
+ let depth = 0;
358
+ let start = -1;
359
+ let inQuotes = false;
360
+ for (let i = 0; i < body.length; i++) {
361
+ const ch = body[i];
362
+ if (ch === '"') {
363
+ inQuotes = !inQuotes;
364
+ continue;
365
+ }
366
+ if (inQuotes)
367
+ continue;
368
+ if (ch === '(') {
369
+ if (depth === 0)
370
+ start = i + 1;
371
+ depth++;
372
+ }
373
+ else if (ch === ')') {
374
+ depth--;
375
+ // A stray ')' means this is not a descriptor we understand.
376
+ if (depth < 0)
377
+ return null;
378
+ if (depth === 0) {
379
+ const group = body.slice(start, i);
380
+ if (!quoteBlindBalanced(group))
381
+ return null;
382
+ groups.push(group);
383
+ }
384
+ }
385
+ }
386
+ // An unterminated quote means the same thing as unbalanced parentheses: we do
387
+ // not know where this ACE ends, so we do not know what it grants.
388
+ return depth === 0 && !inQuotes ? groups : null;
389
+ }
390
+ /** Whether a DACL's own flag string is one we recognise. */
391
+ function daclFlagsAreValid(flags) {
392
+ return flags === 'NO_ACCESS_CONTROL' || DACL_CONTROL_FLAGS.test(flags);
393
+ }
394
+ /**
395
+ * An ACE flag string as the set of two-character codes it is made of, or null if
396
+ * it is not made of them.
397
+ *
398
+ * Returning the set rather than a boolean is what makes the inherit-only test
399
+ * safe. `flags.includes('IO')` matched across a code boundary: `CIOI` — accepted
400
+ * by this order-agnostic validator — contains the substring `IO` spanning CI and
401
+ * OI, so a grant that does apply to the object was discarded as inherit-only.
402
+ * Measured before the fix: `D:(A;CIOI;FA;;;WD)` came back restricted while
403
+ * `D:(A;OICI;FA;;;WD)` correctly came back broad.
404
+ */
405
+ function aceFlagCodes(flags) {
406
+ if (flags.length % 2 !== 0)
407
+ return null;
408
+ const codes = new Set();
409
+ for (let i = 0; i < flags.length; i += 2) {
410
+ const code = flags.slice(i, i + 2);
411
+ if (!ACE_FLAG_CODES.has(code))
412
+ return null;
413
+ codes.add(code);
414
+ }
415
+ return codes;
416
+ }
417
+ /**
418
+ * Trustees this parser is willing to act on: a SID, or an SDDL alias.
419
+ *
420
+ * Anything else is `unparsable` rather than "a principal named oddly". Two
421
+ * reasons. It stops a trustee field from being trusted purely for being
422
+ * non-empty, which is what the merged-group attack above relied on; and the
423
+ * value is interpolated into an `icacls` command the operator is told to paste,
424
+ * where `FOO"BAR` produced an unbalanced-quote command line.
425
+ */
426
+ const TRUSTEE = /^(?:S-1-[0-9]+(?:-[0-9]+)*|[A-Z]{2})$/;
427
+ function nameFor(trustee) {
428
+ return PRINCIPAL_NAMES.get(trustee) ?? trustee;
429
+ }
430
+ /** The SID a trustee names, resolving both kinds of alias. */
431
+ function resolveTrustee(trustee, accountDomain) {
432
+ const fixed = ALIAS_SIDS.get(trustee);
433
+ if (fixed)
434
+ return fixed;
435
+ const rid = DOMAIN_RELATIVE_RIDS.get(trustee);
436
+ if (rid !== undefined && accountDomain)
437
+ return `${accountDomain}-${rid}`;
438
+ // A trustee that already *is* a SID resolves to itself; only an alias this cannot
439
+ // expand comes back null.
440
+ if (trustee.startsWith('S-1-'))
441
+ return trustee;
442
+ // Null, not the trustee. Returning the raw alias let it travel on as `Grant.sid` and
443
+ // be printed as `/remove:g *OW` — and `*` declares a numeric SID to icacls, so that
444
+ // command removes nothing. Reproduced for `AC` (on `C:\Program Files`), `OW` (on
445
+ // redirected profiles), `RD`, `ED`, `PS`, `LW`, and for every domain-relative alias on
446
+ // a host with no derivable account domain: a service identity or an Entra-joined
447
+ // account. The operator restarted into the identical refusal, and
448
+ // --allowUncheckedConfigAcl deliberately does not cover a `broad` verdict, so the
449
+ // message was a dead end. Fifth instance of this defect in this change.
450
+ return null;
451
+ }
452
+ /**
453
+ * What a security descriptor's DACL grants, judged against the SIDs allowed to
454
+ * hold access.
455
+ *
456
+ * Returns `unknown` for anything it cannot fully account for. That is the whole
457
+ * point of the rewrite: the first version had one output — an empty list — for
458
+ * "restricted to the owner" and for "I found nothing I recognise", which are
459
+ * opposite security conclusions. It reported `D:NO_ACCESS_CONTROL` (full
460
+ * control for everyone), a descriptor with no `D:` at all, a conditional ACE,
461
+ * and the string `D:\` as safe.
462
+ *
463
+ * Exported so the parsing runs on every CI platform rather than only on the
464
+ * Windows job. The fixtures are real `icacls /save` output.
465
+ */
466
+ export function parseDacl(descriptor, identity) {
467
+ const { allowed: allowedSids, accountDomain } = identity;
468
+ const unknown = (detail) => ({ status: 'unknown', reason: 'unparsable', detail });
469
+ const marker = descriptor.indexOf('D:');
470
+ if (marker === -1)
471
+ return unknown('no DACL component');
472
+ let body = descriptor.slice(marker + 2);
473
+ // A SACL may follow. Cut at the boundary, which is the ')' closing the last
474
+ // DACL ACE — or immediately, for a DACL with no ACEs at all.
475
+ const saclAfterAce = body.search(/\)S:/);
476
+ if (saclAfterAce !== -1)
477
+ body = body.slice(0, saclAfterAce + 1);
478
+ else if (body.startsWith('S:'))
479
+ body = '';
480
+ else {
481
+ const bareSacl = body.indexOf('S:');
482
+ if (bareSacl !== -1 && !body.slice(0, bareSacl).includes('('))
483
+ body = body.slice(0, bareSacl);
484
+ }
485
+ const firstAce = body.indexOf('(');
486
+ const flags = (firstAce === -1 ? body : body.slice(0, firstAce)).trim();
487
+ if (!daclFlagsAreValid(flags))
488
+ return unknown(`unrecognised DACL flags ${JSON.stringify(flags)}`);
489
+ // A NULL DACL grants full control to every principal. It carries no ACEs, so
490
+ // an ACE scan finds nothing and would call it restricted.
491
+ if (flags === 'NO_ACCESS_CONTROL')
492
+ return { status: 'no-dacl' };
493
+ const groups = aceGroups(firstAce === -1 ? '' : body.slice(firstAce));
494
+ if (groups === null)
495
+ return unknown('unbalanced parentheses in the DACL');
496
+ // An empty DACL is not a missing one: it denies everyone, which is private.
497
+ if (groups.length === 0)
498
+ return { status: 'restricted' };
499
+ const grants = new Map();
500
+ for (const ace of groups) {
501
+ const fields = ace.split(';');
502
+ if (fields.length < 6)
503
+ return unknown(`ACE with ${fields.length} fields: ${JSON.stringify(ace)}`);
504
+ const type = fields[0].trim().toUpperCase();
505
+ if (NON_GRANTING_ACE_TYPES.has(type))
506
+ continue;
507
+ const aceFlags = fields[1].trim().toUpperCase();
508
+ const flagCodes = aceFlagCodes(aceFlags);
509
+ if (flagCodes === null) {
510
+ return unknown(`unrecognised ACE flags ${JSON.stringify(aceFlags)}`);
511
+ }
512
+ // Inherit-only grants nothing on this object; it applies to children, whose
513
+ // own descriptors are read separately.
514
+ if (flagCodes.has('IO'))
515
+ continue;
516
+ const trustee = fields[5].trim().toUpperCase();
517
+ if (!TRUSTEE.test(trustee)) {
518
+ return unknown(`ACE with an unrecognised trustee ${JSON.stringify(fields[5])}`);
519
+ }
520
+ const sid = resolveTrustee(trustee, accountDomain);
521
+ if (sid !== null && allowedSids.has(sid))
522
+ continue;
523
+ grants.set(sid ?? trustee, { name: nameFor(trustee), trustee, sid });
524
+ }
525
+ return grants.size === 0 ? { status: 'restricted' } : { status: 'broad', grants: [...grants.values()] };
526
+ }
527
+ /**
528
+ * Whichever comes first: the verdict, or the deadline.
529
+ *
530
+ * The signal reaches the subprocesses, but `mkdtemp` and `rm` accept none, so
531
+ * without this the budget bounded three of the nine operations the check performs.
532
+ * Expiry resolves to the same verdict a subprocess timeout produces, which warns
533
+ * and loads — a slow machine is not evidence about the file.
534
+ */
535
+ function raceDeadline(verdict, signal) {
536
+ if (!signal)
537
+ return verdict;
538
+ const expired = new Promise((resolve) => {
539
+ if (signal.aborted)
540
+ resolve(TIMED_OUT);
541
+ else
542
+ signal.addEventListener('abort', () => resolve(TIMED_OUT), { once: true });
543
+ });
544
+ return Promise.race([verdict, expired]);
545
+ }
546
+ const TIMED_OUT = {
547
+ status: 'unknown',
548
+ reason: 'timed-out',
549
+ detail: `the check exceeded its ${ACL_TIMEOUT_MS}ms budget`,
550
+ };
551
+ /** Read one path's DACL and judge it. */
552
+ /**
553
+ * Which SIDs a descriptor may name, given the account we run as.
554
+ *
555
+ * Separate from `inspectAcl` so the rule is asserted rather than mirrored. While
556
+ * it was inline, both test suites built a replica of this set by hand — so adding
557
+ * `BUILTIN\Users` to the allowlist, or dropping the RID-500 entry, left every
558
+ * off-Windows test green and failed only on the advisory Windows job. Two
559
+ * mutations survived the required check that way.
560
+ */
561
+ export function aclIdentity(sid) {
562
+ // Classic account domains only: `S-1-5-21-A-B-C-1001` -> `S-1-5-21-A-B-C`. An
563
+ // Entra-joined account is `S-1-12-1-…` and has no account domain, so the
564
+ // domain-relative aliases stay unresolved there and fail closed.
565
+ const accountDomain = /^(S-1-5-21-[0-9-]+)-\d+$/.exec(sid)?.[1];
566
+ const allowed = new Set([...ALWAYS_ALLOWED_SIDS, sid]);
567
+ // RID 500 is the built-in Administrator. In a default configuration it belongs
568
+ // to the Administrators group, which is already allowed, so naming the account
569
+ // directly grants nobody new access — while refusing it is a false positive,
570
+ // and here that means refusing to start a server whose config is fine. (On a
571
+ // domain-joined host the account domain is the domain, so this authorises
572
+ // DOMAIN\Administrator, whose local-admin status comes from Domain Admins
573
+ // nesting — a GPO default rather than a law.)
574
+ if (accountDomain)
575
+ allowed.add(`${accountDomain}-500`);
576
+ return { allowed, accountDomain };
577
+ }
578
+ export async function inspectAcl(path, signal) {
579
+ const sid = await currentUserSid(signal);
580
+ // The budget expiring has to mean the same thing wherever it lands. `whoami` runs
581
+ // first and its failure is indistinguishable inside the catch, so without this an
582
+ // expiry during the identity lookup became `identity-unknown` and *refused*, while
583
+ // the identical expiry one subprocess later became `timed-out` and loaded. Only a
584
+ // success is cached, so it refused a second time for the directory too.
585
+ if (sid === null && signal?.aborted)
586
+ return TIMED_OUT;
587
+ if (sid === null) {
588
+ return { status: 'unknown', reason: 'identity-unknown', detail: 'could not read this account\'s SID' };
589
+ }
590
+ const identity = aclIdentity(sid);
591
+ const read = await readDescriptor(path, signal);
592
+ if ('failure' in read)
593
+ return { status: 'unknown', reason: read.failure, detail: read.detail };
594
+ // The reader does not decide which line is the descriptor; the parser does.
595
+ let lastDetail = 'no line parsed as a security descriptor';
596
+ for (const line of read.lines) {
597
+ const trimmed = line.trim();
598
+ // A descriptor opens with one of its components. Without this, "first line that
599
+ // parses" let an item-name line stand in for the descriptor: `parseDacl('D:')` is a
600
+ // legitimately empty DACL and therefore `restricted`, and `icacls D: /save` for a
601
+ // drive-relative `--config D:config.toml` writes a name line of exactly `D:`.
602
+ if (!/^[OGDS]:/.test(trimmed))
603
+ continue;
604
+ const verdict = parseDacl(trimmed, identity);
605
+ if (verdict.status !== 'unknown')
606
+ return verdict;
607
+ lastDetail = verdict.detail;
608
+ }
609
+ return { status: 'unknown', reason: 'unparsable', detail: lastDetail };
610
+ }
611
+ /**
612
+ * Whether a path is somewhere its ACL can reasonably be tightened.
613
+ *
614
+ * A drive root or the working directory is not: the remediation for a config at
615
+ * `C:\config.toml` used to be printed as `icacls "C:\" /inheritance:d` followed
616
+ * by a `/remove:g` of BUILTIN\Users — which, run as printed, breaks inheritance
617
+ * across the volume and takes the drive away from every non-administrator. The
618
+ * fix for a config in a place like that is to put it somewhere else.
619
+ */
620
+ function isTightenable(path, kind) {
621
+ // A file is always tightenable. Removing BUILTIN\Users from one config file
622
+ // takes nothing away from anyone — the destructive case was only ever the
623
+ // directory, and scoping this to both made the advice for the realistic case
624
+ // ("your config is under C:\sshcfg") worse than what it replaced.
625
+ if (kind === 'file')
626
+ return true;
627
+ const { root } = parsePath(path);
628
+ if (path === root || path === '.' || path === '..')
629
+ return false;
630
+ // For a directory, not merely "is it a root". Everything under `C:\` inherits
631
+ // BUILTIN\Users read-and-execute, so any directory outside the profile can come
632
+ // back broad — and `C:\Users\config.toml` would then be answered with "strip
633
+ // BUILTIN\Users from C:\Users", which removes every other account's traverse to
634
+ // its own profile. The first version keyed on how the path was spelled, so it
635
+ // caught only the two shapes that had been reported.
636
+ //
637
+ // Outside the profile the fix is described rather than printed. A directory the
638
+ // operator does own — `D:\configs`, say — is perfectly tightenable, and telling
639
+ // them to move the file would be poor advice; but that cannot be told apart from
640
+ // a directory other accounts depend on, and only one of those two mistakes is
641
+ // destructive.
642
+ const home = process.env.USERPROFILE;
643
+ // With no profile to compare against the honest answer is "cannot tell", and
644
+ // relocation() is the message that says so without prescribing anything destructive.
645
+ // Returning true here reinstated exactly the advice this function exists to prevent:
646
+ // break inheritance on a shared directory and strip BUILTIN\Users from it.
647
+ if (!home)
648
+ return false;
649
+ // Both sides through realpath, because Windows hands out 8.3 short names and the
650
+ // two ends of this comparison need not agree on which form they use: on a GitHub
651
+ // runner %TEMP% is `C:\\Users\\RUNNER~1\\AppData\\Local\\Temp` while %USERPROFILE% is
652
+ // `C:\\Users\\runneradmin`, so comparing the text said "outside the profile" for a
653
+ // directory squarely inside it, and the operator got "move the config" where the
654
+ // fix was two commands. The Windows job caught that; the VM did not.
655
+ //
656
+ // path.relative rather than a prefix match: a hardcoded separator was wrong on
657
+ // the platform the tests run on, and a bare startsWith would also accept a
658
+ // sibling whose name merely begins with the profile's (`C:\\Users\\meloud`).
659
+ const rel = relative(canonical(home), canonical(path));
660
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
661
+ }
662
+ /**
663
+ * A path in the form the filesystem itself uses, lowercased.
664
+ *
665
+ * `realpathSync.native` is what expands an 8.3 short name. It needs the path to
666
+ * exist, so a missing one falls back to lexical resolution.
667
+ */
668
+ function canonical(path) {
669
+ try {
670
+ return realpathSync.native(resolve(path)).toLowerCase();
671
+ }
672
+ catch {
673
+ return resolve(path).toLowerCase();
674
+ }
675
+ }
676
+ /**
677
+ * The two commands that make a path private again.
678
+ *
679
+ * `grants` is empty for a NULL DACL, where there is nothing to remove and the
680
+ * point is to give the object an ACL at all. Shared rather than written twice:
681
+ * this text has the worst record in the change — three corrected versions so far
682
+ * — and a second copy is a second thing to keep correcting.
683
+ */
684
+ function remediation(path, kind, grants = []) {
685
+ const resolved = grants.filter((g) => g.sid !== null);
686
+ const unresolved = grants.filter((g) => g.sid === null);
687
+ const removals = resolved.length
688
+ ? ` ${resolved.map((g) => `/remove:g *${g.sid}`).join(' ')}`
689
+ : '';
690
+ // Named rather than guessed at: `/remove:g *<alias>` is accepted and removes nothing,
691
+ // which is worse than admitting we cannot name the SID.
692
+ const byHand = unresolved.length
693
+ ? `\nOne entry cannot be named as a SID from here: ${unresolved.map((g) => g.name).join(', ')}. ` +
694
+ `Run \`icacls "${path}"\` to see it and remove it by the SID icacls prints.`
695
+ : '';
696
+ const rights = kind === 'file' ? 'F' : '(OI)(CI)F';
697
+ // The account name is interpolated rather than left as %USERNAME%. That spelling
698
+ // is cmd.exe's; pasted into PowerShell — the default shell on Windows 11 — it
699
+ // reaches icacls literally, which reports "Failed processing 1 files" and
700
+ // changes nothing. Measured. An instruction that only works in one shell is
701
+ // the same defect as the `chmod 600` this whole change replaced.
702
+ const account = userInfo().username;
703
+ return ('Restrict it with these two commands, in this order:\n' +
704
+ ` icacls "${path}" /inheritance:d\n` +
705
+ ` icacls "${path}" /grant:r "${account}:${rights}"${removals}\n` +
706
+ 'The first converts inherited entries into entries this object owns — an inherited ' +
707
+ 'entry cannot be removed while it is still inherited, which is why it takes two ' +
708
+ 'commands. The second grants you full access and drops the broad ones. SYSTEM and ' +
709
+ 'Administrators keep theirs, which is what a file under %APPDATA% looks like.' +
710
+ byHand);
711
+ }
712
+ function relocation(path, kind, grants) {
713
+ // The commands come from `remediation`, not from a copy, and they are printed rather
714
+ // than referred to.
715
+ //
716
+ // Two reasons. Reaching this arm for a directory requires the *file* to have been
717
+ // private — the loop reports the first offending subject — so the earlier wording,
718
+ // "the same two commands shown for the file apply to it", pointed at text that was
719
+ // absent every single time. And a hand-written pair here would have omitted the
720
+ // `/remove:g` arguments, which is a command that reports success and leaves the
721
+ // directory exactly as open as it was: the defect this whole change is about,
722
+ // reproduced one arm over.
723
+ return ('Move the config under %APPDATA%\\ssh-mcp instead, which inherits an ACL restricted ' +
724
+ 'to you, SYSTEM and Administrators.\n' +
725
+ `${path} is a filesystem root, or sits outside your user profile, so removing the ` +
726
+ 'broad entries from it could take access away from every other account on the ' +
727
+ 'machine. If it is in fact yours alone, the commands below do it — but whether it ' +
728
+ 'is, only you can judge.\n' +
729
+ remediation(path, kind, grants));
730
+ }
731
+ /**
732
+ * Refuse a config the owner is not alone in being able to read — the Windows
733
+ * half of `checkPermissions`.
734
+ *
735
+ * Both the file and its directory are examined. An unreadable ACL is refused rather
736
+ * than waved through, with two exceptions: `icacls` absent from the machine, and the
737
+ * check running out of time. Both are statements about the machine rather than about
738
+ * the file, and refusing over a question that cannot be asked here is what #138 was.
739
+ * `--allowUncheckedConfigAcl` is the way past the rest, so an operator is never stuck
740
+ * with no exit.
741
+ */
742
+ export async function assertPrivateOnWindows(filePath, opts = {}, inspect) {
743
+ // One deadline for the whole check, shared by every subprocess it starts.
744
+ // A budget per subject, not one shared across both. Sharing it meant an expiry during
745
+ // the file check aborted the directory check on entry, so one slow call did not degrade
746
+ // the check — it disabled the rest of it.
747
+ const look = inspect ?? ((p) => {
748
+ const signal = AbortSignal.timeout(ACL_TIMEOUT_MS);
749
+ return raceDeadline(inspectAcl(p, signal), signal);
750
+ });
751
+ const report = opts.onUnverified ?? ((e) => {
752
+ console.error(`Warning: the ACL of ${e.path} was not checked (${e.reason}: ${e.detail}), so whether ` +
753
+ 'other accounts can read your config is unverified. Loading it anyway.');
754
+ });
755
+ const subjects = [
756
+ { path: filePath, kind: 'file' },
757
+ { path: parsePath(filePath).dir || '.', kind: 'directory' },
758
+ ];
759
+ for (const { path, kind } of subjects) {
760
+ const verdict = await look(path);
761
+ if (verdict.status === 'restricted')
762
+ continue;
763
+ if (verdict.status === 'broad') {
764
+ const named = verdict.grants.map((g) => g.name).join(', ');
765
+ throw new OperatorError(`Config ${kind} ${path} is readable beyond its owner: access is granted to ${named}. ` +
766
+ 'Required: this account, SYSTEM and Administrators only.\n' +
767
+ (isTightenable(path, kind) ? remediation(path, kind, verdict.grants) : relocation(path, kind, verdict.grants)));
768
+ }
769
+ if (verdict.status === 'no-dacl') {
770
+ throw new OperatorError(`Config ${kind} ${path} has no access control list at all, which on Windows means ` +
771
+ 'full control for every account on the machine.\n' +
772
+ (isTightenable(path, kind) ? remediation(path, kind) : relocation(path, kind, [])));
773
+ }
774
+ // status === 'unknown'
775
+ if (verdict.reason === 'tool-missing' || verdict.reason === 'timed-out' || opts.allowUnchecked) {
776
+ report({ path, reason: verdict.reason, detail: verdict.detail });
777
+ continue;
778
+ }
779
+ throw new OperatorError(`Config ${kind} ${path} could not be checked for access by other accounts ` +
780
+ `(${verdict.reason}: ${verdict.detail}).\n` +
781
+ 'This is refused rather than assumed safe, because the config decides which hosts, ' +
782
+ 'roles and policy rules this server honours.\n' +
783
+ 'Either fix the cause, move the config under %APPDATA%\\ssh-mcp, or pass ' +
784
+ '--allowUncheckedConfigAcl to load it unverified.');
785
+ }
786
+ }