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