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.
- package/README.md +41 -4
- package/build/config/credential-resolver.js +16 -3
- package/build/config/loader.js +79 -26
- package/build/config/windows-acl.js +786 -0
- package/build/errors.js +116 -0
- package/build/index.js +64 -13
- package/build/policy/classifier.js +53 -19
- package/build/policy/engine.js +3 -2
- package/build/ssh/host-key.js +15 -2
- package/build/transport/http.js +10 -4
- package/package.json +2 -1
|
@@ -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
|
+
}
|