@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
|
@@ -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
|
+
}
|