@wrongstack/persistence 0.306.0 → 0.306.3
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/file-permissions.d.ts +53 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +64 -1
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-only file hardening, on both POSIX and Windows.
|
|
3
|
+
*
|
|
4
|
+
* WS-045: this lived as a private function inside `secret-vault.ts` and was
|
|
5
|
+
* therefore reachable only by the config file. Every other secret WrongStack
|
|
6
|
+
* writes — the HQ `auth.json` (bearer tokens), `runtime.json` (local client
|
|
7
|
+
* token), the vault `.key` — got `chmod(0o600)` at best, which on Windows is
|
|
8
|
+
* a no-op for everything except the read-only bit: the file stayed readable by
|
|
9
|
+
* every other account on the machine. One exported module so a new secret file
|
|
10
|
+
* has an obvious thing to call.
|
|
11
|
+
*
|
|
12
|
+
* ## Why it lives in `@wrongstack/persistence`
|
|
13
|
+
*
|
|
14
|
+
* It started in `@wrongstack/core/security`, which put it out of reach of the
|
|
15
|
+
* one project daemon that needed it most: `@wrongstack/kanban` depends only on
|
|
16
|
+
* this package, and it cannot depend on core — core already depends on kanban,
|
|
17
|
+
* so that edge would close a cycle. Every project daemon writes a metadata
|
|
18
|
+
* file holding its per-process IPC auth token, and every one of them needs the
|
|
19
|
+
* same owner-only guarantee, so the helper belongs next to `atomicWrite`: a
|
|
20
|
+
* dependency-free filesystem primitive that all of core, kanban, sage and
|
|
21
|
+
* tools already import. `@wrongstack/core/security` re-exports it, so existing
|
|
22
|
+
* callers are unchanged.
|
|
23
|
+
*
|
|
24
|
+
* @module file-permissions
|
|
25
|
+
*/
|
|
26
|
+
/** Owner read/write, nothing for group or other. */
|
|
27
|
+
export declare const SECRET_FILE_MODE = 384;
|
|
28
|
+
/** Owner-only directory: enter + list + write. */
|
|
29
|
+
export declare const SECRET_DIR_MODE = 448;
|
|
30
|
+
export interface RestrictPermissionsOptions {
|
|
31
|
+
warn?: ((msg: string) => void) | undefined;
|
|
32
|
+
/** Label used in warnings, e.g. `secret-vault` or `hq-auth`. */
|
|
33
|
+
label?: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Restrict a file to owner-only access.
|
|
37
|
+
*
|
|
38
|
+
* POSIX: `chmod 0600`. Windows: `chmod` cannot express this, so we use
|
|
39
|
+
* `icacls` to drop inherited ACEs and grant the current user alone.
|
|
40
|
+
*
|
|
41
|
+
* Failures are warned, never thrown — a hardening step must not be able to
|
|
42
|
+
* block the write it protects, and `icacls` is absent in some minimal
|
|
43
|
+
* environments (containers, WINE, restricted CI images).
|
|
44
|
+
*/
|
|
45
|
+
export declare function restrictFilePermissions(filePath: string, opts?: RestrictPermissionsOptions): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* `restrictFilePermissions` for a directory: `chmod 0700` on POSIX, the same
|
|
48
|
+
* icacls treatment (applied recursively to new children via inheritance) on
|
|
49
|
+
* Windows. Use on the directory that holds secret files so a file created
|
|
50
|
+
* there by a path we do not control still lands owner-only.
|
|
51
|
+
*/
|
|
52
|
+
export declare function restrictDirPermissions(dirPath: string, opts?: RestrictPermissionsOptions): Promise<void>;
|
|
53
|
+
//# sourceMappingURL=file-permissions.d.ts.map
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -212,6 +212,13 @@ async function renameWithRetry(from, to) {
|
|
|
212
212
|
} catch (error) {
|
|
213
213
|
const code = error.code;
|
|
214
214
|
let transient = code !== void 0 && TRANSIENT_RENAME_CODES.has(code);
|
|
215
|
+
if (transient) {
|
|
216
|
+
try {
|
|
217
|
+
const target = await fs.stat(to);
|
|
218
|
+
if (target.isDirectory()) transient = false;
|
|
219
|
+
} catch {
|
|
220
|
+
}
|
|
221
|
+
}
|
|
215
222
|
if (code === "ENOENT") {
|
|
216
223
|
try {
|
|
217
224
|
await fs.stat(from);
|
|
@@ -223,7 +230,7 @@ async function renameWithRetry(from, to) {
|
|
|
223
230
|
if (!transient || attempt === delays.length) {
|
|
224
231
|
if (attempt === delays.length) {
|
|
225
232
|
process.emitWarning(
|
|
226
|
-
`Windows rename retries exhausted for '${from}' \u2192 '${to}' (code=${code}).
|
|
233
|
+
`Windows rename retries exhausted for '${from}' \u2192 '${to}' (code=${code}). Atomic write failed; the original target was left untouched.`,
|
|
227
234
|
{ code: "WRONGSTACK_WIN32_RENAME_EXHAUSTED" }
|
|
228
235
|
);
|
|
229
236
|
}
|
|
@@ -239,6 +246,58 @@ var atomicWrite = defaultPrimitives.atomicWrite;
|
|
|
239
246
|
var ensureDir = defaultPrimitives.ensureDir;
|
|
240
247
|
var withFileLock = defaultPrimitives.withFileLock;
|
|
241
248
|
|
|
249
|
+
// src/file-permissions.ts
|
|
250
|
+
import { chmod as chmod2 } from "node:fs/promises";
|
|
251
|
+
var SECRET_FILE_MODE = 384;
|
|
252
|
+
var SECRET_DIR_MODE = 448;
|
|
253
|
+
async function restrictFilePermissions(filePath, opts) {
|
|
254
|
+
const label = opts?.label ?? "file-permissions";
|
|
255
|
+
const warn = opts?.warn ?? ((msg) => console.warn(msg));
|
|
256
|
+
if (process.platform === "win32") {
|
|
257
|
+
try {
|
|
258
|
+
const { execFile } = await import("node:child_process");
|
|
259
|
+
const { promisify } = await import("node:util");
|
|
260
|
+
const execFileAsync = promisify(execFile);
|
|
261
|
+
const user = windowsAccountName();
|
|
262
|
+
if (!user) {
|
|
263
|
+
warn(
|
|
264
|
+
`[${label}] Could not determine the current Windows user for ${filePath}; skipping icacls hardening.`
|
|
265
|
+
);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
await execFileAsync("icacls", [filePath, "/inheritance:r", "/grant:r", `${user}:(F)`], {
|
|
269
|
+
windowsHide: true
|
|
270
|
+
});
|
|
271
|
+
} catch {
|
|
272
|
+
warn(
|
|
273
|
+
`[${label}] Could not restrict permissions on ${filePath} \u2014 it may be readable by other users on this system.`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
try {
|
|
278
|
+
await chmod2(filePath, SECRET_FILE_MODE);
|
|
279
|
+
} catch {
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function restrictDirPermissions(dirPath, opts) {
|
|
284
|
+
if (process.platform === "win32") {
|
|
285
|
+
await restrictFilePermissions(dirPath, opts);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
await chmod2(dirPath, SECRET_DIR_MODE);
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function windowsAccountName() {
|
|
294
|
+
const username = process.env.USERNAME || process.env.USER;
|
|
295
|
+
if (!username || username.includes("\0")) return void 0;
|
|
296
|
+
const domain = process.env.USERDOMAIN;
|
|
297
|
+
if (domain && !domain.includes("\0")) return `${domain}\\${username}`;
|
|
298
|
+
return username;
|
|
299
|
+
}
|
|
300
|
+
|
|
242
301
|
// src/project-endpoint.ts
|
|
243
302
|
import * as fsPromises from "node:fs/promises";
|
|
244
303
|
import * as net from "node:net";
|
|
@@ -367,6 +426,8 @@ async function bindProjectEndpoint(options) {
|
|
|
367
426
|
}
|
|
368
427
|
export {
|
|
369
428
|
PersistenceFsError,
|
|
429
|
+
SECRET_DIR_MODE,
|
|
430
|
+
SECRET_FILE_MODE,
|
|
370
431
|
assertUnixSocketPathWithinLimit,
|
|
371
432
|
atomicWrite,
|
|
372
433
|
bindProjectEndpoint,
|
|
@@ -374,6 +435,8 @@ export {
|
|
|
374
435
|
createPersistencePrimitives,
|
|
375
436
|
ensureDir,
|
|
376
437
|
isProjectEndpointLive,
|
|
438
|
+
restrictDirPermissions,
|
|
439
|
+
restrictFilePermissions,
|
|
377
440
|
unixSocketPathLimit,
|
|
378
441
|
withFileLock
|
|
379
442
|
};
|