@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.
- package/README.md +94 -217
- package/dist/agents/copilot-cli.js +7 -2
- package/dist/agents/persistence.js +83 -8
- package/dist/agents/registry.js +5 -2
- package/dist/agents/routing.js +31 -14
- package/dist/api/analyze.js +138 -41
- package/dist/audit/deep.js +2 -1
- package/dist/audit/detectors.js +23 -2
- package/dist/audit/rules.js +4 -1
- package/dist/audit-ui/export.js +5 -4
- package/dist/auth/device-login.js +33 -11
- package/dist/auth/login-app.js +17 -12
- package/dist/auth/store.js +65 -9
- package/dist/bin/dg.js +1 -1
- package/dist/commands/audit.js +12 -20
- package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
- package/dist/commands/cooldown.js +2 -1
- package/dist/commands/decisions.js +8 -7
- package/dist/commands/help.js +11 -3
- package/dist/commands/licenses.js +17 -8
- package/dist/commands/login.js +58 -21
- package/dist/commands/router.js +12 -2
- package/dist/commands/sbom.js +20 -5
- package/dist/commands/scan.js +5 -4
- package/dist/commands/service.js +1 -1
- package/dist/commands/setup.js +12 -12
- package/dist/commands/status.js +4 -4
- package/dist/commands/uninstall.js +1 -1
- package/dist/commands/update.js +20 -0
- package/dist/commands/verify.js +4 -4
- package/dist/config/settings.js +81 -12
- package/dist/export-ui/ExportDialog.js +1 -1
- package/dist/gate/cooldown-request.js +22 -0
- package/dist/gate/egress-guard.js +185 -0
- package/dist/gate/host-match.js +48 -0
- package/dist/gate/verdict-core.js +101 -0
- package/dist/gate/verdict-fetch.js +214 -0
- package/dist/launcher/agent-check.js +1096 -42
- package/dist/launcher/agent-hook-exec.js +8 -1
- package/dist/launcher/agent-hook-io.js +5 -3
- package/dist/launcher/classify.js +110 -26
- package/dist/launcher/env.js +84 -18
- package/dist/launcher/install-preflight.js +26 -3
- package/dist/launcher/live-install.js +4 -2
- package/dist/launcher/manifest-screen.js +88 -7
- package/dist/launcher/output-redaction.js +153 -15
- package/dist/launcher/preflight-prompt.js +1 -1
- package/dist/launcher/resolve-real-binary.js +8 -3
- package/dist/launcher/run.js +237 -40
- package/dist/origin/artifact-cache.js +55 -0
- package/dist/origin/cargo-config.js +12 -0
- package/dist/origin/gate-mode.js +7 -0
- package/dist/origin/platform-paths.js +14 -0
- package/dist/origin/precedence.js +345 -0
- package/dist/origin/server.js +611 -0
- package/dist/origin/spike-worker.js +35 -0
- package/dist/origin/worker.js +116 -0
- package/dist/presentation/package-page.js +21 -1
- package/dist/presentation/safe-version.js +11 -0
- package/dist/presentation/sarif.js +109 -0
- package/dist/project/dgfile.js +54 -17
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/enforcement.js +27 -7
- package/dist/proxy/metadata-map.js +33 -7
- package/dist/proxy/preverified.js +0 -3
- package/dist/proxy/server.js +104 -446
- package/dist/proxy/upstream-proxy.js +23 -4
- package/dist/proxy/worker.js +3 -2
- package/dist/publish-set/collect.js +6 -0
- package/dist/runtime/fatal.js +2 -1
- package/dist/runtime/first-run.js +5 -1
- package/dist/runtime/node-version.js +51 -0
- package/dist/runtime/nudges.js +27 -1
- package/dist/sbom/cyclonedx.js +120 -33
- package/dist/sbom/graph.js +236 -0
- package/dist/sbom/spdx.js +53 -0
- package/dist/sbom-ui/SbomApp.js +5 -3
- package/dist/sbom-ui/inventory.js +8 -10
- package/dist/scan/analyze-worker.js +3 -1
- package/dist/scan/collect.js +38 -8
- package/dist/scan/command.js +77 -27
- package/dist/scan/discovery.js +18 -7
- package/dist/scan/installed.js +202 -0
- package/dist/scan/manifest-coverage.js +128 -0
- package/dist/scan/render.js +125 -40
- package/dist/scan/scanner-report.js +92 -22
- package/dist/scan/staged.js +68 -15
- package/dist/scan/types.js +7 -1
- package/dist/scan-ui/LegacyApp.js +3 -3
- package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
- package/dist/scan-ui/hooks/useScan.js +37 -11
- package/dist/scan-ui/launch.js +2 -2
- package/dist/scan-ui/logo.js +6 -2
- package/dist/scan-ui/shims.js +6 -6
- package/dist/scripts/gate.js +53 -35
- package/dist/security/csv.js +9 -0
- package/dist/security/sanitize.js +11 -2
- package/dist/service/state.js +120 -37
- package/dist/service/trust-refresh.js +12 -1
- package/dist/service/trust-store.js +36 -5
- package/dist/service/worker.js +5 -7
- package/dist/setup/git-hook.js +2 -2
- package/dist/setup/install-method.js +36 -0
- package/dist/setup/plan.js +119 -57
- package/dist/setup/uninstall-standalone.js +7 -2
- package/dist/setup-ui/gate.js +1 -8
- package/dist/setup-ui/offer.js +14 -7
- package/dist/setup-ui/selector.js +1 -1
- package/dist/setup-ui/wizard.js +73 -78
- package/dist/standalone/uninstall.mjs +1114 -386
- package/dist/state/cleanup-registry.js +14 -3
- package/dist/state/cooldown-held.js +36 -13
- package/dist/state/locks.js +239 -34
- package/dist/state/sessions.js +2 -2
- package/dist/util/sh-escape.js +6 -0
- package/dist/util/tty-prompt.js +13 -4
- package/dist/verify/local.js +60 -10
- package/dist/verify/package-check.js +35 -49
- package/dist/verify/preflight.js +59 -33
- package/dist/verify/render.js +11 -12
- package/npm-shrinkwrap.json +714 -213
- package/package.json +3 -2
package/dist/proxy/server.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
975
|
+
throw new Error("upstream registry returned a chunked response with no chunk-size terminator");
|
|
1050
976
|
}
|
|
1051
|
-
const
|
|
1052
|
-
if (
|
|
1053
|
-
|
|
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
|
-
|
|
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
|
|
1325
|
-
|
|
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
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
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
|
-
}
|