@openclaw/fs-safe 0.4.5 → 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 +34 -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/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/package.json +22 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
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
|
+
|
|
3
37
|
## 0.4.5 - 2026-07-20
|
|
4
38
|
|
|
5
39
|
### Highlights
|
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)) {
|
|
@@ -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/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",
|