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