@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.
Files changed (138) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +113 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +113 -0
  6. package/dist/agents/gemini.js +107 -0
  7. package/dist/agents/persistence.js +285 -0
  8. package/dist/agents/registry.js +127 -0
  9. package/dist/agents/types.js +1 -0
  10. package/dist/agents/windsurf.js +93 -0
  11. package/dist/api/analyze.js +1 -1
  12. package/dist/audit/detectors.js +0 -11
  13. package/dist/audit/events.js +5 -21
  14. package/dist/audit-ui/AuditApp.js +2 -0
  15. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  16. package/dist/audit-ui/export.js +0 -4
  17. package/dist/audit-ui/format.js +0 -3
  18. package/dist/audit-ui/launch.js +10 -1
  19. package/dist/auth/device-login.js +4 -5
  20. package/dist/auth/login-app.js +7 -7
  21. package/dist/auth/store.js +8 -3
  22. package/dist/bin/dg.js +59 -52
  23. package/dist/commands/agents.js +231 -0
  24. package/dist/commands/audit.js +22 -3
  25. package/dist/commands/config.js +15 -7
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +23 -9
  28. package/dist/commands/doctor.js +1 -1
  29. package/dist/commands/help.js +1 -1
  30. package/dist/commands/licenses.js +10 -22
  31. package/dist/commands/logout.js +4 -11
  32. package/dist/commands/router.js +6 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +1 -1
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/types.js +1 -0
  38. package/dist/commands/update.js +17 -8
  39. package/dist/commands/verify.js +8 -5
  40. package/dist/config/settings.js +14 -44
  41. package/dist/decisions/remember-prompt.js +5 -10
  42. package/dist/export-ui/ExportDialog.js +198 -0
  43. package/dist/install-ui/LiveInstall.js +2 -2
  44. package/dist/install-ui/block-render.js +1 -1
  45. package/dist/install-ui/prep-spinner.js +32 -0
  46. package/dist/launcher/agent-check.js +466 -0
  47. package/dist/launcher/agent-hook-exec.js +70 -0
  48. package/dist/launcher/agent-hook-io.js +31 -0
  49. package/dist/launcher/cargo-cache.js +40 -0
  50. package/dist/launcher/classify.js +17 -6
  51. package/dist/launcher/env.js +71 -24
  52. package/dist/launcher/install-preflight.js +51 -14
  53. package/dist/launcher/live-install.js +25 -5
  54. package/dist/launcher/output-redaction.js +7 -4
  55. package/dist/launcher/preflight-prompt.js +14 -1
  56. package/dist/launcher/run.js +89 -86
  57. package/dist/launcher/spawn-invocation.js +21 -0
  58. package/dist/policy/cooldown.js +18 -5
  59. package/dist/policy/evaluate.js +5 -6
  60. package/dist/policy/pypi-name.js +17 -0
  61. package/dist/presentation/mode.js +3 -2
  62. package/dist/presentation/package-page.js +9 -0
  63. package/dist/presentation/theme.js +7 -7
  64. package/dist/project/dgfile.js +145 -6
  65. package/dist/proxy/auth.js +42 -0
  66. package/dist/proxy/ca.js +29 -9
  67. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  68. package/dist/proxy/enforcement.js +55 -16
  69. package/dist/proxy/metadata-map.js +41 -3
  70. package/dist/proxy/preverified.js +55 -0
  71. package/dist/proxy/server.js +448 -49
  72. package/dist/proxy/worker.js +16 -1
  73. package/dist/publish-set/collect.js +1 -4
  74. package/dist/publish-set/npm.js +8 -5
  75. package/dist/publish-set/pack.js +9 -3
  76. package/dist/runtime/cli.js +0 -4
  77. package/dist/runtime/fatal.js +31 -0
  78. package/dist/runtime/first-run.js +12 -11
  79. package/dist/runtime/node-version.js +43 -6
  80. package/dist/runtime/nudges.js +35 -2
  81. package/dist/sbom/cyclonedx.js +211 -0
  82. package/dist/sbom-ui/SbomApp.js +158 -0
  83. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  84. package/dist/sbom-ui/components/SbomList.js +52 -0
  85. package/dist/sbom-ui/inventory.js +128 -0
  86. package/dist/sbom-ui/launch.js +51 -0
  87. package/dist/sbom-ui/run.js +55 -0
  88. package/dist/sbom-ui/store.js +26 -0
  89. package/dist/scan/collect.js +10 -6
  90. package/dist/scan/command.js +17 -10
  91. package/dist/scan/discovery.js +11 -2
  92. package/dist/scan/render.js +28 -4
  93. package/dist/scan/scanner-report.js +15 -9
  94. package/dist/scan/staged.js +2 -1
  95. package/dist/scan-ui/LegacyApp.js +9 -13
  96. package/dist/scan-ui/alt-screen.js +5 -8
  97. package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
  98. package/dist/scan-ui/components/ProgressBar.js +3 -14
  99. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  100. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  101. package/dist/scan-ui/components/SetupBanner.js +0 -6
  102. package/dist/scan-ui/format-helpers.js +61 -5
  103. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  104. package/dist/scan-ui/hooks/useScan.js +16 -2
  105. package/dist/scan-ui/launch.js +7 -4
  106. package/dist/scan-ui/shims.js +11 -4
  107. package/dist/scripts/detect.js +7 -2
  108. package/dist/scripts/gate.js +1 -1
  109. package/dist/service/state.js +27 -8
  110. package/dist/service/trust-refresh.js +92 -0
  111. package/dist/service/worker.js +23 -1
  112. package/dist/setup/activate-shell.js +28 -0
  113. package/dist/setup/git-hook.js +49 -4
  114. package/dist/setup/plan.js +73 -39
  115. package/dist/setup-ui/gate.js +39 -0
  116. package/dist/setup-ui/offer.js +42 -0
  117. package/dist/setup-ui/selector.js +27 -0
  118. package/dist/setup-ui/tasks.js +56 -0
  119. package/dist/setup-ui/wizard.js +225 -0
  120. package/dist/state/cooldown-held.js +66 -0
  121. package/dist/state/index.js +1 -0
  122. package/dist/state/locks.js +4 -2
  123. package/dist/state/store.js +2 -1
  124. package/dist/util/external-tool.js +25 -0
  125. package/dist/util/git.js +10 -3
  126. package/dist/util/report-writer.js +57 -0
  127. package/dist/verify/local.js +240 -42
  128. package/dist/verify/package-check.js +84 -28
  129. package/dist/verify/preflight.js +242 -49
  130. package/dist/verify/render.js +15 -1
  131. package/npm-shrinkwrap.json +2383 -0
  132. package/package.json +6 -8
  133. package/NOTICE +0 -5
  134. package/dist/commands/completion.js +0 -117
  135. package/dist/commands/explain.js +0 -236
  136. package/dist/commands/unavailable.js +0 -11
  137. package/dist/scripts/rebuild.js +0 -28
  138. package/dist/telemetry/events.js +0 -40
@@ -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 { enforceProtectedInstall } from "./enforcement.js";
14
- import { cooldownRequestParam } from "../policy/cooldown.js";
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 ca = createEphemeralCertificateAuthority(options.session.files.ca);
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
- function isPrivateNetworkHost(url) {
154
- const host = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
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 (/^(127|10|0)\./.test(host) || /^192\.168\./.test(host) || /^169\.254\./.test(host) || /^172\.(1[6-9]|2[0-9]|3[01])\./.test(host)) {
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
- const upstream = await fetchUpstreamFollowingRedirects(request, target, options.env).catch((error) => {
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 null;
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
- headers: request.headers
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 cooldown = resolveCooldownRequest(options.env, identity);
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
- ...(typeof value.dashboardUrl === "string" ? { dashboardUrl: value.dashboardUrl } : {}),
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 resolveCooldownRequest(env, identity) {
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 body = `Dependency Guardian blocked ${redactSecrets(decision.packageName)}: ${redactSecrets(decision.reason)}\n`;
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
- writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, {
913
- encoding: "utf8",
914
- flag: "wx",
915
- mode: 0o600
1301
+ mkdirSync(dirname(session.files.proxy), {
1302
+ recursive: true,
1303
+ mode: 0o700
916
1304
  });
917
- renameSync(tempPath, session.files.proxy);
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
- rmSync(tempPath, {
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",