@westbayberry/dg 2.3.2 → 2.3.4
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/README.md +95 -208
- package/dist/agents/copilot-cli.js +7 -2
- package/dist/agents/persistence.js +83 -8
- package/dist/agents/registry.js +5 -2
- package/dist/agents/routing.js +31 -14
- package/dist/api/analyze.js +138 -41
- package/dist/audit/deep.js +2 -1
- package/dist/audit/detectors.js +23 -2
- package/dist/audit/rules.js +4 -1
- package/dist/audit-ui/export.js +5 -4
- package/dist/auth/device-login.js +33 -11
- package/dist/auth/login-app.js +17 -12
- package/dist/auth/store.js +65 -9
- package/dist/bin/dg.js +1 -1
- package/dist/commands/audit.js +12 -20
- package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
- package/dist/commands/cooldown.js +2 -1
- package/dist/commands/decisions.js +8 -7
- package/dist/commands/help.js +11 -3
- package/dist/commands/licenses.js +17 -8
- package/dist/commands/login.js +58 -21
- package/dist/commands/router.js +12 -2
- package/dist/commands/sbom.js +20 -5
- package/dist/commands/scan.js +5 -4
- package/dist/commands/service.js +1 -1
- package/dist/commands/setup.js +12 -12
- package/dist/commands/status.js +4 -4
- package/dist/commands/uninstall.js +1 -1
- package/dist/commands/update.js +20 -0
- package/dist/commands/verify.js +4 -4
- package/dist/config/settings.js +81 -12
- package/dist/export-ui/ExportDialog.js +1 -1
- package/dist/gate/cooldown-request.js +22 -0
- package/dist/gate/egress-guard.js +185 -0
- package/dist/gate/host-match.js +48 -0
- package/dist/gate/verdict-core.js +101 -0
- package/dist/gate/verdict-fetch.js +214 -0
- package/dist/launcher/agent-check.js +1096 -42
- package/dist/launcher/agent-hook-exec.js +8 -1
- package/dist/launcher/agent-hook-io.js +5 -3
- package/dist/launcher/classify.js +110 -26
- package/dist/launcher/env.js +84 -18
- package/dist/launcher/install-preflight.js +26 -3
- package/dist/launcher/live-install.js +4 -2
- package/dist/launcher/manifest-screen.js +88 -7
- package/dist/launcher/output-redaction.js +153 -15
- package/dist/launcher/preflight-prompt.js +1 -1
- package/dist/launcher/resolve-real-binary.js +8 -3
- package/dist/launcher/run.js +237 -40
- package/dist/origin/artifact-cache.js +55 -0
- package/dist/origin/cargo-config.js +12 -0
- package/dist/origin/gate-mode.js +7 -0
- package/dist/origin/platform-paths.js +14 -0
- package/dist/origin/precedence.js +345 -0
- package/dist/origin/server.js +611 -0
- package/dist/origin/spike-worker.js +35 -0
- package/dist/origin/worker.js +116 -0
- package/dist/presentation/package-page.js +21 -1
- package/dist/presentation/safe-version.js +11 -0
- package/dist/presentation/sarif.js +109 -0
- package/dist/project/dgfile.js +54 -17
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/enforcement.js +27 -7
- package/dist/proxy/metadata-map.js +33 -7
- package/dist/proxy/preverified.js +0 -3
- package/dist/proxy/server.js +104 -446
- package/dist/proxy/upstream-proxy.js +23 -4
- package/dist/proxy/worker.js +3 -2
- package/dist/publish-set/collect.js +6 -0
- package/dist/runtime/fatal.js +2 -1
- package/dist/runtime/first-run.js +5 -1
- package/dist/runtime/node-version.js +51 -0
- package/dist/runtime/nudges.js +27 -1
- package/dist/sbom/cyclonedx.js +120 -33
- package/dist/sbom/graph.js +236 -0
- package/dist/sbom/spdx.js +53 -0
- package/dist/sbom-ui/SbomApp.js +5 -3
- package/dist/sbom-ui/inventory.js +8 -10
- package/dist/scan/analyze-worker.js +3 -1
- package/dist/scan/collect.js +38 -8
- package/dist/scan/command.js +77 -27
- package/dist/scan/discovery.js +18 -7
- package/dist/scan/installed.js +202 -0
- package/dist/scan/manifest-coverage.js +128 -0
- package/dist/scan/render.js +125 -40
- package/dist/scan/scanner-report.js +92 -22
- package/dist/scan/staged.js +68 -15
- package/dist/scan/types.js +7 -1
- package/dist/scan-ui/LegacyApp.js +3 -3
- package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
- package/dist/scan-ui/hooks/useScan.js +37 -11
- package/dist/scan-ui/launch.js +2 -2
- package/dist/scan-ui/logo.js +6 -2
- package/dist/scan-ui/shims.js +6 -6
- package/dist/scripts/gate.js +53 -35
- package/dist/security/csv.js +9 -0
- package/dist/security/sanitize.js +11 -2
- package/dist/service/state.js +120 -37
- package/dist/service/trust-refresh.js +12 -1
- package/dist/service/trust-store.js +36 -5
- package/dist/service/worker.js +5 -7
- package/dist/setup/git-hook.js +2 -2
- package/dist/setup/install-method.js +36 -0
- package/dist/setup/plan.js +119 -57
- package/dist/setup/uninstall-standalone.js +7 -2
- package/dist/setup-ui/gate.js +1 -8
- package/dist/setup-ui/offer.js +14 -7
- package/dist/setup-ui/selector.js +1 -1
- package/dist/setup-ui/wizard.js +73 -78
- package/dist/standalone/uninstall.mjs +1114 -386
- package/dist/state/cleanup-registry.js +14 -3
- package/dist/state/cooldown-held.js +36 -13
- package/dist/state/locks.js +239 -34
- package/dist/state/sessions.js +2 -2
- package/dist/util/sh-escape.js +6 -0
- package/dist/util/tty-prompt.js +13 -4
- package/dist/verify/local.js +60 -10
- package/dist/verify/package-check.js +35 -49
- package/dist/verify/preflight.js +59 -33
- package/dist/verify/render.js +11 -12
- package/npm-shrinkwrap.json +714 -213
- package/package.json +4 -2
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { renameSync } from "node:fs";
|
|
2
2
|
import { rename } from "node:fs/promises";
|
|
3
3
|
import { JsonStoreError, readJsonFile, writeJsonFileAtomic } from "./store.js";
|
|
4
|
-
import {
|
|
4
|
+
import { acquireLockWithRetry, CLEANUP_REGISTRY_LOCK } from "./locks.js";
|
|
5
|
+
// Match the stale window the sync setup/uninstall callers pass; without staleMs
|
|
6
|
+
// the stale-lock takeover is disabled and a crashed holder whose PID was reused
|
|
7
|
+
// (classified "alive") wedges hook install/uninstall forever.
|
|
8
|
+
const CLEANUP_REGISTRY_LOCK_STALE_MS = 30 * 60 * 1000;
|
|
9
|
+
const CLEANUP_REGISTRY_LOCK_TIMEOUT_MS = 5_000;
|
|
5
10
|
export function emptyCleanupRegistry() {
|
|
6
11
|
return {
|
|
7
12
|
version: 1,
|
|
@@ -37,7 +42,10 @@ export async function writeCleanupRegistry(paths, registry) {
|
|
|
37
42
|
await writeJsonFileAtomic(paths.cleanupRegistryPath, registry);
|
|
38
43
|
}
|
|
39
44
|
export async function recordCleanupEntry(paths, entry) {
|
|
40
|
-
const lock = await
|
|
45
|
+
const lock = await acquireLockWithRetry(paths, CLEANUP_REGISTRY_LOCK, {
|
|
46
|
+
staleMs: CLEANUP_REGISTRY_LOCK_STALE_MS,
|
|
47
|
+
timeoutMs: CLEANUP_REGISTRY_LOCK_TIMEOUT_MS
|
|
48
|
+
});
|
|
41
49
|
try {
|
|
42
50
|
const { registry } = await loadCleanupRegistry(paths);
|
|
43
51
|
const nextEntry = {
|
|
@@ -58,7 +66,10 @@ export async function recordCleanupEntry(paths, entry) {
|
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
export async function removeCleanupEntry(paths, target) {
|
|
61
|
-
const lock = await
|
|
69
|
+
const lock = await acquireLockWithRetry(paths, CLEANUP_REGISTRY_LOCK, {
|
|
70
|
+
staleMs: CLEANUP_REGISTRY_LOCK_STALE_MS,
|
|
71
|
+
timeoutMs: CLEANUP_REGISTRY_LOCK_TIMEOUT_MS
|
|
72
|
+
});
|
|
62
73
|
try {
|
|
63
74
|
const { registry } = await loadCleanupRegistry(paths);
|
|
64
75
|
const next = {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { writeJsonAtomic } from "../util/json-file.js";
|
|
4
|
+
import { acquireLockSyncWithRetry } from "./locks.js";
|
|
4
5
|
import { resolveDgPaths } from "./paths.js";
|
|
6
|
+
export const COOLDOWN_HELD_LOCK = "cooldown-held";
|
|
7
|
+
const COOLDOWN_HELD_LOCK_TIMEOUT_MS = 5_000;
|
|
8
|
+
const COOLDOWN_HELD_LOCK_STALE_MS = 60_000;
|
|
5
9
|
export const HELD_PACKAGES_CAP = 500;
|
|
6
10
|
export const UNKNOWN_ELIGIBILITY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
11
|
export function heldPackagesPath(env = process.env) {
|
|
@@ -50,17 +54,36 @@ export function readHeldPackages(env = process.env, now = new Date()) {
|
|
|
50
54
|
}
|
|
51
55
|
export function recordHeldPackage(entry, env = process.env, now = new Date()) {
|
|
52
56
|
const path = heldPackagesPath(env);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
// Serialize the read-modify-write so concurrent installs don't clobber each
|
|
58
|
+
// other's held-package records (last-writer-wins drops entries). Recording is
|
|
59
|
+
// best-effort UX, so a contended/stale lock falls back to an unlocked write
|
|
60
|
+
// rather than failing the install.
|
|
61
|
+
let lock;
|
|
62
|
+
try {
|
|
63
|
+
lock = acquireLockSyncWithRetry(resolveDgPaths(env), COOLDOWN_HELD_LOCK, {
|
|
64
|
+
staleMs: COOLDOWN_HELD_LOCK_STALE_MS,
|
|
65
|
+
timeoutMs: COOLDOWN_HELD_LOCK_TIMEOUT_MS
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
lock = undefined;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const nowIso = now.toISOString();
|
|
73
|
+
const existing = prune(readDocument(path), now);
|
|
74
|
+
const key = heldKey(entry);
|
|
75
|
+
const previous = existing.find((candidate) => heldKey(candidate) === key);
|
|
76
|
+
const updated = {
|
|
77
|
+
...entry,
|
|
78
|
+
firstSeenAt: previous?.firstSeenAt ?? nowIso,
|
|
79
|
+
lastSeenAt: nowIso
|
|
80
|
+
};
|
|
81
|
+
const merged = [...existing.filter((candidate) => heldKey(candidate) !== key), updated]
|
|
82
|
+
.sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt))
|
|
83
|
+
.slice(0, HELD_PACKAGES_CAP);
|
|
84
|
+
writeJsonAtomic(path, { schemaVersion: 1, entries: merged });
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
lock?.release();
|
|
88
|
+
}
|
|
66
89
|
}
|
package/dist/state/locks.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { link, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
export const CLEANUP_REGISTRY_LOCK = "cleanup-registry";
|
|
5
6
|
let takeoverCounter = 0;
|
|
7
|
+
let createCounter = 0;
|
|
8
|
+
let reclaimCounter = 0;
|
|
6
9
|
function isErrno(error, code) {
|
|
7
10
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
8
11
|
}
|
|
@@ -21,26 +24,39 @@ export async function acquireLock(paths, name, options = {}) {
|
|
|
21
24
|
mode: 0o700
|
|
22
25
|
});
|
|
23
26
|
const path = join(paths.locksDir, `${name}.lock`);
|
|
24
|
-
await
|
|
25
|
-
let handle;
|
|
27
|
+
const reclaim = await acquireReclaimLock(path);
|
|
26
28
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
await removeStaleLock(path, options);
|
|
30
|
+
const metadata = {
|
|
31
|
+
pid: process.pid,
|
|
32
|
+
acquiredAt: (options.now ?? new Date()).toISOString()
|
|
33
|
+
};
|
|
34
|
+
// Write the metadata to a temp file, then atomically claim the lock by linking
|
|
35
|
+
// it onto the final path (fails EEXIST if held). The lock therefore never
|
|
36
|
+
// exists empty, so a concurrent reader can't mis-classify a just-created lock
|
|
37
|
+
// as corrupt/stale and wedge or steal it.
|
|
38
|
+
const tmpPath = `${path}.new-${process.pid}-${++createCounter}`;
|
|
39
|
+
const handle = await open(tmpPath, "wx", 0o600);
|
|
40
|
+
try {
|
|
41
|
+
await handle.writeFile(`${JSON.stringify(metadata)}\n`, "utf8");
|
|
32
42
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
finally {
|
|
44
|
+
await handle.close();
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await link(tmpPath, path);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
await rm(tmpPath, { force: true });
|
|
51
|
+
if (isErrno(error, "EEXIST")) {
|
|
52
|
+
throw new LockBusyError(path);
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
await rm(tmpPath, { force: true });
|
|
41
57
|
}
|
|
42
58
|
finally {
|
|
43
|
-
await
|
|
59
|
+
await releaseReclaimLock(path, reclaim);
|
|
44
60
|
}
|
|
45
61
|
return {
|
|
46
62
|
name,
|
|
@@ -59,26 +75,36 @@ export function acquireLockSync(paths, name, options = {}) {
|
|
|
59
75
|
mode: 0o700
|
|
60
76
|
});
|
|
61
77
|
const path = join(paths.locksDir, `${name}.lock`);
|
|
62
|
-
|
|
63
|
-
let fd;
|
|
78
|
+
const reclaim = acquireReclaimLockSync(path);
|
|
64
79
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
removeStaleLockSync(path, options);
|
|
81
|
+
const metadata = {
|
|
82
|
+
pid: process.pid,
|
|
83
|
+
acquiredAt: (options.now ?? new Date()).toISOString()
|
|
84
|
+
};
|
|
85
|
+
// Atomic claim via temp + link, so the lock is never observable empty.
|
|
86
|
+
const tmpPath = `${path}.new-${process.pid}-${++createCounter}`;
|
|
87
|
+
const fd = openSync(tmpPath, "wx", 0o600);
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(fd, `${JSON.stringify(metadata)}\n`, "utf8");
|
|
70
90
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
finally {
|
|
92
|
+
closeSync(fd);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
linkSync(tmpPath, path);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
rmSync(tmpPath, { force: true });
|
|
99
|
+
if (isErrno(error, "EEXIST")) {
|
|
100
|
+
throw new LockBusyError(path);
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
rmSync(tmpPath, { force: true });
|
|
79
105
|
}
|
|
80
106
|
finally {
|
|
81
|
-
|
|
107
|
+
releaseReclaimLockSync(path, reclaim);
|
|
82
108
|
}
|
|
83
109
|
return {
|
|
84
110
|
name,
|
|
@@ -115,6 +141,27 @@ export function acquireLockSyncWithRetry(paths, name, options = {}) {
|
|
|
115
141
|
function sleepSync(ms) {
|
|
116
142
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
117
143
|
}
|
|
144
|
+
export async function acquireLockWithRetry(paths, name, options = {}) {
|
|
145
|
+
const deadline = Date.now() + (options.timeoutMs ?? 5_000);
|
|
146
|
+
const acquireOptions = {
|
|
147
|
+
...(options.staleMs !== undefined ? { staleMs: options.staleMs } : {}),
|
|
148
|
+
...(options.now !== undefined ? { now: options.now } : {})
|
|
149
|
+
};
|
|
150
|
+
for (;;) {
|
|
151
|
+
try {
|
|
152
|
+
return await acquireLock(paths, name, acquireOptions);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (!(error instanceof LockBusyError) || Date.now() + LOCK_RETRY_DELAY_MS > deadline) {
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
await new Promise((resolve) => {
|
|
159
|
+
const timer = setTimeout(resolve, LOCK_RETRY_DELAY_MS);
|
|
160
|
+
timer.unref?.();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
118
165
|
export function isProcessAlive(pid) {
|
|
119
166
|
try {
|
|
120
167
|
process.kill(pid, 0);
|
|
@@ -148,6 +195,12 @@ async function removeStaleLock(path, options) {
|
|
|
148
195
|
}
|
|
149
196
|
content = null;
|
|
150
197
|
}
|
|
198
|
+
if (content !== null && content.trim() === "") {
|
|
199
|
+
// Atomic create never leaves an empty lock, so an empty file here is a
|
|
200
|
+
// leftover from a crash / old version — take it over rather than wedge.
|
|
201
|
+
await takeoverLock(path);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
151
204
|
const holder = content === null ? "unknown" : holderStateFromContent(content);
|
|
152
205
|
if (holder === "alive") {
|
|
153
206
|
return;
|
|
@@ -185,6 +238,10 @@ function removeStaleLockSync(path, options) {
|
|
|
185
238
|
}
|
|
186
239
|
content = null;
|
|
187
240
|
}
|
|
241
|
+
if (content !== null && content.trim() === "") {
|
|
242
|
+
takeoverLockSync(path);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
188
245
|
const holder = content === null ? "unknown" : holderStateFromContent(content);
|
|
189
246
|
if (holder === "alive") {
|
|
190
247
|
return;
|
|
@@ -242,6 +299,154 @@ function takeoverLockSync(path) {
|
|
|
242
299
|
force: true
|
|
243
300
|
});
|
|
244
301
|
}
|
|
302
|
+
// The classify-then-takeover-then-relink reclaim of a stale/dead lock renames by
|
|
303
|
+
// name, so two processes that both saw the same dead holder could each rename the
|
|
304
|
+
// other's freshly relinked lock away and both end up "holding" it. Serialize the
|
|
305
|
+
// reclaim of a given lock name behind this meta-lock, claimed with the same
|
|
306
|
+
// EEXIST-on-create primitive, so only one process reclaims at a time. It is held
|
|
307
|
+
// only across a handful of syscalls; a short mtime fallback lets a crash mid-
|
|
308
|
+
// reclaim self-heal without wedging every future acquirer.
|
|
309
|
+
//
|
|
310
|
+
// The stale window MUST stay strictly below the smallest lock-acquire retry budget
|
|
311
|
+
// (the 5_000ms callers in this module and in dgfile.ts). Otherwise a meta-lock
|
|
312
|
+
// abandoned by a crash less than one budget ago can never be reclaimed inside a
|
|
313
|
+
// caller's retry loop, turning a self-healing situation into a hard acquisition
|
|
314
|
+
// failure.
|
|
315
|
+
export const META_LOCK_STALE_MS = 3_000;
|
|
316
|
+
function reclaimLockPath(path) {
|
|
317
|
+
return `${path}.takeover`;
|
|
318
|
+
}
|
|
319
|
+
// Self-identifying owner token so release only removes the meta-lock this process
|
|
320
|
+
// still owns. Without it, a process whose meta-lock was reclaimed as stale by a
|
|
321
|
+
// peer would delete the peer's successor meta-lock on release, re-opening the very
|
|
322
|
+
// double-reclaim window the meta-lock exists to close.
|
|
323
|
+
function reclaimToken() {
|
|
324
|
+
return `${process.pid}-${++reclaimCounter}-${randomBytes(8).toString("hex")}`;
|
|
325
|
+
}
|
|
326
|
+
export function acquireReclaimLockSync(path) {
|
|
327
|
+
const metaPath = reclaimLockPath(path);
|
|
328
|
+
const token = reclaimToken();
|
|
329
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
330
|
+
try {
|
|
331
|
+
const fd = openSync(metaPath, "wx", 0o600);
|
|
332
|
+
writeFileSync(fd, token, "utf8");
|
|
333
|
+
return { fd, token };
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
if (!isErrno(error, "EEXIST")) {
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
if (!reclaimStaleMetaLockSync(metaPath)) {
|
|
340
|
+
throw new LockBusyError(path);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
throw new LockBusyError(path);
|
|
345
|
+
}
|
|
346
|
+
function reclaimStaleMetaLockSync(metaPath) {
|
|
347
|
+
let details;
|
|
348
|
+
try {
|
|
349
|
+
details = statSync(metaPath);
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
if (isErrno(error, "ENOENT")) {
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
if (Date.now() - details.mtimeMs < META_LOCK_STALE_MS) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
const stalePath = `${metaPath}.stale-${process.pid}-${++takeoverCounter}`;
|
|
361
|
+
try {
|
|
362
|
+
renameSync(metaPath, stalePath);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
if (isErrno(error, "ENOENT")) {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
rmSync(stalePath, { force: true });
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
export function releaseReclaimLockSync(path, reclaim) {
|
|
374
|
+
closeSync(reclaim.fd);
|
|
375
|
+
ownerCheckedRemoveSync(reclaimLockPath(path), reclaim.token);
|
|
376
|
+
}
|
|
377
|
+
function ownerCheckedRemoveSync(metaPath, token) {
|
|
378
|
+
let content;
|
|
379
|
+
try {
|
|
380
|
+
content = readFileSync(metaPath, "utf8");
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (content === token) {
|
|
386
|
+
rmSync(metaPath, { force: true });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async function acquireReclaimLock(path) {
|
|
390
|
+
const metaPath = reclaimLockPath(path);
|
|
391
|
+
const token = reclaimToken();
|
|
392
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
393
|
+
try {
|
|
394
|
+
const handle = await open(metaPath, "wx", 0o600);
|
|
395
|
+
await handle.writeFile(token, "utf8");
|
|
396
|
+
return { handle, token };
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (!isErrno(error, "EEXIST")) {
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
if (!(await reclaimStaleMetaLock(metaPath))) {
|
|
403
|
+
throw new LockBusyError(path);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
throw new LockBusyError(path);
|
|
408
|
+
}
|
|
409
|
+
async function reclaimStaleMetaLock(metaPath) {
|
|
410
|
+
let details;
|
|
411
|
+
try {
|
|
412
|
+
details = await stat(metaPath);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
if (isErrno(error, "ENOENT")) {
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
if (Date.now() - details.mtimeMs < META_LOCK_STALE_MS) {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
const stalePath = `${metaPath}.stale-${process.pid}-${++takeoverCounter}`;
|
|
424
|
+
try {
|
|
425
|
+
await rename(metaPath, stalePath);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
if (isErrno(error, "ENOENT")) {
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
await rm(stalePath, { force: true });
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
async function releaseReclaimLock(path, reclaim) {
|
|
437
|
+
await reclaim.handle.close();
|
|
438
|
+
const metaPath = reclaimLockPath(path);
|
|
439
|
+
let content;
|
|
440
|
+
try {
|
|
441
|
+
content = await readFile(metaPath, "utf8");
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (content === reclaim.token) {
|
|
447
|
+
await rm(metaPath, { force: true });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
245
450
|
function assertLockName(name) {
|
|
246
451
|
if (!/^[a-z][a-z0-9-]{0,63}$/.test(name)) {
|
|
247
452
|
throw new Error(`Invalid dg lock name: ${name}`);
|
package/dist/state/sessions.js
CHANGED
|
@@ -84,7 +84,7 @@ export async function sweepStaleSessions(paths, options) {
|
|
|
84
84
|
}
|
|
85
85
|
throw error;
|
|
86
86
|
});
|
|
87
|
-
if (!details || now - details.mtimeMs < options.olderThanMs) {
|
|
87
|
+
if (!details || now - details.mtimeMs < options.olderThanMs || sessionWorkerAlive(dir)) {
|
|
88
88
|
continue;
|
|
89
89
|
}
|
|
90
90
|
await rm(dir, {
|
|
@@ -129,7 +129,7 @@ export function findStaleSessionsSync(paths, options) {
|
|
|
129
129
|
}
|
|
130
130
|
throw error;
|
|
131
131
|
}
|
|
132
|
-
if (now - details.mtimeMs >= options.olderThanMs) {
|
|
132
|
+
if (now - details.mtimeMs >= options.olderThanMs && !sessionWorkerAlive(dir)) {
|
|
133
133
|
stale.push(entry.name);
|
|
134
134
|
}
|
|
135
135
|
}
|
package/dist/util/tty-prompt.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { closeSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
const EAGAIN_POLL_INTERVAL_MS = 10;
|
|
4
|
+
function sleepSync(ms) {
|
|
5
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
6
|
+
}
|
|
2
7
|
export function promptYesNo(question, defaultYes, out = process.stderr) {
|
|
3
8
|
const answer = promptLine(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, out);
|
|
4
9
|
if (answer === null) {
|
|
@@ -21,14 +26,17 @@ export function promptLine(question, out = process.stderr) {
|
|
|
21
26
|
try {
|
|
22
27
|
out.write(question);
|
|
23
28
|
const byte = Buffer.alloc(1);
|
|
29
|
+
const decoder = new StringDecoder("utf8");
|
|
24
30
|
let answer = "";
|
|
25
|
-
|
|
31
|
+
let terminated = false;
|
|
32
|
+
while (!terminated) {
|
|
26
33
|
let read = 0;
|
|
27
34
|
try {
|
|
28
35
|
read = readSync(tty, byte, 0, 1, null);
|
|
29
36
|
}
|
|
30
37
|
catch (error) {
|
|
31
38
|
if (error.code === "EAGAIN") {
|
|
39
|
+
sleepSync(EAGAIN_POLL_INTERVAL_MS);
|
|
32
40
|
continue;
|
|
33
41
|
}
|
|
34
42
|
break;
|
|
@@ -36,12 +44,13 @@ export function promptLine(question, out = process.stderr) {
|
|
|
36
44
|
if (read === 0) {
|
|
37
45
|
break;
|
|
38
46
|
}
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
if (byte[0] === 0x0a || byte[0] === 0x0d) {
|
|
48
|
+
terminated = true;
|
|
41
49
|
break;
|
|
42
50
|
}
|
|
43
|
-
answer +=
|
|
51
|
+
answer += decoder.write(byte);
|
|
44
52
|
}
|
|
53
|
+
answer += decoder.end();
|
|
45
54
|
return answer;
|
|
46
55
|
}
|
|
47
56
|
finally {
|
package/dist/verify/local.js
CHANGED
|
@@ -12,6 +12,8 @@ const GNU_LONGNAME_TYPE = 0x4c;
|
|
|
12
12
|
const GNU_LONGLINK_TYPE = 0x4b;
|
|
13
13
|
const PAX_EXTENDED_TYPE = 0x78;
|
|
14
14
|
const PAX_GLOBAL_TYPE = 0x67;
|
|
15
|
+
const HARDLINK_TYPE = 0x31;
|
|
16
|
+
const SYMLINK_TYPE = 0x32;
|
|
15
17
|
export function verifyLocalTarget(targetPath, cwd = process.cwd()) {
|
|
16
18
|
const absoluteTarget = resolve(cwd, targetPath);
|
|
17
19
|
if (!existsSync(absoluteTarget)) {
|
|
@@ -120,6 +122,7 @@ function scanTarball(bytes, path) {
|
|
|
120
122
|
let offset = 0;
|
|
121
123
|
let unpackedSizeBytes = 0;
|
|
122
124
|
let pendingLongName = null;
|
|
125
|
+
let pendingLongLink = null;
|
|
123
126
|
let pendingPax = null;
|
|
124
127
|
let globalPax = null;
|
|
125
128
|
while (offset + TAR_BLOCK_SIZE <= tarBytes.length) {
|
|
@@ -151,6 +154,7 @@ function scanTarball(bytes, path) {
|
|
|
151
154
|
continue;
|
|
152
155
|
}
|
|
153
156
|
if (typeflag === GNU_LONGLINK_TYPE) {
|
|
157
|
+
pendingLongLink = readNullTerminated(body, 0, body.length);
|
|
154
158
|
offset = nextOffset;
|
|
155
159
|
continue;
|
|
156
160
|
}
|
|
@@ -180,7 +184,25 @@ function scanTarball(bytes, path) {
|
|
|
180
184
|
break;
|
|
181
185
|
}
|
|
182
186
|
const name = paxPath ?? pendingLongName ?? rawName;
|
|
187
|
+
// A symlink/hardlink target that escapes the archive root is the link-based
|
|
188
|
+
// half of a zip-slip — flag it (the entry name is checked separately). Use
|
|
189
|
+
// the same precedence as the name: GNU LONGLINK, then PAX linkpath, then the
|
|
190
|
+
// ustar header linkname field (offset 157, 100 bytes).
|
|
191
|
+
if (typeflag === SYMLINK_TYPE || typeflag === HARDLINK_TYPE) {
|
|
192
|
+
const paxLink = pendingPax?.get("linkpath") ?? globalPax?.get("linkpath") ?? null;
|
|
193
|
+
const linkTarget = pendingLongLink ?? paxLink ?? readNullTerminated(header, 157, 100);
|
|
194
|
+
if (linkTarget.length > 0 && linkEscapesRoot(name, linkTarget)) {
|
|
195
|
+
findings.push({
|
|
196
|
+
id: "archive-path-traversal",
|
|
197
|
+
severity: "block",
|
|
198
|
+
title: "Unsafe archive link target",
|
|
199
|
+
message: "archive link points outside the extraction root or to an unsafe absolute path",
|
|
200
|
+
location: `${name} -> ${linkTarget}`
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
183
204
|
pendingLongName = null;
|
|
205
|
+
pendingLongLink = null;
|
|
184
206
|
pendingPax = null;
|
|
185
207
|
unpackedSizeBytes += size;
|
|
186
208
|
entries.push({
|
|
@@ -198,7 +220,7 @@ function scanTarball(bytes, path) {
|
|
|
198
220
|
}
|
|
199
221
|
offset = nextOffset;
|
|
200
222
|
}
|
|
201
|
-
if (errors.length === 0 && (pendingLongName !== null || pendingPax !== null)) {
|
|
223
|
+
if (errors.length === 0 && (pendingLongName !== null || pendingLongLink !== null || pendingPax !== null)) {
|
|
202
224
|
errors.push("tar archive ends with an unapplied extended header");
|
|
203
225
|
}
|
|
204
226
|
findings.push(...packageManifestFindings(entries));
|
|
@@ -242,11 +264,13 @@ function scanZipLike(bytes, path) {
|
|
|
242
264
|
location: entry.name
|
|
243
265
|
});
|
|
244
266
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
267
|
+
// Meter the ACTUAL inflated bytes against the cumulative budget, and cap each
|
|
268
|
+
// entry's inflation at the remaining budget — the central-directory
|
|
269
|
+
// uncompressedSize is attacker-forgeable, so a bomb could claim one byte per
|
|
270
|
+
// entry while each inflates to the per-entry cap, exhausting memory.
|
|
271
|
+
const body = readZipEntryBody(bytes, entry, errors, MAX_UNPACKED_BYTES - unpackedSizeBytes);
|
|
272
|
+
unpackedSizeBytes += body.length;
|
|
273
|
+
entries.push({ name: entry.name, body });
|
|
250
274
|
if (entries.length > MAX_ARCHIVE_ENTRIES) {
|
|
251
275
|
findings.push(limitFinding("archive has too many entries"));
|
|
252
276
|
break;
|
|
@@ -368,7 +392,7 @@ function zipLocalHeaderMismatch(bytes, central) {
|
|
|
368
392
|
}
|
|
369
393
|
return null;
|
|
370
394
|
}
|
|
371
|
-
function readZipEntryBody(bytes, entry, errors) {
|
|
395
|
+
function readZipEntryBody(bytes, entry, errors, maxBytes) {
|
|
372
396
|
if ((entry.flags & 0x1) !== 0) {
|
|
373
397
|
return Buffer.alloc(0);
|
|
374
398
|
}
|
|
@@ -380,15 +404,15 @@ function readZipEntryBody(bytes, entry, errors) {
|
|
|
380
404
|
errors.push(`${entry.name}: zip entry data is truncated`);
|
|
381
405
|
return Buffer.alloc(0);
|
|
382
406
|
}
|
|
383
|
-
return readZipBody(bytes.subarray(dataStart, dataStart + entry.compressedSize), entry.method, entry.name, errors);
|
|
407
|
+
return readZipBody(bytes.subarray(dataStart, dataStart + entry.compressedSize), entry.method, entry.name, errors, maxBytes);
|
|
384
408
|
}
|
|
385
|
-
function readZipBody(data, method, name, errors) {
|
|
409
|
+
function readZipBody(data, method, name, errors, maxBytes) {
|
|
386
410
|
if (method === 0) {
|
|
387
411
|
return data;
|
|
388
412
|
}
|
|
389
413
|
if (method === 8) {
|
|
390
414
|
try {
|
|
391
|
-
return inflateRawSync(data, { maxOutputLength: MAX_UNPACKED_BYTES });
|
|
415
|
+
return inflateRawSync(data, { maxOutputLength: Math.max(1, Math.min(MAX_UNPACKED_BYTES, maxBytes)) });
|
|
392
416
|
}
|
|
393
417
|
catch (error) {
|
|
394
418
|
errors.push(`${name}: could not inflate zip entry: ${error instanceof Error ? error.message : "unknown inflate error"}`);
|
|
@@ -454,6 +478,32 @@ function pathSafetyFindings(entryPath) {
|
|
|
454
478
|
}
|
|
455
479
|
return findings;
|
|
456
480
|
}
|
|
481
|
+
// A symlink/hardlink target escapes the archive root if it is absolute, a
|
|
482
|
+
// Windows path, or climbs above the directory holding the link. Unlike an entry
|
|
483
|
+
// path, a link target legitimately uses `..` (npm `.bin/foo -> ../foo.js`), so
|
|
484
|
+
// resolve it relative to the link's own location and reject only a net escape.
|
|
485
|
+
function linkEscapesRoot(entryName, linkTarget) {
|
|
486
|
+
if (linkTarget.startsWith("/") || linkTarget.includes("\\") || /^[a-zA-Z]:/.test(linkTarget)) {
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
const baseDir = entryName.split("/").slice(0, -1);
|
|
490
|
+
let depth = 0;
|
|
491
|
+
for (const part of [...baseDir, ...linkTarget.split("/")]) {
|
|
492
|
+
if (part === "" || part === ".") {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (part === "..") {
|
|
496
|
+
depth -= 1;
|
|
497
|
+
if (depth < 0) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
depth += 1;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
457
507
|
function isSafeArchivePath(entryPath) {
|
|
458
508
|
if (entryPath.length === 0 || entryPath.includes("\\") || entryPath.startsWith("/") || /^[a-zA-Z]:/.test(entryPath)) {
|
|
459
509
|
return false;
|