@westbayberry/dg 2.3.3 → 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +94 -217
  2. package/dist/agents/copilot-cli.js +7 -2
  3. package/dist/agents/persistence.js +83 -8
  4. package/dist/agents/registry.js +5 -2
  5. package/dist/agents/routing.js +31 -14
  6. package/dist/api/analyze.js +138 -41
  7. package/dist/audit/deep.js +2 -1
  8. package/dist/audit/detectors.js +23 -2
  9. package/dist/audit/rules.js +4 -1
  10. package/dist/audit-ui/export.js +5 -4
  11. package/dist/auth/device-login.js +33 -11
  12. package/dist/auth/login-app.js +17 -12
  13. package/dist/auth/store.js +65 -9
  14. package/dist/bin/dg.js +1 -1
  15. package/dist/commands/audit.js +12 -20
  16. package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
  17. package/dist/commands/cooldown.js +2 -1
  18. package/dist/commands/decisions.js +8 -7
  19. package/dist/commands/help.js +11 -3
  20. package/dist/commands/licenses.js +17 -8
  21. package/dist/commands/login.js +58 -21
  22. package/dist/commands/router.js +12 -2
  23. package/dist/commands/sbom.js +20 -5
  24. package/dist/commands/scan.js +5 -4
  25. package/dist/commands/service.js +1 -1
  26. package/dist/commands/setup.js +12 -12
  27. package/dist/commands/status.js +4 -4
  28. package/dist/commands/uninstall.js +1 -1
  29. package/dist/commands/update.js +20 -0
  30. package/dist/commands/verify.js +4 -4
  31. package/dist/config/settings.js +81 -12
  32. package/dist/export-ui/ExportDialog.js +1 -1
  33. package/dist/gate/cooldown-request.js +22 -0
  34. package/dist/gate/egress-guard.js +185 -0
  35. package/dist/gate/host-match.js +48 -0
  36. package/dist/gate/verdict-core.js +101 -0
  37. package/dist/gate/verdict-fetch.js +214 -0
  38. package/dist/launcher/agent-check.js +1096 -42
  39. package/dist/launcher/agent-hook-exec.js +8 -1
  40. package/dist/launcher/agent-hook-io.js +5 -3
  41. package/dist/launcher/classify.js +110 -26
  42. package/dist/launcher/env.js +84 -18
  43. package/dist/launcher/install-preflight.js +26 -3
  44. package/dist/launcher/live-install.js +4 -2
  45. package/dist/launcher/manifest-screen.js +88 -7
  46. package/dist/launcher/output-redaction.js +153 -15
  47. package/dist/launcher/preflight-prompt.js +1 -1
  48. package/dist/launcher/resolve-real-binary.js +8 -3
  49. package/dist/launcher/run.js +237 -40
  50. package/dist/origin/artifact-cache.js +55 -0
  51. package/dist/origin/cargo-config.js +12 -0
  52. package/dist/origin/gate-mode.js +7 -0
  53. package/dist/origin/platform-paths.js +14 -0
  54. package/dist/origin/precedence.js +345 -0
  55. package/dist/origin/server.js +611 -0
  56. package/dist/origin/spike-worker.js +35 -0
  57. package/dist/origin/worker.js +116 -0
  58. package/dist/presentation/package-page.js +21 -1
  59. package/dist/presentation/safe-version.js +11 -0
  60. package/dist/presentation/sarif.js +109 -0
  61. package/dist/project/dgfile.js +54 -17
  62. package/dist/project/override-trust.js +0 -0
  63. package/dist/proxy/enforcement.js +27 -7
  64. package/dist/proxy/metadata-map.js +33 -7
  65. package/dist/proxy/preverified.js +0 -3
  66. package/dist/proxy/server.js +104 -446
  67. package/dist/proxy/upstream-proxy.js +23 -4
  68. package/dist/proxy/worker.js +3 -2
  69. package/dist/publish-set/collect.js +6 -0
  70. package/dist/runtime/fatal.js +2 -1
  71. package/dist/runtime/first-run.js +5 -1
  72. package/dist/runtime/node-version.js +51 -0
  73. package/dist/runtime/nudges.js +27 -1
  74. package/dist/sbom/cyclonedx.js +120 -33
  75. package/dist/sbom/graph.js +236 -0
  76. package/dist/sbom/spdx.js +53 -0
  77. package/dist/sbom-ui/SbomApp.js +5 -3
  78. package/dist/sbom-ui/inventory.js +8 -10
  79. package/dist/scan/analyze-worker.js +3 -1
  80. package/dist/scan/collect.js +38 -8
  81. package/dist/scan/command.js +77 -27
  82. package/dist/scan/discovery.js +18 -7
  83. package/dist/scan/installed.js +202 -0
  84. package/dist/scan/manifest-coverage.js +128 -0
  85. package/dist/scan/render.js +125 -40
  86. package/dist/scan/scanner-report.js +92 -22
  87. package/dist/scan/staged.js +68 -15
  88. package/dist/scan/types.js +7 -1
  89. package/dist/scan-ui/LegacyApp.js +3 -3
  90. package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
  91. package/dist/scan-ui/hooks/useScan.js +37 -11
  92. package/dist/scan-ui/launch.js +2 -2
  93. package/dist/scan-ui/logo.js +6 -2
  94. package/dist/scan-ui/shims.js +6 -6
  95. package/dist/scripts/gate.js +53 -35
  96. package/dist/security/csv.js +9 -0
  97. package/dist/security/sanitize.js +11 -2
  98. package/dist/service/state.js +120 -37
  99. package/dist/service/trust-refresh.js +12 -1
  100. package/dist/service/trust-store.js +36 -5
  101. package/dist/service/worker.js +5 -7
  102. package/dist/setup/git-hook.js +2 -2
  103. package/dist/setup/install-method.js +36 -0
  104. package/dist/setup/plan.js +119 -57
  105. package/dist/setup/uninstall-standalone.js +7 -2
  106. package/dist/setup-ui/gate.js +1 -8
  107. package/dist/setup-ui/offer.js +14 -7
  108. package/dist/setup-ui/selector.js +1 -1
  109. package/dist/setup-ui/wizard.js +73 -78
  110. package/dist/standalone/uninstall.mjs +1114 -386
  111. package/dist/state/cleanup-registry.js +14 -3
  112. package/dist/state/cooldown-held.js +36 -13
  113. package/dist/state/locks.js +239 -34
  114. package/dist/state/sessions.js +2 -2
  115. package/dist/util/sh-escape.js +6 -0
  116. package/dist/util/tty-prompt.js +13 -4
  117. package/dist/verify/local.js +60 -10
  118. package/dist/verify/package-check.js +35 -49
  119. package/dist/verify/preflight.js +59 -33
  120. package/dist/verify/render.js +11 -12
  121. package/npm-shrinkwrap.json +714 -213
  122. package/package.json +3 -2
@@ -7,23 +7,22 @@ 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 { lookup as dnsLookup } from "node:dns";
11
10
  import { createSecureContext, createServer as createTlsServer, connect as tlsConnect, rootCertificates } from "node:tls";
12
11
  import { createEphemeralCertificateAuthority } from "./ca.js";
13
12
  import { shouldMitmHost } from "./classify-host.js";
14
13
  import { generateProxyAuthToken, proxyAuthorizationValue, verifyProxyAuthorization, writeProxyAuthToken } from "./auth.js";
15
14
  import { enforceProtectedInstall, noteEnforcementSideEffectFailure } from "./enforcement.js";
16
- import { cooldownRequestParam, isCooldownExemptByDgFile } from "../policy/cooldown.js";
17
15
  import { loadUserConfig } from "../config/settings.js";
18
- import { artifactDisplayName, artifactUrlHash, extractRegistryMetadataIdentities, isRegistryIndexRequest, resolveArtifactIdentity } from "./metadata-map.js";
19
- import { loadPreverifiedMap, preverifiedKey } from "./preverified.js";
16
+ import { artifactDisplayName, extractRegistryMetadataIdentities, isRegistryIndexRequest, resolveArtifactIdentity } from "./metadata-map.js";
20
17
  import { authorityFor, connectViaUpstreamProxy, selectUpstreamProxy } from "./upstream-proxy.js";
21
18
  import { redactSecrets } from "../launcher/output-redaction.js";
22
- import { sanitize } from "../security/sanitize.js";
23
- import { envAuthToken } from "../auth/env-token.js";
24
- import { identityHeaders } from "../api/analyze.js";
25
- import { dgVersion } from "../commands/version.js";
26
19
  import { recordHeldPackage } from "../state/index.js";
20
+ import { assertTargetEgressAllowed, isLinkLocalHost, isPrivateNetworkHost, lookupForTarget } from "../gate/egress-guard.js";
21
+ import { hostMatchesList, testUpstreamHostMap } from "../gate/host-match.js";
22
+ import { packageNameFromUrl } from "../gate/verdict-core.js";
23
+ import { lookupVerdict } from "../gate/verdict-fetch.js";
24
+ export { isBlockedResolvedIp, isLinkLocalHost, isPrivateNetworkHost } from "../gate/egress-guard.js";
25
+ export { isProxyCause } from "../gate/verdict-core.js";
27
26
  export async function startProductionHttpProxy(options) {
28
27
  const onCaRotate = options.onCaRotate;
29
28
  const ca = createEphemeralCertificateAuthority(options.session.files.ca, onCaRotate ? { onRotate: (caCertPem) => safeCaRotateCallback(onCaRotate, caCertPem) } : {});
@@ -277,6 +276,12 @@ async function fetchUpstreamHeadersFollowingRedirects(request, target, env) {
277
276
  if (next.protocol !== "http:" && next.protocol !== "https:") {
278
277
  throw new Error(`registry redirected to unsupported protocol ${next.protocol}`);
279
278
  }
279
+ // A link-local / cloud-metadata hop is NEVER a legitimate redirect target, even
280
+ // from an already-private registry (the public->private guard below would not
281
+ // fire when both ends are private). Refuse it on every hop, as the origin door does.
282
+ if (isLinkLocalHost(next)) {
283
+ throw new Error(`registry redirected to a link-local/metadata address (${next.hostname})`);
284
+ }
280
285
  if (isPrivateNetworkHost(next) && !isPrivateNetworkHost(target)) {
281
286
  throw new Error(`registry redirected a public artifact request into a private address (${next.hostname})`);
282
287
  }
@@ -296,6 +301,7 @@ function fetchUpstreamHeaders(request, target, env) {
296
301
  path: `${target.pathname}${target.search}`,
297
302
  method: request.method,
298
303
  agent: isHttps ? upstreamHttpsAgent : upstreamHttpAgent,
304
+ lookup: lookupForTarget(target, env),
299
305
  headers: isHttps ? upstreamRequestHeaders(request, target) : stripProxyHopHeaders(request.headers),
300
306
  ...(isHttps ? upstreamTlsOptions(env) : {})
301
307
  }, (upstreamResponse) => {
@@ -414,124 +420,6 @@ async function streamArtifactWithTailHold(response, target, options, state, live
414
420
  function isRedirectStatus(statusCode) {
415
421
  return [301, 302, 303, 307, 308].includes(statusCode);
416
422
  }
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") {
461
- return true;
462
- }
463
- if (isPrivateIpv4(host)) {
464
- return true;
465
- }
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)) {
526
- return true;
527
- }
528
- return /^(fe80|f[cd][0-9a-f]{2}):/.test(host);
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
- }
535
423
  function syntheticGetRequest(headers) {
536
424
  const request = new EventEmitter();
537
425
  request.method = "GET";
@@ -563,6 +451,12 @@ async function fetchUpstreamFollowingRedirects(request, target, env) {
563
451
  if (next.protocol !== "http:" && next.protocol !== "https:") {
564
452
  throw new Error(`registry redirected to unsupported protocol ${next.protocol}`);
565
453
  }
454
+ // A link-local / cloud-metadata hop is NEVER a legitimate redirect target, even
455
+ // from an already-private registry (the public->private guard below would not
456
+ // fire when both ends are private). Refuse it on every hop, as the origin door does.
457
+ if (isLinkLocalHost(next)) {
458
+ throw new Error(`registry redirected to a link-local/metadata address (${next.hostname})`);
459
+ }
566
460
  if (isPrivateNetworkHost(next) && !isPrivateNetworkHost(target)) {
567
461
  throw new Error(`registry redirected a public artifact request into a private address (${next.hostname})`);
568
462
  }
@@ -620,8 +514,29 @@ async function handleArtifactRequest(request, response, target, options, state)
620
514
  if (!upstream) {
621
515
  return;
622
516
  }
517
+ // Metadata is JSON.parse'd / regex-scanned in memory to extract identities; a
518
+ // near-gigabyte body from a malicious/MITM registry would burn the worker (the
519
+ // origin door caps this the same way). Legit packuments/indexes are KB-to-MB.
520
+ // Cap by metadata classification, not the spoofable content-type, so an HTML
521
+ // PyPI Simple index (not application/json) is bounded too.
522
+ const looksLikeMetadata = /\bjson\b/i.test(headerValue(upstream.headers["content-type"])) || isRegistryIndexRequest(target);
523
+ if (looksLikeMetadata && upstream.body.length > maxMetadataBytes(options.env)) {
524
+ const decision = recordDecision(options, state, {
525
+ verdict: "block",
526
+ packageName: packageNameFromUrl(target),
527
+ cause: "policy",
528
+ reason: `registry metadata exceeded the ${maxMetadataBytes(options.env)}-byte limit`
529
+ });
530
+ sendBlocked(response, decision);
531
+ return;
532
+ }
623
533
  const metadataIdentities = extractRegistryMetadataIdentities(target, upstream);
624
- if (metadataIdentities.length > 0 || isRegistryIndexRequest(target)) {
534
+ // An index-shaped path (npm `/-/…`, PyPI `/simple/…`, `…/json`, `.metadata`)
535
+ // is served ungated as metadata — but classify by the response BYTES, not the
536
+ // path or the spoofable content-type. A tarball/wheel a malicious packument
537
+ // points at an index-shaped URL (e.g. dist.tarball=/-/payload.tgz) starts with
538
+ // an archive magic, so it falls through to the verdict gate instead.
539
+ if (metadataIdentities.length > 0 || (isRegistryIndexRequest(target) && !bodyLooksLikeArchive(upstream.body))) {
625
540
  if (metadataIdentities.length > 0) {
626
541
  state.identities = mergeIdentities(state.identities, metadataIdentities);
627
542
  state.events = [...state.events, `metadata:${target.hostname}:${metadataIdentities.length}`];
@@ -696,6 +611,9 @@ async function handleArtifactRequest(request, response, target, options, state)
696
611
  }
697
612
  async function blindTunnel(clientSocket, head, target, options, activeSockets) {
698
613
  const upstreamProxy = selectUpstreamProxy(target, options.env);
614
+ if (upstreamProxy) {
615
+ await assertTargetEgressAllowed(target, options.env);
616
+ }
699
617
  const upstreamSocket = upstreamProxy
700
618
  ? await connectViaUpstreamProxy(target, upstreamProxy)
701
619
  : await connectDirect(target, options.env);
@@ -784,24 +702,6 @@ function upstreamHostname(target, env = process.env) {
784
702
  const hostname = target.hostname.replace(/^\[(.*)\]$/, "$1");
785
703
  return testUpstreamHostMap(env).get(hostname) ?? hostname;
786
704
  }
787
- function testUpstreamHostMap(env) {
788
- if (env.NODE_ENV !== "test") {
789
- return new Map();
790
- }
791
- const entries = (env.DG_TEST_UPSTREAM_HOST_MAP ?? "")
792
- .split(",")
793
- .map((entry) => entry.trim())
794
- .filter(Boolean)
795
- .map((entry) => {
796
- const separator = entry.indexOf("=");
797
- if (separator <= 0) {
798
- return null;
799
- }
800
- return [entry.slice(0, separator), entry.slice(separator + 1)];
801
- })
802
- .filter((entry) => entry !== null);
803
- return new Map(entries);
804
- }
805
705
  function fetchUpstream(request, target, env) {
806
706
  const upstreamProxy = selectUpstreamProxy(target, env);
807
707
  if (upstreamProxy && target.protocol === "http:") {
@@ -843,7 +743,8 @@ function fetchUpstream(request, target, env) {
843
743
  });
844
744
  });
845
745
  }
846
- function fetchHttpViaProxy(request, target, upstreamProxy, env) {
746
+ async function fetchHttpViaProxy(request, target, upstreamProxy, env) {
747
+ await assertTargetEgressAllowed(target, env);
847
748
  return new Promise((resolve, reject) => {
848
749
  const headers = {
849
750
  ...stripProxyHopHeaders(request.headers),
@@ -909,6 +810,7 @@ function fetchHttpsDirect(request, target, env) {
909
810
  });
910
811
  }
911
812
  async function fetchHttpsViaProxy(request, target, upstreamProxy, env) {
813
+ await assertTargetEgressAllowed(target, env);
912
814
  const tunnel = await connectViaUpstreamProxy(target, upstreamProxy);
913
815
  const tlsSocket = await connectTlsOverSocket(tunnel, target, env);
914
816
  const requestBody = await readRequestBody(request);
@@ -956,9 +858,14 @@ function writeRawHttpRequest(tlsSocket, request, target, requestBody) {
956
858
  return new Promise((resolve, reject) => {
957
859
  collectBounded(tlsSocket, { label: target.toString() }).then(resolve).catch(reject);
958
860
  const headers = upstreamRequestHeaders(request, target);
861
+ // Frame the request from the body the proxy actually writes, never the
862
+ // client's forwarded Transfer-Encoding / Content-Length (those are dropped in
863
+ // upstreamRequestHeaders). Emitting the original framing while re-writing the
864
+ // body is a TE/CL request-smuggling desync.
959
865
  const lines = [
960
866
  `${request.method ?? "GET"} ${target.pathname}${target.search} HTTP/1.1`,
961
867
  ...Object.entries(headers).flatMap(([key, value]) => headerLines(key, value)),
868
+ ...(requestBody.length > 0 ? [`Content-Length: ${requestBody.length}`] : []),
962
869
  "Connection: close",
963
870
  "",
964
871
  ""
@@ -970,7 +877,7 @@ function writeRawHttpRequest(tlsSocket, request, target, requestBody) {
970
877
  tlsSocket.end();
971
878
  });
972
879
  }
973
- function parseRawHttpResponse(raw) {
880
+ export function parseRawHttpResponse(raw) {
974
881
  const headerEnd = raw.indexOf("\r\n\r\n");
975
882
  if (headerEnd === -1) {
976
883
  throw new Error("upstream registry returned a malformed HTTP response");
@@ -990,17 +897,33 @@ function parseRawHttpResponse(raw) {
990
897
  const existing = headers[key];
991
898
  headers[key] = existing === undefined ? value : `${headerValue(existing)},${value}`;
992
899
  }
900
+ const codings = headerValue(headers["transfer-encoding"]).toLowerCase()
901
+ .split(",")
902
+ .map((coding) => coding.trim())
903
+ .filter((coding) => coding.length > 0);
904
+ const chunkedIndex = codings.indexOf("chunked");
905
+ const isChunked = chunkedIndex !== -1 && chunkedIndex === codings.length - 1;
906
+ if (chunkedIndex !== -1 && !isChunked) {
907
+ // chunked must be the final transfer-coding (RFC 7230 §3.3.1); a non-final
908
+ // chunked is unframeable, so reject rather than guess where the body ends.
909
+ throw new Error("upstream registry response declared chunked as a non-final transfer-coding");
910
+ }
911
+ if (isChunked && headers["content-length"] !== undefined) {
912
+ // RFC 7230 framing ambiguity (request/response smuggling surface): never
913
+ // guess which one frames the gated bytes — reject the response.
914
+ throw new Error("upstream registry response declared both Transfer-Encoding and Content-Length");
915
+ }
993
916
  return {
994
917
  statusCode: status ? Number(status) : 502,
995
918
  headers,
996
- body: headerValue(headers["transfer-encoding"]).toLowerCase() === "chunked" ? decodeChunkedBody(body) : body
919
+ body: isChunked ? decodeChunkedBody(body) : body
997
920
  };
998
921
  }
999
922
  function upstreamRequestHeaders(request, target) {
1000
923
  const headers = {};
1001
924
  for (const [key, value] of Object.entries(request.headers)) {
1002
925
  const normalized = key.toLowerCase();
1003
- if (["proxy-authorization", "proxy-connection", "connection", "host"].includes(normalized) || value === undefined) {
926
+ if (["proxy-authorization", "proxy-connection", "connection", "host", "transfer-encoding", "content-length"].includes(normalized) || value === undefined) {
1004
927
  continue;
1005
928
  }
1006
929
  headers[key] = Array.isArray(value) ? [...value] : value;
@@ -1041,278 +964,33 @@ function readRequestBody(request) {
1041
964
  return collectBounded(request, { label: "proxied request body" });
1042
965
  }
1043
966
  function decodeChunkedBody(body) {
967
+ // Any malformation throws rather than returning a prefix: the decoded body is
968
+ // hashed + scanned to gate the install, so silently truncating it would let an
969
+ // attacker hide payload in a tail the scanner never sees.
1044
970
  const chunks = [];
1045
971
  let offset = 0;
1046
972
  while (offset < body.length) {
1047
973
  const lineEnd = body.indexOf("\r\n", offset);
1048
974
  if (lineEnd === -1) {
1049
- break;
975
+ throw new Error("upstream registry returned a chunked response with no chunk-size terminator");
1050
976
  }
1051
- const size = Number.parseInt(body.subarray(offset, lineEnd).toString("latin1"), 16);
1052
- if (!Number.isFinite(size) || size < 0) {
1053
- break;
977
+ const sizeToken = (body.subarray(offset, lineEnd).toString("latin1").split(";")[0] ?? "").trim();
978
+ if (!/^[0-9a-fA-F]+$/.test(sizeToken)) {
979
+ throw new Error("upstream registry returned a malformed chunk size");
1054
980
  }
981
+ const size = Number.parseInt(sizeToken, 16);
1055
982
  if (size === 0) {
1056
983
  return Buffer.concat(chunks);
1057
984
  }
1058
985
  const start = lineEnd + 2;
1059
986
  const end = start + size;
987
+ if (end > body.length) {
988
+ throw new Error("upstream registry returned a chunk shorter than its declared size");
989
+ }
1060
990
  chunks.push(body.subarray(start, end));
1061
991
  offset = end + 2;
1062
992
  }
1063
- return Buffer.concat(chunks);
1064
- }
1065
- async function lookupVerdict(options, target, sha256, upstream, identity) {
1066
- if (shouldUseScanTarball(options, target, identity)) {
1067
- return lookupScanTarballVerdict(options, target, sha256, upstream, identity);
1068
- }
1069
- const preverified = preverifiedVerdict(options, target, sha256, identity);
1070
- if (preverified) {
1071
- return preverified;
1072
- }
1073
- const cooldown = resolveCooldownRequest(options, identity);
1074
- const controller = new AbortController();
1075
- const timeout = setTimeout(() => controller.abort(), options.verdictTimeoutMs ?? installVerdictTimeoutMs(options.env));
1076
- try {
1077
- const response = await fetch(`${options.apiBaseUrl}/v1/install-verdict`, {
1078
- method: "POST",
1079
- headers: {
1080
- "Content-Type": "application/json",
1081
- "X-Dg-Version": dgVersion(),
1082
- ...identityHeaders(options.env)
1083
- },
1084
- body: JSON.stringify({
1085
- manager: options.classification.manager,
1086
- action: options.classification.action,
1087
- url: redactSecrets(target.toString()),
1088
- artifactUrlHash: artifactUrlHash(target),
1089
- ecosystem: identity.ecosystem,
1090
- name: identity.name,
1091
- version: identity.version,
1092
- registryHost: identity.registryHost,
1093
- sourceKind: identity.sourceKind,
1094
- sha256,
1095
- statusCode: upstream.statusCode,
1096
- contentType: headerValue(upstream.headers["content-type"]),
1097
- ...(cooldown ? { cooldown } : {})
1098
- }),
1099
- signal: controller.signal
1100
- });
1101
- if (response.status === 402 || response.status === 429) {
1102
- const body = (await response.json().catch(() => ({})));
1103
- return {
1104
- verdict: "block",
1105
- packageName: artifactDisplayName(identity),
1106
- cause: "quota-exceeded",
1107
- reason: "You've reached your monthly scan limit. Upgrade at westbayberry.com/pricing or wait for it to reset.",
1108
- ...(typeof body.resetsAt === "string" ? { resetsAt: sanitize(body.resetsAt) } : {}),
1109
- quotaBehavior: body.quotaBehavior === "pass" ? "pass" : "block"
1110
- };
1111
- }
1112
- if (response.status === 401) {
1113
- return {
1114
- verdict: "block",
1115
- packageName: artifactDisplayName(identity),
1116
- cause: "needs-login",
1117
- unauthenticated: true,
1118
- reason: "Checking a package from the registry before it installs requires sign-in."
1119
- };
1120
- }
1121
- if (!response.ok) {
1122
- return {
1123
- verdict: "block",
1124
- packageName: artifactDisplayName(identity),
1125
- cause: "api-unavailable",
1126
- reason: `Dependency Guardian API returned ${response.status}. Run 'dg doctor' and 'dg login' to verify API access.`
1127
- };
1128
- }
1129
- return normalizeVerdict(await response.json(), target, identity, sha256);
1130
- }
1131
- finally {
1132
- clearTimeout(timeout);
1133
- }
1134
- }
1135
- async function lookupScanTarballVerdict(options, target, sha256, upstream, identity) {
1136
- const body = upstream.body ?? Buffer.alloc(0);
1137
- const uploadPolicy = scanTarballUploadPolicy(options.env);
1138
- if (!uploadPolicy.enabled) {
1139
- return {
1140
- verdict: "block",
1141
- packageName: artifactDisplayName(identity),
1142
- cause: "private-upload-disabled",
1143
- reason: `private artifact scan upload is disabled for ${target.hostname}`
1144
- };
1145
- }
1146
- if (!uploadPolicy.token) {
1147
- return {
1148
- verdict: "block",
1149
- packageName: artifactDisplayName(identity),
1150
- cause: "private-upload-disabled",
1151
- reason: "private artifact scan upload requires DG_API_TOKEN"
1152
- };
1153
- }
1154
- if (body.length > uploadPolicy.maxBytes) {
1155
- return {
1156
- verdict: "block",
1157
- packageName: artifactDisplayName(identity),
1158
- cause: "private-upload-disabled",
1159
- reason: `private artifact is ${body.length} bytes, above the ${uploadPolicy.maxBytes} byte scan-tarball upload limit`
1160
- };
1161
- }
1162
- const controller = new AbortController();
1163
- const timeout = setTimeout(() => controller.abort(), uploadPolicy.timeoutMs);
1164
- try {
1165
- const response = await fetch(`${options.apiBaseUrl}/v1/scan-tarball`, {
1166
- method: "POST",
1167
- headers: {
1168
- "Authorization": `Bearer ${uploadPolicy.token}`,
1169
- "Content-Type": "application/octet-stream",
1170
- "X-DG-Action": options.classification.action,
1171
- "X-DG-Artifact-SHA256": sha256,
1172
- "X-DG-Artifact-URL-Hash": artifactUrlHash(target),
1173
- "X-DG-Cache-Key": `sha256:${sha256}`,
1174
- "X-DG-Ecosystem": identity.ecosystem,
1175
- "X-DG-Manager": options.classification.manager,
1176
- "X-DG-Package-Name": identity.name,
1177
- "X-DG-Package-Version": identity.version,
1178
- "X-DG-Privacy": "private-artifact",
1179
- "X-DG-Registry-Host": identity.registryHost,
1180
- "X-DG-Source-Kind": identity.sourceKind
1181
- },
1182
- body,
1183
- signal: controller.signal
1184
- });
1185
- if (!response.ok) {
1186
- throw new Error(`Dependency Guardian scan-tarball API returned ${response.status}`);
1187
- }
1188
- return normalizeScanTarballVerdict(await response.json(), target, identity, sha256);
1189
- }
1190
- finally {
1191
- clearTimeout(timeout);
1192
- }
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
- }
1227
- function normalizeVerdict(value, target, identity, streamedSha256) {
1228
- if (!isRecord(value)) {
1229
- throw new Error("Dependency Guardian API returned a malformed verdict");
1230
- }
1231
- const verdict = value.verdict;
1232
- if (verdict !== "pass" && verdict !== "warn" && verdict !== "block") {
1233
- throw new Error("Dependency Guardian API returned a malformed verdict");
1234
- }
1235
- const scannedSha256 = typeof value.scannedSha256 === "string" ? value.scannedSha256.toLowerCase() : "";
1236
- if (scannedSha256.length > 0 && scannedSha256 !== streamedSha256) {
1237
- return {
1238
- verdict: "block",
1239
- packageName: artifactDisplayName(identity),
1240
- cause: "hash-mismatch",
1241
- reason: `server scanned SHA-256 ${scannedSha256} did not match streamed artifact SHA-256 ${streamedSha256}`
1242
- };
1243
- }
1244
- const cause = typeof value.cause === "string" ? value.cause : undefined;
1245
- const cooldown = parseCooldownInfo(value.cooldown);
1246
- const dashboardUrl = safeDashboardUrl(value.dashboardUrl);
1247
- return {
1248
- verdict,
1249
- packageName: typeof value.packageName === "string" ? sanitize(value.packageName) : artifactDisplayName(identity) || packageNameFromUrl(target),
1250
- ...(isProxyCause(cause) ? { cause } : {}),
1251
- reason: typeof value.reason === "string" ? sanitize(value.reason) : `API verdict ${verdict}`,
1252
- ...(dashboardUrl ? { dashboardUrl } : {}),
1253
- ...(value.unauthenticated === true ? { unauthenticated: true } : {}),
1254
- ...(cooldown ? { cooldown } : {})
1255
- };
1256
- }
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) {
1270
- if (identity.ecosystem === "unknown" || identity.version === "unknown") {
1271
- return undefined;
1272
- }
1273
- if (isCooldownExemptByDgFile(identity.name, identity.ecosystem, options.cooldownExemptions ?? [])) {
1274
- return undefined;
1275
- }
1276
- try {
1277
- return cooldownRequestParam(loadUserConfig(options.env), options.env, identity.ecosystem, identity.name);
1278
- }
1279
- catch {
1280
- return undefined;
1281
- }
1282
- }
1283
- function parseCooldownInfo(value) {
1284
- if (!isRecord(value) || typeof value.requiredDays !== "number" || !Number.isFinite(value.requiredDays)) {
1285
- return undefined;
1286
- }
1287
- return {
1288
- requiredDays: value.requiredDays,
1289
- ...(typeof value.ageDays === "number" && Number.isFinite(value.ageDays) ? { ageDays: value.ageDays } : {}),
1290
- ...(typeof value.publishedAt === "string" ? { publishedAt: sanitize(value.publishedAt) } : {}),
1291
- ...(typeof value.eligibleAt === "string" ? { eligibleAt: sanitize(value.eligibleAt) } : {})
1292
- };
1293
- }
1294
- function normalizeScanTarballVerdict(value, target, identity, streamedSha256) {
1295
- if (!isRecord(value) || typeof value.scannedSha256 !== "string" || value.scannedSha256.length === 0) {
1296
- throw new Error("Dependency Guardian scan-tarball API did not return scannedSha256");
1297
- }
1298
- return normalizeVerdict(value, target, identity, streamedSha256);
1299
- }
1300
- function shouldUseScanTarball(options, target, identity) {
1301
- if (identity.sourceKind !== "url-fallback") {
1302
- return false;
1303
- }
1304
- return hostMatchesList(target.hostname, options.env.DG_PRIVATE_REGISTRY_HOSTS ?? "");
1305
- }
1306
- function scanTarballUploadPolicy(env) {
1307
- return {
1308
- enabled: env.DG_SCAN_TARBALL_UPLOAD === "1" || env.DG_SCAN_TARBALL_UPLOAD === "true",
1309
- maxBytes: parsePositiveInteger(env.DG_SCAN_TARBALL_MAX_BYTES, 50 * 1024 * 1024),
1310
- timeoutMs: parsePositiveInteger(env.DG_SCAN_TARBALL_TIMEOUT_MS, 5_000),
1311
- token: envAuthToken(env) ?? ""
1312
- };
1313
- }
1314
- function installVerdictTimeoutMs(env) {
1315
- return parsePositiveInteger(env.DG_INSTALL_VERDICT_TIMEOUT_MS, 240_000);
993
+ throw new Error("upstream registry returned an unterminated chunked response");
1316
994
  }
1317
995
  function parsePositiveInteger(value, fallback) {
1318
996
  if (!value) {
@@ -1321,20 +999,8 @@ function parsePositiveInteger(value, fallback) {
1321
999
  const parsed = Number.parseInt(value, 10);
1322
1000
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
1323
1001
  }
1324
- function hostMatchesList(host, rawList) {
1325
- const normalized = normalizeHost(host);
1326
- return rawList.split(",").map((entry) => entry.trim()).filter(Boolean).some((pattern) => hostMatchesPattern(normalized, pattern));
1327
- }
1328
- function hostMatchesPattern(host, pattern) {
1329
- const normalized = normalizeHost(pattern);
1330
- if (normalized.startsWith("*.")) {
1331
- const suffix = normalized.slice(1);
1332
- return host.endsWith(suffix) && host.length > suffix.length;
1333
- }
1334
- return host === normalized;
1335
- }
1336
- function normalizeHost(host) {
1337
- return host.replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "").toLowerCase();
1002
+ function maxMetadataBytes(env) {
1003
+ return parsePositiveInteger(env.DG_ORIGIN_MAX_METADATA_BYTES, 64 * 1024 * 1024);
1338
1004
  }
1339
1005
  function mergeIdentities(existing, next) {
1340
1006
  const merged = [...existing];
@@ -1408,9 +1074,24 @@ function headerValue(value) {
1408
1074
  }
1409
1075
  return "";
1410
1076
  }
1411
- function packageNameFromUrl(url) {
1412
- const name = url.pathname.split("/").filter(Boolean).pop();
1413
- return name ? decodeURIComponent(name) : url.hostname;
1077
+ // True when the body begins with a package-archive magic (gzip .tgz/.tar.gz, zip
1078
+ // .whl/.zip, bzip2, xz). Registry index/API responses are JSON or text, so this
1079
+ // distinguishes a real artifact from metadata by content rather than by a
1080
+ // path shape or a content-type header an upstream can forge.
1081
+ function bodyLooksLikeArchive(body) {
1082
+ if (body.length < 4) {
1083
+ return false;
1084
+ }
1085
+ const [b0, b1, b2, b3] = body;
1086
+ if ((b0 === 0x1f && b1 === 0x8b) ||
1087
+ (b0 === 0x50 && b1 === 0x4b && (b2 === 0x03 || b2 === 0x05 || b2 === 0x07)) ||
1088
+ (b0 === 0x42 && b1 === 0x5a && b2 === 0x68) ||
1089
+ (b0 === 0xfd && b1 === 0x37 && b2 === 0x7a && b3 === 0x58)) {
1090
+ return true;
1091
+ }
1092
+ // Uncompressed POSIX/GNU tar carries the `ustar` magic at offset 257; without
1093
+ // this an index-shaped URL serving a plain .tar artifact passes through ungated.
1094
+ return body.length >= 263 && body.subarray(257, 262).toString("ascii") === "ustar";
1414
1095
  }
1415
1096
  function writeProxyState(session, state) {
1416
1097
  try {
@@ -1445,26 +1126,3 @@ function removeFirst(items, value) {
1445
1126
  }
1446
1127
  return [...items.slice(0, index), ...items.slice(index + 1)];
1447
1128
  }
1448
- function isRecord(value) {
1449
- return typeof value === "object" && value !== null;
1450
- }
1451
- export function isProxyCause(value) {
1452
- return [
1453
- "pass",
1454
- "warn",
1455
- "malware",
1456
- "policy",
1457
- "license",
1458
- "hash-mismatch",
1459
- "private-upload-disabled",
1460
- "needs-login",
1461
- "api-unavailable",
1462
- "quota-exceeded",
1463
- "api-timeout",
1464
- "registry-timeout",
1465
- "analysis-incomplete",
1466
- "cooldown",
1467
- "unsupported-manager",
1468
- "proxy-setup-failure"
1469
- ].includes(value ?? "");
1470
- }