@westbayberry/dg 2.0.11 → 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 (143) 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 +6 -4
  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 +26 -10
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +169 -0
  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 +8 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +3 -2
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/status.js +5 -2
  38. package/dist/commands/types.js +1 -0
  39. package/dist/commands/update.js +17 -8
  40. package/dist/commands/verify.js +8 -5
  41. package/dist/config/settings.js +154 -65
  42. package/dist/decisions/apply.js +128 -0
  43. package/dist/decisions/remember-prompt.js +92 -0
  44. package/dist/export-ui/ExportDialog.js +198 -0
  45. package/dist/install-ui/LiveInstall.js +2 -2
  46. package/dist/install-ui/block-render.js +21 -4
  47. package/dist/install-ui/prep-spinner.js +32 -0
  48. package/dist/install-ui/prompt.js +14 -0
  49. package/dist/launcher/agent-check.js +466 -0
  50. package/dist/launcher/agent-hook-exec.js +70 -0
  51. package/dist/launcher/agent-hook-io.js +31 -0
  52. package/dist/launcher/cargo-cache.js +40 -0
  53. package/dist/launcher/classify.js +17 -6
  54. package/dist/launcher/env.js +71 -24
  55. package/dist/launcher/install-preflight.js +167 -17
  56. package/dist/launcher/live-install.js +25 -5
  57. package/dist/launcher/output-redaction.js +7 -4
  58. package/dist/launcher/preflight-prompt.js +43 -3
  59. package/dist/launcher/run.js +100 -86
  60. package/dist/launcher/spawn-invocation.js +21 -0
  61. package/dist/policy/cooldown.js +117 -0
  62. package/dist/policy/evaluate.js +5 -21
  63. package/dist/policy/pypi-name.js +17 -0
  64. package/dist/presentation/mode.js +3 -2
  65. package/dist/presentation/package-page.js +9 -0
  66. package/dist/presentation/provenance.js +23 -0
  67. package/dist/presentation/theme.js +7 -7
  68. package/dist/project/dgfile.js +446 -0
  69. package/dist/proxy/auth.js +42 -0
  70. package/dist/proxy/ca.js +29 -9
  71. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  72. package/dist/proxy/enforcement.js +57 -17
  73. package/dist/proxy/metadata-map.js +66 -4
  74. package/dist/proxy/preverified.js +55 -0
  75. package/dist/proxy/server.js +473 -45
  76. package/dist/proxy/worker.js +16 -1
  77. package/dist/publish-set/collect.js +1 -4
  78. package/dist/publish-set/npm.js +8 -5
  79. package/dist/publish-set/pack.js +9 -3
  80. package/dist/runtime/cli.js +0 -4
  81. package/dist/runtime/fatal.js +31 -0
  82. package/dist/runtime/first-run.js +12 -11
  83. package/dist/runtime/node-version.js +43 -6
  84. package/dist/runtime/nudges.js +35 -2
  85. package/dist/sbom/cyclonedx.js +211 -0
  86. package/dist/sbom-ui/SbomApp.js +158 -0
  87. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  88. package/dist/sbom-ui/components/SbomList.js +52 -0
  89. package/dist/sbom-ui/inventory.js +128 -0
  90. package/dist/sbom-ui/launch.js +51 -0
  91. package/dist/sbom-ui/run.js +55 -0
  92. package/dist/sbom-ui/store.js +26 -0
  93. package/dist/scan/collect.js +10 -6
  94. package/dist/scan/command.js +51 -17
  95. package/dist/scan/discovery.js +11 -2
  96. package/dist/scan/render.js +63 -8
  97. package/dist/scan/scanner-report.js +42 -9
  98. package/dist/scan/staged.js +71 -11
  99. package/dist/scan-ui/LegacyApp.js +12 -16
  100. package/dist/scan-ui/alt-screen.js +5 -8
  101. package/dist/scan-ui/components/InteractiveResultsView.js +193 -123
  102. package/dist/scan-ui/components/ProgressBar.js +3 -14
  103. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  104. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  105. package/dist/scan-ui/components/SetupBanner.js +0 -6
  106. package/dist/scan-ui/format-helpers.js +61 -5
  107. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  108. package/dist/scan-ui/hooks/useScan.js +46 -4
  109. package/dist/scan-ui/launch.js +7 -4
  110. package/dist/scan-ui/shims.js +14 -4
  111. package/dist/scripts/detect.js +158 -0
  112. package/dist/scripts/gate.js +170 -0
  113. package/dist/service/state.js +27 -8
  114. package/dist/service/trust-refresh.js +92 -0
  115. package/dist/service/worker.js +23 -1
  116. package/dist/setup/activate-shell.js +28 -0
  117. package/dist/setup/git-hook.js +49 -4
  118. package/dist/setup/plan.js +98 -29
  119. package/dist/setup-ui/gate.js +39 -0
  120. package/dist/setup-ui/offer.js +42 -0
  121. package/dist/setup-ui/selector.js +27 -0
  122. package/dist/setup-ui/tasks.js +56 -0
  123. package/dist/setup-ui/wizard.js +225 -0
  124. package/dist/state/cooldown-held.js +66 -0
  125. package/dist/state/index.js +1 -0
  126. package/dist/state/locks.js +4 -2
  127. package/dist/state/store.js +2 -1
  128. package/dist/util/external-tool.js +25 -0
  129. package/dist/util/git.js +10 -3
  130. package/dist/util/json-file.js +24 -0
  131. package/dist/util/report-writer.js +57 -0
  132. package/dist/util/tty-prompt.js +13 -6
  133. package/dist/verify/local.js +240 -42
  134. package/dist/verify/package-check.js +86 -18
  135. package/dist/verify/preflight.js +242 -49
  136. package/dist/verify/render.js +15 -1
  137. package/npm-shrinkwrap.json +2383 -0
  138. package/package.json +14 -8
  139. package/NOTICE +0 -5
  140. package/dist/commands/completion.js +0 -116
  141. package/dist/commands/explain.js +0 -232
  142. package/dist/commands/unavailable.js +0 -11
  143. package/dist/telemetry/events.js +0 -40
@@ -1,24 +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";
13
+ import { generateProxyAuthToken, proxyAuthorizationValue, verifyProxyAuthorization, writeProxyAuthToken } from "./auth.js";
14
+ import { enforceProtectedInstall, noteEnforcementSideEffectFailure } from "./enforcement.js";
15
+ import { cooldownRequestParam, isCooldownExemptByDgFile } from "../policy/cooldown.js";
16
+ import { loadUserConfig } from "../config/settings.js";
14
17
  import { artifactDisplayName, artifactUrlHash, extractRegistryMetadataIdentities, isRegistryIndexRequest, resolveArtifactIdentity } from "./metadata-map.js";
18
+ import { loadPreverifiedMap, preverifiedKey } from "./preverified.js";
15
19
  import { authorityFor, connectViaUpstreamProxy, selectUpstreamProxy } from "./upstream-proxy.js";
16
20
  import { redactSecrets } from "../launcher/output-redaction.js";
21
+ import { sanitize } from "../security/sanitize.js";
17
22
  import { envAuthToken } from "../auth/env-token.js";
18
23
  import { identityHeaders } from "../api/analyze.js";
19
24
  import { dgVersion } from "../commands/version.js";
25
+ import { recordHeldPackage } from "../state/index.js";
20
26
  export async function startProductionHttpProxy(options) {
21
- 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);
22
31
  const state = {
23
32
  ready: false,
24
33
  port: 0,
@@ -30,6 +39,10 @@ export async function startProductionHttpProxy(options) {
30
39
  };
31
40
  const activeSockets = new Set();
32
41
  const server = createServer((request, response) => {
42
+ if (!verifyProxyAuthorization(headerValue(request.headers["proxy-authorization"]), authToken)) {
43
+ sendProxyAuthRequired(response);
44
+ return;
45
+ }
33
46
  handleProxyRequest(request, response, options, state).catch((error) => {
34
47
  const decision = recordDecision(options, state, {
35
48
  verdict: "block",
@@ -43,6 +56,10 @@ export async function startProductionHttpProxy(options) {
43
56
  server.on("connection", (socket) => trackSocket(activeSockets, socket));
44
57
  server.on("connect", (request, socket, head) => {
45
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
+ }
46
63
  handleConnectRequest(request, clientSocket, head, options, state, ca, activeSockets).catch((error) => {
47
64
  const decision = recordDecision(options, state, {
48
65
  verdict: "block",
@@ -63,9 +80,27 @@ export async function startProductionHttpProxy(options) {
63
80
  writeProxyState(options.session, state);
64
81
  return {
65
82
  port: address.port,
83
+ proxyAuthorization: proxyAuthorizationValue(authToken),
66
84
  close: () => closeServer(server, activeSockets)
67
85
  };
68
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
+ }
69
104
  export function readProxySessionState(session) {
70
105
  try {
71
106
  const parsed = JSON.parse(readFileSync(session.files.proxy, "utf8"));
@@ -134,6 +169,16 @@ async function handleConnectRequest(request, clientSocket, head, options, state,
134
169
  clientSocket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
135
170
  return;
136
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
+ }
137
182
  if (!shouldMitmHost(target.hostname, options.env)) {
138
183
  state.events = [...state.events, `tunnel:${redactSecrets(authorityFor(target))}`];
139
184
  writeProxyState(options.session, state);
@@ -145,22 +190,248 @@ async function handleConnectRequest(request, clientSocket, head, options, state,
145
190
  await mitmTunnel(clientSocket, head, target, options, state, ca, activeSockets);
146
191
  }
147
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
+ }
148
391
  function isRedirectStatus(statusCode) {
149
392
  return [301, 302, 303, 307, 308].includes(statusCode);
150
393
  }
151
- function isPrivateNetworkHost(url) {
152
- const host = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
153
- if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1") {
154
- return true;
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}`;
155
409
  }
156
- 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)) {
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);
422
+ if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1") {
157
423
  return true;
158
424
  }
159
- 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)) {
160
426
  return true;
161
427
  }
162
428
  return /^(fe80|f[cd][0-9a-f]{2}):/.test(host);
163
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
+ }
164
435
  function syntheticGetRequest(headers) {
165
436
  const request = new EventEmitter();
166
437
  request.method = "GET";
@@ -202,12 +473,40 @@ async function fetchUpstreamFollowingRedirects(request, target, env) {
202
473
  }
203
474
  const ARTIFACT_CONDITIONAL_HEADERS = ["range", "if-range", "if-none-match", "if-modified-since"];
204
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
+ }
205
486
  if (!isRegistryIndexRequest(target)) {
206
487
  for (const header of ARTIFACT_CONDITIONAL_HEADERS) {
207
488
  delete request.headers[header];
208
489
  }
209
490
  }
210
- 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) {
211
510
  const message = error instanceof Error ? error.message : "registry request failed";
212
511
  const decision = recordDecision(options, state, {
213
512
  verdict: "block",
@@ -216,8 +515,8 @@ async function handleArtifactRequest(request, response, target, options, state)
216
515
  reason: message
217
516
  });
218
517
  sendBlocked(response, decision);
219
- return null;
220
- });
518
+ return;
519
+ }
221
520
  if (!upstream) {
222
521
  return;
223
522
  }
@@ -287,7 +586,7 @@ async function handleArtifactRequest(request, response, target, options, state)
287
586
  reason: error instanceof Error ? error.message : "Dependency Guardian API verdict lookup failed"
288
587
  }));
289
588
  state.inflight = removeFirst(state.inflight, inflightName);
290
- const decision = recordDecision(options, state, verdict);
589
+ const decision = recordDecision(options, state, verdict, identity.identity);
291
590
  if (decision.action === "block") {
292
591
  sendBlocked(response, decision);
293
592
  return;
@@ -386,6 +685,9 @@ function upstreamHostname(target, env = process.env) {
386
685
  return testUpstreamHostMap(env).get(hostname) ?? hostname;
387
686
  }
388
687
  function testUpstreamHostMap(env) {
688
+ if (env.NODE_ENV !== "test") {
689
+ return new Map();
690
+ }
389
691
  const entries = (env.DG_TEST_UPSTREAM_HOST_MAP ?? "")
390
692
  .split(",")
391
693
  .map((entry) => entry.trim())
@@ -403,7 +705,7 @@ function testUpstreamHostMap(env) {
403
705
  function fetchUpstream(request, target, env) {
404
706
  const upstreamProxy = selectUpstreamProxy(target, env);
405
707
  if (upstreamProxy && target.protocol === "http:") {
406
- return fetchHttpViaProxy(request, target, upstreamProxy);
708
+ return fetchHttpViaProxy(request, target, upstreamProxy, env);
407
709
  }
408
710
  if (upstreamProxy && target.protocol === "https:") {
409
711
  return fetchHttpsViaProxy(request, target, upstreamProxy, env);
@@ -417,7 +719,8 @@ function fetchUpstream(request, target, env) {
417
719
  port: target.port ? Number(target.port) : 80,
418
720
  path: `${target.pathname}${target.search}`,
419
721
  method: request.method,
420
- headers: request.headers
722
+ agent: upstreamHttpAgent,
723
+ headers: stripProxyHopHeaders(request.headers)
421
724
  }, (upstreamResponse) => {
422
725
  collectBounded(upstreamResponse, { label: target.toString() })
423
726
  .then((body) => resolve({
@@ -427,6 +730,7 @@ function fetchUpstream(request, target, env) {
427
730
  }))
428
731
  .catch(reject);
429
732
  });
733
+ applyUpstreamTimeBudget(upstream, target, env, reject);
430
734
  upstream.on("error", reject);
431
735
  request.on("data", (chunk) => upstream.write(chunk));
432
736
  request.on("end", () => upstream.end());
@@ -438,10 +742,10 @@ function fetchUpstream(request, target, env) {
438
742
  });
439
743
  });
440
744
  }
441
- function fetchHttpViaProxy(request, target, upstreamProxy) {
745
+ function fetchHttpViaProxy(request, target, upstreamProxy, env) {
442
746
  return new Promise((resolve, reject) => {
443
747
  const headers = {
444
- ...request.headers,
748
+ ...stripProxyHopHeaders(request.headers),
445
749
  ...(upstreamProxy.authorizationHeader ? { "Proxy-Authorization": upstreamProxy.authorizationHeader } : {})
446
750
  };
447
751
  const upstream = httpRequest({
@@ -459,6 +763,7 @@ function fetchHttpViaProxy(request, target, upstreamProxy) {
459
763
  }))
460
764
  .catch(reject);
461
765
  });
766
+ applyUpstreamTimeBudget(upstream, target, env, reject);
462
767
  upstream.on("error", reject);
463
768
  request.on("data", (chunk) => upstream.write(chunk));
464
769
  request.on("end", () => upstream.end());
@@ -477,6 +782,7 @@ function fetchHttpsDirect(request, target, env) {
477
782
  port: target.port ? Number(target.port) : 443,
478
783
  path: `${target.pathname}${target.search}`,
479
784
  method: request.method,
785
+ agent: upstreamHttpsAgent,
480
786
  headers: upstreamRequestHeaders(request, target),
481
787
  ...upstreamTlsOptions(env)
482
788
  }, (upstreamResponse) => {
@@ -488,6 +794,7 @@ function fetchHttpsDirect(request, target, env) {
488
794
  }))
489
795
  .catch(reject);
490
796
  });
797
+ applyUpstreamTimeBudget(upstream, target, env, reject);
491
798
  upstream.on("error", reject);
492
799
  request.on("data", (chunk) => upstream.write(chunk));
493
800
  request.on("end", () => upstream.end());
@@ -514,9 +821,35 @@ function connectTlsOverSocket(socket, target, env) {
514
821
  ALPNProtocols: ["http/1.1"],
515
822
  ...upstreamTlsOptions(env)
516
823
  }, () => resolve(tlsSocket));
824
+ applyUpstreamTimeBudget(tlsSocket, target, env);
517
825
  tlsSocket.once("error", reject);
518
826
  });
519
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
+ }
520
853
  function writeRawHttpRequest(tlsSocket, request, target, requestBody) {
521
854
  return new Promise((resolve, reject) => {
522
855
  collectBounded(tlsSocket, { label: target.toString() }).then(resolve).catch(reject);
@@ -589,7 +922,7 @@ function upstreamTlsOptions(env) {
589
922
  }
590
923
  try {
591
924
  return {
592
- ca: readFileSync(caPath, "utf8")
925
+ ca: [...rootCertificates, readFileSync(caPath, "utf8")]
593
926
  };
594
927
  }
595
928
  catch {
@@ -625,6 +958,11 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
625
958
  if (shouldUseScanTarball(options, target, identity)) {
626
959
  return lookupScanTarballVerdict(options, target, sha256, upstream, identity);
627
960
  }
961
+ const preverified = preverifiedVerdict(options, target, sha256, identity);
962
+ if (preverified) {
963
+ return preverified;
964
+ }
965
+ const cooldown = resolveCooldownRequest(options, identity);
628
966
  const controller = new AbortController();
629
967
  const timeout = setTimeout(() => controller.abort(), options.verdictTimeoutMs ?? installVerdictTimeoutMs(options.env));
630
968
  try {
@@ -638,7 +976,7 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
638
976
  body: JSON.stringify({
639
977
  manager: options.classification.manager,
640
978
  action: options.classification.action,
641
- url: target.toString(),
979
+ url: redactSecrets(target.toString()),
642
980
  artifactUrlHash: artifactUrlHash(target),
643
981
  ecosystem: identity.ecosystem,
644
982
  name: identity.name,
@@ -647,7 +985,8 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
647
985
  sourceKind: identity.sourceKind,
648
986
  sha256,
649
987
  statusCode: upstream.statusCode,
650
- contentType: headerValue(upstream.headers["content-type"])
988
+ contentType: headerValue(upstream.headers["content-type"]),
989
+ ...(cooldown ? { cooldown } : {})
651
990
  }),
652
991
  signal: controller.signal
653
992
  });
@@ -658,7 +997,7 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
658
997
  packageName: artifactDisplayName(identity),
659
998
  cause: "quota-exceeded",
660
999
  reason: "You've reached your monthly scan limit. Upgrade at westbayberry.com/pricing or wait for it to reset.",
661
- ...(typeof body.resetsAt === "string" ? { resetsAt: body.resetsAt } : {}),
1000
+ ...(typeof body.resetsAt === "string" ? { resetsAt: sanitize(body.resetsAt) } : {}),
662
1001
  quotaBehavior: body.quotaBehavior === "pass" ? "pass" : "block"
663
1002
  };
664
1003
  }
@@ -744,6 +1083,31 @@ async function lookupScanTarballVerdict(options, target, sha256, upstream, ident
744
1083
  clearTimeout(timeout);
745
1084
  }
746
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
+ }
747
1111
  function normalizeVerdict(value, target, identity, streamedSha256) {
748
1112
  if (!isRecord(value)) {
749
1113
  throw new Error("Dependency Guardian API returned a malformed verdict");
@@ -762,13 +1126,53 @@ function normalizeVerdict(value, target, identity, streamedSha256) {
762
1126
  };
763
1127
  }
764
1128
  const cause = typeof value.cause === "string" ? value.cause : undefined;
1129
+ const cooldown = parseCooldownInfo(value.cooldown);
1130
+ const dashboardUrl = safeDashboardUrl(value.dashboardUrl);
765
1131
  return {
766
1132
  verdict,
767
- packageName: typeof value.packageName === "string" ? value.packageName : artifactDisplayName(identity) || packageNameFromUrl(target),
1133
+ packageName: typeof value.packageName === "string" ? sanitize(value.packageName) : artifactDisplayName(identity) || packageNameFromUrl(target),
768
1134
  ...(isProxyCause(cause) ? { cause } : {}),
769
- reason: typeof value.reason === "string" ? value.reason : `API verdict ${verdict}`,
770
- ...(typeof value.dashboardUrl === "string" ? { dashboardUrl: value.dashboardUrl } : {}),
771
- ...(value.unauthenticated === true ? { unauthenticated: true } : {})
1135
+ reason: typeof value.reason === "string" ? sanitize(value.reason) : `API verdict ${verdict}`,
1136
+ ...(dashboardUrl ? { dashboardUrl } : {}),
1137
+ ...(value.unauthenticated === true ? { unauthenticated: true } : {}),
1138
+ ...(cooldown ? { cooldown } : {})
1139
+ };
1140
+ }
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) {
1154
+ if (identity.ecosystem === "unknown" || identity.version === "unknown") {
1155
+ return undefined;
1156
+ }
1157
+ if (isCooldownExemptByDgFile(identity.name, identity.ecosystem, options.cooldownExemptions ?? [])) {
1158
+ return undefined;
1159
+ }
1160
+ try {
1161
+ return cooldownRequestParam(loadUserConfig(options.env), options.env, identity.ecosystem, identity.name);
1162
+ }
1163
+ catch {
1164
+ return undefined;
1165
+ }
1166
+ }
1167
+ function parseCooldownInfo(value) {
1168
+ if (!isRecord(value) || typeof value.requiredDays !== "number" || !Number.isFinite(value.requiredDays)) {
1169
+ return undefined;
1170
+ }
1171
+ return {
1172
+ requiredDays: value.requiredDays,
1173
+ ...(typeof value.ageDays === "number" && Number.isFinite(value.ageDays) ? { ageDays: value.ageDays } : {}),
1174
+ ...(typeof value.publishedAt === "string" ? { publishedAt: sanitize(value.publishedAt) } : {}),
1175
+ ...(typeof value.eligibleAt === "string" ? { eligibleAt: sanitize(value.eligibleAt) } : {})
772
1176
  };
773
1177
  }
774
1178
  function normalizeScanTarballVerdict(value, target, identity, streamedSha256) {
@@ -829,7 +1233,7 @@ function mergeIdentities(existing, next) {
829
1233
  }
830
1234
  return merged;
831
1235
  }
832
- function recordDecision(options, state, verdict) {
1236
+ function recordDecision(options, state, verdict, identity) {
833
1237
  const decision = enforceProtectedInstall({
834
1238
  classification: options.classification,
835
1239
  env: options.env,
@@ -839,10 +1243,28 @@ function recordDecision(options, state, verdict) {
839
1243
  state.decisions = [...state.decisions, decision];
840
1244
  state.events = [...state.events, `${decision.action}:${decision.cause}:${redactSecrets(decision.packageName)}`];
841
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
+ }
842
1263
  return decision;
843
1264
  }
844
1265
  function sendBlocked(response, decision) {
845
- 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`;
846
1268
  response.writeHead(403, {
847
1269
  "Content-Type": "text/plain; charset=utf-8",
848
1270
  "Content-Length": Buffer.byteLength(body)
@@ -875,24 +1297,29 @@ function packageNameFromUrl(url) {
875
1297
  return name ? decodeURIComponent(name) : url.hostname;
876
1298
  }
877
1299
  function writeProxyState(session, state) {
878
- mkdirSync(dirname(session.files.proxy), {
879
- recursive: true,
880
- mode: 0o700
881
- });
882
- const tempPath = `${session.files.proxy}.${process.pid}.${process.hrtime.bigint().toString(36)}.tmp`;
883
1300
  try {
884
- writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, {
885
- encoding: "utf8",
886
- flag: "wx",
887
- mode: 0o600
1301
+ mkdirSync(dirname(session.files.proxy), {
1302
+ recursive: true,
1303
+ mode: 0o700
888
1304
  });
889
- 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
+ }
890
1320
  }
891
1321
  catch (error) {
892
- rmSync(tempPath, {
893
- force: true
894
- });
895
- throw error;
1322
+ noteEnforcementSideEffectFailure(error);
896
1323
  }
897
1324
  }
898
1325
  function removeFirst(items, value) {
@@ -905,7 +1332,7 @@ function removeFirst(items, value) {
905
1332
  function isRecord(value) {
906
1333
  return typeof value === "object" && value !== null;
907
1334
  }
908
- function isProxyCause(value) {
1335
+ export function isProxyCause(value) {
909
1336
  return [
910
1337
  "pass",
911
1338
  "warn",
@@ -920,6 +1347,7 @@ function isProxyCause(value) {
920
1347
  "api-timeout",
921
1348
  "registry-timeout",
922
1349
  "analysis-incomplete",
1350
+ "cooldown",
923
1351
  "unsupported-manager",
924
1352
  "proxy-setup-failure"
925
1353
  ].includes(value ?? "");