@westbayberry/dg 1.3.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -201
- package/NOTICE +1 -4
- package/README.md +293 -0
- package/dist/api/analyze.js +210 -0
- package/dist/audit/deep.js +180 -0
- package/dist/audit/detectors.js +247 -0
- package/dist/audit/events.js +41 -0
- package/dist/audit/rules.js +426 -0
- package/dist/audit-ui/AuditApp.js +39 -0
- package/dist/audit-ui/components/AuditHeader.js +24 -0
- package/dist/audit-ui/components/AuditResultsView.js +307 -0
- package/dist/audit-ui/components/DeepStatusRow.js +11 -0
- package/dist/audit-ui/export.js +85 -0
- package/dist/audit-ui/format.js +34 -0
- package/dist/audit-ui/launch.js +34 -0
- package/dist/auth/device-login.js +271 -0
- package/dist/auth/env-token.js +6 -0
- package/dist/auth/login-app.js +156 -0
- package/dist/auth/store.js +147 -0
- package/dist/bin/dg.js +71 -0
- package/dist/commands/audit.js +357 -0
- package/dist/commands/completion.js +116 -0
- package/dist/commands/config.js +99 -0
- package/dist/commands/doctor.js +39 -0
- package/dist/commands/explain.js +100 -0
- package/dist/commands/guard-commit.js +158 -0
- package/dist/commands/help.js +74 -0
- package/dist/commands/licenses.js +435 -0
- package/dist/commands/login.js +81 -0
- package/dist/commands/logout.js +37 -0
- package/dist/commands/router.js +98 -0
- package/dist/commands/scan.js +18 -0
- package/dist/commands/service.js +475 -0
- package/dist/commands/setup.js +302 -0
- package/dist/commands/status.js +115 -0
- package/dist/commands/suggest.js +35 -0
- package/dist/commands/types.js +4 -0
- package/dist/commands/unavailable.js +11 -0
- package/dist/commands/uninstall.js +111 -0
- package/dist/commands/update.js +210 -0
- package/dist/commands/verify.js +151 -0
- package/dist/commands/version.js +22 -0
- package/dist/commands/wrap.js +55 -0
- package/dist/config/settings.js +302 -0
- package/dist/install-ui/LiveInstall.js +24 -0
- package/dist/install-ui/block-render.js +83 -0
- package/dist/install-ui/live-install-app.js +48 -0
- package/dist/install-ui/prompt.js +24 -0
- package/dist/launcher/classify.js +116 -0
- package/dist/launcher/env.js +53 -0
- package/dist/launcher/live-install.js +50 -0
- package/dist/launcher/output-redaction.js +77 -0
- package/dist/launcher/preflight-prompt.js +139 -0
- package/dist/launcher/resolve-real-binary.js +73 -0
- package/dist/launcher/run.js +417 -0
- package/dist/policy/evaluate.js +128 -0
- package/dist/presentation/mode.js +52 -0
- package/dist/presentation/theme.js +29 -0
- package/dist/proxy/buffer-budget.js +64 -0
- package/dist/proxy/ca.js +126 -0
- package/dist/proxy/classify-host.js +26 -0
- package/dist/proxy/enforcement.js +102 -0
- package/dist/proxy/metadata-map.js +336 -0
- package/dist/proxy/server.js +909 -0
- package/dist/proxy/upstream-proxy.js +102 -0
- package/dist/proxy/worker.js +39 -0
- package/dist/publish-set/collect.js +51 -0
- package/dist/publish-set/no-exec-shell.js +19 -0
- package/dist/publish-set/npm.js +109 -0
- package/dist/publish-set/pack.js +36 -0
- package/dist/publish-set/pypi.js +59 -0
- package/dist/runtime/cli.js +17 -0
- package/dist/runtime/first-run.js +60 -0
- package/dist/runtime/node-version.js +58 -0
- package/dist/runtime/nudges.js +105 -0
- package/dist/scan/analyze-worker.js +21 -0
- package/dist/scan/collect.js +153 -0
- package/dist/scan/command.js +159 -0
- package/dist/scan/discovery.js +209 -0
- package/dist/scan/render.js +240 -0
- package/dist/scan/scanner-report.js +82 -0
- package/dist/scan/staged.js +173 -0
- package/dist/scan/types.js +1 -0
- package/dist/scan-ui/LegacyApp.js +156 -0
- package/dist/scan-ui/alt-screen.js +84 -0
- package/dist/scan-ui/api-aliases.js +1 -0
- package/dist/scan-ui/components/ErrorView.js +23 -0
- package/dist/scan-ui/components/InteractiveResultsView.js +1166 -0
- package/dist/scan-ui/components/ProgressBar.js +89 -0
- package/dist/scan-ui/components/ProjectSelector.js +62 -0
- package/dist/scan-ui/components/ScoreHeader.js +20 -0
- package/dist/scan-ui/components/SetupBanner.js +13 -0
- package/dist/scan-ui/components/Spinner.js +4 -0
- package/dist/scan-ui/format-helpers.js +40 -0
- package/dist/scan-ui/hooks/useExpandAnimation.js +40 -0
- package/dist/scan-ui/hooks/useScan.js +113 -0
- package/dist/scan-ui/hooks/useTerminalSize.js +24 -0
- package/dist/scan-ui/launch.js +27 -0
- package/dist/scan-ui/logo.js +91 -0
- package/dist/scan-ui/shims.js +30 -0
- package/dist/security/sanitize.js +28 -0
- package/dist/service/state.js +837 -0
- package/dist/service/trust-store.js +234 -0
- package/dist/service/worker.js +88 -0
- package/dist/setup/git-hook.js +244 -0
- package/dist/setup/optional-support.js +58 -0
- package/dist/setup/plan.js +899 -0
- package/dist/state/cleanup-registry.js +60 -0
- package/dist/state/index.js +5 -0
- package/dist/state/locks.js +161 -0
- package/dist/state/paths.js +24 -0
- package/dist/state/sessions.js +170 -0
- package/dist/state/store.js +50 -0
- package/dist/telemetry/events.js +40 -0
- package/dist/util/git.js +20 -0
- package/dist/util/tty-prompt.js +43 -0
- package/dist/verify/local.js +400 -0
- package/dist/verify/package-check.js +240 -0
- package/dist/verify/preflight.js +698 -0
- package/dist/verify/render.js +184 -0
- package/dist/verify/types.js +1 -0
- package/package.json +33 -50
- package/dist/index.mjs +0 -54141
- package/dist/postinstall.mjs +0 -731
- package/dist/python-hook/dg_pip_hook.pth +0 -1
- package/dist/python-hook/dg_pip_hook.py +0 -130
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { BufferBudgetError, collectBounded } from "./buffer-budget.js";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
6
|
+
import { request as httpsRequest } from "node:https";
|
|
7
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { connect } from "node:net";
|
|
10
|
+
import { createSecureContext, createServer as createTlsServer, connect as tlsConnect } from "node:tls";
|
|
11
|
+
import { createEphemeralCertificateAuthority } from "./ca.js";
|
|
12
|
+
import { shouldMitmHost } from "./classify-host.js";
|
|
13
|
+
import { enforceProtectedInstall } from "./enforcement.js";
|
|
14
|
+
import { artifactDisplayName, artifactUrlHash, extractRegistryMetadataIdentities, isRegistryIndexRequest, resolveArtifactIdentity } from "./metadata-map.js";
|
|
15
|
+
import { authorityFor, connectViaUpstreamProxy, selectUpstreamProxy } from "./upstream-proxy.js";
|
|
16
|
+
import { redactSecrets } from "../launcher/output-redaction.js";
|
|
17
|
+
import { envAuthToken } from "../auth/env-token.js";
|
|
18
|
+
export async function startProductionHttpProxy(options) {
|
|
19
|
+
const ca = createEphemeralCertificateAuthority(options.session.files.ca);
|
|
20
|
+
const state = {
|
|
21
|
+
ready: false,
|
|
22
|
+
port: 0,
|
|
23
|
+
decisions: [],
|
|
24
|
+
inflight: [],
|
|
25
|
+
hashes: [],
|
|
26
|
+
identities: [],
|
|
27
|
+
events: []
|
|
28
|
+
};
|
|
29
|
+
const activeSockets = new Set();
|
|
30
|
+
const server = createServer((request, response) => {
|
|
31
|
+
handleProxyRequest(request, response, options, state).catch((error) => {
|
|
32
|
+
const decision = recordDecision(options, state, {
|
|
33
|
+
verdict: "block",
|
|
34
|
+
packageName: options.classification.manager,
|
|
35
|
+
cause: "proxy-setup-failure",
|
|
36
|
+
reason: error instanceof Error ? error.message : "proxy request failed"
|
|
37
|
+
});
|
|
38
|
+
sendBlocked(response, decision);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
server.on("connection", (socket) => trackSocket(activeSockets, socket));
|
|
42
|
+
server.on("connect", (request, socket, head) => {
|
|
43
|
+
const clientSocket = socket;
|
|
44
|
+
handleConnectRequest(request, clientSocket, head, options, state, ca, activeSockets).catch((error) => {
|
|
45
|
+
const decision = recordDecision(options, state, {
|
|
46
|
+
verdict: "block",
|
|
47
|
+
packageName: request.url ?? options.classification.manager,
|
|
48
|
+
cause: "proxy-setup-failure",
|
|
49
|
+
reason: error instanceof Error ? error.message : "TLS CONNECT proxying failed"
|
|
50
|
+
});
|
|
51
|
+
clientSocket.end(`HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\n\r\nDependency Guardian blocked ${redactSecrets(decision.packageName)}: ${redactSecrets(decision.reason)}\n`);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
await listen(server, options.listenHost ?? "127.0.0.1", options.listenPort ?? 0);
|
|
55
|
+
const address = server.address();
|
|
56
|
+
if (typeof address !== "object" || address === null) {
|
|
57
|
+
throw new Error("production proxy did not bind a TCP port");
|
|
58
|
+
}
|
|
59
|
+
state.ready = true;
|
|
60
|
+
state.port = address.port;
|
|
61
|
+
writeProxyState(options.session, state);
|
|
62
|
+
return {
|
|
63
|
+
port: address.port,
|
|
64
|
+
close: () => closeServer(server, activeSockets)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function readProxySessionState(session) {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(readFileSync(session.files.proxy, "utf8"));
|
|
70
|
+
return {
|
|
71
|
+
ready: parsed.ready === true,
|
|
72
|
+
port: typeof parsed.port === "number" ? parsed.port : 0,
|
|
73
|
+
decisions: Array.isArray(parsed.decisions) ? parsed.decisions : [],
|
|
74
|
+
inflight: Array.isArray(parsed.inflight) ? parsed.inflight.filter((name) => typeof name === "string") : [],
|
|
75
|
+
hashes: Array.isArray(parsed.hashes) ? parsed.hashes : [],
|
|
76
|
+
identities: Array.isArray(parsed.identities) ? parsed.identities : [],
|
|
77
|
+
events: Array.isArray(parsed.events) ? parsed.events.filter((event) => typeof event === "string") : []
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return {
|
|
82
|
+
ready: false,
|
|
83
|
+
port: 0,
|
|
84
|
+
decisions: [],
|
|
85
|
+
inflight: [],
|
|
86
|
+
hashes: [],
|
|
87
|
+
identities: [],
|
|
88
|
+
events: []
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function listen(server, host, port) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
server.once("error", reject);
|
|
95
|
+
server.listen(port, host, () => {
|
|
96
|
+
server.off("error", reject);
|
|
97
|
+
resolve();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function closeServer(server, activeSockets) {
|
|
102
|
+
for (const socket of activeSockets) {
|
|
103
|
+
socket.destroy();
|
|
104
|
+
}
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
server.close(() => resolve());
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function trackSocket(activeSockets, socket) {
|
|
110
|
+
activeSockets.add(socket);
|
|
111
|
+
socket.once("close", () => {
|
|
112
|
+
activeSockets.delete(socket);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async function handleProxyRequest(request, response, options, state) {
|
|
116
|
+
const target = parseProxyTarget(request);
|
|
117
|
+
if (!target || (target.protocol !== "http:" && target.protocol !== "https:")) {
|
|
118
|
+
const decision = recordDecision(options, state, {
|
|
119
|
+
verdict: "block",
|
|
120
|
+
packageName: target?.hostname ?? "unknown-artifact",
|
|
121
|
+
cause: "proxy-setup-failure",
|
|
122
|
+
reason: "proxy request did not include a supported HTTP(S) artifact URL"
|
|
123
|
+
});
|
|
124
|
+
sendBlocked(response, decision);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await handleArtifactRequest(request, response, target, options, state);
|
|
128
|
+
}
|
|
129
|
+
async function handleConnectRequest(request, clientSocket, head, options, state, ca, activeSockets) {
|
|
130
|
+
const target = parseConnectTarget(request.url);
|
|
131
|
+
if (!target) {
|
|
132
|
+
clientSocket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!shouldMitmHost(target.hostname, options.env)) {
|
|
136
|
+
state.events = [...state.events, `tunnel:${redactSecrets(authorityFor(target))}`];
|
|
137
|
+
writeProxyState(options.session, state);
|
|
138
|
+
await blindTunnel(clientSocket, head, target, options, activeSockets);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
state.events = [...state.events, `mitm:${redactSecrets(target.hostname)}`];
|
|
142
|
+
writeProxyState(options.session, state);
|
|
143
|
+
await mitmTunnel(clientSocket, head, target, options, state, ca, activeSockets);
|
|
144
|
+
}
|
|
145
|
+
const REDIRECT_LIMIT = 5;
|
|
146
|
+
function isRedirectStatus(statusCode) {
|
|
147
|
+
return [301, 302, 303, 307, 308].includes(statusCode);
|
|
148
|
+
}
|
|
149
|
+
function isPrivateNetworkHost(url) {
|
|
150
|
+
const host = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
151
|
+
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1") {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
if (/^(127|10|0)\./.test(host) || /^192\.168\./.test(host) || /^169\.254\./.test(host) || /^172\.(1[6-9]|2[0-9]|3[01])\./.test(host)) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (/^::ffff:(127|10|0)\./.test(host) || /^::ffff:192\.168\./.test(host) || /^::ffff:169\.254\./.test(host) || /^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./.test(host)) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return /^(fe80|f[cd][0-9a-f]{2}):/.test(host);
|
|
161
|
+
}
|
|
162
|
+
function syntheticGetRequest(headers) {
|
|
163
|
+
const request = new EventEmitter();
|
|
164
|
+
request.method = "GET";
|
|
165
|
+
request.headers = headers;
|
|
166
|
+
request.complete = true;
|
|
167
|
+
process.nextTick(() => request.emit("end"));
|
|
168
|
+
return request;
|
|
169
|
+
}
|
|
170
|
+
function redirectHopHeaders(request, from, next) {
|
|
171
|
+
const headers = { ...request.headers };
|
|
172
|
+
if (from.host !== next.host) {
|
|
173
|
+
delete headers.authorization;
|
|
174
|
+
delete headers.cookie;
|
|
175
|
+
}
|
|
176
|
+
return headers;
|
|
177
|
+
}
|
|
178
|
+
async function fetchUpstreamFollowingRedirects(request, target, env) {
|
|
179
|
+
let current = target;
|
|
180
|
+
let upstream = await fetchUpstream(request, current, env);
|
|
181
|
+
for (let hop = 0; isRedirectStatus(upstream.statusCode); hop += 1) {
|
|
182
|
+
if (hop >= REDIRECT_LIMIT) {
|
|
183
|
+
throw new Error(`registry redirect chain exceeded ${REDIRECT_LIMIT} hops for ${current.host}`);
|
|
184
|
+
}
|
|
185
|
+
const location = headerValue(upstream.headers.location);
|
|
186
|
+
if (!location) {
|
|
187
|
+
throw new Error(`registry returned a ${upstream.statusCode} redirect with no Location header for ${current.host}`);
|
|
188
|
+
}
|
|
189
|
+
const next = new URL(location, current);
|
|
190
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
191
|
+
throw new Error(`registry redirected to unsupported protocol ${next.protocol}`);
|
|
192
|
+
}
|
|
193
|
+
if (isPrivateNetworkHost(next) && !isPrivateNetworkHost(target)) {
|
|
194
|
+
throw new Error(`registry redirected a public artifact request into a private address (${next.hostname})`);
|
|
195
|
+
}
|
|
196
|
+
upstream = await fetchUpstream(syntheticGetRequest(redirectHopHeaders(request, current, next)), next, env);
|
|
197
|
+
current = next;
|
|
198
|
+
}
|
|
199
|
+
return upstream;
|
|
200
|
+
}
|
|
201
|
+
const ARTIFACT_CONDITIONAL_HEADERS = ["range", "if-range", "if-none-match", "if-modified-since"];
|
|
202
|
+
async function handleArtifactRequest(request, response, target, options, state) {
|
|
203
|
+
if (!isRegistryIndexRequest(target)) {
|
|
204
|
+
for (const header of ARTIFACT_CONDITIONAL_HEADERS) {
|
|
205
|
+
delete request.headers[header];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const upstream = await fetchUpstreamFollowingRedirects(request, target, options.env).catch((error) => {
|
|
209
|
+
const message = error instanceof Error ? error.message : "registry request failed";
|
|
210
|
+
const decision = recordDecision(options, state, {
|
|
211
|
+
verdict: "block",
|
|
212
|
+
packageName: packageNameFromUrl(target),
|
|
213
|
+
cause: error instanceof BufferBudgetError ? "proxy-setup-failure" : "registry-timeout",
|
|
214
|
+
reason: message
|
|
215
|
+
});
|
|
216
|
+
sendBlocked(response, decision);
|
|
217
|
+
return null;
|
|
218
|
+
});
|
|
219
|
+
if (!upstream) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const metadataIdentities = extractRegistryMetadataIdentities(target, upstream);
|
|
223
|
+
if (metadataIdentities.length > 0 || isRegistryIndexRequest(target)) {
|
|
224
|
+
if (metadataIdentities.length > 0) {
|
|
225
|
+
state.identities = mergeIdentities(state.identities, metadataIdentities);
|
|
226
|
+
state.events = [...state.events, `metadata:${target.hostname}:${metadataIdentities.length}`];
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
state.events = [...state.events, `index:${target.hostname}`];
|
|
230
|
+
}
|
|
231
|
+
writeProxyState(options.session, state);
|
|
232
|
+
response.writeHead(upstream.statusCode, upstream.headers);
|
|
233
|
+
response.end(upstream.body);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (upstream.statusCode === 206) {
|
|
237
|
+
const decision = recordDecision(options, state, {
|
|
238
|
+
verdict: "block",
|
|
239
|
+
packageName: packageNameFromUrl(target),
|
|
240
|
+
cause: "policy",
|
|
241
|
+
reason: "registry returned partial content — a partial artifact cannot be verified"
|
|
242
|
+
});
|
|
243
|
+
sendBlocked(response, decision);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
|
|
247
|
+
// A non-success status is the registry's own error (e.g. 404 for a version
|
|
248
|
+
// that does not exist) — not an installable artifact. Pass it through so the
|
|
249
|
+
// package manager handles it (404s drive version resolution); scanning an
|
|
250
|
+
// error body as if it were a package would mis-identify it.
|
|
251
|
+
state.events = [...state.events, `passthrough:${upstream.statusCode}:${target.hostname}`];
|
|
252
|
+
writeProxyState(options.session, state);
|
|
253
|
+
response.writeHead(upstream.statusCode, upstream.headers);
|
|
254
|
+
response.end(upstream.body);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const sha256 = createHash("sha256").update(upstream.body).digest("hex");
|
|
258
|
+
const identity = resolveArtifactIdentity(target, state.identities, options.classification);
|
|
259
|
+
if (identity.kind === "ambiguous") {
|
|
260
|
+
state.hashes = [...state.hashes, {
|
|
261
|
+
url: redactSecrets(target.toString()),
|
|
262
|
+
sha256
|
|
263
|
+
}];
|
|
264
|
+
const decision = recordDecision(options, state, {
|
|
265
|
+
verdict: "block",
|
|
266
|
+
packageName: identity.packageName,
|
|
267
|
+
cause: "policy",
|
|
268
|
+
reason: identity.reason
|
|
269
|
+
});
|
|
270
|
+
sendBlocked(response, decision);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
state.hashes = [...state.hashes, {
|
|
274
|
+
url: redactSecrets(target.toString()),
|
|
275
|
+
sha256,
|
|
276
|
+
identity: identity.identity
|
|
277
|
+
}];
|
|
278
|
+
const inflightName = artifactDisplayName(identity.identity);
|
|
279
|
+
state.inflight = [...state.inflight, inflightName];
|
|
280
|
+
writeProxyState(options.session, state);
|
|
281
|
+
const verdict = await lookupVerdict(options, target, sha256, upstream, identity.identity).catch((error) => ({
|
|
282
|
+
verdict: "block",
|
|
283
|
+
packageName: artifactDisplayName(identity.identity),
|
|
284
|
+
cause: "api-timeout",
|
|
285
|
+
reason: error instanceof Error ? error.message : "Dependency Guardian API verdict lookup failed"
|
|
286
|
+
}));
|
|
287
|
+
state.inflight = removeFirst(state.inflight, inflightName);
|
|
288
|
+
const decision = recordDecision(options, state, verdict);
|
|
289
|
+
if (decision.action === "block") {
|
|
290
|
+
sendBlocked(response, decision);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
response.writeHead(upstream.statusCode, upstream.headers);
|
|
294
|
+
response.end(upstream.body);
|
|
295
|
+
}
|
|
296
|
+
async function blindTunnel(clientSocket, head, target, options, activeSockets) {
|
|
297
|
+
const upstreamProxy = selectUpstreamProxy(target, options.env);
|
|
298
|
+
const upstreamSocket = upstreamProxy
|
|
299
|
+
? await connectViaUpstreamProxy(target, upstreamProxy)
|
|
300
|
+
: await connectDirect(target);
|
|
301
|
+
trackSocket(activeSockets, upstreamSocket);
|
|
302
|
+
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
303
|
+
if (head.length > 0) {
|
|
304
|
+
upstreamSocket.write(head);
|
|
305
|
+
}
|
|
306
|
+
upstreamSocket.pipe(clientSocket);
|
|
307
|
+
clientSocket.pipe(upstreamSocket);
|
|
308
|
+
}
|
|
309
|
+
async function mitmTunnel(clientSocket, head, target, options, state, ca, activeSockets) {
|
|
310
|
+
const leaf = ca.leafForHost(target.hostname);
|
|
311
|
+
const secureContext = createSecureContext({
|
|
312
|
+
cert: leaf.certPem,
|
|
313
|
+
key: leaf.keyPem
|
|
314
|
+
});
|
|
315
|
+
const innerHttp = createServer((request, response) => {
|
|
316
|
+
const artifactTarget = new URL(`${target.protocol}//${target.host}${request.url ?? "/"}`);
|
|
317
|
+
handleArtifactRequest(request, response, artifactTarget, options, state).catch((error) => {
|
|
318
|
+
const decision = recordDecision(options, state, {
|
|
319
|
+
verdict: "block",
|
|
320
|
+
packageName: artifactTarget.hostname,
|
|
321
|
+
cause: "proxy-setup-failure",
|
|
322
|
+
reason: error instanceof Error ? error.message : "MITM request failed"
|
|
323
|
+
});
|
|
324
|
+
sendBlocked(response, decision);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
const tlsServer = createTlsServer({
|
|
328
|
+
SNICallback: (_servername, callback) => callback(null, secureContext),
|
|
329
|
+
cert: leaf.certPem,
|
|
330
|
+
key: leaf.keyPem,
|
|
331
|
+
ALPNProtocols: ["http/1.1"]
|
|
332
|
+
}, (tlsSocket) => {
|
|
333
|
+
trackSocket(activeSockets, tlsSocket);
|
|
334
|
+
innerHttp.emit("connection", tlsSocket);
|
|
335
|
+
});
|
|
336
|
+
tlsServer.once("tlsClientError", () => {
|
|
337
|
+
clientSocket.destroy();
|
|
338
|
+
});
|
|
339
|
+
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
340
|
+
if (head.length > 0) {
|
|
341
|
+
clientSocket.unshift(head);
|
|
342
|
+
}
|
|
343
|
+
tlsServer.emit("connection", clientSocket);
|
|
344
|
+
}
|
|
345
|
+
function parseProxyTarget(request) {
|
|
346
|
+
const rawUrl = request.url ?? "";
|
|
347
|
+
try {
|
|
348
|
+
if (/^https?:\/\//.test(rawUrl)) {
|
|
349
|
+
return new URL(rawUrl);
|
|
350
|
+
}
|
|
351
|
+
const host = request.headers.host;
|
|
352
|
+
if (!host) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
return new URL(`http://${host}${rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`}`);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function parseConnectTarget(authority) {
|
|
362
|
+
if (!authority) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const normalized = authority.startsWith("[")
|
|
367
|
+
? authority
|
|
368
|
+
: authority.replace(/^([^:]+)$/, "$1:443");
|
|
369
|
+
return new URL(`https://${normalized}`);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function connectDirect(target) {
|
|
376
|
+
return new Promise((resolve, reject) => {
|
|
377
|
+
const socket = connect(Number(target.port || "443"), upstreamHostname(target));
|
|
378
|
+
socket.once("connect", () => resolve(socket));
|
|
379
|
+
socket.once("error", reject);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
function upstreamHostname(target, env = process.env) {
|
|
383
|
+
const hostname = target.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
384
|
+
return testUpstreamHostMap(env).get(hostname) ?? hostname;
|
|
385
|
+
}
|
|
386
|
+
function testUpstreamHostMap(env) {
|
|
387
|
+
const entries = (env.DG_TEST_UPSTREAM_HOST_MAP ?? "")
|
|
388
|
+
.split(",")
|
|
389
|
+
.map((entry) => entry.trim())
|
|
390
|
+
.filter(Boolean)
|
|
391
|
+
.map((entry) => {
|
|
392
|
+
const separator = entry.indexOf("=");
|
|
393
|
+
if (separator <= 0) {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
return [entry.slice(0, separator), entry.slice(separator + 1)];
|
|
397
|
+
})
|
|
398
|
+
.filter((entry) => entry !== null);
|
|
399
|
+
return new Map(entries);
|
|
400
|
+
}
|
|
401
|
+
function fetchUpstream(request, target, env) {
|
|
402
|
+
const upstreamProxy = selectUpstreamProxy(target, env);
|
|
403
|
+
if (upstreamProxy && target.protocol === "http:") {
|
|
404
|
+
return fetchHttpViaProxy(request, target, upstreamProxy);
|
|
405
|
+
}
|
|
406
|
+
if (upstreamProxy && target.protocol === "https:") {
|
|
407
|
+
return fetchHttpsViaProxy(request, target, upstreamProxy, env);
|
|
408
|
+
}
|
|
409
|
+
if (target.protocol === "https:") {
|
|
410
|
+
return fetchHttpsDirect(request, target, env);
|
|
411
|
+
}
|
|
412
|
+
return new Promise((resolve, reject) => {
|
|
413
|
+
const upstream = httpRequest({
|
|
414
|
+
hostname: upstreamHostname(target, env),
|
|
415
|
+
port: target.port ? Number(target.port) : 80,
|
|
416
|
+
path: `${target.pathname}${target.search}`,
|
|
417
|
+
method: request.method,
|
|
418
|
+
headers: request.headers
|
|
419
|
+
}, (upstreamResponse) => {
|
|
420
|
+
collectBounded(upstreamResponse, { label: target.toString() })
|
|
421
|
+
.then((body) => resolve({
|
|
422
|
+
statusCode: upstreamResponse.statusCode ?? 502,
|
|
423
|
+
headers: responseHeaders(upstreamResponse),
|
|
424
|
+
body
|
|
425
|
+
}))
|
|
426
|
+
.catch(reject);
|
|
427
|
+
});
|
|
428
|
+
upstream.on("error", reject);
|
|
429
|
+
request.on("data", (chunk) => upstream.write(chunk));
|
|
430
|
+
request.on("end", () => upstream.end());
|
|
431
|
+
request.on("error", reject);
|
|
432
|
+
request.once("close", () => {
|
|
433
|
+
if (!request.complete) {
|
|
434
|
+
upstream.destroy();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function fetchHttpViaProxy(request, target, upstreamProxy) {
|
|
440
|
+
return new Promise((resolve, reject) => {
|
|
441
|
+
const headers = {
|
|
442
|
+
...request.headers,
|
|
443
|
+
...(upstreamProxy.authorizationHeader ? { "Proxy-Authorization": upstreamProxy.authorizationHeader } : {})
|
|
444
|
+
};
|
|
445
|
+
const upstream = httpRequest({
|
|
446
|
+
hostname: upstreamProxy.url.hostname,
|
|
447
|
+
port: upstreamProxy.url.port ? Number(upstreamProxy.url.port) : 80,
|
|
448
|
+
path: target.toString(),
|
|
449
|
+
method: request.method,
|
|
450
|
+
headers
|
|
451
|
+
}, (upstreamResponse) => {
|
|
452
|
+
collectBounded(upstreamResponse, { label: target.toString() })
|
|
453
|
+
.then((body) => resolve({
|
|
454
|
+
statusCode: upstreamResponse.statusCode ?? 502,
|
|
455
|
+
headers: responseHeaders(upstreamResponse),
|
|
456
|
+
body
|
|
457
|
+
}))
|
|
458
|
+
.catch(reject);
|
|
459
|
+
});
|
|
460
|
+
upstream.on("error", reject);
|
|
461
|
+
request.on("data", (chunk) => upstream.write(chunk));
|
|
462
|
+
request.on("end", () => upstream.end());
|
|
463
|
+
request.on("error", reject);
|
|
464
|
+
request.once("close", () => {
|
|
465
|
+
if (!request.complete) {
|
|
466
|
+
upstream.destroy();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
function fetchHttpsDirect(request, target, env) {
|
|
472
|
+
return new Promise((resolve, reject) => {
|
|
473
|
+
const upstream = httpsRequest({
|
|
474
|
+
hostname: upstreamHostname(target, env),
|
|
475
|
+
port: target.port ? Number(target.port) : 443,
|
|
476
|
+
path: `${target.pathname}${target.search}`,
|
|
477
|
+
method: request.method,
|
|
478
|
+
headers: upstreamRequestHeaders(request, target),
|
|
479
|
+
...upstreamTlsOptions(env)
|
|
480
|
+
}, (upstreamResponse) => {
|
|
481
|
+
collectBounded(upstreamResponse, { label: target.toString() })
|
|
482
|
+
.then((body) => resolve({
|
|
483
|
+
statusCode: upstreamResponse.statusCode ?? 502,
|
|
484
|
+
headers: responseHeaders(upstreamResponse),
|
|
485
|
+
body
|
|
486
|
+
}))
|
|
487
|
+
.catch(reject);
|
|
488
|
+
});
|
|
489
|
+
upstream.on("error", reject);
|
|
490
|
+
request.on("data", (chunk) => upstream.write(chunk));
|
|
491
|
+
request.on("end", () => upstream.end());
|
|
492
|
+
request.on("error", reject);
|
|
493
|
+
request.once("close", () => {
|
|
494
|
+
if (!request.complete) {
|
|
495
|
+
upstream.destroy();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
async function fetchHttpsViaProxy(request, target, upstreamProxy, env) {
|
|
501
|
+
const tunnel = await connectViaUpstreamProxy(target, upstreamProxy);
|
|
502
|
+
const tlsSocket = await connectTlsOverSocket(tunnel, target, env);
|
|
503
|
+
const requestBody = await readRequestBody(request);
|
|
504
|
+
const rawResponse = await writeRawHttpRequest(tlsSocket, request, target, requestBody);
|
|
505
|
+
return parseRawHttpResponse(rawResponse);
|
|
506
|
+
}
|
|
507
|
+
function connectTlsOverSocket(socket, target, env) {
|
|
508
|
+
return new Promise((resolve, reject) => {
|
|
509
|
+
const tlsSocket = tlsConnect({
|
|
510
|
+
socket,
|
|
511
|
+
servername: target.hostname,
|
|
512
|
+
ALPNProtocols: ["http/1.1"],
|
|
513
|
+
...upstreamTlsOptions(env)
|
|
514
|
+
}, () => resolve(tlsSocket));
|
|
515
|
+
tlsSocket.once("error", reject);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
function writeRawHttpRequest(tlsSocket, request, target, requestBody) {
|
|
519
|
+
return new Promise((resolve, reject) => {
|
|
520
|
+
collectBounded(tlsSocket, { label: target.toString() }).then(resolve).catch(reject);
|
|
521
|
+
const headers = upstreamRequestHeaders(request, target);
|
|
522
|
+
const lines = [
|
|
523
|
+
`${request.method ?? "GET"} ${target.pathname}${target.search} HTTP/1.1`,
|
|
524
|
+
...Object.entries(headers).flatMap(([key, value]) => headerLines(key, value)),
|
|
525
|
+
"Connection: close",
|
|
526
|
+
"",
|
|
527
|
+
""
|
|
528
|
+
];
|
|
529
|
+
tlsSocket.write(lines.join("\r\n"));
|
|
530
|
+
if (requestBody.length > 0) {
|
|
531
|
+
tlsSocket.write(requestBody);
|
|
532
|
+
}
|
|
533
|
+
tlsSocket.end();
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
function parseRawHttpResponse(raw) {
|
|
537
|
+
const headerEnd = raw.indexOf("\r\n\r\n");
|
|
538
|
+
if (headerEnd === -1) {
|
|
539
|
+
throw new Error("upstream registry returned a malformed HTTP response");
|
|
540
|
+
}
|
|
541
|
+
const head = raw.subarray(0, headerEnd).toString("latin1");
|
|
542
|
+
const body = raw.subarray(headerEnd + 4);
|
|
543
|
+
const [statusLine = "", ...headerLinesRaw] = head.split("\r\n");
|
|
544
|
+
const status = /^HTTP\/1\.[01] (\d{3})\b/.exec(statusLine)?.[1];
|
|
545
|
+
const headers = {};
|
|
546
|
+
for (const line of headerLinesRaw) {
|
|
547
|
+
const separator = line.indexOf(":");
|
|
548
|
+
if (separator === -1) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const key = line.slice(0, separator).toLowerCase();
|
|
552
|
+
const value = line.slice(separator + 1).trim();
|
|
553
|
+
const existing = headers[key];
|
|
554
|
+
headers[key] = existing === undefined ? value : `${headerValue(existing)},${value}`;
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
statusCode: status ? Number(status) : 502,
|
|
558
|
+
headers,
|
|
559
|
+
body: headerValue(headers["transfer-encoding"]).toLowerCase() === "chunked" ? decodeChunkedBody(body) : body
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function upstreamRequestHeaders(request, target) {
|
|
563
|
+
const headers = {};
|
|
564
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
565
|
+
const normalized = key.toLowerCase();
|
|
566
|
+
if (["proxy-authorization", "proxy-connection", "connection", "host"].includes(normalized) || value === undefined) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
headers[key] = Array.isArray(value) ? [...value] : value;
|
|
570
|
+
}
|
|
571
|
+
headers.Host = target.host;
|
|
572
|
+
return headers;
|
|
573
|
+
}
|
|
574
|
+
function headerLines(key, value) {
|
|
575
|
+
if (value === undefined) {
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
if (Array.isArray(value)) {
|
|
579
|
+
return value.map((entry) => `${key}: ${entry}`);
|
|
580
|
+
}
|
|
581
|
+
return [`${key}: ${value}`];
|
|
582
|
+
}
|
|
583
|
+
function upstreamTlsOptions(env) {
|
|
584
|
+
const caPath = env.DG_UPSTREAM_CA_CERT ?? env.NODE_EXTRA_CA_CERTS;
|
|
585
|
+
if (!caPath) {
|
|
586
|
+
return {};
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
return {
|
|
590
|
+
ca: readFileSync(caPath, "utf8")
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
return {};
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function readRequestBody(request) {
|
|
598
|
+
return collectBounded(request, { label: "proxied request body" });
|
|
599
|
+
}
|
|
600
|
+
function decodeChunkedBody(body) {
|
|
601
|
+
const chunks = [];
|
|
602
|
+
let offset = 0;
|
|
603
|
+
while (offset < body.length) {
|
|
604
|
+
const lineEnd = body.indexOf("\r\n", offset);
|
|
605
|
+
if (lineEnd === -1) {
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
const size = Number.parseInt(body.subarray(offset, lineEnd).toString("latin1"), 16);
|
|
609
|
+
if (!Number.isFinite(size) || size < 0) {
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
if (size === 0) {
|
|
613
|
+
return Buffer.concat(chunks);
|
|
614
|
+
}
|
|
615
|
+
const start = lineEnd + 2;
|
|
616
|
+
const end = start + size;
|
|
617
|
+
chunks.push(body.subarray(start, end));
|
|
618
|
+
offset = end + 2;
|
|
619
|
+
}
|
|
620
|
+
return Buffer.concat(chunks);
|
|
621
|
+
}
|
|
622
|
+
async function lookupVerdict(options, target, sha256, upstream, identity) {
|
|
623
|
+
if (shouldUseScanTarball(options, target, identity)) {
|
|
624
|
+
return lookupScanTarballVerdict(options, target, sha256, upstream, identity);
|
|
625
|
+
}
|
|
626
|
+
const controller = new AbortController();
|
|
627
|
+
const timeout = setTimeout(() => controller.abort(), options.verdictTimeoutMs ?? installVerdictTimeoutMs(options.env));
|
|
628
|
+
try {
|
|
629
|
+
const response = await fetch(`${options.apiBaseUrl}/v1/install-verdict`, {
|
|
630
|
+
method: "POST",
|
|
631
|
+
headers: {
|
|
632
|
+
"Content-Type": "application/json"
|
|
633
|
+
},
|
|
634
|
+
body: JSON.stringify({
|
|
635
|
+
manager: options.classification.manager,
|
|
636
|
+
action: options.classification.action,
|
|
637
|
+
url: target.toString(),
|
|
638
|
+
artifactUrlHash: artifactUrlHash(target),
|
|
639
|
+
ecosystem: identity.ecosystem,
|
|
640
|
+
name: identity.name,
|
|
641
|
+
version: identity.version,
|
|
642
|
+
registryHost: identity.registryHost,
|
|
643
|
+
sourceKind: identity.sourceKind,
|
|
644
|
+
sha256,
|
|
645
|
+
statusCode: upstream.statusCode,
|
|
646
|
+
contentType: headerValue(upstream.headers["content-type"])
|
|
647
|
+
}),
|
|
648
|
+
signal: controller.signal
|
|
649
|
+
});
|
|
650
|
+
if (response.status === 402 || response.status === 429) {
|
|
651
|
+
return {
|
|
652
|
+
verdict: "block",
|
|
653
|
+
packageName: artifactDisplayName(identity),
|
|
654
|
+
cause: "quota-exceeded",
|
|
655
|
+
reason: "You've reached your monthly scan limit. Upgrade at westbayberry.com/pricing or wait for it to reset."
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (!response.ok) {
|
|
659
|
+
return {
|
|
660
|
+
verdict: "block",
|
|
661
|
+
packageName: artifactDisplayName(identity),
|
|
662
|
+
cause: "api-unavailable",
|
|
663
|
+
reason: `Dependency Guardian API returned ${response.status}. Run 'dg doctor' and 'dg login' to verify API access.`
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
return normalizeVerdict(await response.json(), target, identity, sha256);
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
clearTimeout(timeout);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async function lookupScanTarballVerdict(options, target, sha256, upstream, identity) {
|
|
673
|
+
const body = upstream.body ?? Buffer.alloc(0);
|
|
674
|
+
const uploadPolicy = scanTarballUploadPolicy(options.env);
|
|
675
|
+
if (!uploadPolicy.enabled) {
|
|
676
|
+
return {
|
|
677
|
+
verdict: "block",
|
|
678
|
+
packageName: artifactDisplayName(identity),
|
|
679
|
+
cause: "private-upload-disabled",
|
|
680
|
+
reason: `private artifact scan upload is disabled for ${target.hostname}`
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
if (!uploadPolicy.token) {
|
|
684
|
+
return {
|
|
685
|
+
verdict: "block",
|
|
686
|
+
packageName: artifactDisplayName(identity),
|
|
687
|
+
cause: "private-upload-disabled",
|
|
688
|
+
reason: "private artifact scan upload requires DG_API_TOKEN"
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
if (body.length > uploadPolicy.maxBytes) {
|
|
692
|
+
return {
|
|
693
|
+
verdict: "block",
|
|
694
|
+
packageName: artifactDisplayName(identity),
|
|
695
|
+
cause: "private-upload-disabled",
|
|
696
|
+
reason: `private artifact is ${body.length} bytes, above the ${uploadPolicy.maxBytes} byte scan-tarball upload limit`
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const controller = new AbortController();
|
|
700
|
+
const timeout = setTimeout(() => controller.abort(), uploadPolicy.timeoutMs);
|
|
701
|
+
try {
|
|
702
|
+
const response = await fetch(`${options.apiBaseUrl}/v1/scan-tarball`, {
|
|
703
|
+
method: "POST",
|
|
704
|
+
headers: {
|
|
705
|
+
"Authorization": `Bearer ${uploadPolicy.token}`,
|
|
706
|
+
"Content-Type": "application/octet-stream",
|
|
707
|
+
"X-DG-Action": options.classification.action,
|
|
708
|
+
"X-DG-Artifact-SHA256": sha256,
|
|
709
|
+
"X-DG-Artifact-URL-Hash": artifactUrlHash(target),
|
|
710
|
+
"X-DG-Cache-Key": `sha256:${sha256}`,
|
|
711
|
+
"X-DG-Ecosystem": identity.ecosystem,
|
|
712
|
+
"X-DG-Manager": options.classification.manager,
|
|
713
|
+
"X-DG-Package-Name": identity.name,
|
|
714
|
+
"X-DG-Package-Version": identity.version,
|
|
715
|
+
"X-DG-Privacy": "private-artifact",
|
|
716
|
+
"X-DG-Registry-Host": identity.registryHost,
|
|
717
|
+
"X-DG-Source-Kind": identity.sourceKind
|
|
718
|
+
},
|
|
719
|
+
body,
|
|
720
|
+
signal: controller.signal
|
|
721
|
+
});
|
|
722
|
+
if (!response.ok) {
|
|
723
|
+
throw new Error(`Dependency Guardian scan-tarball API returned ${response.status}`);
|
|
724
|
+
}
|
|
725
|
+
return normalizeScanTarballVerdict(await response.json(), target, identity, sha256);
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
clearTimeout(timeout);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function normalizeVerdict(value, target, identity, streamedSha256) {
|
|
732
|
+
if (!isRecord(value)) {
|
|
733
|
+
throw new Error("Dependency Guardian API returned a malformed verdict");
|
|
734
|
+
}
|
|
735
|
+
const verdict = value.verdict;
|
|
736
|
+
if (verdict !== "pass" && verdict !== "warn" && verdict !== "block") {
|
|
737
|
+
throw new Error("Dependency Guardian API returned a malformed verdict");
|
|
738
|
+
}
|
|
739
|
+
const scannedSha256 = typeof value.scannedSha256 === "string" ? value.scannedSha256.toLowerCase() : "";
|
|
740
|
+
if (scannedSha256.length > 0 && scannedSha256 !== streamedSha256) {
|
|
741
|
+
return {
|
|
742
|
+
verdict: "block",
|
|
743
|
+
packageName: artifactDisplayName(identity),
|
|
744
|
+
cause: "hash-mismatch",
|
|
745
|
+
reason: `server scanned SHA-256 ${scannedSha256} did not match streamed artifact SHA-256 ${streamedSha256}`
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
const cause = typeof value.cause === "string" ? value.cause : undefined;
|
|
749
|
+
return {
|
|
750
|
+
verdict,
|
|
751
|
+
packageName: typeof value.packageName === "string" ? value.packageName : artifactDisplayName(identity) || packageNameFromUrl(target),
|
|
752
|
+
...(isProxyCause(cause) ? { cause } : {}),
|
|
753
|
+
reason: typeof value.reason === "string" ? value.reason : `API verdict ${verdict}`,
|
|
754
|
+
...(typeof value.dashboardUrl === "string" ? { dashboardUrl: value.dashboardUrl } : {}),
|
|
755
|
+
...(value.unauthenticated === true ? { unauthenticated: true } : {})
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
function normalizeScanTarballVerdict(value, target, identity, streamedSha256) {
|
|
759
|
+
if (!isRecord(value) || typeof value.scannedSha256 !== "string" || value.scannedSha256.length === 0) {
|
|
760
|
+
throw new Error("Dependency Guardian scan-tarball API did not return scannedSha256");
|
|
761
|
+
}
|
|
762
|
+
return normalizeVerdict(value, target, identity, streamedSha256);
|
|
763
|
+
}
|
|
764
|
+
function shouldUseScanTarball(options, target, identity) {
|
|
765
|
+
if (identity.sourceKind !== "url-fallback") {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
return hostMatchesList(target.hostname, options.env.DG_PRIVATE_REGISTRY_HOSTS ?? "");
|
|
769
|
+
}
|
|
770
|
+
function scanTarballUploadPolicy(env) {
|
|
771
|
+
return {
|
|
772
|
+
enabled: env.DG_SCAN_TARBALL_UPLOAD === "1" || env.DG_SCAN_TARBALL_UPLOAD === "true",
|
|
773
|
+
maxBytes: parsePositiveInteger(env.DG_SCAN_TARBALL_MAX_BYTES, 50 * 1024 * 1024),
|
|
774
|
+
timeoutMs: parsePositiveInteger(env.DG_SCAN_TARBALL_TIMEOUT_MS, 5_000),
|
|
775
|
+
token: envAuthToken(env) ?? ""
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
function installVerdictTimeoutMs(env) {
|
|
779
|
+
return parsePositiveInteger(env.DG_INSTALL_VERDICT_TIMEOUT_MS, 30_000);
|
|
780
|
+
}
|
|
781
|
+
function parsePositiveInteger(value, fallback) {
|
|
782
|
+
if (!value) {
|
|
783
|
+
return fallback;
|
|
784
|
+
}
|
|
785
|
+
const parsed = Number.parseInt(value, 10);
|
|
786
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
787
|
+
}
|
|
788
|
+
function hostMatchesList(host, rawList) {
|
|
789
|
+
const normalized = normalizeHost(host);
|
|
790
|
+
return rawList.split(",").map((entry) => entry.trim()).filter(Boolean).some((pattern) => hostMatchesPattern(normalized, pattern));
|
|
791
|
+
}
|
|
792
|
+
function hostMatchesPattern(host, pattern) {
|
|
793
|
+
const normalized = normalizeHost(pattern);
|
|
794
|
+
if (normalized.startsWith("*.")) {
|
|
795
|
+
const suffix = normalized.slice(1);
|
|
796
|
+
return host.endsWith(suffix) && host.length > suffix.length;
|
|
797
|
+
}
|
|
798
|
+
return host === normalized;
|
|
799
|
+
}
|
|
800
|
+
function normalizeHost(host) {
|
|
801
|
+
return host.replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "").toLowerCase();
|
|
802
|
+
}
|
|
803
|
+
function mergeIdentities(existing, next) {
|
|
804
|
+
const merged = [...existing];
|
|
805
|
+
for (const identity of next) {
|
|
806
|
+
if (!merged.some((candidate) => candidate.tarballUrl === identity.tarballUrl
|
|
807
|
+
&& candidate.ecosystem === identity.ecosystem
|
|
808
|
+
&& candidate.name === identity.name
|
|
809
|
+
&& candidate.version === identity.version
|
|
810
|
+
&& candidate.registryHost === identity.registryHost)) {
|
|
811
|
+
merged.push(identity);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return merged;
|
|
815
|
+
}
|
|
816
|
+
function recordDecision(options, state, verdict) {
|
|
817
|
+
const decision = enforceProtectedInstall({
|
|
818
|
+
classification: options.classification,
|
|
819
|
+
env: options.env,
|
|
820
|
+
proxyVerdict: verdict,
|
|
821
|
+
...(options.forceOverride ? { forceOverride: options.forceOverride } : {})
|
|
822
|
+
});
|
|
823
|
+
state.decisions = [...state.decisions, decision];
|
|
824
|
+
state.events = [...state.events, `${decision.action}:${decision.cause}:${redactSecrets(decision.packageName)}`];
|
|
825
|
+
writeProxyState(options.session, state);
|
|
826
|
+
return decision;
|
|
827
|
+
}
|
|
828
|
+
function sendBlocked(response, decision) {
|
|
829
|
+
const body = `Dependency Guardian blocked ${redactSecrets(decision.packageName)}: ${redactSecrets(decision.reason)}\n`;
|
|
830
|
+
response.writeHead(403, {
|
|
831
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
832
|
+
"Content-Length": Buffer.byteLength(body)
|
|
833
|
+
});
|
|
834
|
+
response.end(body);
|
|
835
|
+
}
|
|
836
|
+
function responseHeaders(response) {
|
|
837
|
+
const headers = {};
|
|
838
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
839
|
+
if (value !== undefined) {
|
|
840
|
+
headers[key] = Array.isArray(value) ? [...value] : value;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return headers;
|
|
844
|
+
}
|
|
845
|
+
function headerValue(value) {
|
|
846
|
+
if (Array.isArray(value)) {
|
|
847
|
+
return value.join(",");
|
|
848
|
+
}
|
|
849
|
+
if (typeof value === "number") {
|
|
850
|
+
return String(value);
|
|
851
|
+
}
|
|
852
|
+
if (typeof value === "string") {
|
|
853
|
+
return value;
|
|
854
|
+
}
|
|
855
|
+
return "";
|
|
856
|
+
}
|
|
857
|
+
function packageNameFromUrl(url) {
|
|
858
|
+
const name = url.pathname.split("/").filter(Boolean).pop();
|
|
859
|
+
return name ? decodeURIComponent(name) : url.hostname;
|
|
860
|
+
}
|
|
861
|
+
function writeProxyState(session, state) {
|
|
862
|
+
mkdirSync(dirname(session.files.proxy), {
|
|
863
|
+
recursive: true,
|
|
864
|
+
mode: 0o700
|
|
865
|
+
});
|
|
866
|
+
const tempPath = `${session.files.proxy}.${process.pid}.${process.hrtime.bigint().toString(36)}.tmp`;
|
|
867
|
+
try {
|
|
868
|
+
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, {
|
|
869
|
+
encoding: "utf8",
|
|
870
|
+
flag: "wx",
|
|
871
|
+
mode: 0o600
|
|
872
|
+
});
|
|
873
|
+
renameSync(tempPath, session.files.proxy);
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
rmSync(tempPath, {
|
|
877
|
+
force: true
|
|
878
|
+
});
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
function removeFirst(items, value) {
|
|
883
|
+
const index = items.indexOf(value);
|
|
884
|
+
if (index === -1) {
|
|
885
|
+
return [...items];
|
|
886
|
+
}
|
|
887
|
+
return [...items.slice(0, index), ...items.slice(index + 1)];
|
|
888
|
+
}
|
|
889
|
+
function isRecord(value) {
|
|
890
|
+
return typeof value === "object" && value !== null;
|
|
891
|
+
}
|
|
892
|
+
function isProxyCause(value) {
|
|
893
|
+
return [
|
|
894
|
+
"pass",
|
|
895
|
+
"warn",
|
|
896
|
+
"malware",
|
|
897
|
+
"policy",
|
|
898
|
+
"license",
|
|
899
|
+
"hash-mismatch",
|
|
900
|
+
"private-upload-disabled",
|
|
901
|
+
"api-unavailable",
|
|
902
|
+
"quota-exceeded",
|
|
903
|
+
"api-timeout",
|
|
904
|
+
"registry-timeout",
|
|
905
|
+
"analysis-incomplete",
|
|
906
|
+
"unsupported-manager",
|
|
907
|
+
"proxy-setup-failure"
|
|
908
|
+
].includes(value ?? "");
|
|
909
|
+
}
|