ssh-mcp 2.3.3 → 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.
- package/build/cli.js +221 -0
- package/build/config/sddl.js +453 -0
- package/build/config/windows-acl.js +1 -423
- package/build/index.js +3 -212
- package/package.json +3 -2
|
@@ -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
|
package/build/index.js
CHANGED
|
@@ -1,222 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import {
|
|
5
|
-
import { OperatorError, ConfigNotFoundError, reportFatal } from './errors.js';
|
|
4
|
+
import { reportFatal } from './errors.js';
|
|
6
5
|
import { ConnectionRegistry } from './ssh/connection-registry.js';
|
|
7
|
-
import { PolicyEngine,
|
|
6
|
+
import { PolicyEngine, resolvePolicyRules } from './policy/engine.js';
|
|
8
7
|
import { AuditStore } from './audit/store.js';
|
|
9
8
|
import { registerTools, registerResources, getToolHashes } from './tools/registry.js';
|
|
10
9
|
import { initKeychain } from './config/credential-resolver.js';
|
|
11
10
|
import { SERVER_VERSION } from './version.js';
|
|
12
|
-
|
|
13
|
-
const args = process.argv.slice(2);
|
|
14
|
-
const config = {};
|
|
15
|
-
for (const arg of args) {
|
|
16
|
-
if (arg.startsWith('--')) {
|
|
17
|
-
const eq = arg.indexOf('=');
|
|
18
|
-
if (eq === -1) {
|
|
19
|
-
config[arg.slice(2)] = null;
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
config[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return config;
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Whether a boolean flag was passed at all.
|
|
30
|
-
*
|
|
31
|
-
* `parseArgv` stores `null` for a flag written without `=`, which is how every
|
|
32
|
-
* documented boolean flag is written. Testing that value for truthiness makes
|
|
33
|
-
* the flag a no-op: `--disableApproval`, `--auditEntropyScan` and
|
|
34
|
-
* `--auditTamperEvident` all did nothing unless spelled `--flag=1`, which
|
|
35
|
-
* nothing documents (#91). The two call sites that got it right already used
|
|
36
|
-
* `!== undefined`; this gives the check a name so the next one cannot miss it.
|
|
37
|
-
*
|
|
38
|
-
* `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
|
|
39
|
-
* turned off once written into a wrapper script is its own annoyance.
|
|
40
|
-
*/
|
|
41
|
-
function flagEnabled(argv, name) {
|
|
42
|
-
if (!(name in argv))
|
|
43
|
-
return false;
|
|
44
|
-
const value = argv[name];
|
|
45
|
-
if (value === null || value === '')
|
|
46
|
-
return true;
|
|
47
|
-
return !['false', '0', 'no', 'off'].includes(value.toLowerCase());
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* v1 flags that v2 removed. Silently ignoring them means the user only finds
|
|
51
|
-
* out at the first command, as an auth failure with no hint about the cause.
|
|
52
|
-
*/
|
|
53
|
-
const REMOVED_V1_FLAGS = {
|
|
54
|
-
password: 'Use the SSH_MCP_PASSWORD env var (or a per-profile SSH_MCP_<PROFILE>_PASSWORD).',
|
|
55
|
-
suPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
|
|
56
|
-
sudoPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
|
|
57
|
-
disableSudo: 'Sudo is now a separate tool; restrict it with a role/policy that disallows the "privileged" class.',
|
|
58
|
-
};
|
|
59
|
-
function checkRemovedFlags(argv) {
|
|
60
|
-
const found = Object.keys(REMOVED_V1_FLAGS).filter((f) => f in argv);
|
|
61
|
-
if (found.length === 0)
|
|
62
|
-
return;
|
|
63
|
-
throw new OperatorError('These flags were removed in v2:\n' +
|
|
64
|
-
found.map((f) => ` --${f}: ${REMOVED_V1_FLAGS[f]}`).join('\n') +
|
|
65
|
-
'\nSee the "Migrating from v1" section of the README.');
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* v1 documented `--maxChars=none` (and 0/negative) as "no limit". Parsing it
|
|
69
|
-
* with `parseInt(...) || 5000` silently turned that into a 5000-char cap.
|
|
70
|
-
*/
|
|
71
|
-
function parseMaxChars(raw) {
|
|
72
|
-
if (typeof raw !== 'string' || raw === '')
|
|
73
|
-
return 5_000;
|
|
74
|
-
if (raw.toLowerCase() === 'none')
|
|
75
|
-
return Number.MAX_SAFE_INTEGER;
|
|
76
|
-
const parsed = parseInt(raw);
|
|
77
|
-
if (isNaN(parsed))
|
|
78
|
-
return 5_000;
|
|
79
|
-
return parsed <= 0 ? Number.MAX_SAFE_INTEGER : parsed;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* `strict` was previously unreachable: nothing but test code could select it,
|
|
83
|
-
* yet host-key.ts pointed users at a `--acceptNewHostKey` flag that never
|
|
84
|
-
* existed. `--insecureHostKey` is kept as an alias for the documented flag.
|
|
85
|
-
*/
|
|
86
|
-
function resolveHostKeyMode(argv) {
|
|
87
|
-
if (flagEnabled(argv, 'insecureHostKey'))
|
|
88
|
-
return 'insecure';
|
|
89
|
-
const mode = argv.hostKeyMode;
|
|
90
|
-
if (mode === null || mode === undefined)
|
|
91
|
-
return 'tofu';
|
|
92
|
-
if (mode === 'tofu' || mode === 'strict' || mode === 'insecure')
|
|
93
|
-
return mode;
|
|
94
|
-
throw new OperatorError(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Which policy tier a CLI-configured host belongs to.
|
|
98
|
-
*
|
|
99
|
-
* Without this there was no way to say it from the command line at all. The
|
|
100
|
-
* inline profile carries no group, so it fell to the strictest tier, where the
|
|
101
|
-
* `admin` role has no `privileged` — meaning `sudo` could never run for anyone
|
|
102
|
-
* who had not written a config file (#91).
|
|
103
|
-
*
|
|
104
|
-
* The default stays `prod`. Guessing an unknown host is production is the safe
|
|
105
|
-
* direction; what was missing was a way to correct the guess.
|
|
106
|
-
*/
|
|
107
|
-
export function resolveHostGroup(argv) {
|
|
108
|
-
const group = argv.group;
|
|
109
|
-
if (group === null || group === undefined)
|
|
110
|
-
return 'prod';
|
|
111
|
-
if (HOST_GROUPS.includes(group))
|
|
112
|
-
return group;
|
|
113
|
-
// Falling through would silently apply the prod bindings to a typo, and the
|
|
114
|
-
// operator would read the refusal as policy rather than as their own slip.
|
|
115
|
-
throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Every ACL finding, in the order it happened — both "readable beyond its owner" and
|
|
119
|
-
* "could not be checked".
|
|
120
|
-
*
|
|
121
|
-
* Collected rather than only printed so `main()` can act on them once there is somewhere
|
|
122
|
-
* durable to put them; see the note at the sink below.
|
|
123
|
-
*/
|
|
124
|
-
const aclFindings = [];
|
|
125
|
-
async function buildAppConfig(argv) {
|
|
126
|
-
// The way past an ACL that could not be read, rather than an operator with no
|
|
127
|
-
// exit. See assertPrivateOnWindows.
|
|
128
|
-
// The sink is supplied here rather than left to the module's default, which is the
|
|
129
|
-
// point of having one: "this security check did not run" is a decision about what the
|
|
130
|
-
// operator must be told, and the loader is not the layer that owns that. Routing it to
|
|
131
|
-
// the audit store as well needs an AuditRecord variant for a startup event — that type
|
|
132
|
-
// is hash-chained and tamper-evident, so it is its own change, and it is queued.
|
|
133
|
-
const aclOpts = {
|
|
134
|
-
enforce: flagEnabled(argv, 'strictConfigAcl'),
|
|
135
|
-
allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
|
|
136
|
-
onFinding: (f) => {
|
|
137
|
-
aclFindings.push(f);
|
|
138
|
-
console.error(`Warning: ${f.message}`);
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
if (argv.config !== undefined) {
|
|
142
|
-
// `parseArgv` stores null for a flag written without `=`, so a bare `--config` used to
|
|
143
|
-
// be falsy here and fall into the default-path branch — which now refuses rather than
|
|
144
|
-
// silently ignoring an unusable file, so the operator got a refusal naming a path they
|
|
145
|
-
// never asked for.
|
|
146
|
-
if (argv.config === null || argv.config === '') {
|
|
147
|
-
throw new OperatorError('--config needs a path: --config=<path>');
|
|
148
|
-
}
|
|
149
|
-
return loadConfig(argv.config, aclOpts);
|
|
150
|
-
}
|
|
151
|
-
try {
|
|
152
|
-
const fromFile = await loadConfig(undefined, aclOpts);
|
|
153
|
-
// Before #138 every Windows config was rejected by the mode-bit check and
|
|
154
|
-
// the rejection was swallowed, so an operator who also passed --host/--user
|
|
155
|
-
// was silently running off the flags. Now that the file loads, it takes
|
|
156
|
-
// precedence — a flip worth one line rather than a surprise about which host
|
|
157
|
-
// a command reached.
|
|
158
|
-
if (argv.host || argv.user) {
|
|
159
|
-
console.error(`Note: ${getConfigPath()} was loaded, so --host/--user are ignored. ` +
|
|
160
|
-
'Pass --config <path> to choose a different file.');
|
|
161
|
-
}
|
|
162
|
-
return fromFile;
|
|
163
|
-
}
|
|
164
|
-
catch (err) {
|
|
165
|
-
// Only "there is no file" means fall through. A file that exists and is
|
|
166
|
-
// unusable — malformed TOML, a schema violation, a permission failure —
|
|
167
|
-
// used to be swallowed here too and reported as a missing config, which is
|
|
168
|
-
// how #138 stayed unfindable: the operator's file was in the right place,
|
|
169
|
-
// and the real error was discarded before anyone could read it.
|
|
170
|
-
//
|
|
171
|
-
// Falling through would also be wrong on its own terms whenever
|
|
172
|
-
// --host/--user happen to be present: the server would start, silently
|
|
173
|
-
// ignoring every profile, policy and role binding the operator wrote.
|
|
174
|
-
if (!(err instanceof ConfigNotFoundError))
|
|
175
|
-
throw err;
|
|
176
|
-
}
|
|
177
|
-
if (!argv.host || !argv.user) {
|
|
178
|
-
throw new OperatorError('No config file found and missing required --host/--user.\n' +
|
|
179
|
-
// Was hardcoded to ~/.config/ssh-mcp/config.toml on every platform, so on
|
|
180
|
-
// Windows and macOS it named a path this code never reads.
|
|
181
|
-
`Either create a config file at ${getConfigPath()} or pass --config <path>.\n` +
|
|
182
|
-
'For quick start: --host=<host> --user=<user> (credentials via env vars).');
|
|
183
|
-
}
|
|
184
|
-
const defaults = {
|
|
185
|
-
sessionMaxPerConnection: parseInt(argv.sessionMax) || 5,
|
|
186
|
-
sessionIdleTimeoutMs: parseInt(argv.sessionTtl) || 600_000,
|
|
187
|
-
sessionBackgroundMaxMs: 3_600_000,
|
|
188
|
-
commandTimeoutMs: parseInt(argv.timeout) || 60_000,
|
|
189
|
-
commandMaxChars: parseMaxChars(argv.maxChars),
|
|
190
|
-
commandMaxOutputBytes: 1_048_576,
|
|
191
|
-
connectionIdleReapMs: 900_000,
|
|
192
|
-
commandQuotaPerDay: parseInt(argv.commandQuota) || 0,
|
|
193
|
-
approvalGrantTtlMs: parseInt(argv.approvalGrantTtl) || 0,
|
|
194
|
-
approvalMode: 'ask-destructive',
|
|
195
|
-
};
|
|
196
|
-
const profile = {
|
|
197
|
-
name: 'default',
|
|
198
|
-
host: argv.host,
|
|
199
|
-
port: parseInt(argv.port) || 22,
|
|
200
|
-
user: argv.user,
|
|
201
|
-
auth: argv.key ? 'key' : 'password',
|
|
202
|
-
keyRef: argv.key || undefined,
|
|
203
|
-
workdir: argv.workdir || undefined,
|
|
204
|
-
tty: false,
|
|
205
|
-
timeout: defaults.commandTimeoutMs,
|
|
206
|
-
maxChars: defaults.commandMaxChars,
|
|
207
|
-
maxOutputBytes: defaults.commandMaxOutputBytes,
|
|
208
|
-
role: 'admin',
|
|
209
|
-
group: resolveHostGroup(argv),
|
|
210
|
-
readOnly: false,
|
|
211
|
-
approvalPolicy: flagEnabled(argv, 'disableApproval') ? 'auto' : defaults.approvalMode,
|
|
212
|
-
cert: false,
|
|
213
|
-
sessionMaxPerConnection: defaults.sessionMaxPerConnection,
|
|
214
|
-
sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
|
|
215
|
-
sessionBackgroundMaxMs: defaults.sessionBackgroundMaxMs,
|
|
216
|
-
commandQuotaPerDay: defaults.commandQuotaPerDay,
|
|
217
|
-
};
|
|
218
|
-
return { defaults, profiles: [profile] };
|
|
219
|
-
}
|
|
11
|
+
import { parseArgv, buildAppConfig, checkRemovedFlags, flagEnabled, resolveHostKeyMode, } from './cli.js';
|
|
220
12
|
async function main() {
|
|
221
13
|
const argv = parseArgv();
|
|
222
14
|
checkRemovedFlags(argv);
|
|
@@ -342,4 +134,3 @@ if (isCliEnabled || isTestMode) {
|
|
|
342
134
|
process.exit(reportFatal(error));
|
|
343
135
|
});
|
|
344
136
|
}
|
|
345
|
-
export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars, flagEnabled, resolveHostKeyMode };
|