@evomap/evolver-adapter-public 2.0.0-beta.8 → 2.0.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/dist/antiAbuseTelemetry.js +2 -1
- package/dist/auth/credentialStore.d.ts +87 -3
- package/dist/auth/credentialStore.js +1065 -10
- package/dist/auth/oauthDeviceToken.d.ts +9 -6
- package/dist/auth/oauthDeviceToken.js +71 -18
- package/dist/auth/oauthHttpTransport.d.ts +4 -0
- package/dist/auth/oauthHttpTransport.js +66 -15
- package/dist/auth/windowsPowerShell.d.ts +3 -0
- package/dist/auth/windowsPowerShell.js +91 -0
- package/dist/hubCapability.d.ts +16 -4
- package/dist/hubCapability.js +322 -45
- package/dist/hubFetch.d.ts +44 -11
- package/dist/hubFetch.js +329 -76
- package/dist/hubReuse.d.ts +40 -0
- package/dist/hubReuse.js +303 -32
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/learningPacketFeedback.d.ts +68 -0
- package/dist/learningPacketFeedback.js +104 -0
- package/dist/learningPacketSink.d.ts +40 -0
- package/dist/learningPacketSink.js +153 -0
- package/dist/wireMap.d.ts +3 -1
- package/dist/wireMap.js +29 -3
- package/package.json +6 -3
|
@@ -1,23 +1,1078 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { constants, closeSync, fchmodSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { basename, dirname, resolve, win32 } from 'node:path';
|
|
5
|
+
import { POWERSHELL_STDIN_SCRIPT_COMMAND, windowsAclFailureDetail, } from './windowsPowerShell.js';
|
|
6
|
+
const DIRECTORY_MODE = 0o700;
|
|
7
|
+
const FILE_MODE = 0o600;
|
|
8
|
+
export class CredentialStoreError extends Error {
|
|
9
|
+
constructor(message, options) {
|
|
10
|
+
super(`Unsafe credential path: ${message}`, options);
|
|
11
|
+
this.name = 'CredentialStoreError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Persists OAuth tokens and keypair private keys behind a local secret-file boundary. */
|
|
4
15
|
export class CredentialStore {
|
|
5
16
|
path;
|
|
6
|
-
|
|
7
|
-
|
|
17
|
+
platform;
|
|
18
|
+
windowsAclOps;
|
|
19
|
+
darwinAclReader;
|
|
20
|
+
windowsParentStateReader;
|
|
21
|
+
linkFile;
|
|
22
|
+
// ctime detects in-place ACL drift while dev/ino detects entry replacement.
|
|
23
|
+
securedDirectoryState = null;
|
|
24
|
+
securedCredentialState = null;
|
|
25
|
+
securedAncestorStates = new Map();
|
|
26
|
+
trustedWindowsParentStates = new Map();
|
|
27
|
+
constructor(path, options = {}) {
|
|
28
|
+
this.path = resolve(path);
|
|
29
|
+
this.platform = options.platform ?? process.platform;
|
|
30
|
+
this.windowsAclOps = this.platform === 'win32'
|
|
31
|
+
? (options.windowsAclOps ?? new PowerShellWindowsAclOps())
|
|
32
|
+
: undefined;
|
|
33
|
+
this.darwinAclReader = options.darwinAclReader ?? readDarwinAcl;
|
|
34
|
+
this.windowsParentStateReader = options.windowsParentStateReader ?? parentSecurityStates;
|
|
35
|
+
this.linkFile = options.linkFile ?? linkSync;
|
|
8
36
|
}
|
|
9
37
|
load() {
|
|
10
|
-
if (!
|
|
38
|
+
if (!this.prepareDirectory(false))
|
|
39
|
+
return null;
|
|
40
|
+
const directory = dirname(this.path);
|
|
41
|
+
const directoryIdentity = this.directoryIdentity(directory);
|
|
42
|
+
const fd = this.openCredentialFile();
|
|
43
|
+
if (fd === null)
|
|
11
44
|
return null;
|
|
12
45
|
try {
|
|
13
|
-
|
|
46
|
+
this.assertDirectoryIdentity(directory, directoryIdentity);
|
|
47
|
+
this.secureCredentialFd(fd);
|
|
48
|
+
const raw = readFileSync(fd, 'utf8');
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
52
|
+
return null;
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof SyntaxError)
|
|
57
|
+
return null;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
14
60
|
}
|
|
15
|
-
|
|
61
|
+
finally {
|
|
62
|
+
closeSync(fd);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Validate an existing credential path without changing its mode, DACL, ACL, or contents. */
|
|
66
|
+
inspectTrustedExisting() {
|
|
67
|
+
if (!this.prepareDirectory(false, false))
|
|
68
|
+
return null;
|
|
69
|
+
const directory = dirname(this.path);
|
|
70
|
+
const directoryIdentity = this.directoryIdentity(directory);
|
|
71
|
+
const fd = this.openCredentialFile();
|
|
72
|
+
if (fd === null)
|
|
16
73
|
return null;
|
|
74
|
+
try {
|
|
75
|
+
this.assertDirectoryIdentity(directory, directoryIdentity);
|
|
76
|
+
const stat = this.assertCredentialFdTrustedReadOnly(fd);
|
|
77
|
+
this.assertDirectoryIdentity(directory, directoryIdentity);
|
|
78
|
+
return {
|
|
79
|
+
dev: stat.dev,
|
|
80
|
+
ino: stat.ino,
|
|
81
|
+
birthtimeNs: stat.birthtimeNs,
|
|
82
|
+
ctimeNs: stat.ctimeNs,
|
|
83
|
+
mtimeNs: stat.mtimeNs,
|
|
84
|
+
size: stat.size,
|
|
85
|
+
mode: stat.mode,
|
|
86
|
+
uid: stat.uid,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Validate the existing parent chain for a future create without creating or hardening it. */
|
|
94
|
+
inspectTrustedParentForCreate() {
|
|
95
|
+
const directory = dirname(this.path);
|
|
96
|
+
if (dirname(directory) === directory) {
|
|
97
|
+
throw new CredentialStoreError('filesystem root cannot be used as the credential directory');
|
|
98
|
+
}
|
|
99
|
+
const missing = this.missingDirectoryComponents(directory);
|
|
100
|
+
if (missing.length === 0) {
|
|
101
|
+
void this.prepareDirectory(false, false);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!this.isPosix()) {
|
|
105
|
+
const highestMissing = missing.at(-1);
|
|
106
|
+
if (highestMissing === undefined) {
|
|
107
|
+
throw new CredentialStoreError('credential parent inspection failed');
|
|
108
|
+
}
|
|
109
|
+
this.assertTrustedWindowsParent(dirname(highestMissing), true);
|
|
17
110
|
}
|
|
18
111
|
}
|
|
19
112
|
save(cred) {
|
|
20
|
-
|
|
21
|
-
|
|
113
|
+
void this.saveCredential(cred, true);
|
|
114
|
+
}
|
|
115
|
+
/** Persist a credential only when no filesystem entry already occupies its path. */
|
|
116
|
+
saveIfAbsent(cred) {
|
|
117
|
+
return this.saveCredential(cred, false);
|
|
118
|
+
}
|
|
119
|
+
saveCredential(cred, replaceExisting) {
|
|
120
|
+
this.prepareDirectory(true);
|
|
121
|
+
const existingFd = this.openCredentialFile();
|
|
122
|
+
if (existingFd !== null) {
|
|
123
|
+
try {
|
|
124
|
+
this.secureCredentialFd(existingFd);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
closeSync(existingFd);
|
|
128
|
+
}
|
|
129
|
+
if (!replaceExisting)
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const directory = dirname(this.path);
|
|
133
|
+
const directoryIdentity = this.directoryIdentity(directory);
|
|
134
|
+
const temporaryPath = resolve(directory, `.${basename(this.path)}.tmp-${randomBytes(16).toString('hex')}`);
|
|
135
|
+
let temporaryIdentity = null;
|
|
136
|
+
let fd = null;
|
|
137
|
+
try {
|
|
138
|
+
fd = openSync(temporaryPath, this.exclusiveWriteFlags(), FILE_MODE);
|
|
139
|
+
const temporaryStat = bigFstat(fd);
|
|
140
|
+
if (!temporaryStat.isFile())
|
|
141
|
+
throw new CredentialStoreError('temporary entry is not a regular file');
|
|
142
|
+
temporaryIdentity = identityOf(temporaryStat);
|
|
143
|
+
if (this.isPosix()) {
|
|
144
|
+
this.clearDarwinAcl(fd, temporaryIdentity, 'temporary');
|
|
145
|
+
fchmodSync(fd, FILE_MODE);
|
|
146
|
+
}
|
|
147
|
+
else
|
|
148
|
+
this.secureWindowsFile(temporaryPath, fd, temporaryIdentity);
|
|
149
|
+
writeFileSync(fd, JSON.stringify(cred), 'utf8');
|
|
150
|
+
fsyncSync(fd);
|
|
151
|
+
closeSync(fd);
|
|
152
|
+
fd = null;
|
|
153
|
+
this.assertDirectoryIdentity(directory, directoryIdentity);
|
|
154
|
+
if (replaceExisting) {
|
|
155
|
+
this.assertSafeDestination();
|
|
156
|
+
renameSync(temporaryPath, this.path);
|
|
157
|
+
}
|
|
158
|
+
else if (!this.publishIfAbsent(temporaryPath)) {
|
|
159
|
+
const incumbentFd = this.openCredentialFile();
|
|
160
|
+
if (incumbentFd === null) {
|
|
161
|
+
throw new CredentialStoreError('credential changed during no-clobber publication');
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
this.secureCredentialFd(incumbentFd);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
closeSync(incumbentFd);
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
this.verifySavedFile(temporaryIdentity);
|
|
172
|
+
if (!replaceExisting)
|
|
173
|
+
this.unlinkIfSameFile(temporaryPath, temporaryIdentity);
|
|
174
|
+
temporaryIdentity = null;
|
|
175
|
+
this.syncDirectory(directory);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
if (fd !== null)
|
|
180
|
+
closeSync(fd);
|
|
181
|
+
if (temporaryIdentity !== null)
|
|
182
|
+
this.unlinkIfSameFile(temporaryPath, temporaryIdentity);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
prepareDirectory(create, harden = true) {
|
|
186
|
+
const directory = dirname(this.path);
|
|
187
|
+
if (dirname(directory) === directory) {
|
|
188
|
+
throw new CredentialStoreError('filesystem root cannot be used as the credential directory');
|
|
189
|
+
}
|
|
190
|
+
const missing = this.missingDirectoryComponents(directory);
|
|
191
|
+
if (missing.length > 0) {
|
|
192
|
+
if (!create)
|
|
193
|
+
return false;
|
|
194
|
+
for (const component of missing.reverse())
|
|
195
|
+
this.createDirectoryComponent(component);
|
|
196
|
+
if (this.missingDirectoryComponents(directory).length > 0) {
|
|
197
|
+
throw new CredentialStoreError('parent directory disappeared during creation');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const stat = bigLstat(directory);
|
|
201
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
202
|
+
throw new CredentialStoreError('parent is a symlink or not a directory');
|
|
203
|
+
}
|
|
204
|
+
if (!this.isPosix()) {
|
|
205
|
+
if (harden) {
|
|
206
|
+
this.secureWindowsDirectory(directory, stat);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
this.assertTrustedWindowsParent(dirname(directory), true);
|
|
210
|
+
this.assertTrustedWindowsParent(directory, true);
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
const uid = BigInt(currentUid());
|
|
215
|
+
if (stat.uid !== uid)
|
|
216
|
+
throw new CredentialStoreError('parent directory is not owned by the current user');
|
|
217
|
+
const flags = constants.O_RDONLY | optionalConstant('O_DIRECTORY') | optionalConstant('O_NOFOLLOW');
|
|
218
|
+
const fd = openSync(directory, flags);
|
|
219
|
+
try {
|
|
220
|
+
const opened = bigFstat(fd);
|
|
221
|
+
if (!opened.isDirectory() || !sameIdentity(stat, opened)) {
|
|
222
|
+
throw new CredentialStoreError('parent directory changed during validation');
|
|
223
|
+
}
|
|
224
|
+
if (opened.uid !== uid)
|
|
225
|
+
throw new CredentialStoreError('parent directory is not owned by the current user');
|
|
226
|
+
if ((permissionMode(opened) & 0o022) !== 0) {
|
|
227
|
+
throw new CredentialStoreError('parent directory is writable by group or other');
|
|
228
|
+
}
|
|
229
|
+
this.assertSafeDarwinAncestor(directory, opened, false);
|
|
230
|
+
if (!harden)
|
|
231
|
+
return true;
|
|
232
|
+
this.clearDarwinAcl(fd, identityOf(opened), 'directory');
|
|
233
|
+
if (permissionMode(bigFstat(fd)) !== DIRECTORY_MODE)
|
|
234
|
+
fchmodSync(fd, DIRECTORY_MODE);
|
|
235
|
+
if (permissionMode(bigFstat(fd)) !== DIRECTORY_MODE) {
|
|
236
|
+
throw new CredentialStoreError('parent directory permissions could not be restricted to 0700');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
closeSync(fd);
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
missingDirectoryComponents(directory) {
|
|
245
|
+
let current = directory;
|
|
246
|
+
let isCredentialDirectory = true;
|
|
247
|
+
const missing = [];
|
|
248
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
249
|
+
for (;;) {
|
|
250
|
+
let stat;
|
|
251
|
+
try {
|
|
252
|
+
stat = bigLstat(current);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (!isErrno(error, 'ENOENT'))
|
|
256
|
+
throw error;
|
|
257
|
+
missing.push(current);
|
|
258
|
+
isCredentialDirectory = false;
|
|
259
|
+
const parent = dirname(current);
|
|
260
|
+
if (parent === current)
|
|
261
|
+
return missing;
|
|
262
|
+
current = parent;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
266
|
+
throw new CredentialStoreError(`parent component ${current} is a symlink or not a directory`);
|
|
267
|
+
}
|
|
268
|
+
if (this.isPosix()) {
|
|
269
|
+
if (uid === undefined)
|
|
270
|
+
throw new CredentialStoreError('current user ownership cannot be determined');
|
|
271
|
+
if (isCredentialDirectory && (stat.mode & 2n) !== 0n && (stat.mode & 512n) !== 0n) {
|
|
272
|
+
throw new CredentialStoreError(`credential directory ${current} is a shared sticky directory`);
|
|
273
|
+
}
|
|
274
|
+
if (stat.uid !== BigInt(uid) && stat.uid !== 0n) {
|
|
275
|
+
throw new CredentialStoreError(`parent component ${current} has an untrusted owner`);
|
|
276
|
+
}
|
|
277
|
+
if (!isCredentialDirectory && (stat.uid === BigInt(uid) || stat.uid === 0n)) {
|
|
278
|
+
this.assertSafeDarwinAncestor(current, stat, stat.uid === BigInt(uid) && stat.uid !== 0n);
|
|
279
|
+
}
|
|
280
|
+
if (!isCredentialDirectory && (stat.mode & 18n) !== 0n && (stat.mode & 512n) === 0n) {
|
|
281
|
+
throw new CredentialStoreError(`parent component ${current} is writable by untrusted users`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const parent = dirname(current);
|
|
285
|
+
if (parent === current)
|
|
286
|
+
return missing;
|
|
287
|
+
// Root-owned directories are a stable trust anchor. Stopping here also
|
|
288
|
+
// avoids rejecting platform-managed aliases above that anchor (e.g. /var).
|
|
289
|
+
if (uid !== undefined && stat.uid === 0n)
|
|
290
|
+
return missing;
|
|
291
|
+
isCredentialDirectory = false;
|
|
292
|
+
current = parent;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
createDirectoryComponent(component) {
|
|
296
|
+
if (!this.isPosix())
|
|
297
|
+
this.assertTrustedWindowsParent(dirname(component), true);
|
|
298
|
+
try {
|
|
299
|
+
mkdirSync(component, { mode: DIRECTORY_MODE });
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (!isErrno(error, 'EEXIST'))
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
const stat = bigLstat(component);
|
|
306
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
307
|
+
throw new CredentialStoreError(`parent component ${component} is a symlink or not a directory`);
|
|
308
|
+
}
|
|
309
|
+
if (!this.isPosix()) {
|
|
310
|
+
this.secureWindowsDirectory(component, stat);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (stat.uid !== BigInt(currentUid())) {
|
|
314
|
+
throw new CredentialStoreError(`created parent component ${component} is not owned by the current user`);
|
|
315
|
+
}
|
|
316
|
+
const fd = openSync(component, constants.O_RDONLY | optionalConstant('O_DIRECTORY') | optionalConstant('O_NOFOLLOW'));
|
|
317
|
+
try {
|
|
318
|
+
const opened = bigFstat(fd);
|
|
319
|
+
if (!opened.isDirectory() || !sameIdentity(stat, opened) || opened.uid !== BigInt(currentUid())) {
|
|
320
|
+
throw new CredentialStoreError(`parent component ${component} changed during creation`);
|
|
321
|
+
}
|
|
322
|
+
this.clearDarwinAcl(fd, identityOf(opened), 'directory');
|
|
323
|
+
if (permissionMode(bigFstat(fd)) !== DIRECTORY_MODE)
|
|
324
|
+
fchmodSync(fd, DIRECTORY_MODE);
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
closeSync(fd);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
openCredentialFile() {
|
|
331
|
+
const before = safeLstat(this.path);
|
|
332
|
+
if (!before)
|
|
333
|
+
return null;
|
|
334
|
+
if (before.isSymbolicLink() || !before.isFile()) {
|
|
335
|
+
throw new CredentialStoreError('credential entry is a symlink or not a regular file');
|
|
336
|
+
}
|
|
337
|
+
let fd;
|
|
338
|
+
try {
|
|
339
|
+
fd = openSync(this.path, constants.O_RDONLY | optionalConstant('O_NOFOLLOW') | optionalConstant('O_NONBLOCK'));
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (isErrno(error, 'ENOENT'))
|
|
343
|
+
return null;
|
|
344
|
+
if (isErrno(error, 'ELOOP'))
|
|
345
|
+
throw new CredentialStoreError('credential entry is a symlink');
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const opened = bigFstat(fd);
|
|
350
|
+
const after = bigLstat(this.path);
|
|
351
|
+
if (!opened.isFile() || after.isSymbolicLink() || !after.isFile() ||
|
|
352
|
+
!sameIdentity(before, opened) || !sameIdentity(opened, after)) {
|
|
353
|
+
throw new CredentialStoreError('credential entry changed during validation');
|
|
354
|
+
}
|
|
355
|
+
return fd;
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
closeSync(fd);
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
secureCredentialFd(fd) {
|
|
363
|
+
const stat = bigFstat(fd);
|
|
364
|
+
if (!stat.isFile())
|
|
365
|
+
throw new CredentialStoreError('credential entry is not a regular file');
|
|
366
|
+
if (!this.isPosix()) {
|
|
367
|
+
this.secureWindowsFile(this.path, fd, identityOf(stat));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (stat.uid !== BigInt(currentUid()))
|
|
371
|
+
throw new CredentialStoreError('credential file is not owned by the current user');
|
|
372
|
+
// Reject group/other-writable modes before fchmod and before trusting content.
|
|
373
|
+
// Migrating 0644→0600 is still allowed (read exposure only); write exposure is fail-closed.
|
|
374
|
+
if ((permissionMode(stat) & 0o022) !== 0) {
|
|
375
|
+
throw new CredentialStoreError('credential file is writable by group or other');
|
|
376
|
+
}
|
|
377
|
+
this.clearDarwinAcl(fd, identityOf(stat), 'credential');
|
|
378
|
+
if (permissionMode(bigFstat(fd)) !== FILE_MODE)
|
|
379
|
+
fchmodSync(fd, FILE_MODE);
|
|
380
|
+
if (permissionMode(bigFstat(fd)) !== FILE_MODE) {
|
|
381
|
+
throw new CredentialStoreError('credential file permissions could not be restricted to 0600');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
assertCredentialFdTrustedReadOnly(fd) {
|
|
385
|
+
const stat = bigFstat(fd);
|
|
386
|
+
if (!stat.isFile())
|
|
387
|
+
throw new CredentialStoreError('credential entry is not a regular file');
|
|
388
|
+
if (!this.isPosix()) {
|
|
389
|
+
if (!this.windowsAclOps)
|
|
390
|
+
throw new CredentialStoreError('Windows file ACL policy is unavailable');
|
|
391
|
+
const identity = identityOf(stat);
|
|
392
|
+
this.assertTrustedWindowsParent(dirname(this.path), false);
|
|
393
|
+
try {
|
|
394
|
+
this.windowsAclOps.assertTrustedFile(this.path);
|
|
395
|
+
}
|
|
396
|
+
catch (cause) {
|
|
397
|
+
throw windowsCredentialStoreError('Windows credential file ACL is not trusted', cause);
|
|
398
|
+
}
|
|
399
|
+
const pathStat = bigLstat(this.path);
|
|
400
|
+
const fdStat = bigFstat(fd);
|
|
401
|
+
if (pathStat.isSymbolicLink() || !pathStat.isFile() || !fdStat.isFile() ||
|
|
402
|
+
!sameIdentity(pathStat, identity) || !sameIdentity(fdStat, identity)) {
|
|
403
|
+
throw new CredentialStoreError('credential file changed during read-only ACL validation');
|
|
404
|
+
}
|
|
405
|
+
return fdStat;
|
|
406
|
+
}
|
|
407
|
+
if (stat.uid !== BigInt(currentUid())) {
|
|
408
|
+
throw new CredentialStoreError('credential file is not owned by the current user');
|
|
409
|
+
}
|
|
410
|
+
if ((permissionMode(stat) & 0o022) !== 0) {
|
|
411
|
+
throw new CredentialStoreError('credential file is writable by group or other');
|
|
412
|
+
}
|
|
413
|
+
this.assertTrustedDarwinFile(fd, identityOf(stat));
|
|
414
|
+
return bigFstat(fd);
|
|
415
|
+
}
|
|
416
|
+
assertSafeDestination() {
|
|
417
|
+
const fd = this.openCredentialFile();
|
|
418
|
+
if (fd === null)
|
|
419
|
+
return;
|
|
420
|
+
try {
|
|
421
|
+
this.secureCredentialFd(fd);
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
closeSync(fd);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
verifySavedFile(expectedIdentity) {
|
|
428
|
+
const fd = this.openCredentialFile();
|
|
429
|
+
if (fd === null)
|
|
430
|
+
throw new CredentialStoreError('credential file disappeared after atomic replacement');
|
|
431
|
+
try {
|
|
432
|
+
if (!sameIdentity(bigFstat(fd), expectedIdentity)) {
|
|
433
|
+
throw new CredentialStoreError('credential file changed during atomic replacement');
|
|
434
|
+
}
|
|
435
|
+
this.securedCredentialState = securityStateOf(bigFstat(fd));
|
|
436
|
+
// A same-directory rename preserves the DACL already verified on the
|
|
437
|
+
// temporary inode. Avoid a post-commit ACL mutation that could fail after
|
|
438
|
+
// the old credential has already been replaced.
|
|
439
|
+
if (this.isPosix())
|
|
440
|
+
this.secureCredentialFd(fd);
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
closeSync(fd);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
directoryIdentity(directory) {
|
|
447
|
+
const stat = bigLstat(directory);
|
|
448
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
449
|
+
throw new CredentialStoreError('parent is unsafe');
|
|
450
|
+
return identityOf(stat);
|
|
451
|
+
}
|
|
452
|
+
assertDirectoryIdentity(directory, identity) {
|
|
453
|
+
const stat = bigLstat(directory);
|
|
454
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || !sameIdentity(stat, identity)) {
|
|
455
|
+
throw new CredentialStoreError('parent directory changed during write');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
exclusiveWriteFlags() {
|
|
459
|
+
return constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | optionalConstant('O_NOFOLLOW');
|
|
460
|
+
}
|
|
461
|
+
publishIfAbsent(temporaryPath) {
|
|
462
|
+
try {
|
|
463
|
+
// A same-directory hard link publishes the already-fsynced inode without
|
|
464
|
+
// replacing a destination that appears after the absence preflight.
|
|
465
|
+
this.linkFile(temporaryPath, this.path);
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
if (isErrno(error, 'EEXIST'))
|
|
470
|
+
return false;
|
|
471
|
+
try {
|
|
472
|
+
if (safeLstat(this.path) !== null)
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// Report a stable fail-closed error below.
|
|
477
|
+
}
|
|
478
|
+
throw new CredentialStoreError('atomic no-clobber publication is unavailable');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
syncDirectory(directory) {
|
|
482
|
+
if (!this.isPosix())
|
|
483
|
+
return;
|
|
484
|
+
const fd = openSync(directory, constants.O_RDONLY | optionalConstant('O_DIRECTORY') | optionalConstant('O_NOFOLLOW'));
|
|
485
|
+
try {
|
|
486
|
+
fsyncSync(fd);
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
closeSync(fd);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
unlinkIfSameFile(path, identity) {
|
|
493
|
+
const stat = safeLstat(path);
|
|
494
|
+
if (!stat || stat.isSymbolicLink() || !stat.isFile() || !sameIdentity(stat, identity))
|
|
495
|
+
return;
|
|
496
|
+
unlinkSync(path);
|
|
497
|
+
}
|
|
498
|
+
secureWindowsDirectory(path, stat) {
|
|
499
|
+
if (!this.windowsAclOps)
|
|
500
|
+
throw new CredentialStoreError('Windows directory ACL policy is unavailable');
|
|
501
|
+
const identity = identityOf(stat);
|
|
502
|
+
// An existing directory may itself grant DELETE to another SID. Requiring
|
|
503
|
+
// strict create rights on its direct parent prevents delete-and-recreate
|
|
504
|
+
// with a junction before the pathname-based ACL update completes.
|
|
505
|
+
this.assertTrustedWindowsParent(dirname(path), true);
|
|
506
|
+
// Reject unsafe grants on an existing directory instead of trying to
|
|
507
|
+
// migrate them in place. Tightening a DACL cannot revoke access already
|
|
508
|
+
// granted to a handle opened by an untrusted principal.
|
|
509
|
+
this.assertTrustedWindowsParent(path, true);
|
|
510
|
+
if (this.securedDirectoryState && sameSecurityState(this.securedDirectoryState, stat))
|
|
511
|
+
return;
|
|
512
|
+
try {
|
|
513
|
+
this.windowsAclOps.secureDirectory(path);
|
|
514
|
+
}
|
|
515
|
+
catch (cause) {
|
|
516
|
+
throw windowsCredentialStoreError('Windows directory ACL could not be secured', cause);
|
|
517
|
+
}
|
|
518
|
+
const after = bigLstat(path);
|
|
519
|
+
if (after.isSymbolicLink() || !after.isDirectory() || !sameIdentity(after, identity)) {
|
|
520
|
+
throw new CredentialStoreError('parent directory changed while securing its Windows ACL');
|
|
521
|
+
}
|
|
522
|
+
this.securedDirectoryState = securityStateOf(after);
|
|
523
|
+
}
|
|
524
|
+
secureWindowsFile(path, fd, identity) {
|
|
525
|
+
if (!this.windowsAclOps)
|
|
526
|
+
throw new CredentialStoreError('Windows file ACL policy is unavailable');
|
|
527
|
+
const isCredentialPath = path === this.path;
|
|
528
|
+
const before = bigFstat(fd);
|
|
529
|
+
this.assertTrustedWindowsParent(dirname(path), false);
|
|
530
|
+
// Reject unsafe grants on an existing file instead of migrating them in
|
|
531
|
+
// place and then consuming. Tightening a DACL cannot revoke access already
|
|
532
|
+
// granted to a handle opened by an untrusted principal.
|
|
533
|
+
try {
|
|
534
|
+
this.windowsAclOps.assertTrustedFile(path);
|
|
535
|
+
}
|
|
536
|
+
catch (cause) {
|
|
537
|
+
throw windowsCredentialStoreError('Windows credential file ACL is not trusted', cause);
|
|
538
|
+
}
|
|
539
|
+
if (isCredentialPath && this.securedCredentialState &&
|
|
540
|
+
sameSecurityState(this.securedCredentialState, before))
|
|
541
|
+
return;
|
|
542
|
+
try {
|
|
543
|
+
this.windowsAclOps.secureFile(path);
|
|
544
|
+
}
|
|
545
|
+
catch (cause) {
|
|
546
|
+
throw windowsCredentialStoreError('Windows file ACL could not be secured', cause);
|
|
547
|
+
}
|
|
548
|
+
const pathStat = bigLstat(path);
|
|
549
|
+
const fdStat = bigFstat(fd);
|
|
550
|
+
if (pathStat.isSymbolicLink() || !pathStat.isFile() || !fdStat.isFile() ||
|
|
551
|
+
!sameIdentity(pathStat, identity) || !sameIdentity(fdStat, identity)) {
|
|
552
|
+
throw new CredentialStoreError('credential file changed while securing its Windows ACL');
|
|
553
|
+
}
|
|
554
|
+
if (isCredentialPath)
|
|
555
|
+
this.securedCredentialState = securityStateOf(fdStat);
|
|
556
|
+
}
|
|
557
|
+
clearDarwinAcl(fd, identity, kind) {
|
|
558
|
+
if (this.platform !== 'darwin')
|
|
559
|
+
return;
|
|
560
|
+
const cached = kind === 'directory'
|
|
561
|
+
? this.securedDirectoryState
|
|
562
|
+
: kind === 'credential' ? this.securedCredentialState : null;
|
|
563
|
+
if (cached && sameSecurityState(cached, bigFstat(fd)))
|
|
564
|
+
return;
|
|
565
|
+
try {
|
|
566
|
+
execFileSync('/bin/chmod', ['-N', '/dev/fd/3'], {
|
|
567
|
+
shell: false,
|
|
568
|
+
stdio: ['ignore', 'ignore', 'ignore', fd],
|
|
569
|
+
timeout: 10_000,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
throw new CredentialStoreError(`${kind} extended ACL could not be removed`);
|
|
574
|
+
}
|
|
575
|
+
const after = bigFstat(fd);
|
|
576
|
+
if (!sameIdentity(after, identity)) {
|
|
577
|
+
throw new CredentialStoreError(`${kind} changed while removing its extended ACL`);
|
|
578
|
+
}
|
|
579
|
+
if (kind === 'directory')
|
|
580
|
+
this.securedDirectoryState = securityStateOf(after);
|
|
581
|
+
else if (kind === 'credential')
|
|
582
|
+
this.securedCredentialState = securityStateOf(after);
|
|
583
|
+
}
|
|
584
|
+
assertSafeDarwinAncestor(path, stat, rejectAnyAllow) {
|
|
585
|
+
if (this.platform !== 'darwin')
|
|
586
|
+
return;
|
|
587
|
+
const cached = this.securedAncestorStates.get(path);
|
|
588
|
+
if (cached && sameSecurityState(cached, stat))
|
|
589
|
+
return;
|
|
590
|
+
const identity = identityOf(stat);
|
|
591
|
+
const flags = constants.O_RDONLY | optionalConstant('O_DIRECTORY') | optionalConstant('O_NOFOLLOW');
|
|
592
|
+
const fd = openSync(path, flags);
|
|
593
|
+
try {
|
|
594
|
+
const opened = bigFstat(fd);
|
|
595
|
+
if (!opened.isDirectory() || !sameIdentity(opened, identity)) {
|
|
596
|
+
throw new CredentialStoreError(`ancestor directory ${path} changed during ACL validation`);
|
|
597
|
+
}
|
|
598
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
599
|
+
const initialState = securityStateOf(bigFstat(fd));
|
|
600
|
+
let output;
|
|
601
|
+
try {
|
|
602
|
+
output = this.darwinAclReader(path);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
throw new CredentialStoreError(`ancestor directory ${path} ACL could not be inspected`);
|
|
606
|
+
}
|
|
607
|
+
if (hasUnsafeDarwinAllowAcl(output, rejectAnyAllow)) {
|
|
608
|
+
throw new CredentialStoreError(`ancestor directory ${path} grants access through an extended ACL`);
|
|
609
|
+
}
|
|
610
|
+
const after = bigLstat(path);
|
|
611
|
+
const openedAfter = bigFstat(fd);
|
|
612
|
+
if (after.isSymbolicLink() || !after.isDirectory() ||
|
|
613
|
+
!sameIdentity(after, identity) || !sameIdentity(openedAfter, identity)) {
|
|
614
|
+
throw new CredentialStoreError(`ancestor directory ${path} changed during ACL validation`);
|
|
615
|
+
}
|
|
616
|
+
const metadataStable = sameSecurityState(initialState, after) &&
|
|
617
|
+
sameSecurityState(initialState, openedAfter);
|
|
618
|
+
if (metadataStable) {
|
|
619
|
+
this.securedAncestorStates.set(path, securityStateOf(openedAfter));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
throw new CredentialStoreError(`ancestor directory ${path} changed during ACL validation`);
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
closeSync(fd);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
assertTrustedDarwinFile(fd, identity) {
|
|
630
|
+
if (this.platform !== 'darwin')
|
|
631
|
+
return;
|
|
632
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
633
|
+
const initialState = securityStateOf(bigFstat(fd));
|
|
634
|
+
let output;
|
|
635
|
+
try {
|
|
636
|
+
output = this.darwinAclReader(this.path);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
throw new CredentialStoreError('credential file ACL could not be inspected');
|
|
640
|
+
}
|
|
641
|
+
if (hasUnsafeDarwinAllowAcl(output, true)) {
|
|
642
|
+
throw new CredentialStoreError('credential file grants access through an extended ACL');
|
|
643
|
+
}
|
|
644
|
+
const after = bigLstat(this.path);
|
|
645
|
+
const openedAfter = bigFstat(fd);
|
|
646
|
+
if (after.isSymbolicLink() || !after.isFile() ||
|
|
647
|
+
!sameIdentity(after, identity) || !sameIdentity(openedAfter, identity)) {
|
|
648
|
+
throw new CredentialStoreError('credential file changed during ACL validation');
|
|
649
|
+
}
|
|
650
|
+
if (sameSecurityState(initialState, after) &&
|
|
651
|
+
sameSecurityState(initialState, openedAfter))
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
throw new CredentialStoreError('credential file changed during ACL validation');
|
|
655
|
+
}
|
|
656
|
+
assertTrustedWindowsParent(path, strictCreate) {
|
|
657
|
+
if (!this.windowsAclOps)
|
|
658
|
+
throw new CredentialStoreError('Windows parent ACL policy is unavailable');
|
|
659
|
+
const cacheKey = `${strictCreate ? 'create' : 'existing'}:${path}`;
|
|
660
|
+
try {
|
|
661
|
+
const before = this.windowsParentStateReader(path);
|
|
662
|
+
const cached = this.trustedWindowsParentStates.get(cacheKey);
|
|
663
|
+
if (cached && samePathSecurityStates(cached, before))
|
|
664
|
+
return;
|
|
665
|
+
this.windowsAclOps.assertTrustedParent(path, strictCreate);
|
|
666
|
+
const after = this.windowsParentStateReader(path);
|
|
667
|
+
if (samePathSecurityStates(before, after)) {
|
|
668
|
+
this.trustedWindowsParentStates.set(cacheKey, after);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
catch (cause) {
|
|
672
|
+
throw windowsCredentialStoreError('Windows parent directory chain is not trusted', cause);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
isPosix() {
|
|
676
|
+
return this.platform !== 'win32';
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function windowsCredentialStoreError(message, cause) {
|
|
680
|
+
const detail = cause instanceof Error ? windowsAclFailureDetail(cause) : '';
|
|
681
|
+
return new CredentialStoreError(detail ? `${message} (${detail})` : message, { cause });
|
|
682
|
+
}
|
|
683
|
+
const WINDOWS_ACL_SCRIPT = String.raw `
|
|
684
|
+
$ErrorActionPreference = 'Stop'
|
|
685
|
+
# Progress records can be serialized as CLIXML onto redirected stderr and
|
|
686
|
+
# obscure the actual failure. Suppress them so stderr carries only real errors.
|
|
687
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
688
|
+
|
|
689
|
+
function ConvertTo-OneLineAclDiagnostic([object]$Value) {
|
|
690
|
+
if ($null -eq $Value) { return '' }
|
|
691
|
+
return ([string]$Value -replace '[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+', ' ').Trim()
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function Throw-CredentialAclFailure(
|
|
695
|
+
[string]$Reason,
|
|
696
|
+
[string]$Path = '',
|
|
697
|
+
[string]$Sid = '',
|
|
698
|
+
[object]$Rights = $null,
|
|
699
|
+
[string]$Principal = ''
|
|
700
|
+
) {
|
|
701
|
+
$parts = @($Reason)
|
|
702
|
+
$safePath = ConvertTo-OneLineAclDiagnostic $Path
|
|
703
|
+
$safeSid = ConvertTo-OneLineAclDiagnostic $Sid
|
|
704
|
+
$safeRights = ConvertTo-OneLineAclDiagnostic $Rights
|
|
705
|
+
$safePrincipal = ConvertTo-OneLineAclDiagnostic $Principal
|
|
706
|
+
if (-not [string]::IsNullOrWhiteSpace($safePath)) { $parts += ('path=' + $safePath) }
|
|
707
|
+
if (-not [string]::IsNullOrWhiteSpace($safeSid)) { $parts += ('sid=' + $safeSid) }
|
|
708
|
+
if (-not [string]::IsNullOrWhiteSpace($safePrincipal)) { $parts += ('principal=' + $safePrincipal) }
|
|
709
|
+
if (-not [string]::IsNullOrWhiteSpace($safeRights)) { $parts += ('rights=' + $safeRights) }
|
|
710
|
+
throw ($parts -join '; ')
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
# Windows PowerShell can serialize ordinary error-stream writes as CLIXML when
|
|
714
|
+
# stderr is redirected. Write the terminating exception directly to native stderr so
|
|
715
|
+
# Node receives the actionable message rather than only a serialized record.
|
|
716
|
+
trap {
|
|
717
|
+
$message = ConvertTo-OneLineAclDiagnostic $_.Exception.Message
|
|
718
|
+
if ([string]::IsNullOrWhiteSpace($message)) { $message = 'Credential ACL check failed' }
|
|
719
|
+
[Console]::Error.WriteLine($message)
|
|
720
|
+
exit 1
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
$Target = [Environment]::GetEnvironmentVariable('EVOMAP_CREDENTIAL_ACL_TARGET', 'Process')
|
|
724
|
+
$Kind = [Environment]::GetEnvironmentVariable('EVOMAP_CREDENTIAL_ACL_KIND', 'Process')
|
|
725
|
+
if ([string]::IsNullOrEmpty($Target) -or
|
|
726
|
+
($Kind -ne 'assert-parent' -and $Kind -ne 'assert-create-parent' -and
|
|
727
|
+
$Kind -ne 'assert-file' -and $Kind -ne 'directory' -and $Kind -ne 'file')) {
|
|
728
|
+
throw 'Invalid credential ACL input'
|
|
729
|
+
}
|
|
730
|
+
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
731
|
+
$trustedSids = @(
|
|
732
|
+
$sid.Value,
|
|
733
|
+
'S-1-5-18',
|
|
734
|
+
'S-1-5-32-544',
|
|
735
|
+
'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
|
|
736
|
+
)
|
|
737
|
+
$dangerousRights = [System.Security.AccessControl.FileSystemRights](
|
|
738
|
+
[System.Security.AccessControl.FileSystemRights]::Delete -bor
|
|
739
|
+
[System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
|
740
|
+
[System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
|
741
|
+
[System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
|
742
|
+
)
|
|
743
|
+
$dangerousFileRights = [System.Security.AccessControl.FileSystemRights](
|
|
744
|
+
[System.Security.AccessControl.FileSystemRights]::WriteData -bor
|
|
745
|
+
[System.Security.AccessControl.FileSystemRights]::AppendData -bor
|
|
746
|
+
[System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor
|
|
747
|
+
[System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor
|
|
748
|
+
[System.Security.AccessControl.FileSystemRights]::Delete -bor
|
|
749
|
+
[System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
|
750
|
+
[System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
function Assert-TrustedParent([string]$ParentPath, [bool]$StrictCreate) {
|
|
754
|
+
$full = [System.IO.Path]::GetFullPath($ParentPath)
|
|
755
|
+
$root = [System.IO.Path]::GetPathRoot($full)
|
|
756
|
+
if ([string]::IsNullOrEmpty($root) -or $root -notmatch '^[A-Za-z]:\\$') {
|
|
757
|
+
Throw-CredentialAclFailure -Reason 'Credential parent must be on a local drive' -Path $full
|
|
758
|
+
}
|
|
759
|
+
if ([System.IO.DriveInfo]::new($root).DriveType -ne [System.IO.DriveType]::Fixed) {
|
|
760
|
+
Throw-CredentialAclFailure -Reason 'Credential parent must be on a fixed local drive' -Path $full
|
|
761
|
+
}
|
|
762
|
+
$current = $root
|
|
763
|
+
$relative = $full.Substring($root.Length)
|
|
764
|
+
$segments = $relative.Split(
|
|
765
|
+
[char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar),
|
|
766
|
+
[System.StringSplitOptions]::RemoveEmptyEntries
|
|
767
|
+
)
|
|
768
|
+
$paths = @($root)
|
|
769
|
+
foreach ($segment in $segments) {
|
|
770
|
+
$current = [System.IO.Path]::Combine($current, $segment)
|
|
771
|
+
$paths += $current
|
|
772
|
+
}
|
|
773
|
+
$trimSeparators = [char[]]@(
|
|
774
|
+
[System.IO.Path]::DirectorySeparatorChar,
|
|
775
|
+
[System.IO.Path]::AltDirectorySeparatorChar
|
|
776
|
+
)
|
|
777
|
+
$finalParent = $full.TrimEnd($trimSeparators)
|
|
778
|
+
foreach ($current in $paths) {
|
|
779
|
+
$isCreateParent = $StrictCreate -and [string]::Equals(
|
|
780
|
+
$current.TrimEnd($trimSeparators),
|
|
781
|
+
$finalParent,
|
|
782
|
+
[System.StringComparison]::OrdinalIgnoreCase
|
|
783
|
+
)
|
|
784
|
+
$item = Get-Item -LiteralPath $current -Force
|
|
785
|
+
if (-not $item.PSIsContainer -or
|
|
786
|
+
(($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
|
787
|
+
Throw-CredentialAclFailure -Reason 'Credential parent contains a reparse point or non-directory' -Path $current
|
|
788
|
+
}
|
|
789
|
+
$parentAcl = Get-Acl -LiteralPath $current
|
|
790
|
+
$ownerSid = $parentAcl.GetOwner([System.Security.Principal.SecurityIdentifier])
|
|
791
|
+
if ($trustedSids -notcontains $ownerSid.Value) {
|
|
792
|
+
Throw-CredentialAclFailure -Reason 'Credential parent has an untrusted owner' -Path $current -Sid $ownerSid.Value
|
|
793
|
+
}
|
|
794
|
+
foreach ($parentRule in @($parentAcl.Access)) {
|
|
795
|
+
if ($parentRule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) {
|
|
796
|
+
continue
|
|
797
|
+
}
|
|
798
|
+
$rights = $parentRule.FileSystemRights
|
|
799
|
+
$inheritOnly = (($parentRule.PropagationFlags -band
|
|
800
|
+
[System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0)
|
|
801
|
+
$containerInherit = (($parentRule.InheritanceFlags -band
|
|
802
|
+
[System.Security.AccessControl.InheritanceFlags]::ContainerInherit) -ne 0)
|
|
803
|
+
$objectInherit = (($parentRule.InheritanceFlags -band
|
|
804
|
+
[System.Security.AccessControl.InheritanceFlags]::ObjectInherit) -ne 0)
|
|
805
|
+
$hasGranularDanger = (($rights -band $dangerousRights) -ne 0)
|
|
806
|
+
$hasCreateDanger = (($rights -band (
|
|
807
|
+
[System.Security.AccessControl.FileSystemRights]::CreateDirectories -bor
|
|
808
|
+
[System.Security.AccessControl.FileSystemRights]::CreateFiles
|
|
809
|
+
)) -ne 0)
|
|
810
|
+
$hasCompositeDanger =
|
|
811
|
+
(($rights -band [System.Security.AccessControl.FileSystemRights]::Write) -eq
|
|
812
|
+
[System.Security.AccessControl.FileSystemRights]::Write) -or
|
|
813
|
+
(($rights -band [System.Security.AccessControl.FileSystemRights]::Modify) -eq
|
|
814
|
+
[System.Security.AccessControl.FileSystemRights]::Modify) -or
|
|
815
|
+
(($rights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq
|
|
816
|
+
[System.Security.AccessControl.FileSystemRights]::FullControl)
|
|
817
|
+
if ($inheritOnly) {
|
|
818
|
+
$dangerousGrant = $isCreateParent -and ($containerInherit -or $objectInherit) -and
|
|
819
|
+
($hasGranularDanger -or $hasCreateDanger -or $hasCompositeDanger)
|
|
820
|
+
} else {
|
|
821
|
+
$dangerousGrant = $hasGranularDanger -or ($isCreateParent -and $hasCreateDanger)
|
|
822
|
+
}
|
|
823
|
+
if (-not $dangerousGrant) { continue }
|
|
824
|
+
try {
|
|
825
|
+
$parentRuleSid = $parentRule.IdentityReference.Translate(
|
|
826
|
+
[System.Security.Principal.SecurityIdentifier]
|
|
827
|
+
)
|
|
828
|
+
} catch {
|
|
829
|
+
Throw-CredentialAclFailure -Reason 'Credential parent contains an unresolvable write principal' -Path $current -Principal $parentRule.IdentityReference.Value -Rights $rights
|
|
830
|
+
}
|
|
831
|
+
if ($trustedSids -notcontains $parentRuleSid.Value) {
|
|
832
|
+
Throw-CredentialAclFailure -Reason 'Credential parent grants write access to an untrusted principal' -Path $current -Sid $parentRuleSid.Value -Rights $rights
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function Assert-TrustedFile([string]$FilePath) {
|
|
839
|
+
$item = Get-Item -LiteralPath $FilePath -Force
|
|
840
|
+
if ($item.PSIsContainer -or
|
|
841
|
+
(($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
|
842
|
+
Throw-CredentialAclFailure -Reason 'Credential file is a reparse point or not a regular file' -Path $FilePath
|
|
843
|
+
}
|
|
844
|
+
$fileAcl = Get-Acl -LiteralPath $FilePath
|
|
845
|
+
$ownerSid = $fileAcl.GetOwner([System.Security.Principal.SecurityIdentifier])
|
|
846
|
+
if ($trustedSids -notcontains $ownerSid.Value) {
|
|
847
|
+
Throw-CredentialAclFailure -Reason 'Credential file has an untrusted owner' -Path $FilePath -Sid $ownerSid.Value
|
|
848
|
+
}
|
|
849
|
+
foreach ($fileRule in @($fileAcl.Access)) {
|
|
850
|
+
if ($fileRule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) {
|
|
851
|
+
continue
|
|
852
|
+
}
|
|
853
|
+
$rights = $fileRule.FileSystemRights
|
|
854
|
+
$hasGranularDanger = (($rights -band $dangerousFileRights) -ne 0)
|
|
855
|
+
$hasCompositeDanger =
|
|
856
|
+
(($rights -band [System.Security.AccessControl.FileSystemRights]::Write) -eq
|
|
857
|
+
[System.Security.AccessControl.FileSystemRights]::Write) -or
|
|
858
|
+
(($rights -band [System.Security.AccessControl.FileSystemRights]::Modify) -eq
|
|
859
|
+
[System.Security.AccessControl.FileSystemRights]::Modify) -or
|
|
860
|
+
(($rights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq
|
|
861
|
+
[System.Security.AccessControl.FileSystemRights]::FullControl)
|
|
862
|
+
if (-not ($hasGranularDanger -or $hasCompositeDanger)) { continue }
|
|
863
|
+
try {
|
|
864
|
+
$fileRuleSid = $fileRule.IdentityReference.Translate(
|
|
865
|
+
[System.Security.Principal.SecurityIdentifier]
|
|
866
|
+
)
|
|
867
|
+
} catch {
|
|
868
|
+
Throw-CredentialAclFailure -Reason 'Credential file contains an unresolvable write principal' -Path $FilePath -Principal $fileRule.IdentityReference.Value -Rights $rights
|
|
869
|
+
}
|
|
870
|
+
if ($trustedSids -notcontains $fileRuleSid.Value) {
|
|
871
|
+
Throw-CredentialAclFailure -Reason 'Credential file grants write access to an untrusted principal' -Path $FilePath -Sid $fileRuleSid.Value -Rights $rights
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function Test-CanonicalCredentialAcl(
|
|
877
|
+
[System.Security.AccessControl.FileSystemSecurity]$CandidateAcl,
|
|
878
|
+
[System.Security.Principal.SecurityIdentifier]$ExpectedOwner,
|
|
879
|
+
[System.Security.AccessControl.InheritanceFlags]$ExpectedInheritance
|
|
880
|
+
) {
|
|
881
|
+
try {
|
|
882
|
+
$candidateRules = @($CandidateAcl.Access)
|
|
883
|
+
if (-not $CandidateAcl.AreAccessRulesProtected -or $candidateRules.Count -ne 1) {
|
|
884
|
+
return $false
|
|
885
|
+
}
|
|
886
|
+
$candidateOwner = $CandidateAcl.GetOwner([System.Security.Principal.SecurityIdentifier])
|
|
887
|
+
$candidateRule = $candidateRules[0]
|
|
888
|
+
$candidateRuleSid = $candidateRule.IdentityReference.Translate(
|
|
889
|
+
[System.Security.Principal.SecurityIdentifier]
|
|
890
|
+
)
|
|
891
|
+
return (
|
|
892
|
+
$candidateOwner.Value -eq $ExpectedOwner.Value -and
|
|
893
|
+
$candidateRuleSid.Value -eq $ExpectedOwner.Value -and
|
|
894
|
+
$candidateRule.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow -and
|
|
895
|
+
$candidateRule.FileSystemRights -eq [System.Security.AccessControl.FileSystemRights]::FullControl -and
|
|
896
|
+
$candidateRule.InheritanceFlags -eq $ExpectedInheritance -and
|
|
897
|
+
$candidateRule.PropagationFlags -eq [System.Security.AccessControl.PropagationFlags]::None -and
|
|
898
|
+
-not $candidateRule.IsInherited
|
|
899
|
+
)
|
|
900
|
+
} catch {
|
|
901
|
+
return $false
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if ($Kind -eq 'assert-parent' -or $Kind -eq 'assert-create-parent') {
|
|
906
|
+
# Existing entries only require protection against replacement. Immediately
|
|
907
|
+
# before mkdir, also reject principals that could atomically squat the name.
|
|
908
|
+
Assert-TrustedParent $Target ($Kind -eq 'assert-create-parent')
|
|
909
|
+
exit 0
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if ($Kind -eq 'assert-file') {
|
|
913
|
+
# Fail-closed: refuse untrusted write/modify/delete grants before Set-Acl
|
|
914
|
+
# and before trusting file content. Do not migrate unsafe grants in place.
|
|
915
|
+
Assert-TrustedFile $Target
|
|
916
|
+
exit 0
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
$expectedInheritance = if ($Kind -eq 'directory') {
|
|
920
|
+
[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
921
|
+
} else {
|
|
922
|
+
[System.Security.AccessControl.InheritanceFlags]::None
|
|
923
|
+
}
|
|
924
|
+
$acl = Get-Acl -LiteralPath $Target
|
|
925
|
+
if (Test-CanonicalCredentialAcl $acl $sid $expectedInheritance) {
|
|
926
|
+
exit 0
|
|
927
|
+
}
|
|
928
|
+
$acl.SetOwner($sid)
|
|
929
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
930
|
+
foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($rule) }
|
|
931
|
+
$access = [System.Security.AccessControl.FileSystemAccessRule]::new(
|
|
932
|
+
$sid,
|
|
933
|
+
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
|
934
|
+
$expectedInheritance,
|
|
935
|
+
[System.Security.AccessControl.PropagationFlags]::None,
|
|
936
|
+
[System.Security.AccessControl.AccessControlType]::Allow
|
|
937
|
+
)
|
|
938
|
+
[void]$acl.AddAccessRule($access)
|
|
939
|
+
Set-Acl -LiteralPath $Target -AclObject $acl
|
|
940
|
+
$verified = Get-Acl -LiteralPath $Target
|
|
941
|
+
if (-not (Test-CanonicalCredentialAcl $verified $sid $expectedInheritance)) {
|
|
942
|
+
Throw-CredentialAclFailure -Reason 'Credential ACL verification failed' -Path $Target -Sid $sid.Value
|
|
943
|
+
}
|
|
944
|
+
`;
|
|
945
|
+
class PowerShellWindowsAclOps {
|
|
946
|
+
executable;
|
|
947
|
+
systemRoot;
|
|
948
|
+
constructor() {
|
|
949
|
+
const systemRoot = process.env['SystemRoot'];
|
|
950
|
+
if (!systemRoot || !win32.isAbsolute(systemRoot)) {
|
|
951
|
+
throw new CredentialStoreError('Windows SystemRoot is unavailable or invalid');
|
|
952
|
+
}
|
|
953
|
+
this.systemRoot = systemRoot;
|
|
954
|
+
this.executable = win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
955
|
+
}
|
|
956
|
+
secureDirectory(path) {
|
|
957
|
+
this.run(path, 'directory');
|
|
958
|
+
}
|
|
959
|
+
secureFile(path) {
|
|
960
|
+
this.run(path, 'file');
|
|
961
|
+
}
|
|
962
|
+
assertTrustedParent(path, strictCreate) {
|
|
963
|
+
this.run(path, strictCreate ? 'assert-create-parent' : 'assert-parent');
|
|
964
|
+
}
|
|
965
|
+
assertTrustedFile(path) {
|
|
966
|
+
this.run(path, 'assert-file');
|
|
967
|
+
}
|
|
968
|
+
run(path, kind) {
|
|
969
|
+
try {
|
|
970
|
+
execFileSync(this.executable, [
|
|
971
|
+
'-NoLogo',
|
|
972
|
+
'-NoProfile',
|
|
973
|
+
'-NonInteractive',
|
|
974
|
+
'-ExecutionPolicy', 'Bypass',
|
|
975
|
+
// The fixed wrapper parses stdin once. Bare -Command - can execute
|
|
976
|
+
// PowerShell 5.1 input statement by statement and mask an earlier error.
|
|
977
|
+
'-Command', POWERSHELL_STDIN_SCRIPT_COMMAND,
|
|
978
|
+
], {
|
|
979
|
+
encoding: 'utf8',
|
|
980
|
+
env: {
|
|
981
|
+
SystemRoot: this.systemRoot,
|
|
982
|
+
EVOMAP_CREDENTIAL_ACL_TARGET: path,
|
|
983
|
+
EVOMAP_CREDENTIAL_ACL_KIND: kind,
|
|
984
|
+
},
|
|
985
|
+
shell: false,
|
|
986
|
+
// Capture both streams rather than discarding them: the script's own
|
|
987
|
+
// message names which path level and which SID failed, and without it
|
|
988
|
+
// every rejection is indistinguishable from "PowerShell is missing".
|
|
989
|
+
// The script prints nothing on success, so this stays quiet normally.
|
|
990
|
+
input: WINDOWS_ACL_SCRIPT,
|
|
991
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
992
|
+
timeout: 15_000,
|
|
993
|
+
windowsHide: true,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
catch (cause) {
|
|
997
|
+
const detail = windowsAclFailureDetail(cause);
|
|
998
|
+
throw new Error(detail ? `${kind} check failed: ${detail}` : `${kind} check failed`, { cause });
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
function readDarwinAcl(path) {
|
|
1003
|
+
return execFileSync('/bin/ls', ['-lde', path], {
|
|
1004
|
+
encoding: 'utf8',
|
|
1005
|
+
shell: false,
|
|
1006
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1007
|
+
timeout: 10_000,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
function hasUnsafeDarwinAllowAcl(output, rejectAnyAllow) {
|
|
1011
|
+
const dangerousDirectoryRights = /\b(?:add_file|add_subdirectory|append|delete|delete_child|write|writeattr|writeextattr|writesecurity|chown)\b/;
|
|
1012
|
+
return output.split('\n').some((line) => {
|
|
1013
|
+
if (!/^ \d+:.* allow /.test(line))
|
|
1014
|
+
return false;
|
|
1015
|
+
return rejectAnyAllow || dangerousDirectoryRights.test(line);
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
function currentUid() {
|
|
1019
|
+
if (typeof process.getuid !== 'function') {
|
|
1020
|
+
throw new CredentialStoreError('current user ownership cannot be determined');
|
|
1021
|
+
}
|
|
1022
|
+
return process.getuid();
|
|
1023
|
+
}
|
|
1024
|
+
function optionalConstant(name) {
|
|
1025
|
+
return constants[name] ?? 0;
|
|
1026
|
+
}
|
|
1027
|
+
function bigLstat(path) {
|
|
1028
|
+
return lstatSync(path, { bigint: true });
|
|
1029
|
+
}
|
|
1030
|
+
function bigFstat(fd) {
|
|
1031
|
+
return fstatSync(fd, { bigint: true });
|
|
1032
|
+
}
|
|
1033
|
+
function permissionMode(stat) {
|
|
1034
|
+
return Number(stat.mode & 511n);
|
|
1035
|
+
}
|
|
1036
|
+
function identityOf(stat) {
|
|
1037
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
1038
|
+
}
|
|
1039
|
+
function securityStateOf(stat) {
|
|
1040
|
+
return { dev: stat.dev, ino: stat.ino, ctimeNs: stat.ctimeNs };
|
|
1041
|
+
}
|
|
1042
|
+
function parentSecurityStates(path) {
|
|
1043
|
+
const states = [];
|
|
1044
|
+
let current = path;
|
|
1045
|
+
for (;;) {
|
|
1046
|
+
states.push({ path: current, ...securityStateOf(bigLstat(current)) });
|
|
1047
|
+
const parent = dirname(current);
|
|
1048
|
+
if (parent === current)
|
|
1049
|
+
return states;
|
|
1050
|
+
current = parent;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
function sameIdentity(left, right) {
|
|
1054
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
1055
|
+
}
|
|
1056
|
+
function sameSecurityState(left, right) {
|
|
1057
|
+
return sameIdentity(left, right) && left.ctimeNs === right.ctimeNs;
|
|
1058
|
+
}
|
|
1059
|
+
function samePathSecurityStates(left, right) {
|
|
1060
|
+
return left.length === right.length && left.every((state, index) => {
|
|
1061
|
+
const candidate = right[index];
|
|
1062
|
+
return candidate !== undefined && state.path === candidate.path &&
|
|
1063
|
+
sameIdentity(state, candidate) && state.ctimeNs === candidate.ctimeNs;
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
function safeLstat(path) {
|
|
1067
|
+
try {
|
|
1068
|
+
return bigLstat(path);
|
|
1069
|
+
}
|
|
1070
|
+
catch (error) {
|
|
1071
|
+
if (isErrno(error, 'ENOENT'))
|
|
1072
|
+
return null;
|
|
1073
|
+
throw error;
|
|
22
1074
|
}
|
|
1075
|
+
}
|
|
1076
|
+
function isErrno(error, code) {
|
|
1077
|
+
return error.code === code;
|
|
23
1078
|
}
|