@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
@@ -0,0 +1,185 @@
1
+ import { lookup as dnsLookup } from "node:dns";
2
+ import { hostMatchesList, testUpstreamHostMap } from "./host-match.js";
3
+ // Node's WHATWG URL parser canonicalizes an IPv4-mapped IPv6 literal to the hex
4
+ // hextet form ([::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe]), so a guard that
5
+ // only string-matches the dotted-decimal form silently lets the cloud-metadata
6
+ // IP through as "public". Recover the embedded IPv4 (both forms) before
7
+ // classifying so the IPv4 ranges below cover the mapped address too.
8
+ function hextetsToIpv4(hi, lo) {
9
+ const h = parseInt(hi, 16);
10
+ const l = parseInt(lo, 16);
11
+ return `${(h >> 8) & 0xff}.${h & 0xff}.${(l >> 8) & 0xff}.${l & 0xff}`;
12
+ }
13
+ // Recover an IPv4 address embedded in an IPv6 literal so the IPv4 ranges below
14
+ // also cover it. Covers IPv4-mapped (::ffff:), IPv4-compatible (::), 6to4
15
+ // (2002::/16), and NAT64 (64:ff9b::/96) — each a way to smuggle 169.254.169.254
16
+ // past a guard that only string-matches the dotted-decimal form.
17
+ function recoverIpv4MappedHost(host) {
18
+ const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
19
+ if (mappedDotted?.[1])
20
+ return mappedDotted[1];
21
+ const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
22
+ if (mappedHex?.[1] && mappedHex[2])
23
+ return hextetsToIpv4(mappedHex[1], mappedHex[2]);
24
+ const compatDotted = /^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
25
+ if (compatDotted?.[1])
26
+ return compatDotted[1];
27
+ const compatHex = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
28
+ if (compatHex?.[1] && compatHex[2])
29
+ return hextetsToIpv4(compatHex[1], compatHex[2]);
30
+ const sixToFour = /^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/i.exec(host);
31
+ if (sixToFour?.[1] && sixToFour[2])
32
+ return hextetsToIpv4(sixToFour[1], sixToFour[2]);
33
+ const nat64Dotted = /^64:ff9b::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
34
+ if (nat64Dotted?.[1])
35
+ return nat64Dotted[1];
36
+ const nat64Hex = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
37
+ if (nat64Hex?.[1] && nat64Hex[2])
38
+ return hextetsToIpv4(nat64Hex[1], nat64Hex[2]);
39
+ return host;
40
+ }
41
+ // Block a RESOLVED address (the actual connect target), so a public hostname
42
+ // whose A/AAAA record points at an internal/metadata IP — DNS-rebinding SSRF —
43
+ // is refused at connect time, not just literal-IP targets.
44
+ export function isBlockedResolvedIp(ip) {
45
+ const host = recoverIpv4MappedHost(ip.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""));
46
+ if (host === "0.0.0.0" || host === "::" || host === "::1") {
47
+ return true;
48
+ }
49
+ if (isPrivateIpv4(host)) {
50
+ return true;
51
+ }
52
+ return /^(fe80|f[cd][0-9a-f]{2}|ff[0-9a-f]{2}):/.test(host);
53
+ }
54
+ function guardedLookup(hostname, options, callback) {
55
+ const opts = typeof options === "object" && options !== null ? options : {};
56
+ dnsLookup(hostname, { ...opts, all: true }, (err, addresses) => {
57
+ if (err) {
58
+ callback(err, "", 0);
59
+ return;
60
+ }
61
+ const list = addresses;
62
+ const blocked = list.find((entry) => isBlockedResolvedIp(entry.address));
63
+ if (blocked) {
64
+ callback(new Error(`refusing to connect to ${hostname}: resolves to blocked address ${blocked.address}. If this is a private/tailnet registry you trust, add its host to DG_PRIVATE_REGISTRY_HOSTS.`), "", 0);
65
+ return;
66
+ }
67
+ if (opts.all === true) {
68
+ callback(null, list);
69
+ return;
70
+ }
71
+ const first = list[0];
72
+ if (!first) {
73
+ callback(new Error(`no address found for ${hostname}`), "", 0);
74
+ return;
75
+ }
76
+ callback(null, first.address, first.family);
77
+ });
78
+ }
79
+ // The resolved-IP guard exists for DNS rebinding — a PUBLIC hostname whose A/AAAA
80
+ // record points at an internal IP. It must not fire for: an explicitly-configured
81
+ // private registry (DG_PRIVATE_REGISTRY_HOSTS) whose name legitimately resolves to
82
+ // a private IP; a test host-map target; or a target that is ALREADY a private/
83
+ // loopback literal (the user/test pointed there directly — not rebinding, and the
84
+ // link-local/metadata literal is still blocked at the artifact path).
85
+ export function lookupForTarget(target, env) {
86
+ const host = target.hostname.replace(/^\[(.*)\]$/, "$1");
87
+ if (hostMatchesList(target.hostname, env.DG_PRIVATE_REGISTRY_HOSTS ?? "")) {
88
+ return undefined;
89
+ }
90
+ if (testUpstreamHostMap(env).has(host)) {
91
+ return undefined;
92
+ }
93
+ if (isPrivateNetworkHost(target)) {
94
+ return undefined;
95
+ }
96
+ return guardedLookup;
97
+ }
98
+ // The direct connect paths run the resolve-and-classify rebind guard via
99
+ // lookupForTarget's custom lookup. When an upstream HTTP/HTTPS proxy is used the
100
+ // proxy resolves the target itself, so that guard never runs — pre-resolve here
101
+ // and refuse a public target that resolves into a blocked range, mirroring the
102
+ // same exemptions (private registry / test host-map / already-private literal).
103
+ // Best-effort: a resolution failure surfaces later as the real connect error.
104
+ export async function assertTargetEgressAllowed(target, env) {
105
+ if (lookupForTarget(target, env) === undefined) {
106
+ return;
107
+ }
108
+ const host = target.hostname.replace(/^\[(.*)\]$/, "$1");
109
+ const addresses = await new Promise((resolve) => {
110
+ dnsLookup(host, { all: true }, (err, list) => {
111
+ resolve(err ? [] : list);
112
+ });
113
+ });
114
+ const blocked = addresses.find((entry) => isBlockedResolvedIp(entry.address));
115
+ if (blocked) {
116
+ throw new Error(`refusing to connect to ${host} via the upstream proxy: resolves to blocked address ${blocked.address}. If this is a private/tailnet registry you trust, add its host to DG_PRIVATE_REGISTRY_HOSTS.`);
117
+ }
118
+ }
119
+ // Non-globally-routable / special-purpose IPv4 ranges. None is ever a legitimate
120
+ // public registry, so a public hostname resolving into one is SSRF, and a direct
121
+ // literal target there is the user's own choice (handled by the callers). Covers
122
+ // RFC1918 + loopback + link-local + CGNAT, plus the special-purpose ranges that
123
+ // are still connectable: 198.18/15 benchmarking, 192.0.0/24 IETF, the TEST-NET
124
+ // documentation blocks, and multicast / reserved 224.0.0.0/3 (224-255).
125
+ function isPrivateIpv4(host) {
126
+ return (/^(127|10|0)\./.test(host) ||
127
+ /^192\.168\./.test(host) ||
128
+ /^169\.254\./.test(host) ||
129
+ /^172\.(1[6-9]|2[0-9]|3[01])\./.test(host) ||
130
+ /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./.test(host) ||
131
+ /^198\.1[89]\./.test(host) ||
132
+ /^192\.0\.0\./.test(host) ||
133
+ /^192\.0\.2\./.test(host) ||
134
+ /^198\.51\.100\./.test(host) ||
135
+ /^203\.0\.113\./.test(host) ||
136
+ /^(22[4-9]|23[0-9]|24[0-9]|25[0-5])\./.test(host));
137
+ }
138
+ export function isPrivateNetworkHost(url) {
139
+ const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
140
+ const host = recoverIpv4MappedHost(raw);
141
+ if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1") {
142
+ return true;
143
+ }
144
+ if (isPrivateIpv4(host)) {
145
+ return true;
146
+ }
147
+ return /^(fe80|f[cd][0-9a-f]{2}|ff[0-9a-f]{2}):/.test(host);
148
+ }
149
+ // Zero-expand a compressed IPv6 literal to its eight bare-hextet form so an
150
+ // exact metadata-address compare matches regardless of "::" placement. Returns
151
+ // the input unchanged for anything that is not a plain IPv6 literal.
152
+ function normalizeIpv6(host) {
153
+ if (host.includes(".") || !host.includes(":") || !/^[0-9a-f:]+$/i.test(host)) {
154
+ return host;
155
+ }
156
+ const sides = host.split("::");
157
+ if (sides.length > 2) {
158
+ return host;
159
+ }
160
+ const head = sides[0] ? sides[0].split(":") : [];
161
+ const tail = sides.length === 2 && sides[1] ? sides[1].split(":") : [];
162
+ let parts;
163
+ if (sides.length === 2) {
164
+ const missing = 8 - head.length - tail.length;
165
+ if (missing < 0)
166
+ return host;
167
+ parts = [...head, ...Array(missing).fill("0"), ...tail];
168
+ }
169
+ else {
170
+ parts = head;
171
+ }
172
+ if (parts.length !== 8) {
173
+ return host;
174
+ }
175
+ return parts.map((part) => parseInt(part, 16).toString(16)).join(":");
176
+ }
177
+ // The AWS IPv6 IMDS endpoint is a ULA (fd00::/8), so it is not caught by the
178
+ // fe80 link-local check; match the exact metadata literal only — broadening to
179
+ // all of fd00::/8 would refuse legitimate private/tailnet registries.
180
+ const IPV6_METADATA_HOST = "fd00:ec2:0:0:0:0:0:254";
181
+ export function isLinkLocalHost(url) {
182
+ const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
183
+ const host = recoverIpv4MappedHost(raw);
184
+ return /^169\.254\./.test(host) || /^fe80:/.test(host) || normalizeIpv6(host) === IPV6_METADATA_HOST;
185
+ }
@@ -0,0 +1,48 @@
1
+ export function hostMatchesList(host, rawList) {
2
+ const normalized = normalizeHost(host);
3
+ return rawList.split(",").map((entry) => entry.trim()).filter(Boolean).some((pattern) => hostMatchesPattern(normalized, pattern));
4
+ }
5
+ function hostMatchesPattern(host, pattern) {
6
+ const normalized = normalizeHost(pattern);
7
+ if (normalized.startsWith("*.")) {
8
+ const suffix = normalized.slice(1);
9
+ return host.endsWith(suffix) && host.length > suffix.length;
10
+ }
11
+ return host === normalized;
12
+ }
13
+ function normalizeHost(host) {
14
+ return host.replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "").toLowerCase();
15
+ }
16
+ export function testUpstreamHostMap(env) {
17
+ if (env.NODE_ENV !== "test") {
18
+ return new Map();
19
+ }
20
+ const entries = (env.DG_TEST_UPSTREAM_HOST_MAP ?? "")
21
+ .split(",")
22
+ .map((entry) => entry.trim())
23
+ .filter(Boolean)
24
+ .map((entry) => {
25
+ const separator = entry.indexOf("=");
26
+ if (separator <= 0) {
27
+ return null;
28
+ }
29
+ const target = entry.slice(separator + 1);
30
+ // The seam only exists to point a public host at a LOOPBACK mock in tests.
31
+ // Refusing any non-loopback target stops an attacker who can set
32
+ // NODE_ENV=test from redirecting a registry fetch to an external/internal
33
+ // host (SSRF) and from disabling the egress DNS guard for it.
34
+ if (!isLoopbackHost(target)) {
35
+ return null;
36
+ }
37
+ return [entry.slice(0, separator), target];
38
+ })
39
+ .filter((entry) => entry !== null);
40
+ return new Map(entries);
41
+ }
42
+ export function isLoopbackHost(value) {
43
+ const host = normalizeHost(value);
44
+ // Only the 127.0.0.0/8 IPv4 literal range is loopback — anchor to a full dotted
45
+ // quad so a hostname like 127.evil.com (which resolves anywhere) is NOT treated
46
+ // as loopback and cannot slip past the SSRF confinement.
47
+ return host === "localhost" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?$/.test(host);
48
+ }
@@ -0,0 +1,101 @@
1
+ import { sanitize } from "../security/sanitize.js";
2
+ import { artifactDisplayName, safeDecodeUriComponent } from "../proxy/metadata-map.js";
3
+ export function normalizeVerdict(value, target, identity, streamedSha256) {
4
+ if (!isRecord(value)) {
5
+ throw new Error("Dependency Guardian API returned a malformed verdict");
6
+ }
7
+ const verdict = value.verdict;
8
+ if (verdict !== "pass" && verdict !== "warn" && verdict !== "block") {
9
+ throw new Error("Dependency Guardian API returned a malformed verdict");
10
+ }
11
+ const scannedSha256 = typeof value.scannedSha256 === "string" ? value.scannedSha256.toLowerCase() : "";
12
+ if (scannedSha256.length > 0 && scannedSha256 !== streamedSha256) {
13
+ return {
14
+ verdict: "block",
15
+ packageName: artifactDisplayName(identity),
16
+ cause: "hash-mismatch",
17
+ reason: `server scanned SHA-256 ${scannedSha256} did not match streamed artifact SHA-256 ${streamedSha256}`
18
+ };
19
+ }
20
+ const cause = typeof value.cause === "string" ? value.cause : undefined;
21
+ const cooldown = parseCooldownInfo(value.cooldown);
22
+ const provenance = parseProvenance(value.provenance);
23
+ const dashboardUrl = safeDashboardUrl(value.dashboardUrl);
24
+ return {
25
+ verdict,
26
+ packageName: typeof value.packageName === "string" ? sanitize(value.packageName) : artifactDisplayName(identity) || packageNameFromUrl(target),
27
+ ...(isProxyCause(cause) ? { cause } : {}),
28
+ reason: typeof value.reason === "string" ? sanitize(value.reason) : `API verdict ${verdict}`,
29
+ ...(dashboardUrl ? { dashboardUrl } : {}),
30
+ ...(value.unauthenticated === true ? { unauthenticated: true } : {}),
31
+ ...(cooldown ? { cooldown } : {}),
32
+ ...(provenance ? { provenance } : {})
33
+ };
34
+ }
35
+ export function safeDashboardUrl(value) {
36
+ if (typeof value !== "string") {
37
+ return undefined;
38
+ }
39
+ try {
40
+ const url = new URL(sanitize(value));
41
+ return url.protocol === "https:" ? url.toString() : undefined;
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ export function parseCooldownInfo(value) {
48
+ if (!isRecord(value) || typeof value.requiredDays !== "number" || !Number.isFinite(value.requiredDays)) {
49
+ return undefined;
50
+ }
51
+ return {
52
+ requiredDays: value.requiredDays,
53
+ ...(typeof value.ageDays === "number" && Number.isFinite(value.ageDays) ? { ageDays: value.ageDays } : {}),
54
+ ...(typeof value.publishedAt === "string" ? { publishedAt: sanitize(value.publishedAt) } : {}),
55
+ ...(typeof value.eligibleAt === "string" ? { eligibleAt: sanitize(value.eligibleAt) } : {})
56
+ };
57
+ }
58
+ function parseProvenance(value) {
59
+ if (!isRecord(value)) {
60
+ return undefined;
61
+ }
62
+ const status = value.status;
63
+ if (status !== "attested" && status !== "none" && status !== "unknown") {
64
+ return undefined;
65
+ }
66
+ const downgrade = isRecord(value.downgrade) && typeof value.downgrade.fromVersion === "string"
67
+ ? { fromVersion: sanitize(value.downgrade.fromVersion) }
68
+ : undefined;
69
+ return {
70
+ status,
71
+ ...(typeof value.predicateType === "string" ? { predicateType: sanitize(value.predicateType) } : {}),
72
+ ...(downgrade ? { downgrade } : {})
73
+ };
74
+ }
75
+ export function packageNameFromUrl(url) {
76
+ const name = url.pathname.split("/").filter(Boolean).pop();
77
+ return name ? safeDecodeUriComponent(name) : url.hostname;
78
+ }
79
+ export function isRecord(value) {
80
+ return typeof value === "object" && value !== null;
81
+ }
82
+ export function isProxyCause(value) {
83
+ return [
84
+ "pass",
85
+ "warn",
86
+ "malware",
87
+ "policy",
88
+ "license",
89
+ "hash-mismatch",
90
+ "private-upload-disabled",
91
+ "needs-login",
92
+ "api-unavailable",
93
+ "quota-exceeded",
94
+ "api-timeout",
95
+ "registry-timeout",
96
+ "analysis-incomplete",
97
+ "cooldown",
98
+ "unsupported-manager",
99
+ "proxy-setup-failure"
100
+ ].includes(value ?? "");
101
+ }
@@ -0,0 +1,214 @@
1
+ import { isRecord, normalizeVerdict } from "./verdict-core.js";
2
+ import { resolveCooldownRequest } from "./cooldown-request.js";
3
+ import { hostMatchesList } from "./host-match.js";
4
+ import { artifactDisplayName, artifactUrlHash } from "../proxy/metadata-map.js";
5
+ import { loadPreverifiedMap, preverifiedKey } from "../proxy/preverified.js";
6
+ import { sanitize } from "../security/sanitize.js";
7
+ import { identityHeaders } from "../api/analyze.js";
8
+ import { dgVersion } from "../commands/version.js";
9
+ import { redactSecrets } from "../launcher/output-redaction.js";
10
+ import { envAuthToken } from "../auth/env-token.js";
11
+ export async function lookupVerdict(options, target, sha256, upstream, identity) {
12
+ if (shouldUseScanTarball(options, target, identity)) {
13
+ return lookupScanTarballVerdict(options, target, sha256, upstream, identity);
14
+ }
15
+ const preverified = preverifiedVerdict(options, target, sha256, identity);
16
+ if (preverified) {
17
+ return preverified;
18
+ }
19
+ const cooldown = resolveCooldownRequest(options, identity);
20
+ const controller = new AbortController();
21
+ const timeout = setTimeout(() => controller.abort(), options.verdictTimeoutMs ?? installVerdictTimeoutMs(options.env));
22
+ try {
23
+ const response = await fetch(`${options.apiBaseUrl}/v1/install-verdict`, {
24
+ method: "POST",
25
+ headers: {
26
+ "Content-Type": "application/json",
27
+ "X-Dg-Version": dgVersion(),
28
+ ...identityHeaders(options.env)
29
+ },
30
+ body: JSON.stringify({
31
+ manager: options.classification.manager,
32
+ action: options.classification.action,
33
+ url: redactSecrets(target.toString()),
34
+ artifactUrlHash: artifactUrlHash(target),
35
+ ecosystem: identity.ecosystem,
36
+ name: identity.name,
37
+ version: identity.version,
38
+ registryHost: identity.registryHost,
39
+ sourceKind: identity.sourceKind,
40
+ sha256,
41
+ statusCode: upstream.statusCode,
42
+ contentType: headerValue(upstream.headers["content-type"]),
43
+ ...(cooldown ? { cooldown } : {})
44
+ }),
45
+ signal: controller.signal
46
+ });
47
+ if (response.status === 402 || response.status === 429) {
48
+ const body = (await response.json().catch(() => ({})));
49
+ return {
50
+ verdict: "block",
51
+ packageName: artifactDisplayName(identity),
52
+ cause: "quota-exceeded",
53
+ reason: "You've reached your monthly scan limit. Upgrade at westbayberry.com/pricing or wait for it to reset.",
54
+ ...(typeof body.resetsAt === "string" ? { resetsAt: sanitize(body.resetsAt) } : {}),
55
+ quotaBehavior: body.quotaBehavior === "pass" ? "pass" : "block"
56
+ };
57
+ }
58
+ if (response.status === 401) {
59
+ return {
60
+ verdict: "block",
61
+ packageName: artifactDisplayName(identity),
62
+ cause: "needs-login",
63
+ unauthenticated: true,
64
+ reason: "Checking a package from the registry before it installs requires sign-in."
65
+ };
66
+ }
67
+ if (!response.ok) {
68
+ return {
69
+ verdict: "block",
70
+ packageName: artifactDisplayName(identity),
71
+ cause: "api-unavailable",
72
+ reason: `Dependency Guardian API returned ${response.status}. Run 'dg doctor' and 'dg login' to verify API access.`
73
+ };
74
+ }
75
+ return normalizeVerdict(await response.json(), target, identity, sha256);
76
+ }
77
+ finally {
78
+ clearTimeout(timeout);
79
+ }
80
+ }
81
+ async function lookupScanTarballVerdict(options, target, sha256, upstream, identity) {
82
+ const body = upstream.body ?? Buffer.alloc(0);
83
+ const uploadPolicy = scanTarballUploadPolicy(options.env);
84
+ if (!uploadPolicy.enabled) {
85
+ return {
86
+ verdict: "block",
87
+ packageName: artifactDisplayName(identity),
88
+ cause: "private-upload-disabled",
89
+ reason: `private artifact scan upload is disabled for ${target.hostname}`
90
+ };
91
+ }
92
+ if (!uploadPolicy.token) {
93
+ return {
94
+ verdict: "block",
95
+ packageName: artifactDisplayName(identity),
96
+ cause: "private-upload-disabled",
97
+ reason: "private artifact scan upload requires DG_API_TOKEN"
98
+ };
99
+ }
100
+ if (body.length > uploadPolicy.maxBytes) {
101
+ return {
102
+ verdict: "block",
103
+ packageName: artifactDisplayName(identity),
104
+ cause: "private-upload-disabled",
105
+ reason: `private artifact is ${body.length} bytes, above the ${uploadPolicy.maxBytes} byte scan-tarball upload limit`
106
+ };
107
+ }
108
+ const controller = new AbortController();
109
+ const timeout = setTimeout(() => controller.abort(), uploadPolicy.timeoutMs);
110
+ try {
111
+ const response = await fetch(`${options.apiBaseUrl}/v1/scan-tarball`, {
112
+ method: "POST",
113
+ headers: {
114
+ "Authorization": `Bearer ${uploadPolicy.token}`,
115
+ "Content-Type": "application/octet-stream",
116
+ "X-DG-Action": options.classification.action,
117
+ "X-DG-Artifact-SHA256": sha256,
118
+ "X-DG-Artifact-URL-Hash": artifactUrlHash(target),
119
+ "X-DG-Cache-Key": `sha256:${sha256}`,
120
+ "X-DG-Ecosystem": identity.ecosystem,
121
+ "X-DG-Manager": options.classification.manager,
122
+ "X-DG-Package-Name": identity.name,
123
+ "X-DG-Package-Version": identity.version,
124
+ "X-DG-Privacy": "private-artifact",
125
+ "X-DG-Registry-Host": identity.registryHost,
126
+ "X-DG-Source-Kind": identity.sourceKind
127
+ },
128
+ body,
129
+ signal: controller.signal
130
+ });
131
+ if (!response.ok) {
132
+ throw new Error(`Dependency Guardian scan-tarball API returned ${response.status}`);
133
+ }
134
+ return normalizeScanTarballVerdict(await response.json(), target, identity, sha256);
135
+ }
136
+ finally {
137
+ clearTimeout(timeout);
138
+ }
139
+ }
140
+ // A preflight batch-analyze already verdicted this exact name@version (and the
141
+ // user accepted any warns), so a per-artifact API round-trip would re-answer
142
+ // the same question. Trust it only when the identity came from registry
143
+ // metadata and the preflight applied the same cooldown gate the proxy would;
144
+ // the synthesized verdict goes through normalizeVerdict so the scanned-SHA
145
+ // cross-check blocks tampered bytes exactly like a server response would.
146
+ function preverifiedVerdict(options, target, streamedSha256, identity) {
147
+ if (identity.sourceKind !== "registry-metadata") {
148
+ return null;
149
+ }
150
+ const entry = loadPreverifiedMap(options.session.dir).get(preverifiedKey(identity.ecosystem, identity.name, identity.version));
151
+ if (!entry) {
152
+ return null;
153
+ }
154
+ if (!entry.cooldownEvaluated && resolveCooldownRequest(options, identity) !== undefined) {
155
+ return null;
156
+ }
157
+ if (!entry.scannedSha256) {
158
+ // No byte-level fingerprint to cross-check against the streamed artifact, so
159
+ // a preverified pass/warn can't prove the downloaded bytes match what was
160
+ // screened (a metadata-only preflight, or a registry swap between preflight
161
+ // and fetch). Fall through to a real scan of the streamed bytes — TOCTOU
162
+ // defense — rather than honoring the verdict for whatever now arrives.
163
+ return null;
164
+ }
165
+ return normalizeVerdict({
166
+ verdict: entry.action,
167
+ cause: entry.action,
168
+ packageName: `${identity.name}@${identity.version}`,
169
+ reason: entry.reason ?? (entry.action === "pass" ? "verified before install" : "flagged for review"),
170
+ ...(entry.scannedSha256 ? { scannedSha256: entry.scannedSha256 } : {})
171
+ }, target, identity, streamedSha256);
172
+ }
173
+ function normalizeScanTarballVerdict(value, target, identity, streamedSha256) {
174
+ if (!isRecord(value) || typeof value.scannedSha256 !== "string" || value.scannedSha256.length === 0) {
175
+ throw new Error("Dependency Guardian scan-tarball API did not return scannedSha256");
176
+ }
177
+ return normalizeVerdict(value, target, identity, streamedSha256);
178
+ }
179
+ function shouldUseScanTarball(options, target, identity) {
180
+ if (identity.sourceKind !== "url-fallback") {
181
+ return false;
182
+ }
183
+ return hostMatchesList(target.hostname, options.env.DG_PRIVATE_REGISTRY_HOSTS ?? "");
184
+ }
185
+ function scanTarballUploadPolicy(env) {
186
+ return {
187
+ enabled: env.DG_SCAN_TARBALL_UPLOAD === "1" || env.DG_SCAN_TARBALL_UPLOAD === "true",
188
+ maxBytes: parsePositiveInteger(env.DG_SCAN_TARBALL_MAX_BYTES, 50 * 1024 * 1024),
189
+ timeoutMs: parsePositiveInteger(env.DG_SCAN_TARBALL_TIMEOUT_MS, 5_000),
190
+ token: envAuthToken(env) ?? ""
191
+ };
192
+ }
193
+ function installVerdictTimeoutMs(env) {
194
+ return parsePositiveInteger(env.DG_INSTALL_VERDICT_TIMEOUT_MS, 240_000);
195
+ }
196
+ function parsePositiveInteger(value, fallback) {
197
+ if (!value) {
198
+ return fallback;
199
+ }
200
+ const parsed = Number.parseInt(value, 10);
201
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
202
+ }
203
+ function headerValue(value) {
204
+ if (Array.isArray(value)) {
205
+ return value.join(",");
206
+ }
207
+ if (typeof value === "number") {
208
+ return String(value);
209
+ }
210
+ if (typeof value === "string") {
211
+ return value;
212
+ }
213
+ return "";
214
+ }