@openclaw/fs-safe 0.4.4 → 0.4.6
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 +44 -0
- package/SECURITY.md +40 -7
- package/dist/local-file-access.d.ts.map +1 -1
- package/dist/local-file-access.js +9 -1
- package/dist/permissions.d.ts +11 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +56 -55
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +3 -0
- package/dist/sidecar-lock-reclaim.d.ts +6 -0
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +32 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +35 -6
- package/dist/windows-command.d.ts +2 -0
- package/dist/windows-command.d.ts.map +1 -0
- package/dist/windows-command.js +34 -0
- package/dist/windows-owner.d.ts +29 -0
- package/dist/windows-owner.d.ts.map +1 -0
- package/dist/windows-owner.js +118 -0
- package/docs/contributing.md +19 -4
- package/docs/permissions.md +4 -1
- package/docs/sidecar-lock.md +4 -0
- package/docs/writing.md +1 -1
- package/package.json +22 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.6 - 2026-07-24
|
|
4
|
+
|
|
5
|
+
### Highlights
|
|
6
|
+
|
|
7
|
+
- Reject foreign-owned or unverifiable Windows files in secure reads while
|
|
8
|
+
preserving supported trusted local owners and exact extended drive paths.
|
|
9
|
+
|
|
10
|
+
### Security and Correctness
|
|
11
|
+
|
|
12
|
+
- Report Windows owner SIDs and whether the owner is the current user,
|
|
13
|
+
LocalSystem, or built-in Administrators so credential-bearing executable
|
|
14
|
+
checks can reject foreign-owned paths even when their visible DACL is
|
|
15
|
+
read-only. Secure reads enforce the result and remote filesystems fail
|
|
16
|
+
closed.
|
|
17
|
+
- Read Windows owner and DACL data through the underlying .NET security
|
|
18
|
+
descriptor APIs so verification does not depend on PowerShell security-module
|
|
19
|
+
autoloading.
|
|
20
|
+
- Invoke `icacls.exe` with its supported path-only inspection syntax and use
|
|
21
|
+
the live Windows user/domain environment for named ACE classification, so
|
|
22
|
+
ACL verification works on supported Windows hosts instead of failing on the
|
|
23
|
+
invalid `/sid` argument.
|
|
24
|
+
- Normalize trailing Windows install-root separators with a bounded linear
|
|
25
|
+
scan so library-provided environment maps cannot trigger regex backtracking.
|
|
26
|
+
|
|
27
|
+
### Docs and Tooling
|
|
28
|
+
|
|
29
|
+
- Add the public repository governance baseline, pinned CodeQL analysis,
|
|
30
|
+
package tarball/import validation, and protected tag-driven npm trusted
|
|
31
|
+
publishing with provenance and changelog-derived GitHub releases.
|
|
32
|
+
- Let tag-driven publishing continue from an expected registry miss into npm
|
|
33
|
+
trusted publishing instead of exiting before the publish attempt.
|
|
34
|
+
- Publish the generated tarball through an explicit relative path so npm treats
|
|
35
|
+
it as a local artifact instead of GitHub repository shorthand.
|
|
36
|
+
|
|
37
|
+
## 0.4.5 - 2026-07-20
|
|
38
|
+
|
|
39
|
+
### Highlights
|
|
40
|
+
|
|
41
|
+
- Preserve sidecar-lock ownership across filesystem identity drift without weakening legacy stale-lock checks.
|
|
42
|
+
|
|
43
|
+
### Security and Correctness
|
|
44
|
+
|
|
45
|
+
- Give new sidecar locks an internal random ownership token outside the parsed JSON payload so release remains ownership-checked when virtual filesystems report different descriptor and pathname identities, while legacy locks retain the stricter identity-plus-content check.
|
|
46
|
+
|
|
3
47
|
## 0.4.4 - 2026-07-18
|
|
4
48
|
|
|
5
49
|
### Security and Correctness
|
package/SECURITY.md
CHANGED
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
# Security Policy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
If you believe you found a security issue in `@openclaw/fs-safe`, report it
|
|
4
|
+
privately.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
## Reporting
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- expected impact
|
|
8
|
+
Open a private report through
|
|
9
|
+
[GitHub Security Advisories](https://github.com/openclaw/fs-safe/security/advisories/new)
|
|
10
|
+
or email `security@openclaw.ai`.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Include:
|
|
13
|
+
|
|
14
|
+
1. affected version or commit
|
|
15
|
+
2. Node.js version, operating system, filesystem, and mount details
|
|
16
|
+
3. minimal reproduction
|
|
17
|
+
4. demonstrated impact
|
|
18
|
+
5. suggested remediation, if known
|
|
19
|
+
|
|
20
|
+
Do not open a public issue until maintainers have coordinated disclosure.
|
|
21
|
+
|
|
22
|
+
## Scope
|
|
23
|
+
|
|
24
|
+
Security issues in scope generally include:
|
|
25
|
+
|
|
26
|
+
- traversal or root-confinement bypasses
|
|
27
|
+
- symlink, hardlink, rename, or identity-swap races that cross a documented
|
|
28
|
+
filesystem boundary
|
|
29
|
+
- archive extraction outside the intended destination
|
|
30
|
+
- unsafe secret-file, permission, ownership, or temporary-file behavior
|
|
31
|
+
- package or release-pipeline compromise affecting the published package
|
|
32
|
+
|
|
33
|
+
Reports must demonstrate a concrete boundary bypass or impact. Applications
|
|
34
|
+
remain responsible for operating-system isolation, authorization, and choosing
|
|
35
|
+
trusted root directories unless this package documents and fails to enforce a
|
|
36
|
+
specific invariant.
|
|
37
|
+
|
|
38
|
+
## Operational Guidance
|
|
39
|
+
|
|
40
|
+
- Keep `@openclaw/fs-safe`, Node.js, and optional archive dependencies current.
|
|
41
|
+
- Treat pathnames and archive contents as untrusted.
|
|
42
|
+
- Use OS-level sandboxing when the threat model includes a hostile process.
|
|
43
|
+
- Pin and review dependency updates before publishing.
|
|
44
|
+
|
|
45
|
+
There is currently no paid bug bounty program.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-file-access.d.ts","sourceRoot":"","sources":["../src/local-file-access.ts"],"names":[],"mappings":"AAWA,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEpE;AAED,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,
|
|
1
|
+
{"version":3,"file":"local-file-access.d.ts","sourceRoot":"","sources":["../src/local-file-access.ts"],"names":[],"mappings":"AAWA,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEpE;AAED,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAeT;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAET;AAED,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,SAAS,GAAG,IAAI,CAIjF;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAmBzD;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMxE;AAED,wBAAgB,uBAAuB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAgB3E"}
|
|
@@ -14,7 +14,15 @@ export function isWindowsNetworkPath(filePath, platform = process.platform) {
|
|
|
14
14
|
return false;
|
|
15
15
|
}
|
|
16
16
|
const normalized = filePath.replace(/\//g, "\\");
|
|
17
|
-
|
|
17
|
+
const extendedDrive = normalized.length >= 7 &&
|
|
18
|
+
normalized.startsWith("\\\\?\\") &&
|
|
19
|
+
/^[a-z]$/i.test(normalized[4] ?? "") &&
|
|
20
|
+
normalized[5] === ":" &&
|
|
21
|
+
normalized[6] === "\\";
|
|
22
|
+
if (extendedDrive) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return normalized.startsWith("\\\\");
|
|
18
26
|
}
|
|
19
27
|
export function isWindowsDriveLetterPath(filePath, platform = process.platform) {
|
|
20
28
|
return platform === "win32" && /^[A-Za-z]:[\\/]/.test(filePath);
|
package/dist/permissions.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ export type PermissionCheck = {
|
|
|
13
13
|
groupWritable: boolean;
|
|
14
14
|
worldReadable: boolean;
|
|
15
15
|
groupReadable: boolean;
|
|
16
|
+
/** Canonical Windows owner SID when the owner query succeeds. */
|
|
17
|
+
ownerSid?: string;
|
|
18
|
+
/** Whether the Windows owner is the current user, LocalSystem, or Administrators. */
|
|
19
|
+
ownerTrusted?: boolean;
|
|
20
|
+
/** Owner-query failure detail when Windows ownership could not be verified. */
|
|
21
|
+
ownerError?: string;
|
|
16
22
|
aclSummary?: string;
|
|
17
23
|
error?: string;
|
|
18
24
|
};
|
|
@@ -32,6 +38,8 @@ export type SafeStatResult = {
|
|
|
32
38
|
};
|
|
33
39
|
export type WindowsAclEntry = {
|
|
34
40
|
principal: string;
|
|
41
|
+
/** Canonical principal SID when resolved from Windows. */
|
|
42
|
+
sid?: string;
|
|
35
43
|
rights: string[];
|
|
36
44
|
rawRights: string;
|
|
37
45
|
canRead: boolean;
|
|
@@ -75,6 +83,9 @@ export declare function summarizeWindowsAcl(entries: WindowsAclEntry[], env?: No
|
|
|
75
83
|
export declare function inspectWindowsAcl(targetPath: string, opts?: {
|
|
76
84
|
env?: NodeJS.ProcessEnv;
|
|
77
85
|
exec?: PermissionExec;
|
|
86
|
+
currentUserSid?: string;
|
|
87
|
+
principalSids?: Record<string, string>;
|
|
88
|
+
principalTranslationFailed?: boolean;
|
|
78
89
|
}): Promise<WindowsAclSummary>;
|
|
79
90
|
export declare function formatWindowsAclSummary(summary: WindowsAclSummary): string;
|
|
80
91
|
export declare function formatIcaclsResetCommand(targetPath: string, opts: IcaclsResetCommandOptions): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"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"}
|
package/dist/permissions.js
CHANGED
|
@@ -4,6 +4,8 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
|
|
7
|
+
import { resolveWindowsSystemCommand } from "./windows-command.js";
|
|
8
|
+
import { inspectWindowsOwner, resolveWindowsCurrentUserSid, resolveWindowsPrincipalSids, } from "./windows-owner.js";
|
|
7
9
|
const execFileAsync = promisify(execFile);
|
|
8
10
|
const INHERIT_FLAGS = new Set(["I", "OI", "CI", "IO", "NP"]);
|
|
9
11
|
const WORLD_PRINCIPALS = new Set(["everyone", "users", "builtin\\users", "authenticated users", "nt authority\\authenticated users", "anonymous logon", "nt authority\\anonymous logon", "guests", "builtin\\guests", "interactive", "nt authority\\interactive", "network", "nt authority\\network", "local"]);
|
|
@@ -100,7 +102,23 @@ export async function inspectPathPermissions(targetPath, opts) {
|
|
|
100
102
|
const bits = modeBits(effectiveMode);
|
|
101
103
|
const platform = opts?.platform ?? process.platform;
|
|
102
104
|
if (platform === "win32") {
|
|
103
|
-
const
|
|
105
|
+
const owner = await inspectWindowsOwner({
|
|
106
|
+
targetPath,
|
|
107
|
+
env: opts?.env,
|
|
108
|
+
exec: opts?.exec ?? defaultPermissionExec,
|
|
109
|
+
});
|
|
110
|
+
const acl = await inspectWindowsAcl(targetPath, {
|
|
111
|
+
env: opts?.env,
|
|
112
|
+
exec: opts?.exec,
|
|
113
|
+
currentUserSid: owner.currentUserSid,
|
|
114
|
+
principalSids: owner.principalSids,
|
|
115
|
+
principalTranslationFailed: owner.principalTranslationFailed,
|
|
116
|
+
});
|
|
117
|
+
const ownerFields = {
|
|
118
|
+
...(owner.sid ? { ownerSid: owner.sid } : {}),
|
|
119
|
+
...(owner.trusted !== undefined ? { ownerTrusted: owner.trusted } : {}),
|
|
120
|
+
...(owner.error ? { ownerError: owner.error } : {}),
|
|
121
|
+
};
|
|
104
122
|
if (!acl.ok) {
|
|
105
123
|
return {
|
|
106
124
|
ok: true,
|
|
@@ -113,6 +131,7 @@ export async function inspectPathPermissions(targetPath, opts) {
|
|
|
113
131
|
groupWritable: false,
|
|
114
132
|
worldReadable: false,
|
|
115
133
|
groupReadable: false,
|
|
134
|
+
...ownerFields,
|
|
116
135
|
error: acl.error,
|
|
117
136
|
};
|
|
118
137
|
}
|
|
@@ -127,6 +146,7 @@ export async function inspectPathPermissions(targetPath, opts) {
|
|
|
127
146
|
groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite),
|
|
128
147
|
worldReadable: acl.untrustedWorld.some((entry) => entry.canRead),
|
|
129
148
|
groupReadable: acl.untrustedGroup.some((entry) => entry.canRead),
|
|
149
|
+
...ownerFields,
|
|
130
150
|
aclSummary: formatWindowsAclSummary(acl),
|
|
131
151
|
};
|
|
132
152
|
}
|
|
@@ -204,36 +224,6 @@ function buildTrustedPrincipals(env) {
|
|
|
204
224
|
}
|
|
205
225
|
return trusted;
|
|
206
226
|
}
|
|
207
|
-
function getEnvValueCaseInsensitive(env, name) {
|
|
208
|
-
const direct = env[name];
|
|
209
|
-
if (direct !== undefined) {
|
|
210
|
-
return direct;
|
|
211
|
-
}
|
|
212
|
-
const lower = name.toLowerCase();
|
|
213
|
-
for (const [key, value] of Object.entries(env)) {
|
|
214
|
-
if (key.toLowerCase() === lower) {
|
|
215
|
-
return value;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return undefined;
|
|
219
|
-
}
|
|
220
|
-
function normalizeWindowsInstallRoot(value) {
|
|
221
|
-
const trimmed = value?.trim();
|
|
222
|
-
if (!trimmed || !path.win32.isAbsolute(trimmed)) {
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
return trimmed.replace(/[\\/]+$/, "");
|
|
226
|
-
}
|
|
227
|
-
function resolveWindowsSystemRoot(env) {
|
|
228
|
-
const source = env ?? process.env;
|
|
229
|
-
return (normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "SystemRoot")) ??
|
|
230
|
-
normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "WINDIR")) ??
|
|
231
|
-
"C:\\Windows");
|
|
232
|
-
}
|
|
233
|
-
function resolveWindowsSystemCommand(command, env) {
|
|
234
|
-
const root = resolveWindowsSystemRoot(env);
|
|
235
|
-
return path.win32.join(root, "System32", command);
|
|
236
|
-
}
|
|
237
227
|
function classifyPrincipal(principal, trustedPrincipals) {
|
|
238
228
|
const normalized = normalize(principal);
|
|
239
229
|
if (SID_RE.test(normalized)) {
|
|
@@ -290,7 +280,14 @@ function parseAceEntry(entry) {
|
|
|
290
280
|
const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase()));
|
|
291
281
|
if (rights.length === 0)
|
|
292
282
|
return null;
|
|
293
|
-
|
|
283
|
+
const normalizedPrincipal = normalizeSid(principal);
|
|
284
|
+
return {
|
|
285
|
+
principal,
|
|
286
|
+
...(SID_RE.test(normalizedPrincipal) ? { sid: normalizedPrincipal } : {}),
|
|
287
|
+
rights,
|
|
288
|
+
rawRights,
|
|
289
|
+
...rightsFromTokens(rights),
|
|
290
|
+
};
|
|
294
291
|
}
|
|
295
292
|
export function parseIcaclsOutput(output, targetPath) {
|
|
296
293
|
const entries = [];
|
|
@@ -325,7 +322,7 @@ export function summarizeWindowsAcl(entries, env) {
|
|
|
325
322
|
const untrustedWorld = [];
|
|
326
323
|
const untrustedGroup = [];
|
|
327
324
|
for (const entry of entries) {
|
|
328
|
-
const classification = classifyPrincipal(entry.principal, trustedPrincipals);
|
|
325
|
+
const classification = classifyPrincipal(entry.sid ?? entry.principal, trustedPrincipals);
|
|
329
326
|
if (classification === "trusted")
|
|
330
327
|
trusted.push(entry);
|
|
331
328
|
else if (classification === "world")
|
|
@@ -335,37 +332,41 @@ export function summarizeWindowsAcl(entries, env) {
|
|
|
335
332
|
}
|
|
336
333
|
return { trusted, untrustedWorld, untrustedGroup };
|
|
337
334
|
}
|
|
338
|
-
async function resolveCurrentUserSid(exec, env) {
|
|
339
|
-
try {
|
|
340
|
-
const { stdout, stderr } = await exec(resolveWindowsSystemCommand("whoami.exe", env), [
|
|
341
|
-
"/user",
|
|
342
|
-
"/fo",
|
|
343
|
-
"csv",
|
|
344
|
-
"/nh",
|
|
345
|
-
]);
|
|
346
|
-
const match = `${stdout}\n${stderr}`.match(/\*?S-\d+-\d+(?:-\d+)+/i);
|
|
347
|
-
return match ? normalizeSid(match[0]) : null;
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
335
|
export async function inspectWindowsAcl(targetPath, opts) {
|
|
354
336
|
const exec = opts?.exec ?? defaultPermissionExec;
|
|
355
337
|
try {
|
|
338
|
+
if (opts?.principalTranslationFailed) {
|
|
339
|
+
throw new Error("Windows ACL principal SID translation failed");
|
|
340
|
+
}
|
|
356
341
|
const { stdout, stderr } = await exec(resolveWindowsSystemCommand("icacls.exe", opts?.env), [
|
|
357
342
|
targetPath,
|
|
358
|
-
"/sid",
|
|
359
343
|
]);
|
|
360
|
-
|
|
361
|
-
|
|
344
|
+
let entries = parseIcaclsOutput(`${stdout}\n${stderr}`.trim(), targetPath);
|
|
345
|
+
const unresolvedPrincipals = entries
|
|
346
|
+
.filter((entry) => !entry.sid)
|
|
347
|
+
.map((entry) => entry.principal);
|
|
348
|
+
const principalSids = await resolveWindowsPrincipalSids({
|
|
349
|
+
principals: unresolvedPrincipals,
|
|
350
|
+
known: opts?.principalSids,
|
|
351
|
+
env: opts?.env,
|
|
352
|
+
exec,
|
|
353
|
+
});
|
|
354
|
+
entries = entries.map((entry) => {
|
|
355
|
+
const sid = entry.sid ?? principalSids[entry.principal.toLowerCase()];
|
|
356
|
+
if (!sid) {
|
|
357
|
+
throw new Error(`Windows ACL principal SID could not be verified: ${entry.principal}`);
|
|
358
|
+
}
|
|
359
|
+
return { ...entry, sid };
|
|
360
|
+
});
|
|
361
|
+
let currentUserSid = normalizeSid(opts?.currentUserSid ?? "");
|
|
362
|
+
let effectiveEnv = currentUserSid ? { USERSID: currentUserSid } : undefined;
|
|
362
363
|
let { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv);
|
|
363
|
-
const needsUserSidResolution = !
|
|
364
|
-
untrustedGroup.some((entry) => SID_RE.test(normalize(entry.principal)));
|
|
364
|
+
const needsUserSidResolution = !currentUserSid && untrustedGroup.some((entry) => entry.sid && !TRUSTED_SIDS.has(entry.sid));
|
|
365
365
|
if (needsUserSidResolution) {
|
|
366
|
-
|
|
366
|
+
currentUserSid =
|
|
367
|
+
(await resolveWindowsCurrentUserSid({ exec, env: opts?.env })) ?? "";
|
|
367
368
|
if (currentUserSid) {
|
|
368
|
-
effectiveEnv = {
|
|
369
|
+
effectiveEnv = { USERSID: currentUserSid };
|
|
369
370
|
({ trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv));
|
|
370
371
|
}
|
|
371
372
|
}
|
|
@@ -1 +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;AAYrC,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;
|
|
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;AAYrC,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;AAgLF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAc/B"}
|
package/dist/secure-file.js
CHANGED
|
@@ -117,6 +117,9 @@ async function assertSecurePermissions(options, stat, realPath) {
|
|
|
117
117
|
if (platform === "win32" && permissions.source === "unknown") {
|
|
118
118
|
throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}.`);
|
|
119
119
|
}
|
|
120
|
+
if (platform === "win32" && permissions.ownerTrusted !== true) {
|
|
121
|
+
throw new FsSafeError(permissions.ownerTrusted === false ? "not-owned" : "permission-unverified", `${label(options)} owner could not be trusted on Windows: ${realPath}`);
|
|
122
|
+
}
|
|
120
123
|
const writableByOthers = permissions.worldWritable || permissions.groupWritable;
|
|
121
124
|
const readableByOthers = permissions.worldReadable || permissions.groupReadable;
|
|
122
125
|
if (writableByOthers || (!options.permissions?.allowReadableByOthers && readableByOthers)) {
|
|
@@ -9,6 +9,12 @@ export type SidecarLockSnapshot = {
|
|
|
9
9
|
raw?: string;
|
|
10
10
|
payload: Record<string, unknown> | null;
|
|
11
11
|
stat?: Stats;
|
|
12
|
+
ownershipToken?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function readSidecarLockOwnershipToken(raw: string): string | undefined;
|
|
15
|
+
export declare function serializeSidecarLockPayload(payload: Record<string, unknown>): {
|
|
16
|
+
raw: string;
|
|
17
|
+
ownershipToken: string;
|
|
12
18
|
};
|
|
13
19
|
export declare function readSidecarLockSnapshot(lockPath: string): Promise<SidecarLockSnapshot | null>;
|
|
14
20
|
export declare function sidecarLockSnapshotMatches(current: SidecarLockSnapshot, observed: SidecarLockSnapshot): boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAWrC,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAYF,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE7E;AAED,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc,EAAE,MAAM,CAAC;CACxB,CAMA;AAED,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAoBrC;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAkBT;AAED,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,wBAAsB,+BAA+B,CACnD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAGlB;AAED,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUlF;AAED,wBAAsB,6BAA6B,CACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED,wBAAsB,0BAA0B,CAC9C,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,qBAAqB,CAAC,EAAE,CAAC,QAAQ,EAAE,wBAAwB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5F,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,cAAc,CAAC,CA6BlD"}
|
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import { sameFileIdentity } from "./file-identity.js";
|
|
4
|
+
const SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES = 16;
|
|
5
|
+
const SIDECAR_LOCK_OWNERSHIP_TOKEN_BITS = SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES * 8;
|
|
6
|
+
const SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX = "\t".repeat(8);
|
|
7
|
+
const SIDECAR_LOCK_OWNERSHIP_TOKEN_PATTERN = new RegExp(`\\n(${SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX}[ \\t]{${SIDECAR_LOCK_OWNERSHIP_TOKEN_BITS}})\\n$`);
|
|
8
|
+
function createSidecarLockOwnershipToken() {
|
|
9
|
+
let token = SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX;
|
|
10
|
+
for (const byte of randomBytes(SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES)) {
|
|
11
|
+
for (let bit = 7; bit >= 0; bit -= 1) {
|
|
12
|
+
token += byte & (1 << bit) ? "\t" : " ";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return token;
|
|
16
|
+
}
|
|
17
|
+
export function readSidecarLockOwnershipToken(raw) {
|
|
18
|
+
return SIDECAR_LOCK_OWNERSHIP_TOKEN_PATTERN.exec(raw)?.[1];
|
|
19
|
+
}
|
|
20
|
+
export function serializeSidecarLockPayload(payload) {
|
|
21
|
+
const ownershipToken = createSidecarLockOwnershipToken();
|
|
22
|
+
return {
|
|
23
|
+
raw: `${JSON.stringify(payload, null, 2)}\n${ownershipToken}\n`,
|
|
24
|
+
ownershipToken,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
3
27
|
export async function readSidecarLockSnapshot(lockPath) {
|
|
4
28
|
try {
|
|
5
29
|
const stat = await fs.lstat(lockPath);
|
|
@@ -23,6 +47,14 @@ export async function readSidecarLockSnapshot(lockPath) {
|
|
|
23
47
|
}
|
|
24
48
|
}
|
|
25
49
|
export function sidecarLockSnapshotMatches(current, observed) {
|
|
50
|
+
if (observed.ownershipToken !== undefined) {
|
|
51
|
+
return (current.stat?.isFile() === true &&
|
|
52
|
+
current.raw !== undefined &&
|
|
53
|
+
observed.raw !== undefined &&
|
|
54
|
+
readSidecarLockOwnershipToken(current.raw) === observed.ownershipToken &&
|
|
55
|
+
readSidecarLockOwnershipToken(observed.raw) === observed.ownershipToken &&
|
|
56
|
+
current.raw === observed.raw);
|
|
57
|
+
}
|
|
26
58
|
if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
|
|
27
59
|
return false;
|
|
28
60
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAKA,OAAO,
|
|
1
|
+
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
|
package/dist/sidecar-lock.js
CHANGED
|
@@ -2,7 +2,7 @@ import fsSync from "node:fs";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { sameFileIdentity } from "./file-identity.js";
|
|
5
|
-
import { readSidecarLockSnapshot, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
|
|
5
|
+
import { readSidecarLockSnapshot, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, serializeSidecarLockPayload, sidecarLockSnapshotMatches, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
|
|
6
6
|
const GLOBAL_STATE_KEY = Symbol.for("fsSafe.sidecarLockManagers");
|
|
7
7
|
function getGlobalManagers() {
|
|
8
8
|
const globalWithState = globalThis;
|
|
@@ -32,16 +32,45 @@ function resolveManagerState(key) {
|
|
|
32
32
|
return state;
|
|
33
33
|
}
|
|
34
34
|
function snapshotMatchesSync(lockPath, observed) {
|
|
35
|
+
let fd;
|
|
35
36
|
try {
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
37
|
+
const beforeStat = fsSync.lstatSync(lockPath);
|
|
38
|
+
if (!beforeStat.isFile()) {
|
|
38
39
|
return false;
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
+
const openFlags = fsSync.constants.O_RDONLY |
|
|
42
|
+
(process.platform !== "win32" && typeof fsSync.constants.O_NOFOLLOW === "number"
|
|
43
|
+
? fsSync.constants.O_NOFOLLOW
|
|
44
|
+
: 0) |
|
|
45
|
+
(typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0);
|
|
46
|
+
fd = fsSync.openSync(lockPath, openFlags);
|
|
47
|
+
const openedStat = fsSync.fstatSync(fd);
|
|
48
|
+
if (!openedStat.isFile()) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (observed.raw !== undefined && openedStat.size !== Buffer.byteLength(observed.raw)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const raw = fsSync.readFileSync(fd, "utf8");
|
|
55
|
+
const afterStat = fsSync.lstatSync(lockPath);
|
|
56
|
+
if (!afterStat.isFile() || !sameFileIdentity(beforeStat, afterStat)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return sidecarLockSnapshotMatches({ raw, payload: null, stat: afterStat }, observed);
|
|
41
60
|
}
|
|
42
61
|
catch {
|
|
43
62
|
return false;
|
|
44
63
|
}
|
|
64
|
+
finally {
|
|
65
|
+
if (fd !== undefined) {
|
|
66
|
+
try {
|
|
67
|
+
fsSync.closeSync(fd);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Best-effort process-exit cleanup.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
45
74
|
}
|
|
46
75
|
async function resolveNormalizedTargetPath(targetPath) {
|
|
47
76
|
const resolved = path.resolve(targetPath);
|
|
@@ -197,9 +226,9 @@ export function createSidecarLockManager(key) {
|
|
|
197
226
|
try {
|
|
198
227
|
handle = await fs.open(lockPath, "wx");
|
|
199
228
|
const payload = await options.payload();
|
|
200
|
-
const raw =
|
|
229
|
+
const { raw, ownershipToken } = serializeSidecarLockPayload(payload);
|
|
201
230
|
await handle.writeFile(raw, "utf8");
|
|
202
|
-
const snapshot = { raw, payload, stat: await handle.stat() };
|
|
231
|
+
const snapshot = { raw, payload, stat: await handle.stat(), ownershipToken };
|
|
203
232
|
const createdHeld = {
|
|
204
233
|
count: 1,
|
|
205
234
|
handle,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"windows-command.d.ts","sourceRoot":"","sources":["../src/windows-command.ts"],"names":[],"mappings":"AAwCA,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,GACtB,MAAM,CAER"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
function getEnvValueCaseInsensitive(env, name) {
|
|
3
|
+
const direct = env[name];
|
|
4
|
+
if (direct !== undefined) {
|
|
5
|
+
return direct;
|
|
6
|
+
}
|
|
7
|
+
const lower = name.toLowerCase();
|
|
8
|
+
for (const [key, value] of Object.entries(env)) {
|
|
9
|
+
if (key.toLowerCase() === lower) {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
function normalizeWindowsInstallRoot(value) {
|
|
16
|
+
const trimmed = value?.trim();
|
|
17
|
+
if (!trimmed || !path.win32.isAbsolute(trimmed)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
let end = trimmed.length;
|
|
21
|
+
while (end > 0 && (trimmed[end - 1] === "\\" || trimmed[end - 1] === "/")) {
|
|
22
|
+
end -= 1;
|
|
23
|
+
}
|
|
24
|
+
return trimmed.slice(0, end);
|
|
25
|
+
}
|
|
26
|
+
function resolveWindowsSystemRoot(env) {
|
|
27
|
+
const source = env ?? process.env;
|
|
28
|
+
return (normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "SystemRoot")) ??
|
|
29
|
+
normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "WINDIR")) ??
|
|
30
|
+
"C:\\Windows");
|
|
31
|
+
}
|
|
32
|
+
export function resolveWindowsSystemCommand(command, env) {
|
|
33
|
+
return path.win32.join(resolveWindowsSystemRoot(env), "System32", command);
|
|
34
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type WindowsOwnerExec = (command: string, args: string[]) => Promise<{
|
|
2
|
+
stdout: string;
|
|
3
|
+
stderr: string;
|
|
4
|
+
}>;
|
|
5
|
+
export type WindowsOwnerSummary = {
|
|
6
|
+
sid?: string;
|
|
7
|
+
currentUserSid?: string;
|
|
8
|
+
principalSids?: Record<string, string>;
|
|
9
|
+
principalTranslationFailed?: boolean;
|
|
10
|
+
remote?: boolean;
|
|
11
|
+
trusted?: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function resolveWindowsPrincipalSids(params: {
|
|
15
|
+
principals: string[];
|
|
16
|
+
known?: Record<string, string>;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
exec: WindowsOwnerExec;
|
|
19
|
+
}): Promise<Record<string, string>>;
|
|
20
|
+
export declare function resolveWindowsCurrentUserSid(params: {
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
exec: WindowsOwnerExec;
|
|
23
|
+
}): Promise<string | null>;
|
|
24
|
+
export declare function inspectWindowsOwner(params: {
|
|
25
|
+
targetPath: string;
|
|
26
|
+
env?: NodeJS.ProcessEnv;
|
|
27
|
+
exec: WindowsOwnerExec;
|
|
28
|
+
}): Promise<WindowsOwnerSummary>;
|
|
29
|
+
//# sourceMappingURL=windows-owner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"windows-owner.d.ts","sourceRoot":"","sources":["../src/windows-owner.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,gBAAgB,GAAG,CAC7B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,KACX,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEjD,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA4DF,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAyBlC;AAED,wBAAsB,4BAA4B,CAAC,MAAM,EAAE;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAWzB;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA2C/B"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { resolveWindowsSystemCommand } from "./windows-command.js";
|
|
2
|
+
const SID_RE = /^\*?s-\d+-\d+(-\d+)+$/i;
|
|
3
|
+
const TRUSTED_OWNER_SIDS = new Set(["s-1-5-18", "s-1-5-32-544"]);
|
|
4
|
+
function normalizeSid(value) {
|
|
5
|
+
const normalized = value.trim().toLowerCase();
|
|
6
|
+
return normalized.startsWith("*") ? normalized.slice(1) : normalized;
|
|
7
|
+
}
|
|
8
|
+
function encodePowerShellCommand(source) {
|
|
9
|
+
return Buffer.from(source, "utf16le").toString("base64");
|
|
10
|
+
}
|
|
11
|
+
function windowsOwnerQueryCommand(targetPath) {
|
|
12
|
+
const encodedPath = Buffer.from(targetPath, "utf8").toString("base64");
|
|
13
|
+
return [
|
|
14
|
+
"$ErrorActionPreference='Stop'",
|
|
15
|
+
`$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`,
|
|
16
|
+
"$sections=[System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner",
|
|
17
|
+
"$acl=if([IO.Directory]::Exists($p)){[IO.Directory]::GetAccessControl($p,$sections)}else{[IO.File]::GetAccessControl($p,$sections)}",
|
|
18
|
+
"$ownerSid=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value",
|
|
19
|
+
"$currentSid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
|
|
20
|
+
"$root=[IO.Path]::GetPathRoot($p)",
|
|
21
|
+
"$extendedDrive=$p.Length -ge 7 -and $p.StartsWith('\\\\?\\') -and [char]::IsLetter($p[4]) -and $p[5] -eq ':' -and $p[6] -eq '\\'",
|
|
22
|
+
"$driveRoot=if($extendedDrive){$p.Substring(4,3)}else{$root}",
|
|
23
|
+
"$namespacePath=$p.StartsWith('\\\\')",
|
|
24
|
+
"$remote=($namespacePath -and -not $extendedDrive) -or ([IO.DriveInfo]::new($driveRoot).DriveType -eq [IO.DriveType]::Network)",
|
|
25
|
+
"$rules=$acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])",
|
|
26
|
+
"$principalSids=@($rules|ForEach-Object {$identity=$_.IdentityReference;$sid=$identity.Value;@{name=$sid;sid=$sid};try{@{name=$identity.Translate([System.Security.Principal.NTAccount]).Value;sid=$sid}}catch{}})",
|
|
27
|
+
"@{ownerSid=$ownerSid;currentUserSid=$currentSid;principalSids=$principalSids;principalTranslationFailed=$false;remote=$remote}|ConvertTo-Json -Depth 4 -Compress",
|
|
28
|
+
].join(";");
|
|
29
|
+
}
|
|
30
|
+
function windowsPrincipalQueryCommand(principals) {
|
|
31
|
+
const encodedPrincipals = Buffer.from(JSON.stringify(principals), "utf8").toString("base64");
|
|
32
|
+
return [
|
|
33
|
+
"$ErrorActionPreference='Stop'",
|
|
34
|
+
`$names=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPrincipals}'))|ConvertFrom-Json`,
|
|
35
|
+
"$rows=@($names|ForEach-Object {@{name=$_;sid=(New-Object System.Security.Principal.NTAccount($_)).Translate([System.Security.Principal.SecurityIdentifier]).Value}})",
|
|
36
|
+
"ConvertTo-Json -InputObject $rows -Compress",
|
|
37
|
+
].join(";");
|
|
38
|
+
}
|
|
39
|
+
function parsePrincipalSidRows(value) {
|
|
40
|
+
const rows = Array.isArray(value) ? value : value ? [value] : [];
|
|
41
|
+
const result = {};
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
if (!row || typeof row !== "object") {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const name = "name" in row && typeof row.name === "string" ? row.name.trim() : "";
|
|
47
|
+
const sid = "sid" in row && typeof row.sid === "string" ? normalizeSid(row.sid) : "";
|
|
48
|
+
if (name && SID_RE.test(sid)) {
|
|
49
|
+
result[name.toLowerCase()] = sid;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
export async function resolveWindowsPrincipalSids(params) {
|
|
55
|
+
const principals = [...new Set(params.principals.map((value) => value.trim()).filter(Boolean))];
|
|
56
|
+
const known = Object.fromEntries(Object.entries(params.known ?? {}).map(([name, sid]) => [name.toLowerCase(), normalizeSid(sid)]));
|
|
57
|
+
const unresolved = principals.filter((principal) => !known[principal.toLowerCase()]);
|
|
58
|
+
if (unresolved.length === 0) {
|
|
59
|
+
return known;
|
|
60
|
+
}
|
|
61
|
+
const command = resolveWindowsSystemCommand(String.raw `WindowsPowerShell\v1.0\powershell.exe`, params.env);
|
|
62
|
+
const { stdout } = await params.exec(command, [
|
|
63
|
+
"-NoLogo",
|
|
64
|
+
"-NoProfile",
|
|
65
|
+
"-NonInteractive",
|
|
66
|
+
"-EncodedCommand",
|
|
67
|
+
encodePowerShellCommand(windowsPrincipalQueryCommand(unresolved)),
|
|
68
|
+
]);
|
|
69
|
+
const resolved = { ...known, ...parsePrincipalSidRows(JSON.parse(stdout.trim())) };
|
|
70
|
+
if (principals.some((principal) => !resolved[principal.toLowerCase()])) {
|
|
71
|
+
throw new Error("Windows ACL principal translation returned incomplete SID data");
|
|
72
|
+
}
|
|
73
|
+
return resolved;
|
|
74
|
+
}
|
|
75
|
+
export async function resolveWindowsCurrentUserSid(params) {
|
|
76
|
+
try {
|
|
77
|
+
const { stdout, stderr } = await params.exec(resolveWindowsSystemCommand("whoami.exe", params.env), ["/user", "/fo", "csv", "/nh"]);
|
|
78
|
+
const match = `${stdout}\n${stderr}`.match(/\*?S-\d+-\d+(?:-\d+)+/i);
|
|
79
|
+
return match ? normalizeSid(match[0]) : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function inspectWindowsOwner(params) {
|
|
86
|
+
try {
|
|
87
|
+
const command = resolveWindowsSystemCommand(String.raw `WindowsPowerShell\v1.0\powershell.exe`, params.env);
|
|
88
|
+
const { stdout } = await params.exec(command, [
|
|
89
|
+
"-NoLogo",
|
|
90
|
+
"-NoProfile",
|
|
91
|
+
"-NonInteractive",
|
|
92
|
+
"-EncodedCommand",
|
|
93
|
+
encodePowerShellCommand(windowsOwnerQueryCommand(params.targetPath)),
|
|
94
|
+
]);
|
|
95
|
+
const parsed = JSON.parse(stdout.trim());
|
|
96
|
+
const ownerSid = typeof parsed.ownerSid === "string" && SID_RE.test(parsed.ownerSid)
|
|
97
|
+
? normalizeSid(parsed.ownerSid)
|
|
98
|
+
: undefined;
|
|
99
|
+
const currentUserSid = typeof parsed.currentUserSid === "string" && SID_RE.test(parsed.currentUserSid)
|
|
100
|
+
? normalizeSid(parsed.currentUserSid)
|
|
101
|
+
: undefined;
|
|
102
|
+
if (!ownerSid || !currentUserSid) {
|
|
103
|
+
return { error: "Windows owner query returned invalid SID data" };
|
|
104
|
+
}
|
|
105
|
+
const remote = parsed.remote === true;
|
|
106
|
+
return {
|
|
107
|
+
sid: ownerSid,
|
|
108
|
+
currentUserSid,
|
|
109
|
+
principalSids: parsePrincipalSidRows(parsed.principalSids),
|
|
110
|
+
principalTranslationFailed: parsed.principalTranslationFailed === true,
|
|
111
|
+
remote,
|
|
112
|
+
trusted: !remote && (ownerSid === currentUserSid || TRUSTED_OWNER_SIDS.has(ownerSid)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return { error: String(err) };
|
|
117
|
+
}
|
|
118
|
+
}
|
package/docs/contributing.md
CHANGED
|
@@ -10,7 +10,8 @@ cd fs-safe
|
|
|
10
10
|
pnpm install
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Node 22 or newer. The dev toolchain
|
|
13
|
+
Node 22 or newer. The dev toolchain and lockfile use pnpm; use the package
|
|
14
|
+
manager version declared in `package.json`.
|
|
14
15
|
|
|
15
16
|
## Build
|
|
16
17
|
|
|
@@ -34,9 +35,16 @@ pnpm test test/archive.test.ts
|
|
|
34
35
|
|
|
35
36
|
Use `vi.mock` sparingly. Most tests should drive real disk operations in a `mkdtemp`-created scratch directory, asserting on observable behavior. The library has [test hooks](testing.md) for the rare cases where you need to inject a TOCTOU race deterministically.
|
|
36
37
|
|
|
37
|
-
##
|
|
38
|
+
## Checks
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
Run the complete repository gate before handoff:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pnpm check
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This runs the filesystem boundary checks, build, tests, and package
|
|
47
|
+
tarball/import validation.
|
|
40
48
|
|
|
41
49
|
## Docs
|
|
42
50
|
|
|
@@ -69,7 +77,14 @@ Small, focused PRs land faster. The general shape:
|
|
|
69
77
|
|
|
70
78
|
## Releases
|
|
71
79
|
|
|
72
|
-
|
|
80
|
+
Maintainers publish from a protected `vX.Y.Z` tag on `main` through
|
|
81
|
+
`.github/workflows/release.yml`. The workflow requires the package version and a
|
|
82
|
+
dated `CHANGELOG.md` section to match the tag, then publishes with npm trusted
|
|
83
|
+
publishing and provenance before creating the GitHub release.
|
|
84
|
+
|
|
85
|
+
External contributors do not need to do anything beyond getting the pull
|
|
86
|
+
request merged. Maintainers must not publish locally or add npm automation
|
|
87
|
+
tokens.
|
|
73
88
|
|
|
74
89
|
## Reporting security issues
|
|
75
90
|
|
package/docs/permissions.md
CHANGED
|
@@ -38,7 +38,7 @@ isWorldReadable(bits);
|
|
|
38
38
|
isGroupReadable(bits);
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
`inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below.
|
|
41
|
+
`inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below and also reports `ownerSid` plus `ownerTrusted` when ownership can be verified. `ownerTrusted` is true only for a local volume owned by the current user, LocalSystem, or built-in Administrators; remote filesystems fail closed. Secure reads and callers that protect credential-bearing execution require `ownerTrusted === true`.
|
|
42
42
|
|
|
43
43
|
## Advanced Windows ACL helpers
|
|
44
44
|
|
|
@@ -82,6 +82,9 @@ type PermissionCheck = {
|
|
|
82
82
|
groupWritable: boolean;
|
|
83
83
|
worldReadable: boolean;
|
|
84
84
|
groupReadable: boolean;
|
|
85
|
+
ownerSid?: string;
|
|
86
|
+
ownerTrusted?: boolean;
|
|
87
|
+
ownerError?: string;
|
|
85
88
|
aclSummary?: string;
|
|
86
89
|
error?: string;
|
|
87
90
|
};
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -23,6 +23,10 @@ The lock file sits next to the protected resource. If a process crashes mid-lock
|
|
|
23
23
|
|
|
24
24
|
The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashed holders leave their sidecar behind; recover only after an application-owned liveness policy proves the holder cannot still be writing.
|
|
25
25
|
|
|
26
|
+
Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check.
|
|
27
|
+
|
|
28
|
+
The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
|
|
29
|
+
|
|
26
30
|
## API
|
|
27
31
|
|
|
28
32
|
```ts
|
package/docs/writing.md
CHANGED
|
@@ -250,7 +250,7 @@ await fs.write("state.json", body); // succeeds on rclone FUSE
|
|
|
250
250
|
|
|
251
251
|
**Security note.** `verify-content-with-lock` proves that the bytes observed after rename match the requested write and prevents *cooperating* writers from interleaving. It does **not** prove that the destination still names the temp-file object, retain the Python helper's fd-relative parent pinning, or stop a same-UID process that ignores the advisory lock. Do not use this option on directories writable by untrusted same-UID processes. Strict identity verification remains the default.
|
|
252
252
|
|
|
253
|
-
Lock recovery is fail-closed. If a process crashes and leaves the root-level `.fs-safe-write-<sha256>.lock`, a later write reports the stale lock instead of deleting it based on a host-local PID. Recover only under external authority that excludes every competing writer; see [File lock](sidecar-lock.md#stale-recovery-
|
|
253
|
+
Lock recovery is fail-closed. If a process crashes and leaves the root-level `.fs-safe-write-<sha256>.lock`, a later write reports the stale lock instead of deleting it based on a host-local PID. Recover only under external authority that excludes every competing writer; see [File lock](sidecar-lock.md#stale-recovery-guarded-remove-if-unchanged).
|
|
254
254
|
|
|
255
255
|
## See also
|
|
256
256
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"filesystem",
|
|
7
|
+
"openclaw",
|
|
8
|
+
"path-traversal",
|
|
9
|
+
"security",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://fs-safe.io",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/openclaw/fs-safe/issues"
|
|
15
|
+
},
|
|
5
16
|
"license": "MIT",
|
|
17
|
+
"author": "OpenClaw Team <dev@openclaw.ai>",
|
|
6
18
|
"repository": {
|
|
7
19
|
"type": "git",
|
|
8
20
|
"url": "git+https://github.com/openclaw/fs-safe.git"
|
|
9
21
|
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"provenance": true
|
|
25
|
+
},
|
|
10
26
|
"files": [
|
|
11
27
|
"dist/**/*.js",
|
|
12
28
|
"dist/**/*.d.ts",
|
|
@@ -95,7 +111,8 @@
|
|
|
95
111
|
"./test-hooks": {
|
|
96
112
|
"types": "./dist/test-hooks.d.ts",
|
|
97
113
|
"default": "./dist/test-hooks.js"
|
|
98
|
-
}
|
|
114
|
+
},
|
|
115
|
+
"./package.json": "./package.json"
|
|
99
116
|
},
|
|
100
117
|
"scripts": {
|
|
101
118
|
"benchmark": "node scripts/benchmark.mjs",
|
|
@@ -106,9 +123,11 @@
|
|
|
106
123
|
"test": "vitest run",
|
|
107
124
|
"test:coverage": "vitest run --coverage",
|
|
108
125
|
"test:security": "vitest run test/fs-safe.test.ts test/read-boundary-bypass.test.ts test/write-boundary-bypass.test.ts test/additional-boundary-bypass.test.ts test/adversarial-boundary-payloads.test.ts",
|
|
109
|
-
"check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test",
|
|
126
|
+
"check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test && node scripts/check-pack.mjs",
|
|
110
127
|
"docs:site": "node scripts/build-docs-site.mjs",
|
|
128
|
+
"pack:check": "pnpm build && node scripts/check-pack.mjs",
|
|
111
129
|
"check:changed": "pnpm run check",
|
|
130
|
+
"release:notes": "node scripts/release-notes.mjs",
|
|
112
131
|
"test:changed": "pnpm run test",
|
|
113
132
|
"crabbox:hydrate": "crabbox actions hydrate",
|
|
114
133
|
"crabbox:run": "crabbox run",
|