@openclaw/fs-safe 0.1.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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +390 -0
- package/SECURITY.md +12 -0
- package/dist/absolute-path.d.ts +19 -0
- package/dist/absolute-path.d.ts.map +1 -0
- package/dist/absolute-path.js +98 -0
- package/dist/advanced.d.ts +22 -0
- package/dist/advanced.d.ts.map +1 -0
- package/dist/advanced.js +24 -0
- package/dist/archive-entry.d.ts +13 -0
- package/dist/archive-entry.d.ts.map +1 -0
- package/dist/archive-entry.js +47 -0
- package/dist/archive-kind.d.ts +8 -0
- package/dist/archive-kind.d.ts.map +1 -0
- package/dist/archive-kind.js +58 -0
- package/dist/archive-limits.d.ts +40 -0
- package/dist/archive-limits.d.ts.map +1 -0
- package/dist/archive-limits.js +92 -0
- package/dist/archive-staging.d.ts +26 -0
- package/dist/archive-staging.d.ts.map +1 -0
- package/dist/archive-staging.js +171 -0
- package/dist/archive-tar.d.ts +14 -0
- package/dist/archive-tar.d.ts.map +1 -0
- package/dist/archive-tar.js +52 -0
- package/dist/archive-utils.d.ts +3 -0
- package/dist/archive-utils.d.ts.map +1 -0
- package/dist/archive-utils.js +26 -0
- package/dist/archive-zip-preflight.d.ts +7 -0
- package/dist/archive-zip-preflight.d.ts.map +1 -0
- package/dist/archive-zip-preflight.js +170 -0
- package/dist/archive.d.ts +24 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +251 -0
- package/dist/async-lock.d.ts +2 -0
- package/dist/async-lock.d.ts.map +1 -0
- package/dist/async-lock.js +17 -0
- package/dist/atomic.d.ts +5 -0
- package/dist/atomic.d.ts.map +1 -0
- package/dist/atomic.js +4 -0
- package/dist/boundary-file-read.d.ts +44 -0
- package/dist/boundary-file-read.d.ts.map +1 -0
- package/dist/boundary-file-read.js +129 -0
- package/dist/boundary-path.d.ts +39 -0
- package/dist/boundary-path.d.ts.map +1 -0
- package/dist/boundary-path.js +598 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +20 -0
- package/dist/file-identity.d.ts +6 -0
- package/dist/file-identity.d.ts.map +1 -0
- package/dist/file-identity.js +14 -0
- package/dist/file-lock.d.ts +20 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +34 -0
- package/dist/file-store.d.ts +66 -0
- package/dist/file-store.d.ts.map +1 -0
- package/dist/file-store.js +436 -0
- package/dist/file-url.d.ts +2 -0
- package/dist/file-url.d.ts.map +1 -0
- package/dist/file-url.js +1 -0
- package/dist/filename.d.ts +2 -0
- package/dist/filename.d.ts.map +1 -0
- package/dist/filename.js +25 -0
- package/dist/fs-pinned-path-helper.d.ts +7 -0
- package/dist/fs-pinned-path-helper.d.ts.map +1 -0
- package/dist/fs-pinned-path-helper.js +182 -0
- package/dist/fs-pinned-write-helper.d.ts +21 -0
- package/dist/fs-pinned-write-helper.d.ts.map +1 -0
- package/dist/fs-pinned-write-helper.js +263 -0
- package/dist/fs.d.ts +13 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +30 -0
- package/dist/hardlink-guards.d.ts +7 -0
- package/dist/hardlink-guards.d.ts.map +1 -0
- package/dist/hardlink-guards.js +30 -0
- package/dist/home-dir.d.ts +22 -0
- package/dist/home-dir.d.ts.map +1 -0
- package/dist/home-dir.js +107 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/install-path.d.ts +20 -0
- package/dist/install-path.d.ts.map +1 -0
- package/dist/install-path.js +94 -0
- package/dist/install-safe-path.d.ts +20 -0
- package/dist/install-safe-path.d.ts.map +1 -0
- package/dist/install-safe-path.js +94 -0
- package/dist/json-document-store.d.ts +30 -0
- package/dist/json-document-store.d.ts.map +1 -0
- package/dist/json-document-store.js +68 -0
- package/dist/json-file.d.ts +3 -0
- package/dist/json-file.d.ts.map +1 -0
- package/dist/json-file.js +123 -0
- package/dist/json-files.d.ts +20 -0
- package/dist/json-files.d.ts.map +1 -0
- package/dist/json-files.js +153 -0
- package/dist/json-store.d.ts +9 -0
- package/dist/json-store.d.ts.map +1 -0
- package/dist/json-store.js +12 -0
- package/dist/json.d.ts +17 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +198 -0
- package/dist/local-file-access.d.ts +8 -0
- package/dist/local-file-access.d.ts.map +1 -0
- package/dist/local-file-access.js +73 -0
- package/dist/local-roots.d.ts +26 -0
- package/dist/local-roots.d.ts.map +1 -0
- package/dist/local-roots.js +150 -0
- package/dist/mode.d.ts +2 -0
- package/dist/mode.d.ts.map +1 -0
- package/dist/mode.js +3 -0
- package/dist/move-path.d.ts +6 -0
- package/dist/move-path.d.ts.map +1 -0
- package/dist/move-path.js +18 -0
- package/dist/path-alias-guards.d.ts +19 -0
- package/dist/path-alias-guards.d.ts.map +1 -0
- package/dist/path-alias-guards.js +21 -0
- package/dist/path-guards.d.ts +7 -0
- package/dist/path-guards.d.ts.map +1 -0
- package/dist/path-guards.js +49 -0
- package/dist/path-policy.d.ts +25 -0
- package/dist/path-policy.d.ts.map +1 -0
- package/dist/path-policy.js +50 -0
- package/dist/path-safety.d.ts +12 -0
- package/dist/path-safety.d.ts.map +1 -0
- package/dist/path-safety.js +50 -0
- package/dist/path-scope.d.ts +2 -0
- package/dist/path-scope.d.ts.map +1 -0
- package/dist/path-scope.js +1 -0
- package/dist/path.d.ts +18 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +123 -0
- package/dist/permissions-public.d.ts +2 -0
- package/dist/permissions-public.d.ts.map +1 -0
- package/dist/permissions-public.js +1 -0
- package/dist/permissions.d.ts +86 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +435 -0
- package/dist/pinned-helper.d.ts +9 -0
- package/dist/pinned-helper.d.ts.map +1 -0
- package/dist/pinned-helper.js +19 -0
- package/dist/pinned-open.d.ts +24 -0
- package/dist/pinned-open.d.ts.map +1 -0
- package/dist/pinned-open.js +71 -0
- package/dist/pinned-path.d.ts +7 -0
- package/dist/pinned-path.d.ts.map +1 -0
- package/dist/pinned-path.js +21 -0
- package/dist/pinned-python-config.d.ts +9 -0
- package/dist/pinned-python-config.d.ts.map +1 -0
- package/dist/pinned-python-config.js +39 -0
- package/dist/pinned-python.d.ts +12 -0
- package/dist/pinned-python.d.ts.map +1 -0
- package/dist/pinned-python.js +599 -0
- package/dist/pinned-write.d.ts +33 -0
- package/dist/pinned-write.d.ts.map +1 -0
- package/dist/pinned-write.js +198 -0
- package/dist/private-file-store.d.ts +60 -0
- package/dist/private-file-store.d.ts.map +1 -0
- package/dist/private-file-store.js +233 -0
- package/dist/private-temp-workspace.d.ts +39 -0
- package/dist/private-temp-workspace.d.ts.map +1 -0
- package/dist/private-temp-workspace.js +185 -0
- package/dist/regular-file.d.ts +38 -0
- package/dist/regular-file.d.ts.map +1 -0
- package/dist/regular-file.js +239 -0
- package/dist/replace-directory.d.ts +7 -0
- package/dist/replace-directory.d.ts.map +1 -0
- package/dist/replace-directory.js +32 -0
- package/dist/replace-file.d.ts +41 -0
- package/dist/replace-file.d.ts.map +1 -0
- package/dist/replace-file.js +325 -0
- package/dist/root-file.d.ts +44 -0
- package/dist/root-file.d.ts.map +1 -0
- package/dist/root-file.js +129 -0
- package/dist/root-path.d.ts +39 -0
- package/dist/root-path.d.ts.map +1 -0
- package/dist/root-path.js +598 -0
- package/dist/root-paths.d.ts +92 -0
- package/dist/root-paths.d.ts.map +1 -0
- package/dist/root-paths.js +338 -0
- package/dist/root.d.ts +108 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +1332 -0
- package/dist/safe-open-sync.d.ts +24 -0
- package/dist/safe-open-sync.d.ts.map +1 -0
- package/dist/safe-open-sync.js +71 -0
- package/dist/safe-root.d.ts +123 -0
- package/dist/safe-root.d.ts.map +1 -0
- package/dist/safe-root.js +1060 -0
- package/dist/secret-file.d.ts +17 -0
- package/dist/secret-file.d.ts.map +1 -0
- package/dist/secret-file.js +232 -0
- package/dist/secret.d.ts +2 -0
- package/dist/secret.d.ts.map +1 -0
- package/dist/secret.js +1 -0
- package/dist/secure-file.d.ts +32 -0
- package/dist/secure-file.d.ts.map +1 -0
- package/dist/secure-file.js +163 -0
- package/dist/secure-temp-dir.d.ts +27 -0
- package/dist/secure-temp-dir.d.ts.map +1 -0
- package/dist/secure-temp-dir.js +155 -0
- package/dist/secure-temp-workspace.d.ts +25 -0
- package/dist/secure-temp-workspace.d.ts.map +1 -0
- package/dist/secure-temp-workspace.js +136 -0
- package/dist/sibling-temp-file.d.ts +16 -0
- package/dist/sibling-temp-file.d.ts.map +1 -0
- package/dist/sibling-temp-file.js +73 -0
- package/dist/sibling-temp-write.d.ts +8 -0
- package/dist/sibling-temp-write.d.ts.map +1 -0
- package/dist/sibling-temp-write.js +40 -0
- package/dist/sibling-temp.d.ts +23 -0
- package/dist/sibling-temp.d.ts.map +1 -0
- package/dist/sibling-temp.js +119 -0
- package/dist/sidecar-lock.d.ts +50 -0
- package/dist/sidecar-lock.d.ts.map +1 -0
- package/dist/sidecar-lock.js +235 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +2 -0
- package/dist/string-coerce.d.ts +14 -0
- package/dist/string-coerce.d.ts.map +1 -0
- package/dist/string-coerce.js +72 -0
- package/dist/symlink-parents.d.ts +12 -0
- package/dist/symlink-parents.d.ts.map +1 -0
- package/dist/symlink-parents.js +78 -0
- package/dist/temp-cleanup.d.ts +6 -0
- package/dist/temp-cleanup.d.ts.map +1 -0
- package/dist/temp-cleanup.js +42 -0
- package/dist/temp-target.d.ts +28 -0
- package/dist/temp-target.d.ts.map +1 -0
- package/dist/temp-target.js +84 -0
- package/dist/temp.d.ts +3 -0
- package/dist/temp.d.ts.map +1 -0
- package/dist/temp.js +2 -0
- package/dist/test-hooks.d.ts +9 -0
- package/dist/test-hooks.d.ts.map +1 -0
- package/dist/test-hooks.js +13 -0
- package/dist/text-atomic.d.ts +7 -0
- package/dist/text-atomic.d.ts.map +1 -0
- package/dist/text-atomic.js +13 -0
- package/dist/timing.d.ts +6 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +24 -0
- package/dist/trash.d.ts +5 -0
- package/dist/trash.d.ts.map +1 -0
- package/dist/trash.js +127 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/walk.d.ts +26 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +169 -0
- package/dist/write-queue.d.ts +2 -0
- package/dist/write-queue.d.ts.map +1 -0
- package/dist/write-queue.js +18 -0
- package/package.json +117 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const DEFAULT_SECRET_FILE_MAX_BYTES: number;
|
|
2
|
+
export declare const PRIVATE_SECRET_DIR_MODE = 448;
|
|
3
|
+
export declare const PRIVATE_SECRET_FILE_MODE = 384;
|
|
4
|
+
export type SecretFileReadOptions = {
|
|
5
|
+
maxBytes?: number;
|
|
6
|
+
rejectSymlink?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function readSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): string;
|
|
9
|
+
export declare function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: SecretFileReadOptions): string | undefined;
|
|
10
|
+
export declare function writeSecretFileAtomic(params: {
|
|
11
|
+
rootDir: string;
|
|
12
|
+
filePath: string;
|
|
13
|
+
content: string | Uint8Array;
|
|
14
|
+
mode?: number;
|
|
15
|
+
dirMode?: number;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
//# sourceMappingURL=secret-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAuGF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAMpB;AAoFD,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDhB"}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import fsp from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { FsSafeError } from "./errors.js";
|
|
6
|
+
import { resolveHomeRelativePath } from "./home-dir.js";
|
|
7
|
+
import { openPinnedFileSync } from "./pinned-open.js";
|
|
8
|
+
export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
|
|
9
|
+
export const PRIVATE_SECRET_DIR_MODE = 0o700;
|
|
10
|
+
export const PRIVATE_SECRET_FILE_MODE = 0o600;
|
|
11
|
+
function normalizeSecretReadError(error) {
|
|
12
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
13
|
+
}
|
|
14
|
+
function resolveUserPath(input) {
|
|
15
|
+
return resolveHomeRelativePath(input);
|
|
16
|
+
}
|
|
17
|
+
function readSecretFileOutcomeSync(filePath, label, options = {}) {
|
|
18
|
+
const trimmedPath = filePath.trim();
|
|
19
|
+
const resolvedPath = resolveUserPath(trimmedPath);
|
|
20
|
+
if (!resolvedPath) {
|
|
21
|
+
return { ok: false, code: "invalid-path", message: `${label} file path is empty.` };
|
|
22
|
+
}
|
|
23
|
+
const maxBytes = options.maxBytes ?? DEFAULT_SECRET_FILE_MAX_BYTES;
|
|
24
|
+
let previewStat;
|
|
25
|
+
try {
|
|
26
|
+
previewStat = fs.lstatSync(resolvedPath);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const normalized = normalizeSecretReadError(error);
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: error.code === "ENOENT" ? "not-found" : "invalid-path",
|
|
33
|
+
error: normalized,
|
|
34
|
+
message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (options.rejectSymlink && previewStat.isSymbolicLink()) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
code: "symlink",
|
|
41
|
+
message: `${label} file at ${resolvedPath} must not be a symlink.`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (!previewStat.isFile()) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
code: "not-file",
|
|
48
|
+
message: `${label} file at ${resolvedPath} must be a regular file.`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (previewStat.size > maxBytes) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
code: "too-large",
|
|
55
|
+
message: `${label} file at ${resolvedPath} exceeds ${maxBytes} bytes.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const opened = openPinnedFileSync({
|
|
59
|
+
filePath: resolvedPath,
|
|
60
|
+
rejectPathSymlink: options.rejectSymlink,
|
|
61
|
+
maxBytes,
|
|
62
|
+
});
|
|
63
|
+
if (!opened.ok) {
|
|
64
|
+
const error = normalizeSecretReadError(opened.reason === "validation" ? new Error("security validation failed") : opened.error);
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
code: opened.reason === "path" ? "not-found" : "path-mismatch",
|
|
68
|
+
error,
|
|
69
|
+
message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const raw = fs.readFileSync(opened.fd, "utf8");
|
|
74
|
+
const secret = raw.trim();
|
|
75
|
+
if (!secret) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
code: "invalid-path",
|
|
79
|
+
message: `${label} file at ${resolvedPath} is empty.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, secret };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const normalized = normalizeSecretReadError(error);
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
code: "invalid-path",
|
|
89
|
+
error: normalized,
|
|
90
|
+
message: `Failed to read ${label} file at ${resolvedPath}: ${String(normalized)}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
fs.closeSync(opened.fd);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function readSecretFileSync(filePath, label, options = {}) {
|
|
98
|
+
const result = readSecretFileOutcomeSync(filePath, label, options);
|
|
99
|
+
if (result.ok) {
|
|
100
|
+
return result.secret;
|
|
101
|
+
}
|
|
102
|
+
throw new FsSafeError(result.code, result.message, {
|
|
103
|
+
cause: result.error,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
export function tryReadSecretFileSync(filePath, label, options = {}) {
|
|
107
|
+
if (!filePath?.trim()) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const result = readSecretFileOutcomeSync(filePath, label, options);
|
|
111
|
+
return result.ok ? result.secret : undefined;
|
|
112
|
+
}
|
|
113
|
+
function assertPathWithinRoot(rootDir, targetPath) {
|
|
114
|
+
const relative = path.relative(rootDir, targetPath);
|
|
115
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
116
|
+
throw new Error(`Private secret path must stay under ${rootDir}.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function assertRealPathWithinRoot(rootDir, targetPath) {
|
|
120
|
+
const relative = path.relative(rootDir, targetPath);
|
|
121
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
122
|
+
throw new Error(`Private secret path must stay under ${rootDir}.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {
|
|
126
|
+
if (process.platform === "win32") {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await fsp.chmod(resolvedPath, expectedMode);
|
|
130
|
+
const stat = await fsp.stat(resolvedPath);
|
|
131
|
+
const actualMode = stat.mode & 0o777;
|
|
132
|
+
if (actualMode !== expectedMode) {
|
|
133
|
+
throw new Error(`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function ensurePrivateDirectory(rootDir, targetDir, mode) {
|
|
137
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
138
|
+
const resolvedTarget = path.resolve(targetDir);
|
|
139
|
+
if (resolvedTarget === resolvedRoot) {
|
|
140
|
+
await fsp.mkdir(resolvedRoot, { recursive: true, mode });
|
|
141
|
+
const rootStat = await fsp.lstat(resolvedRoot);
|
|
142
|
+
if (rootStat.isSymbolicLink()) {
|
|
143
|
+
throw new Error(`Private secret root ${resolvedRoot} must not be a symlink.`);
|
|
144
|
+
}
|
|
145
|
+
if (!rootStat.isDirectory()) {
|
|
146
|
+
throw new Error(`Private secret root ${resolvedRoot} must be a directory.`);
|
|
147
|
+
}
|
|
148
|
+
await enforcePrivatePathMode(resolvedRoot, mode, "directory");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
assertPathWithinRoot(resolvedRoot, resolvedTarget);
|
|
152
|
+
await ensurePrivateDirectory(resolvedRoot, resolvedRoot, mode);
|
|
153
|
+
const resolvedRootReal = await fsp.realpath(resolvedRoot);
|
|
154
|
+
let current = resolvedRoot;
|
|
155
|
+
for (const segment of path
|
|
156
|
+
.relative(resolvedRoot, resolvedTarget)
|
|
157
|
+
.split(path.sep)
|
|
158
|
+
.filter(Boolean)) {
|
|
159
|
+
current = path.join(current, segment);
|
|
160
|
+
try {
|
|
161
|
+
const stat = await fsp.lstat(current);
|
|
162
|
+
if (stat.isSymbolicLink()) {
|
|
163
|
+
throw new Error(`Private secret directory component ${current} must not be a symlink.`);
|
|
164
|
+
}
|
|
165
|
+
if (!stat.isDirectory()) {
|
|
166
|
+
throw new Error(`Private secret directory component ${current} must be a directory.`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
await fsp.mkdir(current, { mode });
|
|
174
|
+
}
|
|
175
|
+
const currentReal = await fsp.realpath(current);
|
|
176
|
+
assertRealPathWithinRoot(resolvedRootReal, currentReal);
|
|
177
|
+
await enforcePrivatePathMode(currentReal, mode, "directory");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export async function writeSecretFileAtomic(params) {
|
|
181
|
+
const mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;
|
|
182
|
+
const dirMode = params.dirMode ?? PRIVATE_SECRET_DIR_MODE;
|
|
183
|
+
const resolvedRoot = path.resolve(params.rootDir);
|
|
184
|
+
const resolvedFile = path.resolve(params.filePath);
|
|
185
|
+
assertPathWithinRoot(resolvedRoot, resolvedFile);
|
|
186
|
+
const intendedParentDir = path.dirname(resolvedFile);
|
|
187
|
+
await ensurePrivateDirectory(resolvedRoot, intendedParentDir, dirMode);
|
|
188
|
+
const resolvedRootReal = await fsp.realpath(resolvedRoot);
|
|
189
|
+
const parentDir = await fsp.realpath(intendedParentDir);
|
|
190
|
+
assertRealPathWithinRoot(resolvedRootReal, parentDir);
|
|
191
|
+
const fileName = path.basename(resolvedFile);
|
|
192
|
+
const finalFilePath = path.join(parentDir, fileName);
|
|
193
|
+
try {
|
|
194
|
+
const stat = await fsp.lstat(finalFilePath);
|
|
195
|
+
if (stat.isSymbolicLink()) {
|
|
196
|
+
throw new Error(`Private secret file ${finalFilePath} must not be a symlink.`);
|
|
197
|
+
}
|
|
198
|
+
if (!stat.isFile()) {
|
|
199
|
+
throw new Error(`Private secret file ${finalFilePath} must be a regular file.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const tempPath = path.join(parentDir, `.tmp-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}`);
|
|
208
|
+
let createdTemp = false;
|
|
209
|
+
try {
|
|
210
|
+
const handle = await fsp.open(tempPath, "wx", mode);
|
|
211
|
+
createdTemp = true;
|
|
212
|
+
try {
|
|
213
|
+
await handle.writeFile(params.content);
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await handle.close();
|
|
217
|
+
}
|
|
218
|
+
await enforcePrivatePathMode(tempPath, mode, "file");
|
|
219
|
+
const refreshedParentReal = await fsp.realpath(intendedParentDir);
|
|
220
|
+
if (refreshedParentReal !== parentDir) {
|
|
221
|
+
throw new Error(`Private secret parent directory changed during write for ${finalFilePath}.`);
|
|
222
|
+
}
|
|
223
|
+
await fsp.rename(tempPath, finalFilePath);
|
|
224
|
+
createdTemp = false;
|
|
225
|
+
await enforcePrivatePathMode(finalFilePath, mode, "file");
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
if (createdTemp) {
|
|
229
|
+
await fsp.unlink(tempPath).catch(() => undefined);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
package/dist/secret.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret.d.ts","sourceRoot":"","sources":["../src/secret.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,6BAA6B,EAC7B,uBAAuB,EACvB,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAC"}
|
package/dist/secret.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DEFAULT_SECRET_FILE_MAX_BYTES, PRIVATE_SECRET_DIR_MODE, PRIVATE_SECRET_FILE_MODE, readSecretFileSync, tryReadSecretFileSync, writeSecretFileAtomic, } from "./secret-file.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { type PermissionCheck, type PermissionCheckOptions } from "./permissions.js";
|
|
3
|
+
export type SecureFileReadOptions = {
|
|
4
|
+
filePath: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
trust?: SecureFileTrustOptions;
|
|
7
|
+
permissions?: SecureFilePermissionOptions;
|
|
8
|
+
inject?: SecureFileInjectOptions;
|
|
9
|
+
io?: SecureFileIoOptions;
|
|
10
|
+
};
|
|
11
|
+
export type SecureFileTrustOptions = {
|
|
12
|
+
trustedDirs?: string[];
|
|
13
|
+
allowSymlink?: boolean;
|
|
14
|
+
allowNetworkPath?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type SecureFilePermissionOptions = {
|
|
17
|
+
allowInsecure?: boolean;
|
|
18
|
+
allowReadableByOthers?: boolean;
|
|
19
|
+
};
|
|
20
|
+
export type SecureFileInjectOptions = PermissionCheckOptions;
|
|
21
|
+
export type SecureFileIoOptions = {
|
|
22
|
+
maxBytes?: number;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
};
|
|
25
|
+
export type SecureFileReadResult = {
|
|
26
|
+
buffer: Buffer;
|
|
27
|
+
realPath: string;
|
|
28
|
+
stat: Stats;
|
|
29
|
+
permissions?: PermissionCheck;
|
|
30
|
+
};
|
|
31
|
+
export declare function readSecureFile(options: SecureFileReadOptions): Promise<SecureFileReadResult>;
|
|
32
|
+
//# sourceMappingURL=secure-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAUrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAkKF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAa/B"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { FsSafeError } from "./errors.js";
|
|
5
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
6
|
+
import { isWindowsDriveLetterPath, isWindowsNetworkPath } from "./local-file-access.js";
|
|
7
|
+
import { isPathInside, isSymlinkOpenError } from "./path.js";
|
|
8
|
+
import { inspectPathPermissions, isGroupReadable, isGroupWritable, isWorldReadable, isWorldWritable, modeBits, } from "./permissions.js";
|
|
9
|
+
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
|
|
10
|
+
const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
11
|
+
function isAbsolutePathname(value) {
|
|
12
|
+
return path.isAbsolute(value) || isWindowsDriveLetterPath(value, "win32") || isWindowsNetworkPath(value, "win32");
|
|
13
|
+
}
|
|
14
|
+
function label(options) {
|
|
15
|
+
return options.label ?? "Secure file";
|
|
16
|
+
}
|
|
17
|
+
async function openSecureHandle(options) {
|
|
18
|
+
if (isWindowsNetworkPath(options.filePath, "win32") && !options.trust?.allowNetworkPath) {
|
|
19
|
+
throw new FsSafeError("invalid-path", `${label(options)} must be a local absolute path.`);
|
|
20
|
+
}
|
|
21
|
+
if (!isAbsolutePathname(options.filePath)) {
|
|
22
|
+
throw new FsSafeError("invalid-path", `${label(options)} must be an absolute path.`);
|
|
23
|
+
}
|
|
24
|
+
const preStat = await fs.lstat(options.filePath).catch((err) => {
|
|
25
|
+
throw new FsSafeError("not-found", `${label(options)} is not readable: ${options.filePath}`, {
|
|
26
|
+
cause: err,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
if (preStat.isDirectory()) {
|
|
30
|
+
throw new FsSafeError("not-file", `${label(options)} must be a file: ${options.filePath}`);
|
|
31
|
+
}
|
|
32
|
+
if (preStat.isSymbolicLink() && !options.trust?.allowSymlink) {
|
|
33
|
+
throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
|
|
34
|
+
}
|
|
35
|
+
let handle;
|
|
36
|
+
try {
|
|
37
|
+
handle = await fs.open(options.filePath, options.trust?.allowSymlink ? fsConstants.O_RDONLY : OPEN_READ_FLAGS);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (isSymlinkOpenError(err)) {
|
|
41
|
+
throw new FsSafeError("symlink", `${label(options)} symlink open blocked`, { cause: err });
|
|
42
|
+
}
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const openedStat = await handle.stat();
|
|
47
|
+
if (!openedStat.isFile()) {
|
|
48
|
+
throw new FsSafeError("not-file", `${label(options)} must be a file: ${options.filePath}`);
|
|
49
|
+
}
|
|
50
|
+
const pathStat = options.trust?.allowSymlink
|
|
51
|
+
? await fs.stat(options.filePath)
|
|
52
|
+
: await fs.lstat(options.filePath);
|
|
53
|
+
if (!options.trust?.allowSymlink && pathStat.isSymbolicLink()) {
|
|
54
|
+
throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
|
|
55
|
+
}
|
|
56
|
+
if (!sameFileIdentity(pathStat, openedStat)) {
|
|
57
|
+
throw new FsSafeError("path-mismatch", `${label(options)} changed during open.`);
|
|
58
|
+
}
|
|
59
|
+
const realPath = await fs.realpath(options.filePath);
|
|
60
|
+
const realStat = await fs.stat(realPath);
|
|
61
|
+
if (!sameFileIdentity(realStat, openedStat)) {
|
|
62
|
+
throw new FsSafeError("path-mismatch", `${label(options)} real path changed during open.`);
|
|
63
|
+
}
|
|
64
|
+
if (options.io?.maxBytes !== undefined && openedStat.size > options.io.maxBytes) {
|
|
65
|
+
throw new FsSafeError("too-large", `${label(options)} exceeded maxBytes (${options.io.maxBytes}).`);
|
|
66
|
+
}
|
|
67
|
+
return { handle, pathStat: openedStat, realPath };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
await handle.close().catch(() => undefined);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function assertTrustedDirs(options, realPath) {
|
|
75
|
+
if (!options.trust?.trustedDirs || options.trust.trustedDirs.length === 0) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const trusted = await Promise.all(options.trust.trustedDirs.map(async (dir) => {
|
|
79
|
+
const resolved = path.resolve(dir);
|
|
80
|
+
return await fs.realpath(resolved).catch(() => resolved);
|
|
81
|
+
}));
|
|
82
|
+
if (!trusted.some((dir) => isPathInside(dir, realPath))) {
|
|
83
|
+
throw new FsSafeError("outside-workspace", `${label(options)} is outside trustedDirs: ${realPath}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function inspectOpenedPermissions(stat, platform) {
|
|
87
|
+
const bits = modeBits(typeof stat.mode === "number" ? stat.mode : null);
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
isSymlink: false,
|
|
91
|
+
isDir: stat.isDirectory(),
|
|
92
|
+
mode: typeof stat.mode === "number" ? stat.mode : null,
|
|
93
|
+
bits,
|
|
94
|
+
source: platform === "win32" ? "unknown" : "posix",
|
|
95
|
+
worldWritable: isWorldWritable(bits),
|
|
96
|
+
groupWritable: isGroupWritable(bits),
|
|
97
|
+
worldReadable: isWorldReadable(bits),
|
|
98
|
+
groupReadable: isGroupReadable(bits),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async function assertSecurePermissions(options, stat, realPath) {
|
|
102
|
+
if (options.permissions?.allowInsecure) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const platform = options.inject?.platform ?? process.platform;
|
|
106
|
+
const permissions = platform === "win32"
|
|
107
|
+
? await inspectPathPermissions(realPath, options.inject)
|
|
108
|
+
: inspectOpenedPermissions(stat, platform);
|
|
109
|
+
if (!permissions.ok) {
|
|
110
|
+
throw new FsSafeError("permission-unverified", `${label(options)} permissions could not be verified: ${realPath}`);
|
|
111
|
+
}
|
|
112
|
+
if (platform === "win32" && permissions.source === "unknown") {
|
|
113
|
+
throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}.`);
|
|
114
|
+
}
|
|
115
|
+
const writableByOthers = permissions.worldWritable || permissions.groupWritable;
|
|
116
|
+
const readableByOthers = permissions.worldReadable || permissions.groupReadable;
|
|
117
|
+
if (writableByOthers || (!options.permissions?.allowReadableByOthers && readableByOthers)) {
|
|
118
|
+
throw new FsSafeError("insecure-permissions", `${label(options)} permissions are too open: ${realPath}`);
|
|
119
|
+
}
|
|
120
|
+
if (platform !== "win32" && typeof process.getuid === "function" && stat.uid != null) {
|
|
121
|
+
const uid = process.getuid();
|
|
122
|
+
if (stat.uid !== uid) {
|
|
123
|
+
throw new FsSafeError("not-owned", `${label(options)} must be owned by the current user (uid=${uid}): ${realPath}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return permissions;
|
|
127
|
+
}
|
|
128
|
+
async function readHandleWithTimeout(handle, timeoutMs) {
|
|
129
|
+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
130
|
+
return await handle.readFile();
|
|
131
|
+
}
|
|
132
|
+
let timeout;
|
|
133
|
+
try {
|
|
134
|
+
return await Promise.race([
|
|
135
|
+
handle.readFile(),
|
|
136
|
+
new Promise((_resolve, reject) => {
|
|
137
|
+
timeout = setTimeout(() => {
|
|
138
|
+
void handle.close().catch(() => undefined);
|
|
139
|
+
reject(new FsSafeError("timeout", `secure file read timed out after ${timeoutMs}ms`));
|
|
140
|
+
}, timeoutMs);
|
|
141
|
+
}),
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
if (timeout)
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function readSecureFile(options) {
|
|
150
|
+
const opened = await openSecureHandle(options);
|
|
151
|
+
try {
|
|
152
|
+
await assertTrustedDirs(options, opened.realPath);
|
|
153
|
+
const permissions = await assertSecurePermissions(options, opened.pathStat, opened.realPath);
|
|
154
|
+
const buffer = await readHandleWithTimeout(opened.handle, options.io?.timeoutMs);
|
|
155
|
+
if (options.io?.maxBytes !== undefined && buffer.byteLength > options.io.maxBytes) {
|
|
156
|
+
throw new FsSafeError("too-large", `${label(options)} exceeded maxBytes (${options.io.maxBytes}).`);
|
|
157
|
+
}
|
|
158
|
+
return { buffer, realPath: opened.realPath, stat: opened.pathStat, permissions };
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
await opened.handle.close().catch(() => undefined);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type SecureDirStat = {
|
|
2
|
+
isDirectory(): boolean;
|
|
3
|
+
isSymbolicLink(): boolean;
|
|
4
|
+
mode?: number;
|
|
5
|
+
uid?: number;
|
|
6
|
+
};
|
|
7
|
+
export type ResolveSecureTempRootOptions = {
|
|
8
|
+
accessSync?: (path: string, mode?: number) => void;
|
|
9
|
+
chmodSync?: (path: string, mode: number) => void;
|
|
10
|
+
fallbackPrefix: string;
|
|
11
|
+
getuid?: () => number | undefined;
|
|
12
|
+
lstatSync?: (path: string) => SecureDirStat;
|
|
13
|
+
mkdirSync?: (path: string, opts: {
|
|
14
|
+
recursive: boolean;
|
|
15
|
+
mode?: number;
|
|
16
|
+
}) => void;
|
|
17
|
+
platform?: NodeJS.Platform;
|
|
18
|
+
preferredDir?: string;
|
|
19
|
+
skipPreferredOnWindows?: boolean;
|
|
20
|
+
tmpdir?: () => string;
|
|
21
|
+
unsafeFallbackLabel?: string;
|
|
22
|
+
warn?: (message: string) => void;
|
|
23
|
+
warningPrefix?: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function resolveSecureTempRoot(options: ResolveSecureTempRootOptions): string;
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=secure-temp-dir.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secure-temp-dir.d.ts","sourceRoot":"","sources":["../src/secure-temp-dir.ts"],"names":[],"mappings":"AAMA,KAAK,aAAa,GAAG;IACnB,WAAW,IAAI,OAAO,CAAC;IACvB,cAAc,IAAI,OAAO,CAAC;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG;IACzC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACnD,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAClC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,aAAa,CAAC;IAC5C,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAWF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,4BAA4B,GAAG,MAAM,CA0JnF"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { tmpdir as getOsTmpDir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function isNodeErrorWithCode(err, code) {
|
|
5
|
+
return (typeof err === "object" &&
|
|
6
|
+
err !== null &&
|
|
7
|
+
"code" in err &&
|
|
8
|
+
err.code === code);
|
|
9
|
+
}
|
|
10
|
+
export function resolveSecureTempRoot(options) {
|
|
11
|
+
const TMP_DIR_ACCESS_MODE = fs.constants.W_OK | fs.constants.X_OK;
|
|
12
|
+
const accessSync = options.accessSync ?? fs.accessSync;
|
|
13
|
+
const chmodSync = options.chmodSync ?? fs.chmodSync;
|
|
14
|
+
const lstatSync = options.lstatSync ?? fs.lstatSync;
|
|
15
|
+
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
|
16
|
+
const warn = options.warn ?? ((message) => console.warn(message));
|
|
17
|
+
const warningPrefix = options.warningPrefix ?? "[fs-safe]";
|
|
18
|
+
const unsafeFallbackLabel = options.unsafeFallbackLabel ?? "secure temp dir";
|
|
19
|
+
const getuid = options.getuid ??
|
|
20
|
+
(() => {
|
|
21
|
+
try {
|
|
22
|
+
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
const tmpdir = typeof options.tmpdir === "function" ? options.tmpdir : getOsTmpDir;
|
|
29
|
+
const platform = options.platform ?? process.platform;
|
|
30
|
+
const uid = getuid();
|
|
31
|
+
const isSecureDirForUser = (st) => {
|
|
32
|
+
if (uid === undefined) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (typeof st.uid === "number" && st.uid !== uid) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (typeof st.mode === "number" && (st.mode & 0o022) !== 0) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
const fallback = () => {
|
|
44
|
+
const base = tmpdir();
|
|
45
|
+
const suffix = uid === undefined ? options.fallbackPrefix : `${options.fallbackPrefix}-${uid}`;
|
|
46
|
+
const joiner = platform === "win32" ? path.win32.join : path.join;
|
|
47
|
+
return joiner(base, suffix);
|
|
48
|
+
};
|
|
49
|
+
const isTrustedTmpDir = (st) => {
|
|
50
|
+
return st.isDirectory() && !st.isSymbolicLink() && isSecureDirForUser(st);
|
|
51
|
+
};
|
|
52
|
+
const resolveDirState = (candidatePath) => {
|
|
53
|
+
try {
|
|
54
|
+
const candidate = lstatSync(candidatePath);
|
|
55
|
+
if (!isTrustedTmpDir(candidate)) {
|
|
56
|
+
return "invalid";
|
|
57
|
+
}
|
|
58
|
+
accessSync(candidatePath, TMP_DIR_ACCESS_MODE);
|
|
59
|
+
return "available";
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (isNodeErrorWithCode(err, "ENOENT")) {
|
|
63
|
+
return "missing";
|
|
64
|
+
}
|
|
65
|
+
return "invalid";
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const tryRepairWritableBits = (candidatePath) => {
|
|
69
|
+
try {
|
|
70
|
+
const st = lstatSync(candidatePath);
|
|
71
|
+
if (!st.isDirectory() || st.isSymbolicLink()) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (uid !== undefined && typeof st.uid === "number" && st.uid !== uid) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (typeof st.mode !== "number") {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if ((st.mode & 0o022) === 0) {
|
|
81
|
+
return resolveDirState(candidatePath) === "available";
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
chmodSync(candidatePath, 0o700);
|
|
85
|
+
}
|
|
86
|
+
catch (chmodErr) {
|
|
87
|
+
if (isNodeErrorWithCode(chmodErr, "EPERM") ||
|
|
88
|
+
isNodeErrorWithCode(chmodErr, "EACCES") ||
|
|
89
|
+
isNodeErrorWithCode(chmodErr, "ENOENT")) {
|
|
90
|
+
return resolveDirState(candidatePath) === "available";
|
|
91
|
+
}
|
|
92
|
+
throw chmodErr;
|
|
93
|
+
}
|
|
94
|
+
warn(`${warningPrefix} tightened permissions on temp dir: ${candidatePath}`);
|
|
95
|
+
return resolveDirState(candidatePath) === "available";
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const ensureTrustedFallbackDir = () => {
|
|
102
|
+
const fallbackPath = fallback();
|
|
103
|
+
const state = resolveDirState(fallbackPath);
|
|
104
|
+
if (state === "available") {
|
|
105
|
+
return fallbackPath;
|
|
106
|
+
}
|
|
107
|
+
if (state === "invalid") {
|
|
108
|
+
if (tryRepairWritableBits(fallbackPath)) {
|
|
109
|
+
return fallbackPath;
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`Unsafe fallback ${unsafeFallbackLabel}: ${fallbackPath}`);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
mkdirSync(fallbackPath, { recursive: true, mode: 0o700 });
|
|
115
|
+
chmodSync(fallbackPath, 0o700);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new Error(`Unable to create fallback ${unsafeFallbackLabel}: ${fallbackPath}`);
|
|
119
|
+
}
|
|
120
|
+
if (resolveDirState(fallbackPath) !== "available" && !tryRepairWritableBits(fallbackPath)) {
|
|
121
|
+
throw new Error(`Unsafe fallback ${unsafeFallbackLabel}: ${fallbackPath}`);
|
|
122
|
+
}
|
|
123
|
+
return fallbackPath;
|
|
124
|
+
};
|
|
125
|
+
if (options.skipPreferredOnWindows === true && platform === "win32") {
|
|
126
|
+
return ensureTrustedFallbackDir();
|
|
127
|
+
}
|
|
128
|
+
if (!options.preferredDir) {
|
|
129
|
+
return ensureTrustedFallbackDir();
|
|
130
|
+
}
|
|
131
|
+
const existingPreferredState = resolveDirState(options.preferredDir);
|
|
132
|
+
if (existingPreferredState === "available") {
|
|
133
|
+
return options.preferredDir;
|
|
134
|
+
}
|
|
135
|
+
if (existingPreferredState === "invalid") {
|
|
136
|
+
if (tryRepairWritableBits(options.preferredDir)) {
|
|
137
|
+
return options.preferredDir;
|
|
138
|
+
}
|
|
139
|
+
return ensureTrustedFallbackDir();
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const preferredParentDir = path.dirname(options.preferredDir);
|
|
143
|
+
accessSync(preferredParentDir, TMP_DIR_ACCESS_MODE);
|
|
144
|
+
mkdirSync(options.preferredDir, { recursive: true, mode: 0o700 });
|
|
145
|
+
chmodSync(options.preferredDir, 0o700);
|
|
146
|
+
if (resolveDirState(options.preferredDir) !== "available" &&
|
|
147
|
+
!tryRepairWritableBits(options.preferredDir)) {
|
|
148
|
+
return ensureTrustedFallbackDir();
|
|
149
|
+
}
|
|
150
|
+
return options.preferredDir;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return ensureTrustedFallbackDir();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type SecureTempWorkspaceOptions = {
|
|
2
|
+
rootDir: string;
|
|
3
|
+
prefix: string;
|
|
4
|
+
dirMode?: number;
|
|
5
|
+
fileMode?: number;
|
|
6
|
+
};
|
|
7
|
+
export type SecureTempWorkspace = {
|
|
8
|
+
dir: string;
|
|
9
|
+
path(fileName: string): string;
|
|
10
|
+
writePrivate(fileName: string, data: string | Uint8Array): Promise<string>;
|
|
11
|
+
read(fileName: string): Promise<Buffer>;
|
|
12
|
+
cleanup(): Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
export type SecureTempWorkspaceSync = {
|
|
15
|
+
dir: string;
|
|
16
|
+
path(fileName: string): string;
|
|
17
|
+
writePrivate(fileName: string, data: string | Uint8Array): string;
|
|
18
|
+
read(fileName: string): Buffer;
|
|
19
|
+
cleanup(): void;
|
|
20
|
+
};
|
|
21
|
+
export declare function createSecureTempWorkspace(options: SecureTempWorkspaceOptions): Promise<SecureTempWorkspace>;
|
|
22
|
+
export declare function withSecureTempWorkspace<T>(options: SecureTempWorkspaceOptions, run: (workspace: SecureTempWorkspace) => Promise<T>): Promise<T>;
|
|
23
|
+
export declare function createSecureTempWorkspaceSync(options: SecureTempWorkspaceOptions): SecureTempWorkspaceSync;
|
|
24
|
+
export declare function withSecureTempWorkspaceSync<T>(options: SecureTempWorkspaceOptions, run: (workspace: SecureTempWorkspaceSync) => T): T;
|
|
25
|
+
//# sourceMappingURL=secure-temp-workspace.d.ts.map
|