@westbayberry/dg 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -226
- package/dist/agents/claude-code.js +113 -0
- package/dist/agents/codex.js +65 -0
- package/dist/agents/copilot-cli.js +115 -0
- package/dist/agents/cursor.js +113 -0
- package/dist/agents/gemini.js +107 -0
- package/dist/agents/persistence.js +285 -0
- package/dist/agents/registry.js +127 -0
- package/dist/agents/types.js +1 -0
- package/dist/agents/windsurf.js +93 -0
- package/dist/api/analyze.js +1 -1
- package/dist/audit/detectors.js +0 -11
- package/dist/audit/events.js +5 -21
- package/dist/audit-ui/AuditApp.js +2 -0
- package/dist/audit-ui/components/AuditResultsView.js +55 -92
- package/dist/audit-ui/export.js +0 -4
- package/dist/audit-ui/format.js +0 -3
- package/dist/audit-ui/launch.js +10 -1
- package/dist/auth/device-login.js +4 -5
- package/dist/auth/login-app.js +7 -7
- package/dist/auth/store.js +8 -3
- package/dist/bin/dg.js +59 -52
- package/dist/commands/agents.js +231 -0
- package/dist/commands/audit.js +22 -3
- package/dist/commands/config.js +15 -7
- package/dist/commands/cooldown.js +389 -0
- package/dist/commands/decisions.js +23 -9
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/licenses.js +10 -22
- package/dist/commands/logout.js +4 -11
- package/dist/commands/router.js +6 -4
- package/dist/commands/sbom.js +206 -0
- package/dist/commands/scan.js +1 -1
- package/dist/commands/service.js +32 -13
- package/dist/commands/setup.js +197 -27
- package/dist/commands/types.js +1 -0
- package/dist/commands/update.js +17 -8
- package/dist/commands/verify.js +8 -5
- package/dist/config/settings.js +14 -44
- package/dist/decisions/remember-prompt.js +5 -10
- package/dist/export-ui/ExportDialog.js +198 -0
- package/dist/install-ui/LiveInstall.js +2 -2
- package/dist/install-ui/block-render.js +1 -1
- package/dist/install-ui/prep-spinner.js +32 -0
- package/dist/launcher/agent-check.js +466 -0
- package/dist/launcher/agent-hook-exec.js +70 -0
- package/dist/launcher/agent-hook-io.js +31 -0
- package/dist/launcher/cargo-cache.js +40 -0
- package/dist/launcher/classify.js +17 -6
- package/dist/launcher/env.js +71 -24
- package/dist/launcher/install-preflight.js +51 -14
- package/dist/launcher/live-install.js +25 -5
- package/dist/launcher/output-redaction.js +7 -4
- package/dist/launcher/preflight-prompt.js +14 -1
- package/dist/launcher/run.js +89 -86
- package/dist/launcher/spawn-invocation.js +21 -0
- package/dist/policy/cooldown.js +18 -5
- package/dist/policy/evaluate.js +5 -6
- package/dist/policy/pypi-name.js +17 -0
- package/dist/presentation/mode.js +3 -2
- package/dist/presentation/package-page.js +9 -0
- package/dist/presentation/theme.js +7 -7
- package/dist/project/dgfile.js +145 -6
- package/dist/proxy/auth.js +42 -0
- package/dist/proxy/ca.js +29 -9
- package/dist/proxy/cooldown-exemptions-file.js +33 -0
- package/dist/proxy/enforcement.js +55 -16
- package/dist/proxy/metadata-map.js +41 -3
- package/dist/proxy/preverified.js +55 -0
- package/dist/proxy/server.js +448 -49
- package/dist/proxy/worker.js +16 -1
- package/dist/publish-set/collect.js +1 -4
- package/dist/publish-set/npm.js +8 -5
- package/dist/publish-set/pack.js +9 -3
- package/dist/runtime/cli.js +0 -4
- package/dist/runtime/fatal.js +31 -0
- package/dist/runtime/first-run.js +12 -11
- package/dist/runtime/node-version.js +43 -6
- package/dist/runtime/nudges.js +35 -2
- package/dist/sbom/cyclonedx.js +211 -0
- package/dist/sbom-ui/SbomApp.js +158 -0
- package/dist/sbom-ui/components/SbomHeader.js +32 -0
- package/dist/sbom-ui/components/SbomList.js +52 -0
- package/dist/sbom-ui/inventory.js +128 -0
- package/dist/sbom-ui/launch.js +51 -0
- package/dist/sbom-ui/run.js +55 -0
- package/dist/sbom-ui/store.js +26 -0
- package/dist/scan/collect.js +10 -6
- package/dist/scan/command.js +17 -10
- package/dist/scan/discovery.js +11 -2
- package/dist/scan/render.js +28 -4
- package/dist/scan/scanner-report.js +15 -9
- package/dist/scan/staged.js +2 -1
- package/dist/scan-ui/LegacyApp.js +9 -13
- package/dist/scan-ui/alt-screen.js +5 -8
- package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
- package/dist/scan-ui/components/ProgressBar.js +3 -14
- package/dist/scan-ui/components/ProjectSelector.js +1 -1
- package/dist/scan-ui/components/ScoreHeader.js +2 -3
- package/dist/scan-ui/components/SetupBanner.js +0 -6
- package/dist/scan-ui/format-helpers.js +61 -5
- package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
- package/dist/scan-ui/hooks/useScan.js +16 -2
- package/dist/scan-ui/launch.js +7 -4
- package/dist/scan-ui/shims.js +11 -4
- package/dist/scripts/detect.js +7 -2
- package/dist/scripts/gate.js +1 -1
- package/dist/service/state.js +27 -8
- package/dist/service/trust-refresh.js +92 -0
- package/dist/service/worker.js +23 -1
- package/dist/setup/activate-shell.js +28 -0
- package/dist/setup/git-hook.js +49 -4
- package/dist/setup/plan.js +73 -39
- package/dist/setup-ui/gate.js +39 -0
- package/dist/setup-ui/offer.js +42 -0
- package/dist/setup-ui/selector.js +27 -0
- package/dist/setup-ui/tasks.js +56 -0
- package/dist/setup-ui/wizard.js +225 -0
- package/dist/state/cooldown-held.js +66 -0
- package/dist/state/index.js +1 -0
- package/dist/state/locks.js +4 -2
- package/dist/state/store.js +2 -1
- package/dist/util/external-tool.js +25 -0
- package/dist/util/git.js +10 -3
- package/dist/util/report-writer.js +57 -0
- package/dist/verify/local.js +240 -42
- package/dist/verify/package-check.js +84 -28
- package/dist/verify/preflight.js +242 -49
- package/dist/verify/render.js +15 -1
- package/npm-shrinkwrap.json +2383 -0
- package/package.json +6 -8
- package/NOTICE +0 -5
- package/dist/commands/completion.js +0 -117
- package/dist/commands/explain.js +0 -236
- package/dist/commands/unavailable.js +0 -11
- package/dist/scripts/rebuild.js +0 -28
- package/dist/telemetry/events.js +0 -40
package/dist/proxy/server.js
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
-
import { BufferBudgetError, collectBounded } from "./buffer-budget.js";
|
|
3
|
+
import { BufferBudgetError, collectBounded, maxArtifactBytes } from "./buffer-budget.js";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
|
-
import { createServer, request as httpRequest } from "node:http";
|
|
6
|
-
import { request as httpsRequest } from "node:https";
|
|
5
|
+
import { Agent as HttpAgent, createServer, request as httpRequest } from "node:http";
|
|
6
|
+
import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
|
|
7
7
|
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { dirname } from "node:path";
|
|
9
9
|
import { connect } from "node:net";
|
|
10
|
-
import { createSecureContext, createServer as createTlsServer, connect as tlsConnect } from "node:tls";
|
|
10
|
+
import { createSecureContext, createServer as createTlsServer, connect as tlsConnect, rootCertificates } from "node:tls";
|
|
11
11
|
import { createEphemeralCertificateAuthority } from "./ca.js";
|
|
12
12
|
import { shouldMitmHost } from "./classify-host.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { generateProxyAuthToken, proxyAuthorizationValue, verifyProxyAuthorization, writeProxyAuthToken } from "./auth.js";
|
|
14
|
+
import { enforceProtectedInstall, noteEnforcementSideEffectFailure } from "./enforcement.js";
|
|
15
|
+
import { cooldownRequestParam, isCooldownExemptByDgFile } from "../policy/cooldown.js";
|
|
15
16
|
import { loadUserConfig } from "../config/settings.js";
|
|
16
17
|
import { artifactDisplayName, artifactUrlHash, extractRegistryMetadataIdentities, isRegistryIndexRequest, resolveArtifactIdentity } from "./metadata-map.js";
|
|
18
|
+
import { loadPreverifiedMap, preverifiedKey } from "./preverified.js";
|
|
17
19
|
import { authorityFor, connectViaUpstreamProxy, selectUpstreamProxy } from "./upstream-proxy.js";
|
|
18
20
|
import { redactSecrets } from "../launcher/output-redaction.js";
|
|
21
|
+
import { sanitize } from "../security/sanitize.js";
|
|
19
22
|
import { envAuthToken } from "../auth/env-token.js";
|
|
20
23
|
import { identityHeaders } from "../api/analyze.js";
|
|
21
24
|
import { dgVersion } from "../commands/version.js";
|
|
25
|
+
import { recordHeldPackage } from "../state/index.js";
|
|
22
26
|
export async function startProductionHttpProxy(options) {
|
|
23
|
-
const
|
|
27
|
+
const onCaRotate = options.onCaRotate;
|
|
28
|
+
const ca = createEphemeralCertificateAuthority(options.session.files.ca, onCaRotate ? { onRotate: (caCertPem) => safeCaRotateCallback(onCaRotate, caCertPem) } : {});
|
|
29
|
+
const authToken = generateProxyAuthToken();
|
|
30
|
+
writeProxyAuthToken(options.session.dir, authToken);
|
|
24
31
|
const state = {
|
|
25
32
|
ready: false,
|
|
26
33
|
port: 0,
|
|
@@ -32,6 +39,10 @@ export async function startProductionHttpProxy(options) {
|
|
|
32
39
|
};
|
|
33
40
|
const activeSockets = new Set();
|
|
34
41
|
const server = createServer((request, response) => {
|
|
42
|
+
if (!verifyProxyAuthorization(headerValue(request.headers["proxy-authorization"]), authToken)) {
|
|
43
|
+
sendProxyAuthRequired(response);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
35
46
|
handleProxyRequest(request, response, options, state).catch((error) => {
|
|
36
47
|
const decision = recordDecision(options, state, {
|
|
37
48
|
verdict: "block",
|
|
@@ -45,6 +56,10 @@ export async function startProductionHttpProxy(options) {
|
|
|
45
56
|
server.on("connection", (socket) => trackSocket(activeSockets, socket));
|
|
46
57
|
server.on("connect", (request, socket, head) => {
|
|
47
58
|
const clientSocket = socket;
|
|
59
|
+
if (!verifyProxyAuthorization(headerValue(request.headers["proxy-authorization"]), authToken)) {
|
|
60
|
+
clientSocket.end("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"dg\"\r\nConnection: close\r\n\r\n");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
48
63
|
handleConnectRequest(request, clientSocket, head, options, state, ca, activeSockets).catch((error) => {
|
|
49
64
|
const decision = recordDecision(options, state, {
|
|
50
65
|
verdict: "block",
|
|
@@ -65,9 +80,27 @@ export async function startProductionHttpProxy(options) {
|
|
|
65
80
|
writeProxyState(options.session, state);
|
|
66
81
|
return {
|
|
67
82
|
port: address.port,
|
|
83
|
+
proxyAuthorization: proxyAuthorizationValue(authToken),
|
|
68
84
|
close: () => closeServer(server, activeSockets)
|
|
69
85
|
};
|
|
70
86
|
}
|
|
87
|
+
function safeCaRotateCallback(onCaRotate, caCertPem) {
|
|
88
|
+
try {
|
|
89
|
+
onCaRotate(caCertPem);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
noteEnforcementSideEffectFailure(error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function sendProxyAuthRequired(response) {
|
|
96
|
+
const body = "Dependency Guardian proxy requires the per-session credential issued to the wrapped package manager.\n";
|
|
97
|
+
response.writeHead(407, {
|
|
98
|
+
"Proxy-Authenticate": "Basic realm=\"dg\"",
|
|
99
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
100
|
+
"Content-Length": Buffer.byteLength(body)
|
|
101
|
+
});
|
|
102
|
+
response.end(body);
|
|
103
|
+
}
|
|
71
104
|
export function readProxySessionState(session) {
|
|
72
105
|
try {
|
|
73
106
|
const parsed = JSON.parse(readFileSync(session.files.proxy, "utf8"));
|
|
@@ -136,6 +169,16 @@ async function handleConnectRequest(request, clientSocket, head, options, state,
|
|
|
136
169
|
clientSocket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
137
170
|
return;
|
|
138
171
|
}
|
|
172
|
+
if (isLinkLocalHost(target)) {
|
|
173
|
+
recordDecision(options, state, {
|
|
174
|
+
verdict: "block",
|
|
175
|
+
packageName: target.hostname,
|
|
176
|
+
cause: "policy",
|
|
177
|
+
reason: `refusing CONNECT to link-local/metadata address ${target.hostname}`
|
|
178
|
+
});
|
|
179
|
+
clientSocket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
139
182
|
if (!shouldMitmHost(target.hostname, options.env)) {
|
|
140
183
|
state.events = [...state.events, `tunnel:${redactSecrets(authorityFor(target))}`];
|
|
141
184
|
writeProxyState(options.session, state);
|
|
@@ -147,22 +190,248 @@ async function handleConnectRequest(request, clientSocket, head, options, state,
|
|
|
147
190
|
await mitmTunnel(clientSocket, head, target, options, state, ca, activeSockets);
|
|
148
191
|
}
|
|
149
192
|
const REDIRECT_LIMIT = 5;
|
|
193
|
+
// Streaming tail-hold: an OPT-IN fast path for artifacts so large their raw
|
|
194
|
+
// download alone would outlast the client read timeout. It streams bytes while
|
|
195
|
+
// hashing and withholds only the final TAIL bytes until the verdict arrives —
|
|
196
|
+
// so on a block the client receives most of the (unverified) artifact and is
|
|
197
|
+
// relied on to reject the truncated body via its own size/integrity checks.
|
|
198
|
+
// That is a weaker guarantee than the default buffered path, which delivers
|
|
199
|
+
// ZERO bytes until a pass verdict. It is therefore DISABLED by default; the
|
|
200
|
+
// preferred fix for a slow verify is a generous client read timeout (set in
|
|
201
|
+
// buildProxyChildEnv), which keeps the strong buffered guarantee. Enable only
|
|
202
|
+
// for genuinely huge artifacts via DG_STREAM_THRESHOLD_BYTES=<bytes>.
|
|
203
|
+
const STREAM_TAIL_BYTES = 64 * 1024;
|
|
204
|
+
const STREAMING_DISABLED = Number.POSITIVE_INFINITY;
|
|
205
|
+
// A package install fetches dozens-to-hundreds of artifacts from the same few
|
|
206
|
+
// registry hosts. Without connection reuse each is a fresh TCP+TLS handshake
|
|
207
|
+
// (~100-250ms to registry.npmjs.org); keep-alive agents pool sockets per host
|
|
208
|
+
// so an N-tarball install pays one handshake, not N. Reuse is keyed by the full
|
|
209
|
+
// connection option-set (host, port, TLS params), so this never crosses hosts
|
|
210
|
+
// or mixes TLS trust. Purely transport-level — no effect on what gets verified.
|
|
211
|
+
const upstreamHttpAgent = new HttpAgent({ keepAlive: true, maxSockets: 64, scheduling: "fifo" });
|
|
212
|
+
const upstreamHttpsAgent = new HttpsAgent({ keepAlive: true, maxSockets: 64, scheduling: "fifo" });
|
|
213
|
+
function streamThresholdBytes(env) {
|
|
214
|
+
const raw = env.DG_STREAM_THRESHOLD_BYTES;
|
|
215
|
+
if (raw === undefined || raw === "") {
|
|
216
|
+
return STREAMING_DISABLED;
|
|
217
|
+
}
|
|
218
|
+
const parsed = Number(raw);
|
|
219
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : STREAMING_DISABLED;
|
|
220
|
+
}
|
|
221
|
+
function canStreamTarget(target, options) {
|
|
222
|
+
if (selectUpstreamProxy(target, options.env)) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
if (isRegistryIndexRequest(target)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
return !hostMatchesList(target.hostname, options.env.DG_PRIVATE_REGISTRY_HOSTS ?? "");
|
|
229
|
+
}
|
|
230
|
+
function shouldStreamArtifactResponse(live, env) {
|
|
231
|
+
if (live.statusCode < 200 || live.statusCode >= 300 || live.statusCode === 206) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
const contentType = headerValue(live.headers["content-type"]) ?? "";
|
|
235
|
+
if (/\bjson\b/i.test(contentType)) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
const contentLength = Number(headerValue(live.headers["content-length"]) ?? "");
|
|
239
|
+
return Number.isFinite(contentLength) && contentLength > streamThresholdBytes(env);
|
|
240
|
+
}
|
|
241
|
+
async function fetchUpstreamHeadersFollowingRedirects(request, target, env) {
|
|
242
|
+
let current = target;
|
|
243
|
+
let live = await fetchUpstreamHeaders(request, current, env);
|
|
244
|
+
for (let hop = 0; isRedirectStatus(live.statusCode); hop += 1) {
|
|
245
|
+
live.stream.resume();
|
|
246
|
+
if (hop >= REDIRECT_LIMIT) {
|
|
247
|
+
throw new Error(`registry redirect chain exceeded ${REDIRECT_LIMIT} hops for ${current.host}`);
|
|
248
|
+
}
|
|
249
|
+
const location = headerValue(live.headers.location);
|
|
250
|
+
if (!location) {
|
|
251
|
+
throw new Error(`registry returned a ${live.statusCode} redirect with no Location header for ${current.host}`);
|
|
252
|
+
}
|
|
253
|
+
const next = new URL(location, current);
|
|
254
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
255
|
+
throw new Error(`registry redirected to unsupported protocol ${next.protocol}`);
|
|
256
|
+
}
|
|
257
|
+
if (isPrivateNetworkHost(next) && !isPrivateNetworkHost(target)) {
|
|
258
|
+
throw new Error(`registry redirected a public artifact request into a private address (${next.hostname})`);
|
|
259
|
+
}
|
|
260
|
+
live = await fetchUpstreamHeaders(syntheticGetRequest(redirectHopHeaders(request, current, next)), next, env);
|
|
261
|
+
current = next;
|
|
262
|
+
}
|
|
263
|
+
return live;
|
|
264
|
+
}
|
|
265
|
+
function fetchUpstreamHeaders(request, target, env) {
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
const isHttps = target.protocol === "https:";
|
|
268
|
+
const requester = isHttps ? httpsRequest : httpRequest;
|
|
269
|
+
let liveStream = null;
|
|
270
|
+
const upstream = requester({
|
|
271
|
+
hostname: upstreamHostname(target, env),
|
|
272
|
+
port: target.port ? Number(target.port) : isHttps ? 443 : 80,
|
|
273
|
+
path: `${target.pathname}${target.search}`,
|
|
274
|
+
method: request.method,
|
|
275
|
+
agent: isHttps ? upstreamHttpsAgent : upstreamHttpAgent,
|
|
276
|
+
headers: isHttps ? upstreamRequestHeaders(request, target) : stripProxyHopHeaders(request.headers),
|
|
277
|
+
...(isHttps ? upstreamTlsOptions(env) : {})
|
|
278
|
+
}, (upstreamResponse) => {
|
|
279
|
+
liveStream = upstreamResponse;
|
|
280
|
+
resolve({
|
|
281
|
+
statusCode: upstreamResponse.statusCode ?? 502,
|
|
282
|
+
headers: responseHeaders(upstreamResponse),
|
|
283
|
+
stream: upstreamResponse
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
applyUpstreamTimeBudget(upstream, target, env, (error) => {
|
|
287
|
+
liveStream?.destroy(error);
|
|
288
|
+
reject(error);
|
|
289
|
+
});
|
|
290
|
+
upstream.on("error", reject);
|
|
291
|
+
request.on("data", (chunk) => upstream.write(chunk));
|
|
292
|
+
request.on("end", () => upstream.end());
|
|
293
|
+
request.on("error", reject);
|
|
294
|
+
request.once("close", () => {
|
|
295
|
+
if (!request.complete) {
|
|
296
|
+
upstream.destroy();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
async function streamArtifactWithTailHold(response, target, options, state, live) {
|
|
302
|
+
const identityResolution = resolveArtifactIdentity(target, state.identities, options.classification);
|
|
303
|
+
if (identityResolution.kind === "ambiguous") {
|
|
304
|
+
live.stream.destroy();
|
|
305
|
+
const decision = recordDecision(options, state, {
|
|
306
|
+
verdict: "block",
|
|
307
|
+
packageName: identityResolution.packageName,
|
|
308
|
+
cause: "policy",
|
|
309
|
+
reason: identityResolution.reason
|
|
310
|
+
});
|
|
311
|
+
sendBlocked(response, decision);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const identity = identityResolution.identity;
|
|
315
|
+
const inflightName = artifactDisplayName(identity);
|
|
316
|
+
state.inflight = [...state.inflight, inflightName];
|
|
317
|
+
writeProxyState(options.session, state);
|
|
318
|
+
const limit = maxArtifactBytes(options.env);
|
|
319
|
+
const hash = createHash("sha256");
|
|
320
|
+
const tail = [];
|
|
321
|
+
let tailSize = 0;
|
|
322
|
+
let total = 0;
|
|
323
|
+
response.writeHead(live.statusCode, live.headers);
|
|
324
|
+
response.once("close", () => {
|
|
325
|
+
if (!response.writableEnded) {
|
|
326
|
+
live.stream.destroy();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
const finishInflight = () => {
|
|
330
|
+
state.inflight = removeFirst(state.inflight, inflightName);
|
|
331
|
+
};
|
|
332
|
+
try {
|
|
333
|
+
await new Promise((resolve, reject) => {
|
|
334
|
+
live.stream.on("data", (chunk) => {
|
|
335
|
+
total += chunk.length;
|
|
336
|
+
if (total > limit) {
|
|
337
|
+
reject(new BufferBudgetError(`artifact exceeded the ${limit}-byte streaming budget for ${target.host}`));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
hash.update(chunk);
|
|
341
|
+
tail.push(chunk);
|
|
342
|
+
tailSize += chunk.length;
|
|
343
|
+
while (tail.length > 1 && tailSize - (tail[0]?.length ?? 0) >= STREAM_TAIL_BYTES) {
|
|
344
|
+
const released = tail.shift();
|
|
345
|
+
if (released) {
|
|
346
|
+
tailSize -= released.length;
|
|
347
|
+
response.write(released);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
live.stream.once("end", () => resolve());
|
|
352
|
+
live.stream.once("error", reject);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
finishInflight();
|
|
357
|
+
live.stream.destroy();
|
|
358
|
+
recordDecision(options, state, {
|
|
359
|
+
verdict: "block",
|
|
360
|
+
packageName: artifactDisplayName(identity),
|
|
361
|
+
cause: error instanceof BufferBudgetError ? "proxy-setup-failure" : "registry-timeout",
|
|
362
|
+
reason: error instanceof Error ? error.message : "registry stream failed"
|
|
363
|
+
});
|
|
364
|
+
response.destroy();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const sha256 = hash.digest("hex");
|
|
368
|
+
state.hashes = [...state.hashes, {
|
|
369
|
+
url: redactSecrets(target.toString()),
|
|
370
|
+
sha256,
|
|
371
|
+
identity
|
|
372
|
+
}];
|
|
373
|
+
writeProxyState(options.session, state);
|
|
374
|
+
const verdict = await lookupVerdict(options, target, sha256, { statusCode: live.statusCode, headers: live.headers }, identity).catch((error) => ({
|
|
375
|
+
verdict: "block",
|
|
376
|
+
packageName: artifactDisplayName(identity),
|
|
377
|
+
cause: "api-timeout",
|
|
378
|
+
reason: error instanceof Error ? error.message : "Dependency Guardian API verdict lookup failed"
|
|
379
|
+
}));
|
|
380
|
+
finishInflight();
|
|
381
|
+
const decision = recordDecision(options, state, verdict);
|
|
382
|
+
if (decision.action === "block") {
|
|
383
|
+
response.destroy();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
for (const chunk of tail) {
|
|
387
|
+
response.write(chunk);
|
|
388
|
+
}
|
|
389
|
+
response.end();
|
|
390
|
+
}
|
|
150
391
|
function isRedirectStatus(statusCode) {
|
|
151
392
|
return [301, 302, 303, 307, 308].includes(statusCode);
|
|
152
393
|
}
|
|
153
|
-
|
|
154
|
-
|
|
394
|
+
// Node's WHATWG URL parser canonicalizes an IPv4-mapped IPv6 literal to the hex
|
|
395
|
+
// hextet form ([::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe]), so a guard that
|
|
396
|
+
// only string-matches the dotted-decimal form silently lets the cloud-metadata
|
|
397
|
+
// IP through as "public". Recover the embedded IPv4 (both forms) before
|
|
398
|
+
// classifying so the IPv4 ranges below cover the mapped address too.
|
|
399
|
+
function recoverIpv4MappedHost(host) {
|
|
400
|
+
const dotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
|
|
401
|
+
if (dotted?.[1]) {
|
|
402
|
+
return dotted[1];
|
|
403
|
+
}
|
|
404
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
405
|
+
if (hex?.[1] && hex?.[2]) {
|
|
406
|
+
const hi = parseInt(hex[1], 16);
|
|
407
|
+
const lo = parseInt(hex[2], 16);
|
|
408
|
+
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
409
|
+
}
|
|
410
|
+
return host;
|
|
411
|
+
}
|
|
412
|
+
function isPrivateIpv4(host) {
|
|
413
|
+
return (/^(127|10|0)\./.test(host) ||
|
|
414
|
+
/^192\.168\./.test(host) ||
|
|
415
|
+
/^169\.254\./.test(host) ||
|
|
416
|
+
/^172\.(1[6-9]|2[0-9]|3[01])\./.test(host) ||
|
|
417
|
+
/^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./.test(host));
|
|
418
|
+
}
|
|
419
|
+
export function isPrivateNetworkHost(url) {
|
|
420
|
+
const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
421
|
+
const host = recoverIpv4MappedHost(raw);
|
|
155
422
|
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1") {
|
|
156
423
|
return true;
|
|
157
424
|
}
|
|
158
|
-
if (
|
|
159
|
-
return true;
|
|
160
|
-
}
|
|
161
|
-
if (/^::ffff:(127|10|0)\./.test(host) || /^::ffff:192\.168\./.test(host) || /^::ffff:169\.254\./.test(host) || /^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./.test(host)) {
|
|
425
|
+
if (isPrivateIpv4(host)) {
|
|
162
426
|
return true;
|
|
163
427
|
}
|
|
164
428
|
return /^(fe80|f[cd][0-9a-f]{2}):/.test(host);
|
|
165
429
|
}
|
|
430
|
+
export function isLinkLocalHost(url) {
|
|
431
|
+
const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
432
|
+
const host = recoverIpv4MappedHost(raw);
|
|
433
|
+
return /^169\.254\./.test(host) || /^fe80:/.test(host);
|
|
434
|
+
}
|
|
166
435
|
function syntheticGetRequest(headers) {
|
|
167
436
|
const request = new EventEmitter();
|
|
168
437
|
request.method = "GET";
|
|
@@ -204,12 +473,40 @@ async function fetchUpstreamFollowingRedirects(request, target, env) {
|
|
|
204
473
|
}
|
|
205
474
|
const ARTIFACT_CONDITIONAL_HEADERS = ["range", "if-range", "if-none-match", "if-modified-since"];
|
|
206
475
|
async function handleArtifactRequest(request, response, target, options, state) {
|
|
476
|
+
if (isLinkLocalHost(target)) {
|
|
477
|
+
const decision = recordDecision(options, state, {
|
|
478
|
+
verdict: "block",
|
|
479
|
+
packageName: packageNameFromUrl(target),
|
|
480
|
+
cause: "policy",
|
|
481
|
+
reason: `refusing to fetch from link-local/metadata address ${target.hostname}`
|
|
482
|
+
});
|
|
483
|
+
sendBlocked(response, decision);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
207
486
|
if (!isRegistryIndexRequest(target)) {
|
|
208
487
|
for (const header of ARTIFACT_CONDITIONAL_HEADERS) {
|
|
209
488
|
delete request.headers[header];
|
|
210
489
|
}
|
|
211
490
|
}
|
|
212
|
-
|
|
491
|
+
let upstream;
|
|
492
|
+
try {
|
|
493
|
+
if (canStreamTarget(target, options)) {
|
|
494
|
+
const live = await fetchUpstreamHeadersFollowingRedirects(request, target, options.env);
|
|
495
|
+
if (shouldStreamArtifactResponse(live, options.env)) {
|
|
496
|
+
await streamArtifactWithTailHold(response, target, options, state, live);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
upstream = {
|
|
500
|
+
statusCode: live.statusCode,
|
|
501
|
+
headers: live.headers,
|
|
502
|
+
body: await collectBounded(live.stream, { label: target.toString() })
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
upstream = await fetchUpstreamFollowingRedirects(request, target, options.env);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
213
510
|
const message = error instanceof Error ? error.message : "registry request failed";
|
|
214
511
|
const decision = recordDecision(options, state, {
|
|
215
512
|
verdict: "block",
|
|
@@ -218,8 +515,8 @@ async function handleArtifactRequest(request, response, target, options, state)
|
|
|
218
515
|
reason: message
|
|
219
516
|
});
|
|
220
517
|
sendBlocked(response, decision);
|
|
221
|
-
return
|
|
222
|
-
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
223
520
|
if (!upstream) {
|
|
224
521
|
return;
|
|
225
522
|
}
|
|
@@ -289,7 +586,7 @@ async function handleArtifactRequest(request, response, target, options, state)
|
|
|
289
586
|
reason: error instanceof Error ? error.message : "Dependency Guardian API verdict lookup failed"
|
|
290
587
|
}));
|
|
291
588
|
state.inflight = removeFirst(state.inflight, inflightName);
|
|
292
|
-
const decision = recordDecision(options, state, verdict);
|
|
589
|
+
const decision = recordDecision(options, state, verdict, identity.identity);
|
|
293
590
|
if (decision.action === "block") {
|
|
294
591
|
sendBlocked(response, decision);
|
|
295
592
|
return;
|
|
@@ -388,6 +685,9 @@ function upstreamHostname(target, env = process.env) {
|
|
|
388
685
|
return testUpstreamHostMap(env).get(hostname) ?? hostname;
|
|
389
686
|
}
|
|
390
687
|
function testUpstreamHostMap(env) {
|
|
688
|
+
if (env.NODE_ENV !== "test") {
|
|
689
|
+
return new Map();
|
|
690
|
+
}
|
|
391
691
|
const entries = (env.DG_TEST_UPSTREAM_HOST_MAP ?? "")
|
|
392
692
|
.split(",")
|
|
393
693
|
.map((entry) => entry.trim())
|
|
@@ -405,7 +705,7 @@ function testUpstreamHostMap(env) {
|
|
|
405
705
|
function fetchUpstream(request, target, env) {
|
|
406
706
|
const upstreamProxy = selectUpstreamProxy(target, env);
|
|
407
707
|
if (upstreamProxy && target.protocol === "http:") {
|
|
408
|
-
return fetchHttpViaProxy(request, target, upstreamProxy);
|
|
708
|
+
return fetchHttpViaProxy(request, target, upstreamProxy, env);
|
|
409
709
|
}
|
|
410
710
|
if (upstreamProxy && target.protocol === "https:") {
|
|
411
711
|
return fetchHttpsViaProxy(request, target, upstreamProxy, env);
|
|
@@ -419,7 +719,8 @@ function fetchUpstream(request, target, env) {
|
|
|
419
719
|
port: target.port ? Number(target.port) : 80,
|
|
420
720
|
path: `${target.pathname}${target.search}`,
|
|
421
721
|
method: request.method,
|
|
422
|
-
|
|
722
|
+
agent: upstreamHttpAgent,
|
|
723
|
+
headers: stripProxyHopHeaders(request.headers)
|
|
423
724
|
}, (upstreamResponse) => {
|
|
424
725
|
collectBounded(upstreamResponse, { label: target.toString() })
|
|
425
726
|
.then((body) => resolve({
|
|
@@ -429,6 +730,7 @@ function fetchUpstream(request, target, env) {
|
|
|
429
730
|
}))
|
|
430
731
|
.catch(reject);
|
|
431
732
|
});
|
|
733
|
+
applyUpstreamTimeBudget(upstream, target, env, reject);
|
|
432
734
|
upstream.on("error", reject);
|
|
433
735
|
request.on("data", (chunk) => upstream.write(chunk));
|
|
434
736
|
request.on("end", () => upstream.end());
|
|
@@ -440,10 +742,10 @@ function fetchUpstream(request, target, env) {
|
|
|
440
742
|
});
|
|
441
743
|
});
|
|
442
744
|
}
|
|
443
|
-
function fetchHttpViaProxy(request, target, upstreamProxy) {
|
|
745
|
+
function fetchHttpViaProxy(request, target, upstreamProxy, env) {
|
|
444
746
|
return new Promise((resolve, reject) => {
|
|
445
747
|
const headers = {
|
|
446
|
-
...request.headers,
|
|
748
|
+
...stripProxyHopHeaders(request.headers),
|
|
447
749
|
...(upstreamProxy.authorizationHeader ? { "Proxy-Authorization": upstreamProxy.authorizationHeader } : {})
|
|
448
750
|
};
|
|
449
751
|
const upstream = httpRequest({
|
|
@@ -461,6 +763,7 @@ function fetchHttpViaProxy(request, target, upstreamProxy) {
|
|
|
461
763
|
}))
|
|
462
764
|
.catch(reject);
|
|
463
765
|
});
|
|
766
|
+
applyUpstreamTimeBudget(upstream, target, env, reject);
|
|
464
767
|
upstream.on("error", reject);
|
|
465
768
|
request.on("data", (chunk) => upstream.write(chunk));
|
|
466
769
|
request.on("end", () => upstream.end());
|
|
@@ -479,6 +782,7 @@ function fetchHttpsDirect(request, target, env) {
|
|
|
479
782
|
port: target.port ? Number(target.port) : 443,
|
|
480
783
|
path: `${target.pathname}${target.search}`,
|
|
481
784
|
method: request.method,
|
|
785
|
+
agent: upstreamHttpsAgent,
|
|
482
786
|
headers: upstreamRequestHeaders(request, target),
|
|
483
787
|
...upstreamTlsOptions(env)
|
|
484
788
|
}, (upstreamResponse) => {
|
|
@@ -490,6 +794,7 @@ function fetchHttpsDirect(request, target, env) {
|
|
|
490
794
|
}))
|
|
491
795
|
.catch(reject);
|
|
492
796
|
});
|
|
797
|
+
applyUpstreamTimeBudget(upstream, target, env, reject);
|
|
493
798
|
upstream.on("error", reject);
|
|
494
799
|
request.on("data", (chunk) => upstream.write(chunk));
|
|
495
800
|
request.on("end", () => upstream.end());
|
|
@@ -516,9 +821,35 @@ function connectTlsOverSocket(socket, target, env) {
|
|
|
516
821
|
ALPNProtocols: ["http/1.1"],
|
|
517
822
|
...upstreamTlsOptions(env)
|
|
518
823
|
}, () => resolve(tlsSocket));
|
|
824
|
+
applyUpstreamTimeBudget(tlsSocket, target, env);
|
|
519
825
|
tlsSocket.once("error", reject);
|
|
520
826
|
});
|
|
521
827
|
}
|
|
828
|
+
function upstreamIdleTimeoutMs(env) {
|
|
829
|
+
return parsePositiveInteger(env.DG_UPSTREAM_IDLE_TIMEOUT_MS, 30_000);
|
|
830
|
+
}
|
|
831
|
+
function upstreamTotalTimeoutMs(env) {
|
|
832
|
+
return parsePositiveInteger(env.DG_UPSTREAM_TOTAL_TIMEOUT_MS, 600_000);
|
|
833
|
+
}
|
|
834
|
+
function applyUpstreamTimeBudget(upstream, target, env, onTimeout) {
|
|
835
|
+
const idleMs = upstreamIdleTimeoutMs(env);
|
|
836
|
+
const totalMs = upstreamTotalTimeoutMs(env);
|
|
837
|
+
const expire = (detail) => {
|
|
838
|
+
const error = new Error(`upstream registry request for ${target.host} timed out: ${detail}`);
|
|
839
|
+
onTimeout?.(error);
|
|
840
|
+
upstream.destroy(error);
|
|
841
|
+
};
|
|
842
|
+
upstream.setTimeout(idleMs, () => expire(`no data for ${idleMs}ms`));
|
|
843
|
+
const totalTimer = setTimeout(() => expire(`exceeded the ${totalMs}ms total budget`), totalMs);
|
|
844
|
+
totalTimer.unref();
|
|
845
|
+
upstream.once("close", () => clearTimeout(totalTimer));
|
|
846
|
+
}
|
|
847
|
+
function stripProxyHopHeaders(headers) {
|
|
848
|
+
const stripped = { ...headers };
|
|
849
|
+
delete stripped["proxy-authorization"];
|
|
850
|
+
delete stripped["proxy-connection"];
|
|
851
|
+
return stripped;
|
|
852
|
+
}
|
|
522
853
|
function writeRawHttpRequest(tlsSocket, request, target, requestBody) {
|
|
523
854
|
return new Promise((resolve, reject) => {
|
|
524
855
|
collectBounded(tlsSocket, { label: target.toString() }).then(resolve).catch(reject);
|
|
@@ -591,7 +922,7 @@ function upstreamTlsOptions(env) {
|
|
|
591
922
|
}
|
|
592
923
|
try {
|
|
593
924
|
return {
|
|
594
|
-
ca: readFileSync(caPath, "utf8")
|
|
925
|
+
ca: [...rootCertificates, readFileSync(caPath, "utf8")]
|
|
595
926
|
};
|
|
596
927
|
}
|
|
597
928
|
catch {
|
|
@@ -627,7 +958,11 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
|
|
|
627
958
|
if (shouldUseScanTarball(options, target, identity)) {
|
|
628
959
|
return lookupScanTarballVerdict(options, target, sha256, upstream, identity);
|
|
629
960
|
}
|
|
630
|
-
const
|
|
961
|
+
const preverified = preverifiedVerdict(options, target, sha256, identity);
|
|
962
|
+
if (preverified) {
|
|
963
|
+
return preverified;
|
|
964
|
+
}
|
|
965
|
+
const cooldown = resolveCooldownRequest(options, identity);
|
|
631
966
|
const controller = new AbortController();
|
|
632
967
|
const timeout = setTimeout(() => controller.abort(), options.verdictTimeoutMs ?? installVerdictTimeoutMs(options.env));
|
|
633
968
|
try {
|
|
@@ -641,7 +976,7 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
|
|
|
641
976
|
body: JSON.stringify({
|
|
642
977
|
manager: options.classification.manager,
|
|
643
978
|
action: options.classification.action,
|
|
644
|
-
url: target.toString(),
|
|
979
|
+
url: redactSecrets(target.toString()),
|
|
645
980
|
artifactUrlHash: artifactUrlHash(target),
|
|
646
981
|
ecosystem: identity.ecosystem,
|
|
647
982
|
name: identity.name,
|
|
@@ -662,7 +997,7 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
|
|
|
662
997
|
packageName: artifactDisplayName(identity),
|
|
663
998
|
cause: "quota-exceeded",
|
|
664
999
|
reason: "You've reached your monthly scan limit. Upgrade at westbayberry.com/pricing or wait for it to reset.",
|
|
665
|
-
...(typeof body.resetsAt === "string" ? { resetsAt: body.resetsAt } : {}),
|
|
1000
|
+
...(typeof body.resetsAt === "string" ? { resetsAt: sanitize(body.resetsAt) } : {}),
|
|
666
1001
|
quotaBehavior: body.quotaBehavior === "pass" ? "pass" : "block"
|
|
667
1002
|
};
|
|
668
1003
|
}
|
|
@@ -748,6 +1083,31 @@ async function lookupScanTarballVerdict(options, target, sha256, upstream, ident
|
|
|
748
1083
|
clearTimeout(timeout);
|
|
749
1084
|
}
|
|
750
1085
|
}
|
|
1086
|
+
// A preflight batch-analyze already verdicted this exact name@version (and the
|
|
1087
|
+
// user accepted any warns), so a per-artifact API round-trip would re-answer
|
|
1088
|
+
// the same question. Trust it only when the identity came from registry
|
|
1089
|
+
// metadata and the preflight applied the same cooldown gate the proxy would;
|
|
1090
|
+
// the synthesized verdict goes through normalizeVerdict so the scanned-SHA
|
|
1091
|
+
// cross-check blocks tampered bytes exactly like a server response would.
|
|
1092
|
+
function preverifiedVerdict(options, target, streamedSha256, identity) {
|
|
1093
|
+
if (identity.sourceKind !== "registry-metadata") {
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
const entry = loadPreverifiedMap(options.session.dir).get(preverifiedKey(identity.ecosystem, identity.name, identity.version));
|
|
1097
|
+
if (!entry) {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
if (!entry.cooldownEvaluated && resolveCooldownRequest(options, identity) !== undefined) {
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
return normalizeVerdict({
|
|
1104
|
+
verdict: entry.action,
|
|
1105
|
+
cause: entry.action,
|
|
1106
|
+
packageName: `${identity.name}@${identity.version}`,
|
|
1107
|
+
reason: entry.reason ?? (entry.action === "pass" ? "verified before install" : "flagged for review"),
|
|
1108
|
+
...(entry.scannedSha256 ? { scannedSha256: entry.scannedSha256 } : {})
|
|
1109
|
+
}, target, identity, streamedSha256);
|
|
1110
|
+
}
|
|
751
1111
|
function normalizeVerdict(value, target, identity, streamedSha256) {
|
|
752
1112
|
if (!isRecord(value)) {
|
|
753
1113
|
throw new Error("Dependency Guardian API returned a malformed verdict");
|
|
@@ -767,22 +1127,38 @@ function normalizeVerdict(value, target, identity, streamedSha256) {
|
|
|
767
1127
|
}
|
|
768
1128
|
const cause = typeof value.cause === "string" ? value.cause : undefined;
|
|
769
1129
|
const cooldown = parseCooldownInfo(value.cooldown);
|
|
1130
|
+
const dashboardUrl = safeDashboardUrl(value.dashboardUrl);
|
|
770
1131
|
return {
|
|
771
1132
|
verdict,
|
|
772
|
-
packageName: typeof value.packageName === "string" ? value.packageName : artifactDisplayName(identity) || packageNameFromUrl(target),
|
|
1133
|
+
packageName: typeof value.packageName === "string" ? sanitize(value.packageName) : artifactDisplayName(identity) || packageNameFromUrl(target),
|
|
773
1134
|
...(isProxyCause(cause) ? { cause } : {}),
|
|
774
|
-
reason: typeof value.reason === "string" ? value.reason : `API verdict ${verdict}`,
|
|
775
|
-
...(
|
|
1135
|
+
reason: typeof value.reason === "string" ? sanitize(value.reason) : `API verdict ${verdict}`,
|
|
1136
|
+
...(dashboardUrl ? { dashboardUrl } : {}),
|
|
776
1137
|
...(value.unauthenticated === true ? { unauthenticated: true } : {}),
|
|
777
1138
|
...(cooldown ? { cooldown } : {})
|
|
778
1139
|
};
|
|
779
1140
|
}
|
|
780
|
-
function
|
|
1141
|
+
function safeDashboardUrl(value) {
|
|
1142
|
+
if (typeof value !== "string") {
|
|
1143
|
+
return undefined;
|
|
1144
|
+
}
|
|
1145
|
+
try {
|
|
1146
|
+
const url = new URL(sanitize(value));
|
|
1147
|
+
return url.protocol === "https:" ? url.toString() : undefined;
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
return undefined;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
function resolveCooldownRequest(options, identity) {
|
|
781
1154
|
if (identity.ecosystem === "unknown" || identity.version === "unknown") {
|
|
782
1155
|
return undefined;
|
|
783
1156
|
}
|
|
1157
|
+
if (isCooldownExemptByDgFile(identity.name, identity.ecosystem, options.cooldownExemptions ?? [])) {
|
|
1158
|
+
return undefined;
|
|
1159
|
+
}
|
|
784
1160
|
try {
|
|
785
|
-
return cooldownRequestParam(loadUserConfig(env), env, identity.ecosystem, identity.name);
|
|
1161
|
+
return cooldownRequestParam(loadUserConfig(options.env), options.env, identity.ecosystem, identity.name);
|
|
786
1162
|
}
|
|
787
1163
|
catch {
|
|
788
1164
|
return undefined;
|
|
@@ -795,8 +1171,8 @@ function parseCooldownInfo(value) {
|
|
|
795
1171
|
return {
|
|
796
1172
|
requiredDays: value.requiredDays,
|
|
797
1173
|
...(typeof value.ageDays === "number" && Number.isFinite(value.ageDays) ? { ageDays: value.ageDays } : {}),
|
|
798
|
-
...(typeof value.publishedAt === "string" ? { publishedAt: value.publishedAt } : {}),
|
|
799
|
-
...(typeof value.eligibleAt === "string" ? { eligibleAt: value.eligibleAt } : {})
|
|
1174
|
+
...(typeof value.publishedAt === "string" ? { publishedAt: sanitize(value.publishedAt) } : {}),
|
|
1175
|
+
...(typeof value.eligibleAt === "string" ? { eligibleAt: sanitize(value.eligibleAt) } : {})
|
|
800
1176
|
};
|
|
801
1177
|
}
|
|
802
1178
|
function normalizeScanTarballVerdict(value, target, identity, streamedSha256) {
|
|
@@ -857,7 +1233,7 @@ function mergeIdentities(existing, next) {
|
|
|
857
1233
|
}
|
|
858
1234
|
return merged;
|
|
859
1235
|
}
|
|
860
|
-
function recordDecision(options, state, verdict) {
|
|
1236
|
+
function recordDecision(options, state, verdict, identity) {
|
|
861
1237
|
const decision = enforceProtectedInstall({
|
|
862
1238
|
classification: options.classification,
|
|
863
1239
|
env: options.env,
|
|
@@ -867,10 +1243,28 @@ function recordDecision(options, state, verdict) {
|
|
|
867
1243
|
state.decisions = [...state.decisions, decision];
|
|
868
1244
|
state.events = [...state.events, `${decision.action}:${decision.cause}:${redactSecrets(decision.packageName)}`];
|
|
869
1245
|
writeProxyState(options.session, state);
|
|
1246
|
+
if (identity && decision.action === "block" && decision.cause === "cooldown" && decision.cooldown) {
|
|
1247
|
+
try {
|
|
1248
|
+
recordHeldPackage({
|
|
1249
|
+
ecosystem: identity.ecosystem,
|
|
1250
|
+
name: identity.name,
|
|
1251
|
+
version: identity.version,
|
|
1252
|
+
requiredDays: decision.cooldown.requiredDays,
|
|
1253
|
+
...(decision.cooldown.ageDays !== undefined ? { ageDays: decision.cooldown.ageDays } : {}),
|
|
1254
|
+
...(decision.cooldown.publishedAt ? { publishedAt: decision.cooldown.publishedAt } : {}),
|
|
1255
|
+
...(decision.cooldown.eligibleAt ? { eligibleAt: decision.cooldown.eligibleAt } : {}),
|
|
1256
|
+
manager: options.classification.manager
|
|
1257
|
+
}, options.env);
|
|
1258
|
+
}
|
|
1259
|
+
catch (error) {
|
|
1260
|
+
noteEnforcementSideEffectFailure(error);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
870
1263
|
return decision;
|
|
871
1264
|
}
|
|
872
1265
|
function sendBlocked(response, decision) {
|
|
873
|
-
const
|
|
1266
|
+
const breadcrumb = decision.cause === "cooldown" ? " · holds: dg cooldown" : "";
|
|
1267
|
+
const body = `Dependency Guardian blocked ${redactSecrets(decision.packageName)}: ${redactSecrets(decision.reason)}${breadcrumb}\n`;
|
|
874
1268
|
response.writeHead(403, {
|
|
875
1269
|
"Content-Type": "text/plain; charset=utf-8",
|
|
876
1270
|
"Content-Length": Buffer.byteLength(body)
|
|
@@ -903,24 +1297,29 @@ function packageNameFromUrl(url) {
|
|
|
903
1297
|
return name ? decodeURIComponent(name) : url.hostname;
|
|
904
1298
|
}
|
|
905
1299
|
function writeProxyState(session, state) {
|
|
906
|
-
mkdirSync(dirname(session.files.proxy), {
|
|
907
|
-
recursive: true,
|
|
908
|
-
mode: 0o700
|
|
909
|
-
});
|
|
910
|
-
const tempPath = `${session.files.proxy}.${process.pid}.${process.hrtime.bigint().toString(36)}.tmp`;
|
|
911
1300
|
try {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
mode: 0o600
|
|
1301
|
+
mkdirSync(dirname(session.files.proxy), {
|
|
1302
|
+
recursive: true,
|
|
1303
|
+
mode: 0o700
|
|
916
1304
|
});
|
|
917
|
-
|
|
1305
|
+
const tempPath = `${session.files.proxy}.${process.pid}.${process.hrtime.bigint().toString(36)}.tmp`;
|
|
1306
|
+
try {
|
|
1307
|
+
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, {
|
|
1308
|
+
encoding: "utf8",
|
|
1309
|
+
flag: "wx",
|
|
1310
|
+
mode: 0o600
|
|
1311
|
+
});
|
|
1312
|
+
renameSync(tempPath, session.files.proxy);
|
|
1313
|
+
}
|
|
1314
|
+
catch (error) {
|
|
1315
|
+
rmSync(tempPath, {
|
|
1316
|
+
force: true
|
|
1317
|
+
});
|
|
1318
|
+
throw error;
|
|
1319
|
+
}
|
|
918
1320
|
}
|
|
919
1321
|
catch (error) {
|
|
920
|
-
|
|
921
|
-
force: true
|
|
922
|
-
});
|
|
923
|
-
throw error;
|
|
1322
|
+
noteEnforcementSideEffectFailure(error);
|
|
924
1323
|
}
|
|
925
1324
|
}
|
|
926
1325
|
function removeFirst(items, value) {
|
|
@@ -933,7 +1332,7 @@ function removeFirst(items, value) {
|
|
|
933
1332
|
function isRecord(value) {
|
|
934
1333
|
return typeof value === "object" && value !== null;
|
|
935
1334
|
}
|
|
936
|
-
function isProxyCause(value) {
|
|
1335
|
+
export function isProxyCause(value) {
|
|
937
1336
|
return [
|
|
938
1337
|
"pass",
|
|
939
1338
|
"warn",
|