@westbayberry/dg 2.3.3 → 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 +94 -217
- 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 +3 -2
|
@@ -10,20 +10,110 @@ const bearerPattern = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g;
|
|
|
10
10
|
// Alnum-only look-around instead of \b so a fixed-length token immediately
|
|
11
11
|
// adjacent to an underscore-word (e.g. npm_<36>_ci) is still redacted — \b fails
|
|
12
12
|
// there because the boundary would fall between two word characters.
|
|
13
|
-
const knownTokenShapePattern = /(?<![A-Za-z0-9])(npm_[A-Za-z0-9]{36}|gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255}|pypi-[A-Za-z0-9_-]{20,}|glpat-[A-Za-z0-9_-]{20,}|hf_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|sk_(?:live|test)_[A-Za-z0-9]{20,}|
|
|
13
|
+
const knownTokenShapePattern = /(?<![A-Za-z0-9])(npm_[A-Za-z0-9]{36}|gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255}|pypi-[A-Za-z0-9_-]{20,}|glpat-[A-Za-z0-9_-]{20,}|hf_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|sk_(?:live|test)_[A-Za-z0-9]{20,}|rk_(?:live|test)_[A-Za-z0-9]{20,}|sk-(?:proj-)?[A-Za-z0-9_-]{20,}|xox[abdeprs]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{10,})(?![A-Za-z0-9])/g;
|
|
14
|
+
const pemPrivateKeyPattern = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----/g;
|
|
14
15
|
export function redactSecrets(text) {
|
|
15
|
-
|
|
16
|
+
// De-fragment CR-split secrets against the ORIGINAL text first: a bare \r
|
|
17
|
+
// landing inside a value (`_authToken=npm_<head>\r<tail>`) is reassembled by
|
|
18
|
+
// its shape and redacted before the per-line pass runs. Doing it after the
|
|
19
|
+
// per-line pass (the prior order) leaked the tail, because npmrcAuthPattern had
|
|
20
|
+
// already replaced the shape prefix `npm_<head>` with <redacted>, leaving the
|
|
21
|
+
// shape defrag nothing to anchor on.
|
|
22
|
+
const base = (text.includes("\r") ? redactCrFragmented(text) : text)
|
|
16
23
|
.replace(credentialUrlPattern, "$1<redacted>@")
|
|
17
24
|
.replace(authHeaderPattern, (_match, header) => `${header}: <redacted>`)
|
|
18
25
|
.replace(npmrcAuthPattern, "$1=<redacted>")
|
|
19
26
|
.replace(jsonSecretPattern, '$1"<redacted>"')
|
|
20
27
|
.replace(tokenAssignmentPattern, "$1$2$3<redacted>")
|
|
21
28
|
.replace(bearerPattern, "Bearer <redacted>")
|
|
29
|
+
.replace(pemPrivateKeyPattern, "<redacted>")
|
|
22
30
|
.replace(knownTokenShapePattern, "<redacted>");
|
|
31
|
+
return base;
|
|
32
|
+
}
|
|
33
|
+
// A bare carriage return (terminal progress redraw) inside a secret splits it
|
|
34
|
+
// into sub-threshold halves the per-line patterns can't match — their value
|
|
35
|
+
// classes stop at \r, so the post-CR tail leaks. Re-scan a CR-stripped view and
|
|
36
|
+
// redact any matched range that actually spans a dropped CR, mapping the match
|
|
37
|
+
// back onto the original (the keptIndex mechanism) so a legitimate progress CR
|
|
38
|
+
// between non-secret tokens is preserved. The assignment patterns join the shape
|
|
39
|
+
// patterns so a CR-split `_auth=…` / `password=…` value reassembles and redacts
|
|
40
|
+
// whole. Deliberate security-first tradeoff: two assignments separated by ONLY a
|
|
41
|
+
// bare CR reassemble into a single redaction — both secret values still go.
|
|
42
|
+
const crFragmentablePatterns = [
|
|
43
|
+
credentialUrlPattern,
|
|
44
|
+
authHeaderPattern,
|
|
45
|
+
npmrcAuthPattern,
|
|
46
|
+
jsonSecretPattern,
|
|
47
|
+
tokenAssignmentPattern,
|
|
48
|
+
bearerPattern,
|
|
49
|
+
knownTokenShapePattern,
|
|
50
|
+
];
|
|
51
|
+
function redactCrFragmented(text) {
|
|
52
|
+
const keptIndex = [];
|
|
53
|
+
let stripped = "";
|
|
54
|
+
for (let i = 0; i < text.length; i++) {
|
|
55
|
+
if (text[i] === "\r" && text[i + 1] !== "\n")
|
|
56
|
+
continue;
|
|
57
|
+
stripped += text[i];
|
|
58
|
+
keptIndex.push(i);
|
|
59
|
+
}
|
|
60
|
+
const ranges = [];
|
|
61
|
+
for (const re of crFragmentablePatterns) {
|
|
62
|
+
re.lastIndex = 0;
|
|
63
|
+
let m;
|
|
64
|
+
while ((m = re.exec(stripped)) !== null) {
|
|
65
|
+
if (m[0].length === 0) {
|
|
66
|
+
re.lastIndex++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const start = keptIndex[m.index];
|
|
70
|
+
const end = keptIndex[m.index + m[0].length - 1] + 1;
|
|
71
|
+
if (end - start > m[0].length)
|
|
72
|
+
ranges.push([start, end]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (ranges.length === 0)
|
|
76
|
+
return text;
|
|
77
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
78
|
+
let out = "";
|
|
79
|
+
let cursor = 0;
|
|
80
|
+
for (const [start, end] of ranges) {
|
|
81
|
+
if (start < cursor)
|
|
82
|
+
continue;
|
|
83
|
+
out += text.slice(cursor, start) + "<redacted>";
|
|
84
|
+
cursor = end;
|
|
85
|
+
}
|
|
86
|
+
return out + text.slice(cursor);
|
|
23
87
|
}
|
|
24
88
|
const STREAM_FLUSH_QUIET_MS = 80;
|
|
25
|
-
|
|
26
|
-
|
|
89
|
+
// Must exceed the longest token shape (gh*/github_pat_ run to 255) so a partial
|
|
90
|
+
// token near the end of the buffer is still anchored by its shape prefix.
|
|
91
|
+
const SECRET_TAIL_SCAN_CHARS = 512;
|
|
92
|
+
const secretTailPattern = /(Bearer\s+[\w.~+/=-]*|npm_[A-Za-z0-9]*|gh[pousr]_[A-Za-z0-9]*|github_pat_[A-Za-z0-9_]*|pypi-[\w-]*|glpat-[\w-]*|hf_[A-Za-z0-9]*|AKIA[0-9A-Z]*|ASIA[0-9A-Z]*|AIza[\w-]*|sk_(?:live|test)_[A-Za-z0-9]*|rk_(?:live|test)_[A-Za-z0-9]*|sk-(?:proj-)?[\w-]*|xox[abdeprs]-[\w-]*|eyJ[\w-]*(?:\.[\w-]*){0,2}|-----BEGIN[^\n]*|[a-z][a-z0-9+.-]{0,31}:\/\/[^\s/]*|[\w-]*(?:_authToken|_auth|_password|secret[_-]key|access[_-]key|api[_-]key|token|password|secret)\s*[:=]\s*"?\S*|"[\w.$-]*(?:secret[_-]key|access[_-]key|api[_-]key|token|password|secret)"\s*:\s*"?[^"\n]*)$/i;
|
|
93
|
+
// A PEM private key spans many lines, and the redaction pattern only matches a
|
|
94
|
+
// complete BEGIN...END pair. The newline-boundary flush below emits each line as
|
|
95
|
+
// it arrives, so a key streamed line-by-line would leak its head before the END
|
|
96
|
+
// line is seen. Detect the index of an unterminated BEGIN so the stream holds
|
|
97
|
+
// from there until the END arrives (or fails safe and redacts the held block).
|
|
98
|
+
const PEM_BEGIN = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/g;
|
|
99
|
+
const PEM_END = /-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----/;
|
|
100
|
+
// A real private key is a few KB; cap the held block so a never-terminated
|
|
101
|
+
// BEGIN cannot grow the buffer without bound. Past the cap we redact and drop.
|
|
102
|
+
const MAX_SECRET_HOLD = 1_000_000;
|
|
103
|
+
function unterminatedPemStart(text) {
|
|
104
|
+
PEM_BEGIN.lastIndex = 0;
|
|
105
|
+
let lastBegin = -1;
|
|
106
|
+
let m;
|
|
107
|
+
while ((m = PEM_BEGIN.exec(text)) !== null) {
|
|
108
|
+
lastBegin = m.index;
|
|
109
|
+
if (m[0].length === 0)
|
|
110
|
+
PEM_BEGIN.lastIndex++;
|
|
111
|
+
}
|
|
112
|
+
if (lastBegin === -1) {
|
|
113
|
+
return -1;
|
|
114
|
+
}
|
|
115
|
+
return PEM_END.test(text.slice(lastBegin)) ? -1 : lastBegin;
|
|
116
|
+
}
|
|
27
117
|
export function createStreamRedactor(emit) {
|
|
28
118
|
let pending = "";
|
|
29
119
|
let timer;
|
|
@@ -37,17 +127,48 @@ export function createStreamRedactor(emit) {
|
|
|
37
127
|
const emitPending = () => {
|
|
38
128
|
clearTimer();
|
|
39
129
|
if (pending) {
|
|
40
|
-
|
|
130
|
+
const pemStart = unterminatedPemStart(pending);
|
|
131
|
+
if (pemStart >= 0) {
|
|
132
|
+
emit(redactSecrets(pending.slice(0, pemStart)) + "<redacted>");
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
emit(redactSecrets(pending));
|
|
136
|
+
}
|
|
41
137
|
pending = "";
|
|
42
138
|
}
|
|
43
139
|
tailHeldOnce = false;
|
|
44
140
|
};
|
|
45
|
-
const
|
|
141
|
+
const armTimer = () => {
|
|
142
|
+
clearTimer();
|
|
143
|
+
if (pending) {
|
|
144
|
+
timer = setTimeout(onQuietTimer, STREAM_FLUSH_QUIET_MS);
|
|
145
|
+
timer.unref?.();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
function onQuietTimer() {
|
|
46
149
|
timer = undefined;
|
|
47
150
|
if (!pending) {
|
|
48
151
|
tailHeldOnce = false;
|
|
49
152
|
return;
|
|
50
153
|
}
|
|
154
|
+
const pemStart = unterminatedPemStart(pending);
|
|
155
|
+
if (pemStart >= 0) {
|
|
156
|
+
if (pemStart > 0) {
|
|
157
|
+
emit(redactSecrets(pending.slice(0, pemStart)));
|
|
158
|
+
}
|
|
159
|
+
if (!tailHeldOnce) {
|
|
160
|
+
pending = pending.slice(pemStart);
|
|
161
|
+
tailHeldOnce = true;
|
|
162
|
+
timer = setTimeout(onQuietTimer, STREAM_FLUSH_QUIET_MS);
|
|
163
|
+
timer.unref?.();
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
emit("<redacted>");
|
|
167
|
+
pending = "";
|
|
168
|
+
tailHeldOnce = false;
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
51
172
|
const scanStart = Math.max(0, pending.length - SECRET_TAIL_SCAN_CHARS);
|
|
52
173
|
const tailMatch = secretTailPattern.exec(pending.slice(scanStart));
|
|
53
174
|
if (tailMatch && !tailHeldOnce) {
|
|
@@ -64,21 +185,38 @@ export function createStreamRedactor(emit) {
|
|
|
64
185
|
emit(redactSecrets(pending));
|
|
65
186
|
pending = "";
|
|
66
187
|
tailHeldOnce = false;
|
|
67
|
-
}
|
|
188
|
+
}
|
|
68
189
|
return {
|
|
69
190
|
write(chunk) {
|
|
70
191
|
pending += chunk;
|
|
71
192
|
tailHeldOnce = false;
|
|
72
|
-
|
|
193
|
+
// Flush only at a NEWLINE, never at a bare CR. A secret fragmented by a bare
|
|
194
|
+
// \r (terminal progress redraw) must stay whole in `pending` so redactSecrets
|
|
195
|
+
// (redactCrFragmented) can de-fragment it; flushing at the \r would emit the
|
|
196
|
+
// two halves in separate, individually-unredactable chunks. The trailing
|
|
197
|
+
// region after the last newline is held and drained by the quiet timer.
|
|
198
|
+
const boundary = pending.lastIndexOf("\n");
|
|
73
199
|
if (boundary >= 0) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
200
|
+
let flushEnd = boundary + 1;
|
|
201
|
+
const pemStart = unterminatedPemStart(pending.slice(0, flushEnd));
|
|
202
|
+
if (pemStart >= 0 && pending.length - pemStart > MAX_SECRET_HOLD) {
|
|
203
|
+
if (pemStart > 0) {
|
|
204
|
+
emit(redactSecrets(pending.slice(0, pemStart)));
|
|
205
|
+
}
|
|
206
|
+
emit("<redacted>");
|
|
207
|
+
pending = pending.slice(flushEnd);
|
|
208
|
+
armTimer();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (pemStart >= 0) {
|
|
212
|
+
flushEnd = pemStart;
|
|
213
|
+
}
|
|
214
|
+
if (flushEnd > 0) {
|
|
215
|
+
emit(redactSecrets(pending.slice(0, flushEnd)));
|
|
216
|
+
pending = pending.slice(flushEnd);
|
|
217
|
+
}
|
|
81
218
|
}
|
|
219
|
+
armTimer();
|
|
82
220
|
},
|
|
83
221
|
flush: emitPending
|
|
84
222
|
};
|
|
@@ -49,7 +49,7 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
|
|
|
49
49
|
const covered = [];
|
|
50
50
|
const spinner = startPrepSpinner("DG preparing…");
|
|
51
51
|
try {
|
|
52
|
-
const response = await (options.analyze ?? analyzePackages)(specs.map((spec) => ({ name: spec.name, version: spec.version })), { ecosystem, env });
|
|
52
|
+
const response = await (options.analyze ?? analyzePackages)(specs.map((spec) => ({ name: spec.name, version: spec.version })), { ecosystem, env, publicFallback: true });
|
|
53
53
|
spinner.stop();
|
|
54
54
|
renderProvenanceDowngrades(response.packages, io.output);
|
|
55
55
|
for (const spec of specs) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { accessSync, constants, readFileSync } from "node:fs";
|
|
2
|
-
import { delimiter, join, resolve, sep } from "node:path";
|
|
2
|
+
import { delimiter, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
3
|
import { resolveDgPaths } from "../state/index.js";
|
|
4
4
|
// Many systems (homebrew/macOS, some Linux) ship only the version-suffixed
|
|
5
5
|
// pip3/python3, not a bare pip/python. Fall back to those instead of reporting
|
|
@@ -20,12 +20,17 @@ export function resolveRealBinary(options) {
|
|
|
20
20
|
for (const name of candidateNames) {
|
|
21
21
|
for (const rawDir of pathDirs) {
|
|
22
22
|
const dir = resolve(rawDir);
|
|
23
|
-
|
|
23
|
+
// A relative PATH entry would yield a cwd-relative binary path that the
|
|
24
|
+
// caller re-resolves against whatever cwd it later runs in — an attacker can
|
|
25
|
+
// plant a binary under that cwd. Skip it, and build the candidate from the
|
|
26
|
+
// resolved absolute dir so result.path is always absolute (the documented
|
|
27
|
+
// contract in util/external-tool.ts).
|
|
28
|
+
if (!isAbsolute(rawDir) || isSkippedDir(dir, skipDirs)) {
|
|
24
29
|
skipped.push(rawDir);
|
|
25
30
|
continue;
|
|
26
31
|
}
|
|
27
32
|
for (const extension of extensions) {
|
|
28
|
-
const candidate = join(
|
|
33
|
+
const candidate = join(dir, `${name}${extension}`);
|
|
29
34
|
searched.push(candidate);
|
|
30
35
|
if (isExecutable(candidate) && !isDgShim(candidate)) {
|
|
31
36
|
return {
|
package/dist/launcher/run.js
CHANGED
|
@@ -6,18 +6,22 @@ import { EXIT_UNAVAILABLE } from "../commands/types.js";
|
|
|
6
6
|
import { loadUserConfig, trustsProjectOverrides } from "../config/settings.js";
|
|
7
7
|
import { findProjectRoot, loadDgFile } from "../project/dgfile.js";
|
|
8
8
|
import { honoredOverrides } from "../project/override-trust.js";
|
|
9
|
-
import { writeCooldownExemptionsFile } from "../proxy/cooldown-exemptions-file.js";
|
|
9
|
+
import { COOLDOWN_EXEMPTIONS_ENV, writeCooldownExemptionsFile } from "../proxy/cooldown-exemptions-file.js";
|
|
10
10
|
import { writePreverifiedFile } from "../proxy/preverified.js";
|
|
11
11
|
import { describeBlockedInstall, describeFlaggedWarn, renderInstallDecision } from "../install-ui/block-render.js";
|
|
12
12
|
import { enforceProtectedInstall } from "../proxy/enforcement.js";
|
|
13
13
|
import { isCiEnv, resolvePresentation } from "../presentation/mode.js";
|
|
14
|
-
import { maybeSetupNudge } from "../runtime/nudges.js";
|
|
14
|
+
import { maybeSetupNudge, maybeUpdateNudge } from "../runtime/nudges.js";
|
|
15
|
+
import { authStatus } from "../auth/store.js";
|
|
15
16
|
import { createTheme } from "../presentation/theme.js";
|
|
16
17
|
import { readProxySessionState } from "../proxy/server.js";
|
|
17
18
|
import { readServiceState } from "../service/state.js";
|
|
18
19
|
import { cleanupSessionSync, createSessionSync, resolveDgPaths } from "../state/index.js";
|
|
19
20
|
import { classifyPackageManagerInvocation } from "./classify.js";
|
|
20
|
-
import { buildProxyChildEnv, scrubChildSecrets } from "./env.js";
|
|
21
|
+
import { buildProxyChildEnv, buildRegistryOriginEnv, scrubChildSecrets } from "./env.js";
|
|
22
|
+
import { isRegistryGateDisabled } from "../origin/gate-mode.js";
|
|
23
|
+
import { writeCargoOriginConfig } from "../origin/cargo-config.js";
|
|
24
|
+
import { resolveOriginPrecedence } from "../origin/precedence.js";
|
|
21
25
|
import { prepareCargoHome, userCargoHome } from "./cargo-cache.js";
|
|
22
26
|
import { cachedPipResolution } from "./install-preflight.js";
|
|
23
27
|
import { createStreamRedactor, redactSecrets } from "./output-redaction.js";
|
|
@@ -145,7 +149,11 @@ export async function runPackageManager(manager, args, options = {}) {
|
|
|
145
149
|
};
|
|
146
150
|
}
|
|
147
151
|
if (!options.proxyVerdict) {
|
|
148
|
-
|
|
152
|
+
const result = shouldUseRegistryOrigin(plan.classification, options.env ?? process.env)
|
|
153
|
+
? await runWithRegistryOrigin(plan, args, options)
|
|
154
|
+
: await runWithProductionProxy(plan, args, options);
|
|
155
|
+
meterInstallTree(plan, options, result.exitCode);
|
|
156
|
+
return result;
|
|
149
157
|
}
|
|
150
158
|
const decision = enforceProtectedInstall({
|
|
151
159
|
classification: plan.classification,
|
|
@@ -163,10 +171,11 @@ export async function runPackageManager(manager, args, options = {}) {
|
|
|
163
171
|
};
|
|
164
172
|
}
|
|
165
173
|
const child = await spawnPackageManager(plan, args, options);
|
|
174
|
+
meterInstallTree(plan, options, child.exitCode);
|
|
166
175
|
return {
|
|
167
176
|
exitCode: child.exitCode,
|
|
168
177
|
stdout: streamedOut(child.stdout, options),
|
|
169
|
-
stderr: `${rendered}${streamedErr(child.stderr, options)}${scriptGateLine(plan, child.exitCode, options)}${setupNudgeAfterInstall(plan, child.exitCode, options)}`
|
|
178
|
+
stderr: `${rendered}${streamedErr(child.stderr, options)}${scriptGateLine(plan, child.exitCode, options)}${setupNudgeAfterInstall(plan, child.exitCode, options)}${maybeUpdateNudge({ env: options.env ?? process.env })}`
|
|
170
179
|
};
|
|
171
180
|
}
|
|
172
181
|
const child = await spawnPackageManager(plan, args, options);
|
|
@@ -176,6 +185,36 @@ export async function runPackageManager(manager, args, options = {}) {
|
|
|
176
185
|
stderr: streamedErr(child.stderr, options)
|
|
177
186
|
};
|
|
178
187
|
}
|
|
188
|
+
// Meter the resolved dependency tree against the user's unified monthly quota
|
|
189
|
+
// without slowing the install: after a successful protected install, spawn
|
|
190
|
+
// `dg scan --install-origin` detached so the shell returns immediately. The
|
|
191
|
+
// server dedupes the tree per user per month (X-Install-Tree-Hash), so an
|
|
192
|
+
// unchanged tree bills once and reinstalls / ephemeral CI re-runs are free.
|
|
193
|
+
// Best-effort and silent — metering must never affect the install's outcome.
|
|
194
|
+
export function meterInstallTree(plan, options, exitCode) {
|
|
195
|
+
if (exitCode !== 0 || !plan.startsProxy)
|
|
196
|
+
return;
|
|
197
|
+
const env = options.env ?? process.env;
|
|
198
|
+
if (env.DG_NO_INSTALL_METERING === "1")
|
|
199
|
+
return;
|
|
200
|
+
if (!authStatus(env).authenticated)
|
|
201
|
+
return;
|
|
202
|
+
const entry = process.argv[1];
|
|
203
|
+
if (!entry)
|
|
204
|
+
return;
|
|
205
|
+
try {
|
|
206
|
+
const child = spawn(process.execPath, [entry, "scan", "--install-origin", process.cwd()], {
|
|
207
|
+
detached: true,
|
|
208
|
+
stdio: "ignore",
|
|
209
|
+
env: { ...env, DG_NO_INSTALL_METERING: "1" }
|
|
210
|
+
});
|
|
211
|
+
child.on("error", () => { });
|
|
212
|
+
child.unref();
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// best-effort; metering never blocks or fails an install
|
|
216
|
+
}
|
|
217
|
+
}
|
|
179
218
|
async function runWithProductionProxy(plan, args, options) {
|
|
180
219
|
const env = options.env ?? process.env;
|
|
181
220
|
const proxy = await startProxyWorker(plan.classification, env, options.forceOverride);
|
|
@@ -200,45 +239,170 @@ async function runWithProductionProxy(plan, args, options) {
|
|
|
200
239
|
})
|
|
201
240
|
};
|
|
202
241
|
const child = await spawnPackageManager(proxiedPlan, args, options);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
242
|
+
return finalizeGatedInstall(child, proxy.session, plan, options);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
restoreSignalHandlers();
|
|
246
|
+
stopProxyWorker(proxy);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// The decision-handling tail shared by the proxy and the registry-origin doors:
|
|
250
|
+
// both write the same ProxySessionState, so both render its decisions, fail
|
|
251
|
+
// closed on a block, and fail closed when nothing was verified (unless the
|
|
252
|
+
// manager errored for its own reasons first). One funnel so the two doors cannot
|
|
253
|
+
// drift in how a verdict becomes an exit code.
|
|
254
|
+
function finalizeGatedInstall(child, session, plan, options) {
|
|
255
|
+
const env = options.env ?? process.env;
|
|
256
|
+
const state = readProxySessionState(session);
|
|
257
|
+
const rendered = renderDecisions(state.decisions, env);
|
|
258
|
+
const blocked = state.decisions.find((decision) => decision.action === "block");
|
|
259
|
+
if (blocked) {
|
|
260
|
+
return {
|
|
261
|
+
exitCode: EXIT_INSTALL_BLOCKED,
|
|
262
|
+
stdout: streamedOut(child.stdout, options),
|
|
263
|
+
stderr: `${rendered}${streamedErr(child.stderr, options)}`
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (state.decisions.length === 0) {
|
|
267
|
+
if (child.exitCode !== 0) {
|
|
227
268
|
return {
|
|
228
|
-
exitCode:
|
|
269
|
+
exitCode: child.exitCode,
|
|
229
270
|
stdout: streamedOut(child.stdout, options),
|
|
230
|
-
stderr: `${streamedErr(child.stderr, options)}${
|
|
271
|
+
stderr: `${streamedErr(child.stderr, options)}\n ? dg did not check this install — ${plan.classification.manager} exited with an error before any package was fetched.\n`
|
|
231
272
|
};
|
|
232
273
|
}
|
|
233
274
|
return {
|
|
234
|
-
exitCode:
|
|
275
|
+
exitCode: EXIT_INSTALL_BLOCKED,
|
|
235
276
|
stdout: streamedOut(child.stdout, options),
|
|
236
|
-
stderr: `${
|
|
277
|
+
stderr: `${streamedErr(child.stderr, options)}${cacheOnlyNotice(installOutcome(child.stdout).length > 0)}`
|
|
237
278
|
};
|
|
238
279
|
}
|
|
280
|
+
const remainder = child.exitCode === 0 ? unverifiedRemainder(child.stdout, state.decisions.length) : 0;
|
|
281
|
+
if (remainder > 0) {
|
|
282
|
+
return {
|
|
283
|
+
exitCode: EXIT_INSTALL_BLOCKED,
|
|
284
|
+
stdout: streamedOut(child.stdout, options),
|
|
285
|
+
stderr: `${rendered}${streamedErr(child.stderr, options)}${partialCacheNotice(remainder)}`
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
exitCode: child.exitCode,
|
|
290
|
+
stdout: streamedOut(child.stdout, options),
|
|
291
|
+
stderr: `${rendered}${streamedErr(child.stderr, options)}${scriptGateLine(plan, child.exitCode, options)}${setupNudgeAfterInstall(plan, child.exitCode, options)}${maybeUpdateNudge({ env })}`
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
// Eligible for the registry-origin door: managers whose lockfiles stay portable
|
|
295
|
+
// when the origin gates artifacts directly (pnpm integrity-only, cargo
|
|
296
|
+
// source+cksum, pip/uv no lockfile). npm records the fetch URL in its lockfile,
|
|
297
|
+
// so it stays on the proxy until the canonical+proxy hybrid is wired; Yarn Berry
|
|
298
|
+
// ignores npm_config_registry. Opt-in via DG_REGISTRY_GATE; the default door is
|
|
299
|
+
// the proxy, so this changes nothing until a deployment turns it on.
|
|
300
|
+
const ORIGIN_ELIGIBLE_MANAGERS = new Set(["pnpm", "pnpx", "cargo", "pip", "pipx", "uv", "uvx"]);
|
|
301
|
+
// The worker process reads DG_FORCE_OVERRIDE_REQUEST to decide whether a block is
|
|
302
|
+
// silently downgraded to a warn. It must reflect ONLY this invocation's
|
|
303
|
+
// --dg-force-install, never a value inherited from an ancestor environment, so any
|
|
304
|
+
// inherited one is dropped before the per-invocation value (if any) is set.
|
|
305
|
+
export function workerSpawnEnv(env, extra, forceOverride) {
|
|
306
|
+
const spawnEnv = { ...env, ...extra };
|
|
307
|
+
delete spawnEnv.DG_FORCE_OVERRIDE_REQUEST;
|
|
308
|
+
if (forceOverride) {
|
|
309
|
+
spawnEnv.DG_FORCE_OVERRIDE_REQUEST = JSON.stringify(forceOverride);
|
|
310
|
+
}
|
|
311
|
+
// The cooldown-exemptions file path is written per-session into `extra`; an
|
|
312
|
+
// inherited value would let an ancestor env point the worker at an arbitrary
|
|
313
|
+
// exemptions file and waive the cooldown gate for any package. Honor only this
|
|
314
|
+
// invocation's value.
|
|
315
|
+
if (extra[COOLDOWN_EXEMPTIONS_ENV] === undefined) {
|
|
316
|
+
delete spawnEnv[COOLDOWN_EXEMPTIONS_ENV];
|
|
317
|
+
}
|
|
318
|
+
return spawnEnv;
|
|
319
|
+
}
|
|
320
|
+
export function shouldUseRegistryOrigin(classification, env, cwd = process.cwd()) {
|
|
321
|
+
if (isRegistryGateDisabled(env)) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
if (env.DG_REGISTRY_GATE !== "1" && env.DG_REGISTRY_GATE !== "true") {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
if (!ORIGIN_ELIGIBLE_MANAGERS.has(classification.manager)) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
// A higher-precedence override (a CLI --registry/--index-url, an npm
|
|
331
|
+
// @scope:registry) routes some or all fetches PAST the origin — the manager
|
|
332
|
+
// would install from it un-gated while the origin sees nothing. The proxy door
|
|
333
|
+
// intercepts at the transport layer and covers those, so fall back to it rather
|
|
334
|
+
// than leave an install partly un-gated.
|
|
335
|
+
return resolveOriginPrecedence(classification, env, cwd).overrides.length === 0;
|
|
336
|
+
}
|
|
337
|
+
async function runWithRegistryOrigin(plan, args, options) {
|
|
338
|
+
const env = options.env ?? process.env;
|
|
339
|
+
const origin = await startOriginWorker(plan.classification, env, options.forceOverride);
|
|
340
|
+
if ("decision" in origin) {
|
|
341
|
+
// The origin worker could not start. Rather than fail the install outright,
|
|
342
|
+
// fall back to the proxy door — it gates too, so the user still gets a
|
|
343
|
+
// screened install (never un-gated). The proxy fails closed on its own if it
|
|
344
|
+
// also cannot start.
|
|
345
|
+
return runWithProductionProxy(plan, args, options);
|
|
346
|
+
}
|
|
347
|
+
const restoreSignalHandlers = installProxySignalHandlers(origin);
|
|
348
|
+
try {
|
|
349
|
+
const manager = plan.classification.manager;
|
|
350
|
+
const cacheDir = `${origin.session.dir}/pm-cache`;
|
|
351
|
+
let originEnv;
|
|
352
|
+
if (manager === "cargo") {
|
|
353
|
+
writeCargoOriginConfig(cacheDir, origin.proxyUrl);
|
|
354
|
+
originEnv = buildRegistryOriginEnv({ manager, baseEnv: plan.childEnv, originUrl: origin.proxyUrl, cargoHome: cacheDir });
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
originEnv = buildRegistryOriginEnv({ manager, baseEnv: plan.childEnv, originUrl: origin.proxyUrl, cacheDir });
|
|
358
|
+
}
|
|
359
|
+
const originPlan = { ...plan, childEnv: originEnv };
|
|
360
|
+
const child = await spawnPackageManager(originPlan, args, options);
|
|
361
|
+
return finalizeGatedInstall(child, origin.session, plan, options);
|
|
362
|
+
}
|
|
239
363
|
finally {
|
|
240
364
|
restoreSignalHandlers();
|
|
241
|
-
stopProxyWorker(
|
|
365
|
+
stopProxyWorker(origin);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async function startOriginWorker(classification, env, forceOverride) {
|
|
369
|
+
const paths = resolveDgPaths(env);
|
|
370
|
+
const session = createSessionSync(paths);
|
|
371
|
+
const sessionBootstrapPath = `${session.dir}/session.json`;
|
|
372
|
+
const workerPath = fileURLToPath(new URL("../origin/worker.js", import.meta.url));
|
|
373
|
+
try {
|
|
374
|
+
writeFileSync(sessionBootstrapPath, `${JSON.stringify(session)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
375
|
+
if (!existsSync(workerPath)) {
|
|
376
|
+
throw new Error("registry origin worker is not built");
|
|
377
|
+
}
|
|
378
|
+
const config = loadUserConfig(env);
|
|
379
|
+
const exemptions = loadProjectCooldownExemptions(env);
|
|
380
|
+
maybeNoticeDgFileExemptions(exemptions);
|
|
381
|
+
const exemptionsEnv = writeCooldownExemptionsFile(session.dir, exemptions);
|
|
382
|
+
const child = spawn(process.execPath, [workerPath, sessionBootstrapPath, config.api.baseUrl], {
|
|
383
|
+
env: workerSpawnEnv(env, { DG_ORIGIN_CLASSIFICATION: JSON.stringify(classification), ...exemptionsEnv }, forceOverride),
|
|
384
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
385
|
+
});
|
|
386
|
+
const ready = await waitForProxyReady(session);
|
|
387
|
+
if (!ready.ready || ready.port <= 0) {
|
|
388
|
+
terminateProxyProcess(child);
|
|
389
|
+
throw new Error("registry origin did not become ready");
|
|
390
|
+
}
|
|
391
|
+
return { process: child, session, proxyUrl: `http://127.0.0.1:${ready.port}` };
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
cleanupSessionSync(session);
|
|
395
|
+
return {
|
|
396
|
+
decision: enforceProtectedInstall({
|
|
397
|
+
classification,
|
|
398
|
+
env,
|
|
399
|
+
proxyVerdict: {
|
|
400
|
+
verdict: "block",
|
|
401
|
+
cause: "proxy-setup-failure",
|
|
402
|
+
reason: error instanceof Error ? error.message : "registry origin startup failed"
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
};
|
|
242
406
|
}
|
|
243
407
|
}
|
|
244
408
|
function scriptGateLine(plan, exitCode, options) {
|
|
@@ -354,6 +518,10 @@ export async function runWithProductionProxyLive(plan, args, options, onView, pr
|
|
|
354
518
|
const outcome = installOutcome(finished.stdout);
|
|
355
519
|
return { exitCode: EXIT_INSTALL_BLOCKED, stdout: outcome, stderr: cacheOnlyNotice(outcome.length > 0) };
|
|
356
520
|
}
|
|
521
|
+
const remainder = finished.exitCode === 0 ? unverifiedRemainder(finished.stdout, proxyState.decisions.length) : 0;
|
|
522
|
+
if (remainder > 0) {
|
|
523
|
+
return { exitCode: EXIT_INSTALL_BLOCKED, stdout: installOutcome(finished.stdout), stderr: partialCacheNotice(remainder) };
|
|
524
|
+
}
|
|
357
525
|
return { exitCode: finished.exitCode, stdout: installOutcome(finished.stdout), stderr: `${scriptGateLine(plan, finished.exitCode, options)}${setupNudgeAfterInstall(plan, finished.exitCode, options)}` };
|
|
358
526
|
}
|
|
359
527
|
finally {
|
|
@@ -378,6 +546,40 @@ function installOutcome(stdout) {
|
|
|
378
546
|
/^(added|changed|removed|updated) \d/.test(line.trim()));
|
|
379
547
|
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
380
548
|
}
|
|
549
|
+
// Packages the manager reported landing on disk, parsed from the same outcome
|
|
550
|
+
// lines installOutcome trusts: npm's `added N packages` and pip's
|
|
551
|
+
// `Successfully installed <list>`. Each landed package from the registry is one
|
|
552
|
+
// artifact the proxy should have screened.
|
|
553
|
+
export function installedPackageCount(stdout) {
|
|
554
|
+
let count = 0;
|
|
555
|
+
for (const raw of stdout.split("\n")) {
|
|
556
|
+
const line = raw.trim();
|
|
557
|
+
const npm = /^added (\d+) packages?\b/.exec(line);
|
|
558
|
+
if (npm && npm[1]) {
|
|
559
|
+
count += Number.parseInt(npm[1], 10);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const pip = /^Successfully installed (.+)$/.exec(line);
|
|
563
|
+
if (pip && pip[1]) {
|
|
564
|
+
count += pip[1].trim().split(/\s+/).filter(Boolean).length;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return count;
|
|
568
|
+
}
|
|
569
|
+
// The all-cached path fails closed when packages land with zero proxy verdicts.
|
|
570
|
+
// A partial-cache install is the same hazard scaled down: more packages landed
|
|
571
|
+
// than the proxy screened, so the surplus was served from a cache the proxy never
|
|
572
|
+
// saw. Only the positive surplus counts; a larger verdict count (deduped fetches)
|
|
573
|
+
// is never treated as a remainder.
|
|
574
|
+
export function unverifiedRemainder(stdout, verifiedCount) {
|
|
575
|
+
const installed = installedPackageCount(stdout);
|
|
576
|
+
return installed > verifiedCount ? installed - verifiedCount : 0;
|
|
577
|
+
}
|
|
578
|
+
function partialCacheNotice(remainder) {
|
|
579
|
+
const theme = createTheme(resolvePresentation().color);
|
|
580
|
+
const noun = remainder === 1 ? "package was" : "packages were";
|
|
581
|
+
return `\n ${theme.paint("muted", `– ${remainder} ${noun} served from a local cache and not screened. Run dg scan to check them.`)}\n`;
|
|
582
|
+
}
|
|
381
583
|
// scriptGate.mode === "enforce" is applied HERE, at the spawn boundary: it
|
|
382
584
|
// rewrites the install args (--ignore-scripts) and child env
|
|
383
585
|
// (npm_config_ignore_scripts) for npm/yarn so a reputation-clean package can't
|
|
@@ -525,12 +727,7 @@ async function startProxyWorker(classification, env, forceOverride) {
|
|
|
525
727
|
maybeNoticeDgFileExemptions(exemptions);
|
|
526
728
|
const exemptionsEnv = writeCooldownExemptionsFile(session.dir, exemptions);
|
|
527
729
|
const child = spawn(process.execPath, [workerPath, sessionBootstrapPath, config.api.baseUrl], {
|
|
528
|
-
env: {
|
|
529
|
-
...env,
|
|
530
|
-
DG_PROXY_CLASSIFICATION: JSON.stringify(classification),
|
|
531
|
-
...(forceOverride ? { DG_FORCE_OVERRIDE_REQUEST: JSON.stringify(forceOverride) } : {}),
|
|
532
|
-
...exemptionsEnv
|
|
533
|
-
},
|
|
730
|
+
env: workerSpawnEnv(env, { DG_PROXY_CLASSIFICATION: JSON.stringify(classification), ...exemptionsEnv }, forceOverride),
|
|
534
731
|
stdio: ["pipe", "pipe", "pipe"]
|
|
535
732
|
});
|
|
536
733
|
const ready = await waitForProxyReady(session);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
// A warm cache that stays GATED: each blob is content-addressed by its sha256
|
|
5
|
+
// and only stored after an allow verdict, so the hash IS the proof it was
|
|
6
|
+
// screened. A URL index maps a repeat fetch to its already-allowed blob, served
|
|
7
|
+
// with no re-download and no re-scan — strictly safer than a plain cache-bust
|
|
8
|
+
// because a hit is provably the exact bytes that passed.
|
|
9
|
+
export function lookupCachedArtifact(cacheDir, url) {
|
|
10
|
+
try {
|
|
11
|
+
const indexPath = join(cacheDir, "index", urlKey(url));
|
|
12
|
+
if (!existsSync(indexPath)) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const sha256 = readFileSync(indexPath, "utf8").trim();
|
|
16
|
+
// The index content becomes a blob filename, so it must be exactly a hex
|
|
17
|
+
// digest — never a path-traversal — even though the index is dg-owned.
|
|
18
|
+
if (!/^[0-9a-f]{64}$/.test(sha256)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const blobPath = join(cacheDir, "blobs", sha256);
|
|
22
|
+
if (!existsSync(blobPath)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const bytes = readFileSync(blobPath);
|
|
26
|
+
if (createHash("sha256").update(bytes).digest("hex") !== sha256) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return { bytes, sha256 };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function storeCachedArtifact(cacheDir, url, bytes, sha256) {
|
|
36
|
+
try {
|
|
37
|
+
const blobDir = join(cacheDir, "blobs");
|
|
38
|
+
const indexDir = join(cacheDir, "index");
|
|
39
|
+
mkdirSync(blobDir, { recursive: true, mode: 0o700 });
|
|
40
|
+
mkdirSync(indexDir, { recursive: true, mode: 0o700 });
|
|
41
|
+
atomicWrite(join(blobDir, sha256), bytes);
|
|
42
|
+
atomicWrite(join(indexDir, urlKey(url)), Buffer.from(sha256));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// best-effort: a cache write failure must never break an install.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function urlKey(url) {
|
|
49
|
+
return createHash("sha256").update(url).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
function atomicWrite(path, data) {
|
|
52
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
53
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
54
|
+
renameSync(tmp, path);
|
|
55
|
+
}
|