@westbayberry/dg 2.1.0 → 2.3.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 (148) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +123 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +118 -0
  6. package/dist/agents/gate-posture.js +21 -0
  7. package/dist/agents/gemini.js +107 -0
  8. package/dist/agents/persistence.js +351 -0
  9. package/dist/agents/registry.js +128 -0
  10. package/dist/agents/routing.js +118 -0
  11. package/dist/agents/types.js +1 -0
  12. package/dist/agents/windsurf.js +93 -0
  13. package/dist/api/analyze.js +1 -1
  14. package/dist/audit/detectors.js +0 -11
  15. package/dist/audit/events.js +5 -21
  16. package/dist/audit/rules.js +6 -2
  17. package/dist/audit-ui/AuditApp.js +2 -0
  18. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  19. package/dist/audit-ui/export.js +0 -4
  20. package/dist/audit-ui/format.js +0 -3
  21. package/dist/audit-ui/launch.js +10 -1
  22. package/dist/auth/device-login.js +4 -5
  23. package/dist/auth/login-app.js +7 -7
  24. package/dist/auth/store.js +17 -5
  25. package/dist/bin/dg.js +59 -52
  26. package/dist/commands/agents.js +275 -0
  27. package/dist/commands/audit.js +28 -3
  28. package/dist/commands/config.js +15 -7
  29. package/dist/commands/cooldown.js +389 -0
  30. package/dist/commands/decisions.js +23 -9
  31. package/dist/commands/doctor.js +1 -1
  32. package/dist/commands/help.js +1 -1
  33. package/dist/commands/licenses.js +10 -22
  34. package/dist/commands/logout.js +4 -11
  35. package/dist/commands/router.js +6 -4
  36. package/dist/commands/sbom.js +206 -0
  37. package/dist/commands/scan.js +1 -1
  38. package/dist/commands/service.js +32 -13
  39. package/dist/commands/setup.js +196 -27
  40. package/dist/commands/types.js +1 -0
  41. package/dist/commands/uninstall.js +2 -1
  42. package/dist/commands/update.js +17 -8
  43. package/dist/commands/verify.js +8 -5
  44. package/dist/config/settings.js +49 -48
  45. package/dist/decisions/remember-prompt.js +5 -10
  46. package/dist/export-ui/ExportDialog.js +198 -0
  47. package/dist/install-ui/LiveInstall.js +5 -4
  48. package/dist/install-ui/block-render.js +18 -14
  49. package/dist/install-ui/prep-spinner.js +32 -0
  50. package/dist/launcher/agent-check.js +896 -0
  51. package/dist/launcher/agent-hook-exec.js +70 -0
  52. package/dist/launcher/agent-hook-io.js +39 -0
  53. package/dist/launcher/cargo-cache.js +40 -0
  54. package/dist/launcher/classify.js +44 -7
  55. package/dist/launcher/env.js +105 -26
  56. package/dist/launcher/install-preflight.js +64 -18
  57. package/dist/launcher/live-install.js +29 -8
  58. package/dist/launcher/manifest-screen.js +171 -0
  59. package/dist/launcher/output-redaction.js +10 -4
  60. package/dist/launcher/preflight-prompt.js +18 -4
  61. package/dist/launcher/run.js +178 -103
  62. package/dist/launcher/spawn-invocation.js +21 -0
  63. package/dist/policy/cooldown.js +18 -5
  64. package/dist/policy/evaluate.js +5 -6
  65. package/dist/policy/pypi-name.js +17 -0
  66. package/dist/presentation/mode.js +3 -2
  67. package/dist/presentation/package-page.js +9 -0
  68. package/dist/presentation/theme.js +7 -7
  69. package/dist/project/dgfile.js +146 -6
  70. package/dist/project/override-trust.js +0 -0
  71. package/dist/proxy/auth.js +42 -0
  72. package/dist/proxy/ca.js +29 -9
  73. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  74. package/dist/proxy/enforcement.js +55 -16
  75. package/dist/proxy/metadata-map.js +66 -12
  76. package/dist/proxy/preverified.js +55 -0
  77. package/dist/proxy/server.js +568 -53
  78. package/dist/proxy/worker.js +16 -1
  79. package/dist/publish-set/collect.js +1 -4
  80. package/dist/publish-set/npm.js +8 -5
  81. package/dist/publish-set/pack.js +9 -3
  82. package/dist/runtime/cli.js +0 -4
  83. package/dist/runtime/fatal.js +31 -0
  84. package/dist/runtime/first-run.js +14 -12
  85. package/dist/runtime/node-version.js +43 -6
  86. package/dist/runtime/nudges.js +35 -2
  87. package/dist/sbom/cyclonedx.js +211 -0
  88. package/dist/sbom-ui/SbomApp.js +158 -0
  89. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  90. package/dist/sbom-ui/components/SbomList.js +52 -0
  91. package/dist/sbom-ui/inventory.js +132 -0
  92. package/dist/sbom-ui/launch.js +51 -0
  93. package/dist/sbom-ui/run.js +55 -0
  94. package/dist/sbom-ui/store.js +26 -0
  95. package/dist/scan/collect.js +10 -6
  96. package/dist/scan/command.js +17 -10
  97. package/dist/scan/discovery.js +11 -2
  98. package/dist/scan/render.js +28 -4
  99. package/dist/scan/scanner-report.js +15 -9
  100. package/dist/scan/staged.js +33 -9
  101. package/dist/scan-ui/LegacyApp.js +9 -13
  102. package/dist/scan-ui/alt-screen.js +5 -8
  103. package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
  104. package/dist/scan-ui/components/ProgressBar.js +3 -14
  105. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  106. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  107. package/dist/scan-ui/components/SetupBanner.js +0 -6
  108. package/dist/scan-ui/format-helpers.js +61 -5
  109. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  110. package/dist/scan-ui/hooks/useScan.js +19 -2
  111. package/dist/scan-ui/launch.js +7 -4
  112. package/dist/scan-ui/shims.js +11 -4
  113. package/dist/scripts/detect.js +7 -2
  114. package/dist/scripts/gate.js +1 -1
  115. package/dist/security/sanitize.js +8 -4
  116. package/dist/service/state.js +28 -9
  117. package/dist/service/trust-refresh.js +92 -0
  118. package/dist/service/trust-store.js +5 -9
  119. package/dist/service/worker.js +23 -1
  120. package/dist/setup/activate-shell.js +28 -0
  121. package/dist/setup/git-hook.js +49 -4
  122. package/dist/setup/plan.js +227 -49
  123. package/dist/setup/uninstall-standalone.js +25 -0
  124. package/dist/setup-ui/gate.js +39 -0
  125. package/dist/setup-ui/offer.js +42 -0
  126. package/dist/setup-ui/selector.js +27 -0
  127. package/dist/setup-ui/tasks.js +56 -0
  128. package/dist/setup-ui/wizard.js +233 -0
  129. package/dist/standalone/uninstall.mjs +2123 -0
  130. package/dist/state/cooldown-held.js +66 -0
  131. package/dist/state/index.js +1 -0
  132. package/dist/state/locks.js +4 -2
  133. package/dist/state/store.js +2 -1
  134. package/dist/util/external-tool.js +25 -0
  135. package/dist/util/git.js +10 -3
  136. package/dist/util/report-writer.js +57 -0
  137. package/dist/verify/local.js +242 -44
  138. package/dist/verify/package-check.js +87 -29
  139. package/dist/verify/preflight.js +242 -49
  140. package/dist/verify/render.js +15 -1
  141. package/npm-shrinkwrap.json +2383 -0
  142. package/package.json +7 -8
  143. package/NOTICE +0 -5
  144. package/dist/commands/completion.js +0 -117
  145. package/dist/commands/explain.js +0 -236
  146. package/dist/commands/unavailable.js +0 -11
  147. package/dist/scripts/rebuild.js +0 -28
  148. package/dist/telemetry/events.js +0 -40
@@ -1,26 +1,34 @@
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 { lookup as dnsLookup } from "node:dns";
11
+ import { createSecureContext, createServer as createTlsServer, connect as tlsConnect, rootCertificates } from "node:tls";
11
12
  import { createEphemeralCertificateAuthority } from "./ca.js";
12
13
  import { shouldMitmHost } from "./classify-host.js";
13
- import { enforceProtectedInstall } from "./enforcement.js";
14
- import { cooldownRequestParam } from "../policy/cooldown.js";
14
+ import { generateProxyAuthToken, proxyAuthorizationValue, verifyProxyAuthorization, writeProxyAuthToken } from "./auth.js";
15
+ import { enforceProtectedInstall, noteEnforcementSideEffectFailure } from "./enforcement.js";
16
+ import { cooldownRequestParam, isCooldownExemptByDgFile } from "../policy/cooldown.js";
15
17
  import { loadUserConfig } from "../config/settings.js";
16
18
  import { artifactDisplayName, artifactUrlHash, extractRegistryMetadataIdentities, isRegistryIndexRequest, resolveArtifactIdentity } from "./metadata-map.js";
19
+ import { loadPreverifiedMap, preverifiedKey } from "./preverified.js";
17
20
  import { authorityFor, connectViaUpstreamProxy, selectUpstreamProxy } from "./upstream-proxy.js";
18
21
  import { redactSecrets } from "../launcher/output-redaction.js";
22
+ import { sanitize } from "../security/sanitize.js";
19
23
  import { envAuthToken } from "../auth/env-token.js";
20
24
  import { identityHeaders } from "../api/analyze.js";
21
25
  import { dgVersion } from "../commands/version.js";
26
+ import { recordHeldPackage } from "../state/index.js";
22
27
  export async function startProductionHttpProxy(options) {
23
- const ca = createEphemeralCertificateAuthority(options.session.files.ca);
28
+ const onCaRotate = options.onCaRotate;
29
+ const ca = createEphemeralCertificateAuthority(options.session.files.ca, onCaRotate ? { onRotate: (caCertPem) => safeCaRotateCallback(onCaRotate, caCertPem) } : {});
30
+ const authToken = generateProxyAuthToken();
31
+ writeProxyAuthToken(options.session.dir, authToken);
24
32
  const state = {
25
33
  ready: false,
26
34
  port: 0,
@@ -32,6 +40,10 @@ export async function startProductionHttpProxy(options) {
32
40
  };
33
41
  const activeSockets = new Set();
34
42
  const server = createServer((request, response) => {
43
+ if (!verifyProxyAuthorization(headerValue(request.headers["proxy-authorization"]), authToken)) {
44
+ sendProxyAuthRequired(response);
45
+ return;
46
+ }
35
47
  handleProxyRequest(request, response, options, state).catch((error) => {
36
48
  const decision = recordDecision(options, state, {
37
49
  verdict: "block",
@@ -45,6 +57,10 @@ export async function startProductionHttpProxy(options) {
45
57
  server.on("connection", (socket) => trackSocket(activeSockets, socket));
46
58
  server.on("connect", (request, socket, head) => {
47
59
  const clientSocket = socket;
60
+ if (!verifyProxyAuthorization(headerValue(request.headers["proxy-authorization"]), authToken)) {
61
+ clientSocket.end("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"dg\"\r\nConnection: close\r\n\r\n");
62
+ return;
63
+ }
48
64
  handleConnectRequest(request, clientSocket, head, options, state, ca, activeSockets).catch((error) => {
49
65
  const decision = recordDecision(options, state, {
50
66
  verdict: "block",
@@ -65,9 +81,27 @@ export async function startProductionHttpProxy(options) {
65
81
  writeProxyState(options.session, state);
66
82
  return {
67
83
  port: address.port,
84
+ proxyAuthorization: proxyAuthorizationValue(authToken),
68
85
  close: () => closeServer(server, activeSockets)
69
86
  };
70
87
  }
88
+ function safeCaRotateCallback(onCaRotate, caCertPem) {
89
+ try {
90
+ onCaRotate(caCertPem);
91
+ }
92
+ catch (error) {
93
+ noteEnforcementSideEffectFailure(error);
94
+ }
95
+ }
96
+ function sendProxyAuthRequired(response) {
97
+ const body = "Dependency Guardian proxy requires the per-session credential issued to the wrapped package manager.\n";
98
+ response.writeHead(407, {
99
+ "Proxy-Authenticate": "Basic realm=\"dg\"",
100
+ "Content-Type": "text/plain; charset=utf-8",
101
+ "Content-Length": Buffer.byteLength(body)
102
+ });
103
+ response.end(body);
104
+ }
71
105
  export function readProxySessionState(session) {
72
106
  try {
73
107
  const parsed = JSON.parse(readFileSync(session.files.proxy, "utf8"));
@@ -136,7 +170,27 @@ async function handleConnectRequest(request, clientSocket, head, options, state,
136
170
  clientSocket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
137
171
  return;
138
172
  }
173
+ if (isLinkLocalHost(target)) {
174
+ recordDecision(options, state, {
175
+ verdict: "block",
176
+ packageName: target.hostname,
177
+ cause: "policy",
178
+ reason: `refusing CONNECT to link-local/metadata address ${target.hostname}`
179
+ });
180
+ clientSocket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
181
+ return;
182
+ }
139
183
  if (!shouldMitmHost(target.hostname, options.env)) {
184
+ if (strictEgressEnabled(options.env)) {
185
+ recordDecision(options, state, {
186
+ verdict: "block",
187
+ packageName: target.hostname,
188
+ cause: "policy",
189
+ reason: `strict egress: refusing to tunnel to un-screened host ${target.hostname} (add it to DG_PROXY_MITM_HOSTS to screen it, or disable policy.strictEgress)`
190
+ });
191
+ clientSocket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
192
+ return;
193
+ }
140
194
  state.events = [...state.events, `tunnel:${redactSecrets(authorityFor(target))}`];
141
195
  writeProxyState(options.session, state);
142
196
  await blindTunnel(clientSocket, head, target, options, activeSockets);
@@ -147,22 +201,337 @@ async function handleConnectRequest(request, clientSocket, head, options, state,
147
201
  await mitmTunnel(clientSocket, head, target, options, state, ca, activeSockets);
148
202
  }
149
203
  const REDIRECT_LIMIT = 5;
204
+ // Streaming tail-hold: an OPT-IN fast path for artifacts so large their raw
205
+ // download alone would outlast the client read timeout. It streams bytes while
206
+ // hashing and withholds only the final TAIL bytes until the verdict arrives —
207
+ // so on a block the client receives most of the (unverified) artifact and is
208
+ // relied on to reject the truncated body via its own size/integrity checks.
209
+ // That is a weaker guarantee than the default buffered path, which delivers
210
+ // ZERO bytes until a pass verdict. It is therefore DISABLED by default; the
211
+ // preferred fix for a slow verify is a generous client read timeout (set in
212
+ // buildProxyChildEnv), which keeps the strong buffered guarantee. Enable only
213
+ // for genuinely huge artifacts via DG_STREAM_THRESHOLD_BYTES=<bytes>.
214
+ const STREAM_TAIL_BYTES = 64 * 1024;
215
+ const STREAMING_DISABLED = Number.POSITIVE_INFINITY;
216
+ // A package install fetches dozens-to-hundreds of artifacts from the same few
217
+ // registry hosts. Without connection reuse each is a fresh TCP+TLS handshake
218
+ // (~100-250ms to registry.npmjs.org); keep-alive agents pool sockets per host
219
+ // so an N-tarball install pays one handshake, not N. Reuse is keyed by the full
220
+ // connection option-set (host, port, TLS params), so this never crosses hosts
221
+ // or mixes TLS trust. Purely transport-level — no effect on what gets verified.
222
+ const upstreamHttpAgent = new HttpAgent({ keepAlive: true, maxSockets: 64, scheduling: "fifo" });
223
+ const upstreamHttpsAgent = new HttpsAgent({ keepAlive: true, maxSockets: 64, scheduling: "fifo" });
224
+ // Opt-in (policy.strictEgress, default off): a CONNECT to a host dg doesn't MITM
225
+ // is blind-tunnelled, so its artifacts arrive unscanned. Managed/CI environments
226
+ // can fail those closed instead. A corrupt config reads as off so a broken config
227
+ // never blocks installs that the default would have allowed.
228
+ function strictEgressEnabled(env) {
229
+ try {
230
+ return loadUserConfig(env).policy.strictEgress;
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ }
236
+ function streamThresholdBytes(env) {
237
+ const raw = env.DG_STREAM_THRESHOLD_BYTES;
238
+ if (raw === undefined || raw === "") {
239
+ return STREAMING_DISABLED;
240
+ }
241
+ const parsed = Number(raw);
242
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : STREAMING_DISABLED;
243
+ }
244
+ function canStreamTarget(target, options) {
245
+ if (selectUpstreamProxy(target, options.env)) {
246
+ return false;
247
+ }
248
+ if (isRegistryIndexRequest(target)) {
249
+ return false;
250
+ }
251
+ return !hostMatchesList(target.hostname, options.env.DG_PRIVATE_REGISTRY_HOSTS ?? "");
252
+ }
253
+ function shouldStreamArtifactResponse(live, env) {
254
+ if (live.statusCode < 200 || live.statusCode >= 300 || live.statusCode === 206) {
255
+ return false;
256
+ }
257
+ const contentType = headerValue(live.headers["content-type"]) ?? "";
258
+ if (/\bjson\b/i.test(contentType)) {
259
+ return false;
260
+ }
261
+ const contentLength = Number(headerValue(live.headers["content-length"]) ?? "");
262
+ return Number.isFinite(contentLength) && contentLength > streamThresholdBytes(env);
263
+ }
264
+ async function fetchUpstreamHeadersFollowingRedirects(request, target, env) {
265
+ let current = target;
266
+ let live = await fetchUpstreamHeaders(request, current, env);
267
+ for (let hop = 0; isRedirectStatus(live.statusCode); hop += 1) {
268
+ live.stream.resume();
269
+ if (hop >= REDIRECT_LIMIT) {
270
+ throw new Error(`registry redirect chain exceeded ${REDIRECT_LIMIT} hops for ${current.host}`);
271
+ }
272
+ const location = headerValue(live.headers.location);
273
+ if (!location) {
274
+ throw new Error(`registry returned a ${live.statusCode} redirect with no Location header for ${current.host}`);
275
+ }
276
+ const next = new URL(location, current);
277
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
278
+ throw new Error(`registry redirected to unsupported protocol ${next.protocol}`);
279
+ }
280
+ if (isPrivateNetworkHost(next) && !isPrivateNetworkHost(target)) {
281
+ throw new Error(`registry redirected a public artifact request into a private address (${next.hostname})`);
282
+ }
283
+ live = await fetchUpstreamHeaders(syntheticGetRequest(redirectHopHeaders(request, current, next)), next, env);
284
+ current = next;
285
+ }
286
+ return live;
287
+ }
288
+ function fetchUpstreamHeaders(request, target, env) {
289
+ return new Promise((resolve, reject) => {
290
+ const isHttps = target.protocol === "https:";
291
+ const requester = isHttps ? httpsRequest : httpRequest;
292
+ let liveStream = null;
293
+ const upstream = requester({
294
+ hostname: upstreamHostname(target, env),
295
+ port: target.port ? Number(target.port) : isHttps ? 443 : 80,
296
+ path: `${target.pathname}${target.search}`,
297
+ method: request.method,
298
+ agent: isHttps ? upstreamHttpsAgent : upstreamHttpAgent,
299
+ headers: isHttps ? upstreamRequestHeaders(request, target) : stripProxyHopHeaders(request.headers),
300
+ ...(isHttps ? upstreamTlsOptions(env) : {})
301
+ }, (upstreamResponse) => {
302
+ liveStream = upstreamResponse;
303
+ resolve({
304
+ statusCode: upstreamResponse.statusCode ?? 502,
305
+ headers: responseHeaders(upstreamResponse),
306
+ stream: upstreamResponse
307
+ });
308
+ });
309
+ applyUpstreamTimeBudget(upstream, target, env, (error) => {
310
+ liveStream?.destroy(error);
311
+ reject(error);
312
+ });
313
+ upstream.on("error", reject);
314
+ request.on("data", (chunk) => upstream.write(chunk));
315
+ request.on("end", () => upstream.end());
316
+ request.on("error", reject);
317
+ request.once("close", () => {
318
+ if (!request.complete) {
319
+ upstream.destroy();
320
+ }
321
+ });
322
+ });
323
+ }
324
+ async function streamArtifactWithTailHold(response, target, options, state, live) {
325
+ const identityResolution = resolveArtifactIdentity(target, state.identities, options.classification);
326
+ if (identityResolution.kind === "ambiguous") {
327
+ live.stream.destroy();
328
+ const decision = recordDecision(options, state, {
329
+ verdict: "block",
330
+ packageName: identityResolution.packageName,
331
+ cause: "policy",
332
+ reason: identityResolution.reason
333
+ });
334
+ sendBlocked(response, decision);
335
+ return;
336
+ }
337
+ const identity = identityResolution.identity;
338
+ const inflightName = artifactDisplayName(identity);
339
+ state.inflight = [...state.inflight, inflightName];
340
+ writeProxyState(options.session, state);
341
+ const limit = maxArtifactBytes(options.env);
342
+ const hash = createHash("sha256");
343
+ const tail = [];
344
+ let tailSize = 0;
345
+ let total = 0;
346
+ response.writeHead(live.statusCode, live.headers);
347
+ response.once("close", () => {
348
+ if (!response.writableEnded) {
349
+ live.stream.destroy();
350
+ }
351
+ });
352
+ const finishInflight = () => {
353
+ state.inflight = removeFirst(state.inflight, inflightName);
354
+ };
355
+ try {
356
+ await new Promise((resolve, reject) => {
357
+ live.stream.on("data", (chunk) => {
358
+ total += chunk.length;
359
+ if (total > limit) {
360
+ reject(new BufferBudgetError(`artifact exceeded the ${limit}-byte streaming budget for ${target.host}`));
361
+ return;
362
+ }
363
+ hash.update(chunk);
364
+ tail.push(chunk);
365
+ tailSize += chunk.length;
366
+ while (tail.length > 1 && tailSize - (tail[0]?.length ?? 0) >= STREAM_TAIL_BYTES) {
367
+ const released = tail.shift();
368
+ if (released) {
369
+ tailSize -= released.length;
370
+ response.write(released);
371
+ }
372
+ }
373
+ });
374
+ live.stream.once("end", () => resolve());
375
+ live.stream.once("error", reject);
376
+ });
377
+ }
378
+ catch (error) {
379
+ finishInflight();
380
+ live.stream.destroy();
381
+ recordDecision(options, state, {
382
+ verdict: "block",
383
+ packageName: artifactDisplayName(identity),
384
+ cause: error instanceof BufferBudgetError ? "proxy-setup-failure" : "registry-timeout",
385
+ reason: error instanceof Error ? error.message : "registry stream failed"
386
+ });
387
+ response.destroy();
388
+ return;
389
+ }
390
+ const sha256 = hash.digest("hex");
391
+ state.hashes = [...state.hashes, {
392
+ url: redactSecrets(target.toString()),
393
+ sha256,
394
+ identity
395
+ }];
396
+ writeProxyState(options.session, state);
397
+ const verdict = await lookupVerdict(options, target, sha256, { statusCode: live.statusCode, headers: live.headers }, identity).catch((error) => ({
398
+ verdict: "block",
399
+ packageName: artifactDisplayName(identity),
400
+ cause: "api-timeout",
401
+ reason: error instanceof Error ? error.message : "Dependency Guardian API verdict lookup failed"
402
+ }));
403
+ finishInflight();
404
+ const decision = recordDecision(options, state, verdict);
405
+ if (decision.action === "block") {
406
+ response.destroy();
407
+ return;
408
+ }
409
+ for (const chunk of tail) {
410
+ response.write(chunk);
411
+ }
412
+ response.end();
413
+ }
150
414
  function isRedirectStatus(statusCode) {
151
415
  return [301, 302, 303, 307, 308].includes(statusCode);
152
416
  }
153
- function isPrivateNetworkHost(url) {
154
- const host = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
155
- if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1") {
417
+ // Node's WHATWG URL parser canonicalizes an IPv4-mapped IPv6 literal to the hex
418
+ // hextet form ([::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe]), so a guard that
419
+ // only string-matches the dotted-decimal form silently lets the cloud-metadata
420
+ // IP through as "public". Recover the embedded IPv4 (both forms) before
421
+ // classifying so the IPv4 ranges below cover the mapped address too.
422
+ function hextetsToIpv4(hi, lo) {
423
+ const h = parseInt(hi, 16);
424
+ const l = parseInt(lo, 16);
425
+ return `${(h >> 8) & 0xff}.${h & 0xff}.${(l >> 8) & 0xff}.${l & 0xff}`;
426
+ }
427
+ // Recover an IPv4 address embedded in an IPv6 literal so the IPv4 ranges below
428
+ // also cover it. Covers IPv4-mapped (::ffff:), IPv4-compatible (::), 6to4
429
+ // (2002::/16), and NAT64 (64:ff9b::/96) — each a way to smuggle 169.254.169.254
430
+ // past a guard that only string-matches the dotted-decimal form.
431
+ function recoverIpv4MappedHost(host) {
432
+ const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
433
+ if (mappedDotted?.[1])
434
+ return mappedDotted[1];
435
+ const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
436
+ if (mappedHex?.[1] && mappedHex[2])
437
+ return hextetsToIpv4(mappedHex[1], mappedHex[2]);
438
+ const compatDotted = /^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
439
+ if (compatDotted?.[1])
440
+ return compatDotted[1];
441
+ const compatHex = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
442
+ if (compatHex?.[1] && compatHex[2])
443
+ return hextetsToIpv4(compatHex[1], compatHex[2]);
444
+ const sixToFour = /^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/i.exec(host);
445
+ if (sixToFour?.[1] && sixToFour[2])
446
+ return hextetsToIpv4(sixToFour[1], sixToFour[2]);
447
+ const nat64Dotted = /^64:ff9b::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
448
+ if (nat64Dotted?.[1])
449
+ return nat64Dotted[1];
450
+ const nat64Hex = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
451
+ if (nat64Hex?.[1] && nat64Hex[2])
452
+ return hextetsToIpv4(nat64Hex[1], nat64Hex[2]);
453
+ return host;
454
+ }
455
+ // Block a RESOLVED address (the actual connect target), so a public hostname
456
+ // whose A/AAAA record points at an internal/metadata IP — DNS-rebinding SSRF —
457
+ // is refused at connect time, not just literal-IP targets.
458
+ export function isBlockedResolvedIp(ip) {
459
+ const host = recoverIpv4MappedHost(ip.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""));
460
+ if (host === "0.0.0.0" || host === "::" || host === "::1") {
156
461
  return true;
157
462
  }
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)) {
463
+ if (isPrivateIpv4(host)) {
159
464
  return true;
160
465
  }
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)) {
466
+ return /^(fe80|f[cd][0-9a-f]{2}):/.test(host);
467
+ }
468
+ function guardedLookup(hostname, options, callback) {
469
+ const opts = typeof options === "object" && options !== null ? options : {};
470
+ dnsLookup(hostname, { ...opts, all: true }, (err, addresses) => {
471
+ if (err) {
472
+ callback(err, "", 0);
473
+ return;
474
+ }
475
+ const list = addresses;
476
+ const blocked = list.find((entry) => isBlockedResolvedIp(entry.address));
477
+ if (blocked) {
478
+ callback(new Error(`refusing to connect to ${hostname}: resolves to blocked address ${blocked.address}`), "", 0);
479
+ return;
480
+ }
481
+ if (opts.all === true) {
482
+ callback(null, list);
483
+ return;
484
+ }
485
+ const first = list[0];
486
+ if (!first) {
487
+ callback(new Error(`no address found for ${hostname}`), "", 0);
488
+ return;
489
+ }
490
+ callback(null, first.address, first.family);
491
+ });
492
+ }
493
+ // The resolved-IP guard exists for DNS rebinding — a PUBLIC hostname whose A/AAAA
494
+ // record points at an internal IP. It must not fire for: an explicitly-configured
495
+ // private registry (DG_PRIVATE_REGISTRY_HOSTS) whose name legitimately resolves to
496
+ // a private IP; a test host-map target; or a target that is ALREADY a private/
497
+ // loopback literal (the user/test pointed there directly — not rebinding, and the
498
+ // link-local/metadata literal is still blocked at the artifact path).
499
+ function lookupForTarget(target, env) {
500
+ const host = target.hostname.replace(/^\[(.*)\]$/, "$1");
501
+ if (hostMatchesList(target.hostname, env.DG_PRIVATE_REGISTRY_HOSTS ?? "")) {
502
+ return undefined;
503
+ }
504
+ if (testUpstreamHostMap(env).has(host)) {
505
+ return undefined;
506
+ }
507
+ if (isPrivateNetworkHost(target)) {
508
+ return undefined;
509
+ }
510
+ return guardedLookup;
511
+ }
512
+ function isPrivateIpv4(host) {
513
+ return (/^(127|10|0)\./.test(host) ||
514
+ /^192\.168\./.test(host) ||
515
+ /^169\.254\./.test(host) ||
516
+ /^172\.(1[6-9]|2[0-9]|3[01])\./.test(host) ||
517
+ /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./.test(host));
518
+ }
519
+ export function isPrivateNetworkHost(url) {
520
+ const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
521
+ const host = recoverIpv4MappedHost(raw);
522
+ if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1") {
523
+ return true;
524
+ }
525
+ if (isPrivateIpv4(host)) {
162
526
  return true;
163
527
  }
164
528
  return /^(fe80|f[cd][0-9a-f]{2}):/.test(host);
165
529
  }
530
+ export function isLinkLocalHost(url) {
531
+ const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
532
+ const host = recoverIpv4MappedHost(raw);
533
+ return /^169\.254\./.test(host) || /^fe80:/.test(host);
534
+ }
166
535
  function syntheticGetRequest(headers) {
167
536
  const request = new EventEmitter();
168
537
  request.method = "GET";
@@ -204,12 +573,40 @@ async function fetchUpstreamFollowingRedirects(request, target, env) {
204
573
  }
205
574
  const ARTIFACT_CONDITIONAL_HEADERS = ["range", "if-range", "if-none-match", "if-modified-since"];
206
575
  async function handleArtifactRequest(request, response, target, options, state) {
576
+ if (isLinkLocalHost(target)) {
577
+ const decision = recordDecision(options, state, {
578
+ verdict: "block",
579
+ packageName: packageNameFromUrl(target),
580
+ cause: "policy",
581
+ reason: `refusing to fetch from link-local/metadata address ${target.hostname}`
582
+ });
583
+ sendBlocked(response, decision);
584
+ return;
585
+ }
207
586
  if (!isRegistryIndexRequest(target)) {
208
587
  for (const header of ARTIFACT_CONDITIONAL_HEADERS) {
209
588
  delete request.headers[header];
210
589
  }
211
590
  }
212
- const upstream = await fetchUpstreamFollowingRedirects(request, target, options.env).catch((error) => {
591
+ let upstream;
592
+ try {
593
+ if (canStreamTarget(target, options)) {
594
+ const live = await fetchUpstreamHeadersFollowingRedirects(request, target, options.env);
595
+ if (shouldStreamArtifactResponse(live, options.env)) {
596
+ await streamArtifactWithTailHold(response, target, options, state, live);
597
+ return;
598
+ }
599
+ upstream = {
600
+ statusCode: live.statusCode,
601
+ headers: live.headers,
602
+ body: await collectBounded(live.stream, { label: target.toString() })
603
+ };
604
+ }
605
+ else {
606
+ upstream = await fetchUpstreamFollowingRedirects(request, target, options.env);
607
+ }
608
+ }
609
+ catch (error) {
213
610
  const message = error instanceof Error ? error.message : "registry request failed";
214
611
  const decision = recordDecision(options, state, {
215
612
  verdict: "block",
@@ -218,8 +615,8 @@ async function handleArtifactRequest(request, response, target, options, state)
218
615
  reason: message
219
616
  });
220
617
  sendBlocked(response, decision);
221
- return null;
222
- });
618
+ return;
619
+ }
223
620
  if (!upstream) {
224
621
  return;
225
622
  }
@@ -289,7 +686,7 @@ async function handleArtifactRequest(request, response, target, options, state)
289
686
  reason: error instanceof Error ? error.message : "Dependency Guardian API verdict lookup failed"
290
687
  }));
291
688
  state.inflight = removeFirst(state.inflight, inflightName);
292
- const decision = recordDecision(options, state, verdict);
689
+ const decision = recordDecision(options, state, verdict, identity.identity);
293
690
  if (decision.action === "block") {
294
691
  sendBlocked(response, decision);
295
692
  return;
@@ -301,7 +698,7 @@ async function blindTunnel(clientSocket, head, target, options, activeSockets) {
301
698
  const upstreamProxy = selectUpstreamProxy(target, options.env);
302
699
  const upstreamSocket = upstreamProxy
303
700
  ? await connectViaUpstreamProxy(target, upstreamProxy)
304
- : await connectDirect(target);
701
+ : await connectDirect(target, options.env);
305
702
  trackSocket(activeSockets, upstreamSocket);
306
703
  clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
307
704
  if (head.length > 0) {
@@ -376,9 +773,9 @@ function parseConnectTarget(authority) {
376
773
  return null;
377
774
  }
378
775
  }
379
- function connectDirect(target) {
776
+ function connectDirect(target, env = process.env) {
380
777
  return new Promise((resolve, reject) => {
381
- const socket = connect(Number(target.port || "443"), upstreamHostname(target));
778
+ const socket = connect({ port: Number(target.port || "443"), host: upstreamHostname(target, env), lookup: lookupForTarget(target, env) });
382
779
  socket.once("connect", () => resolve(socket));
383
780
  socket.once("error", reject);
384
781
  });
@@ -388,6 +785,9 @@ function upstreamHostname(target, env = process.env) {
388
785
  return testUpstreamHostMap(env).get(hostname) ?? hostname;
389
786
  }
390
787
  function testUpstreamHostMap(env) {
788
+ if (env.NODE_ENV !== "test") {
789
+ return new Map();
790
+ }
391
791
  const entries = (env.DG_TEST_UPSTREAM_HOST_MAP ?? "")
392
792
  .split(",")
393
793
  .map((entry) => entry.trim())
@@ -405,7 +805,7 @@ function testUpstreamHostMap(env) {
405
805
  function fetchUpstream(request, target, env) {
406
806
  const upstreamProxy = selectUpstreamProxy(target, env);
407
807
  if (upstreamProxy && target.protocol === "http:") {
408
- return fetchHttpViaProxy(request, target, upstreamProxy);
808
+ return fetchHttpViaProxy(request, target, upstreamProxy, env);
409
809
  }
410
810
  if (upstreamProxy && target.protocol === "https:") {
411
811
  return fetchHttpsViaProxy(request, target, upstreamProxy, env);
@@ -419,7 +819,9 @@ function fetchUpstream(request, target, env) {
419
819
  port: target.port ? Number(target.port) : 80,
420
820
  path: `${target.pathname}${target.search}`,
421
821
  method: request.method,
422
- headers: request.headers
822
+ agent: upstreamHttpAgent,
823
+ lookup: lookupForTarget(target, env),
824
+ headers: stripProxyHopHeaders(request.headers)
423
825
  }, (upstreamResponse) => {
424
826
  collectBounded(upstreamResponse, { label: target.toString() })
425
827
  .then((body) => resolve({
@@ -429,6 +831,7 @@ function fetchUpstream(request, target, env) {
429
831
  }))
430
832
  .catch(reject);
431
833
  });
834
+ applyUpstreamTimeBudget(upstream, target, env, reject);
432
835
  upstream.on("error", reject);
433
836
  request.on("data", (chunk) => upstream.write(chunk));
434
837
  request.on("end", () => upstream.end());
@@ -440,10 +843,10 @@ function fetchUpstream(request, target, env) {
440
843
  });
441
844
  });
442
845
  }
443
- function fetchHttpViaProxy(request, target, upstreamProxy) {
846
+ function fetchHttpViaProxy(request, target, upstreamProxy, env) {
444
847
  return new Promise((resolve, reject) => {
445
848
  const headers = {
446
- ...request.headers,
849
+ ...stripProxyHopHeaders(request.headers),
447
850
  ...(upstreamProxy.authorizationHeader ? { "Proxy-Authorization": upstreamProxy.authorizationHeader } : {})
448
851
  };
449
852
  const upstream = httpRequest({
@@ -461,6 +864,7 @@ function fetchHttpViaProxy(request, target, upstreamProxy) {
461
864
  }))
462
865
  .catch(reject);
463
866
  });
867
+ applyUpstreamTimeBudget(upstream, target, env, reject);
464
868
  upstream.on("error", reject);
465
869
  request.on("data", (chunk) => upstream.write(chunk));
466
870
  request.on("end", () => upstream.end());
@@ -479,6 +883,8 @@ function fetchHttpsDirect(request, target, env) {
479
883
  port: target.port ? Number(target.port) : 443,
480
884
  path: `${target.pathname}${target.search}`,
481
885
  method: request.method,
886
+ agent: upstreamHttpsAgent,
887
+ lookup: lookupForTarget(target, env),
482
888
  headers: upstreamRequestHeaders(request, target),
483
889
  ...upstreamTlsOptions(env)
484
890
  }, (upstreamResponse) => {
@@ -490,6 +896,7 @@ function fetchHttpsDirect(request, target, env) {
490
896
  }))
491
897
  .catch(reject);
492
898
  });
899
+ applyUpstreamTimeBudget(upstream, target, env, reject);
493
900
  upstream.on("error", reject);
494
901
  request.on("data", (chunk) => upstream.write(chunk));
495
902
  request.on("end", () => upstream.end());
@@ -516,9 +923,35 @@ function connectTlsOverSocket(socket, target, env) {
516
923
  ALPNProtocols: ["http/1.1"],
517
924
  ...upstreamTlsOptions(env)
518
925
  }, () => resolve(tlsSocket));
926
+ applyUpstreamTimeBudget(tlsSocket, target, env);
519
927
  tlsSocket.once("error", reject);
520
928
  });
521
929
  }
930
+ function upstreamIdleTimeoutMs(env) {
931
+ return parsePositiveInteger(env.DG_UPSTREAM_IDLE_TIMEOUT_MS, 30_000);
932
+ }
933
+ function upstreamTotalTimeoutMs(env) {
934
+ return parsePositiveInteger(env.DG_UPSTREAM_TOTAL_TIMEOUT_MS, 600_000);
935
+ }
936
+ function applyUpstreamTimeBudget(upstream, target, env, onTimeout) {
937
+ const idleMs = upstreamIdleTimeoutMs(env);
938
+ const totalMs = upstreamTotalTimeoutMs(env);
939
+ const expire = (detail) => {
940
+ const error = new Error(`upstream registry request for ${target.host} timed out: ${detail}`);
941
+ onTimeout?.(error);
942
+ upstream.destroy(error);
943
+ };
944
+ upstream.setTimeout(idleMs, () => expire(`no data for ${idleMs}ms`));
945
+ const totalTimer = setTimeout(() => expire(`exceeded the ${totalMs}ms total budget`), totalMs);
946
+ totalTimer.unref();
947
+ upstream.once("close", () => clearTimeout(totalTimer));
948
+ }
949
+ function stripProxyHopHeaders(headers) {
950
+ const stripped = { ...headers };
951
+ delete stripped["proxy-authorization"];
952
+ delete stripped["proxy-connection"];
953
+ return stripped;
954
+ }
522
955
  function writeRawHttpRequest(tlsSocket, request, target, requestBody) {
523
956
  return new Promise((resolve, reject) => {
524
957
  collectBounded(tlsSocket, { label: target.toString() }).then(resolve).catch(reject);
@@ -584,14 +1017,20 @@ function headerLines(key, value) {
584
1017
  }
585
1018
  return [`${key}: ${value}`];
586
1019
  }
587
- function upstreamTlsOptions(env) {
588
- const caPath = env.DG_UPSTREAM_CA_CERT ?? env.NODE_EXTRA_CA_CERTS;
1020
+ export function upstreamTlsOptions(env) {
1021
+ // Trust an extra upstream CA only from DG_UPSTREAM_CA_CERT an explicit,
1022
+ // dg-specific knob for corporate TLS-intercepting proxies / private mirrors.
1023
+ // The ambient NODE_EXTRA_CA_CERTS is deliberately NOT honored here: dg's own
1024
+ // agent routing sets that variable to dg's MITM CA for the CLIENT side, so
1025
+ // re-consuming it as an UPSTREAM trust anchor would let any process that can
1026
+ // set it MITM the real registry through dg's proxy.
1027
+ const caPath = env.DG_UPSTREAM_CA_CERT;
589
1028
  if (!caPath) {
590
1029
  return {};
591
1030
  }
592
1031
  try {
593
1032
  return {
594
- ca: readFileSync(caPath, "utf8")
1033
+ ca: [...rootCertificates, readFileSync(caPath, "utf8")]
595
1034
  };
596
1035
  }
597
1036
  catch {
@@ -627,7 +1066,11 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
627
1066
  if (shouldUseScanTarball(options, target, identity)) {
628
1067
  return lookupScanTarballVerdict(options, target, sha256, upstream, identity);
629
1068
  }
630
- const cooldown = resolveCooldownRequest(options.env, identity);
1069
+ const preverified = preverifiedVerdict(options, target, sha256, identity);
1070
+ if (preverified) {
1071
+ return preverified;
1072
+ }
1073
+ const cooldown = resolveCooldownRequest(options, identity);
631
1074
  const controller = new AbortController();
632
1075
  const timeout = setTimeout(() => controller.abort(), options.verdictTimeoutMs ?? installVerdictTimeoutMs(options.env));
633
1076
  try {
@@ -641,7 +1084,7 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
641
1084
  body: JSON.stringify({
642
1085
  manager: options.classification.manager,
643
1086
  action: options.classification.action,
644
- url: target.toString(),
1087
+ url: redactSecrets(target.toString()),
645
1088
  artifactUrlHash: artifactUrlHash(target),
646
1089
  ecosystem: identity.ecosystem,
647
1090
  name: identity.name,
@@ -662,7 +1105,7 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
662
1105
  packageName: artifactDisplayName(identity),
663
1106
  cause: "quota-exceeded",
664
1107
  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 } : {}),
1108
+ ...(typeof body.resetsAt === "string" ? { resetsAt: sanitize(body.resetsAt) } : {}),
666
1109
  quotaBehavior: body.quotaBehavior === "pass" ? "pass" : "block"
667
1110
  };
668
1111
  }
@@ -748,6 +1191,39 @@ async function lookupScanTarballVerdict(options, target, sha256, upstream, ident
748
1191
  clearTimeout(timeout);
749
1192
  }
750
1193
  }
1194
+ // A preflight batch-analyze already verdicted this exact name@version (and the
1195
+ // user accepted any warns), so a per-artifact API round-trip would re-answer
1196
+ // the same question. Trust it only when the identity came from registry
1197
+ // metadata and the preflight applied the same cooldown gate the proxy would;
1198
+ // the synthesized verdict goes through normalizeVerdict so the scanned-SHA
1199
+ // cross-check blocks tampered bytes exactly like a server response would.
1200
+ function preverifiedVerdict(options, target, streamedSha256, identity) {
1201
+ if (identity.sourceKind !== "registry-metadata") {
1202
+ return null;
1203
+ }
1204
+ const entry = loadPreverifiedMap(options.session.dir).get(preverifiedKey(identity.ecosystem, identity.name, identity.version));
1205
+ if (!entry) {
1206
+ return null;
1207
+ }
1208
+ if (!entry.cooldownEvaluated && resolveCooldownRequest(options, identity) !== undefined) {
1209
+ return null;
1210
+ }
1211
+ if (!entry.scannedSha256) {
1212
+ // No byte-level fingerprint to cross-check against the streamed artifact, so
1213
+ // a preverified pass/warn can't prove the downloaded bytes match what was
1214
+ // screened (a metadata-only preflight, or a registry swap between preflight
1215
+ // and fetch). Fall through to a real scan of the streamed bytes — TOCTOU
1216
+ // defense — rather than honoring the verdict for whatever now arrives.
1217
+ return null;
1218
+ }
1219
+ return normalizeVerdict({
1220
+ verdict: entry.action,
1221
+ cause: entry.action,
1222
+ packageName: `${identity.name}@${identity.version}`,
1223
+ reason: entry.reason ?? (entry.action === "pass" ? "verified before install" : "flagged for review"),
1224
+ ...(entry.scannedSha256 ? { scannedSha256: entry.scannedSha256 } : {})
1225
+ }, target, identity, streamedSha256);
1226
+ }
751
1227
  function normalizeVerdict(value, target, identity, streamedSha256) {
752
1228
  if (!isRecord(value)) {
753
1229
  throw new Error("Dependency Guardian API returned a malformed verdict");
@@ -767,22 +1243,38 @@ function normalizeVerdict(value, target, identity, streamedSha256) {
767
1243
  }
768
1244
  const cause = typeof value.cause === "string" ? value.cause : undefined;
769
1245
  const cooldown = parseCooldownInfo(value.cooldown);
1246
+ const dashboardUrl = safeDashboardUrl(value.dashboardUrl);
770
1247
  return {
771
1248
  verdict,
772
- packageName: typeof value.packageName === "string" ? value.packageName : artifactDisplayName(identity) || packageNameFromUrl(target),
1249
+ packageName: typeof value.packageName === "string" ? sanitize(value.packageName) : artifactDisplayName(identity) || packageNameFromUrl(target),
773
1250
  ...(isProxyCause(cause) ? { cause } : {}),
774
- reason: typeof value.reason === "string" ? value.reason : `API verdict ${verdict}`,
775
- ...(typeof value.dashboardUrl === "string" ? { dashboardUrl: value.dashboardUrl } : {}),
1251
+ reason: typeof value.reason === "string" ? sanitize(value.reason) : `API verdict ${verdict}`,
1252
+ ...(dashboardUrl ? { dashboardUrl } : {}),
776
1253
  ...(value.unauthenticated === true ? { unauthenticated: true } : {}),
777
1254
  ...(cooldown ? { cooldown } : {})
778
1255
  };
779
1256
  }
780
- function resolveCooldownRequest(env, identity) {
1257
+ function safeDashboardUrl(value) {
1258
+ if (typeof value !== "string") {
1259
+ return undefined;
1260
+ }
1261
+ try {
1262
+ const url = new URL(sanitize(value));
1263
+ return url.protocol === "https:" ? url.toString() : undefined;
1264
+ }
1265
+ catch {
1266
+ return undefined;
1267
+ }
1268
+ }
1269
+ function resolveCooldownRequest(options, identity) {
781
1270
  if (identity.ecosystem === "unknown" || identity.version === "unknown") {
782
1271
  return undefined;
783
1272
  }
1273
+ if (isCooldownExemptByDgFile(identity.name, identity.ecosystem, options.cooldownExemptions ?? [])) {
1274
+ return undefined;
1275
+ }
784
1276
  try {
785
- return cooldownRequestParam(loadUserConfig(env), env, identity.ecosystem, identity.name);
1277
+ return cooldownRequestParam(loadUserConfig(options.env), options.env, identity.ecosystem, identity.name);
786
1278
  }
787
1279
  catch {
788
1280
  return undefined;
@@ -795,8 +1287,8 @@ function parseCooldownInfo(value) {
795
1287
  return {
796
1288
  requiredDays: value.requiredDays,
797
1289
  ...(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 } : {})
1290
+ ...(typeof value.publishedAt === "string" ? { publishedAt: sanitize(value.publishedAt) } : {}),
1291
+ ...(typeof value.eligibleAt === "string" ? { eligibleAt: sanitize(value.eligibleAt) } : {})
800
1292
  };
801
1293
  }
802
1294
  function normalizeScanTarballVerdict(value, target, identity, streamedSha256) {
@@ -857,7 +1349,7 @@ function mergeIdentities(existing, next) {
857
1349
  }
858
1350
  return merged;
859
1351
  }
860
- function recordDecision(options, state, verdict) {
1352
+ function recordDecision(options, state, verdict, identity) {
861
1353
  const decision = enforceProtectedInstall({
862
1354
  classification: options.classification,
863
1355
  env: options.env,
@@ -867,10 +1359,28 @@ function recordDecision(options, state, verdict) {
867
1359
  state.decisions = [...state.decisions, decision];
868
1360
  state.events = [...state.events, `${decision.action}:${decision.cause}:${redactSecrets(decision.packageName)}`];
869
1361
  writeProxyState(options.session, state);
1362
+ if (identity && decision.action === "block" && decision.cause === "cooldown" && decision.cooldown) {
1363
+ try {
1364
+ recordHeldPackage({
1365
+ ecosystem: identity.ecosystem,
1366
+ name: identity.name,
1367
+ version: identity.version,
1368
+ requiredDays: decision.cooldown.requiredDays,
1369
+ ...(decision.cooldown.ageDays !== undefined ? { ageDays: decision.cooldown.ageDays } : {}),
1370
+ ...(decision.cooldown.publishedAt ? { publishedAt: decision.cooldown.publishedAt } : {}),
1371
+ ...(decision.cooldown.eligibleAt ? { eligibleAt: decision.cooldown.eligibleAt } : {}),
1372
+ manager: options.classification.manager
1373
+ }, options.env);
1374
+ }
1375
+ catch (error) {
1376
+ noteEnforcementSideEffectFailure(error);
1377
+ }
1378
+ }
870
1379
  return decision;
871
1380
  }
872
1381
  function sendBlocked(response, decision) {
873
- const body = `Dependency Guardian blocked ${redactSecrets(decision.packageName)}: ${redactSecrets(decision.reason)}\n`;
1382
+ const breadcrumb = decision.cause === "cooldown" ? " · holds: dg cooldown" : "";
1383
+ const body = `Dependency Guardian blocked ${redactSecrets(decision.packageName)}: ${redactSecrets(decision.reason)}${breadcrumb}\n`;
874
1384
  response.writeHead(403, {
875
1385
  "Content-Type": "text/plain; charset=utf-8",
876
1386
  "Content-Length": Buffer.byteLength(body)
@@ -903,24 +1413,29 @@ function packageNameFromUrl(url) {
903
1413
  return name ? decodeURIComponent(name) : url.hostname;
904
1414
  }
905
1415
  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
1416
  try {
912
- writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, {
913
- encoding: "utf8",
914
- flag: "wx",
915
- mode: 0o600
1417
+ mkdirSync(dirname(session.files.proxy), {
1418
+ recursive: true,
1419
+ mode: 0o700
916
1420
  });
917
- renameSync(tempPath, session.files.proxy);
1421
+ const tempPath = `${session.files.proxy}.${process.pid}.${process.hrtime.bigint().toString(36)}.tmp`;
1422
+ try {
1423
+ writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, {
1424
+ encoding: "utf8",
1425
+ flag: "wx",
1426
+ mode: 0o600
1427
+ });
1428
+ renameSync(tempPath, session.files.proxy);
1429
+ }
1430
+ catch (error) {
1431
+ rmSync(tempPath, {
1432
+ force: true
1433
+ });
1434
+ throw error;
1435
+ }
918
1436
  }
919
1437
  catch (error) {
920
- rmSync(tempPath, {
921
- force: true
922
- });
923
- throw error;
1438
+ noteEnforcementSideEffectFailure(error);
924
1439
  }
925
1440
  }
926
1441
  function removeFirst(items, value) {
@@ -933,7 +1448,7 @@ function removeFirst(items, value) {
933
1448
  function isRecord(value) {
934
1449
  return typeof value === "object" && value !== null;
935
1450
  }
936
- function isProxyCause(value) {
1451
+ export function isProxyCause(value) {
937
1452
  return [
938
1453
  "pass",
939
1454
  "warn",