@westbayberry/dg 2.1.0 → 2.3.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/README.md +216 -226
- package/dist/agents/claude-code.js +123 -0
- package/dist/agents/codex.js +65 -0
- package/dist/agents/copilot-cli.js +115 -0
- package/dist/agents/cursor.js +118 -0
- package/dist/agents/gate-posture.js +21 -0
- package/dist/agents/gemini.js +107 -0
- package/dist/agents/persistence.js +351 -0
- package/dist/agents/registry.js +128 -0
- package/dist/agents/routing.js +118 -0
- package/dist/agents/types.js +1 -0
- package/dist/agents/windsurf.js +93 -0
- package/dist/api/analyze.js +1 -1
- package/dist/audit/detectors.js +0 -11
- package/dist/audit/events.js +5 -21
- package/dist/audit/rules.js +6 -2
- package/dist/audit-ui/AuditApp.js +2 -0
- package/dist/audit-ui/components/AuditResultsView.js +55 -92
- package/dist/audit-ui/export.js +0 -4
- package/dist/audit-ui/format.js +0 -3
- package/dist/audit-ui/launch.js +10 -1
- package/dist/auth/device-login.js +4 -5
- package/dist/auth/login-app.js +7 -7
- package/dist/auth/store.js +17 -5
- package/dist/bin/dg.js +59 -52
- package/dist/commands/agents.js +275 -0
- package/dist/commands/audit.js +28 -3
- package/dist/commands/config.js +15 -7
- package/dist/commands/cooldown.js +389 -0
- package/dist/commands/decisions.js +23 -9
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/licenses.js +10 -22
- package/dist/commands/logout.js +4 -11
- package/dist/commands/router.js +6 -4
- package/dist/commands/sbom.js +206 -0
- package/dist/commands/scan.js +1 -1
- package/dist/commands/service.js +32 -13
- package/dist/commands/setup.js +196 -27
- package/dist/commands/types.js +1 -0
- package/dist/commands/uninstall.js +2 -1
- package/dist/commands/update.js +17 -8
- package/dist/commands/verify.js +8 -5
- package/dist/config/settings.js +49 -48
- package/dist/decisions/remember-prompt.js +5 -10
- package/dist/export-ui/ExportDialog.js +198 -0
- package/dist/install-ui/LiveInstall.js +5 -4
- package/dist/install-ui/block-render.js +18 -14
- package/dist/install-ui/prep-spinner.js +32 -0
- package/dist/launcher/agent-check.js +896 -0
- package/dist/launcher/agent-hook-exec.js +70 -0
- package/dist/launcher/agent-hook-io.js +39 -0
- package/dist/launcher/cargo-cache.js +40 -0
- package/dist/launcher/classify.js +44 -7
- package/dist/launcher/env.js +105 -26
- package/dist/launcher/install-preflight.js +64 -18
- package/dist/launcher/live-install.js +29 -8
- package/dist/launcher/manifest-screen.js +171 -0
- package/dist/launcher/output-redaction.js +10 -4
- package/dist/launcher/preflight-prompt.js +18 -4
- package/dist/launcher/run.js +178 -103
- package/dist/launcher/spawn-invocation.js +21 -0
- package/dist/policy/cooldown.js +18 -5
- package/dist/policy/evaluate.js +5 -6
- package/dist/policy/pypi-name.js +17 -0
- package/dist/presentation/mode.js +3 -2
- package/dist/presentation/package-page.js +9 -0
- package/dist/presentation/theme.js +7 -7
- package/dist/project/dgfile.js +146 -6
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/auth.js +42 -0
- package/dist/proxy/ca.js +29 -9
- package/dist/proxy/cooldown-exemptions-file.js +33 -0
- package/dist/proxy/enforcement.js +55 -16
- package/dist/proxy/metadata-map.js +66 -12
- package/dist/proxy/preverified.js +55 -0
- package/dist/proxy/server.js +568 -53
- package/dist/proxy/worker.js +16 -1
- package/dist/publish-set/collect.js +1 -4
- package/dist/publish-set/npm.js +8 -5
- package/dist/publish-set/pack.js +9 -3
- package/dist/runtime/cli.js +0 -4
- package/dist/runtime/fatal.js +31 -0
- package/dist/runtime/first-run.js +14 -12
- package/dist/runtime/node-version.js +43 -6
- package/dist/runtime/nudges.js +35 -2
- package/dist/sbom/cyclonedx.js +211 -0
- package/dist/sbom-ui/SbomApp.js +158 -0
- package/dist/sbom-ui/components/SbomHeader.js +32 -0
- package/dist/sbom-ui/components/SbomList.js +52 -0
- package/dist/sbom-ui/inventory.js +132 -0
- package/dist/sbom-ui/launch.js +51 -0
- package/dist/sbom-ui/run.js +55 -0
- package/dist/sbom-ui/store.js +26 -0
- package/dist/scan/collect.js +10 -6
- package/dist/scan/command.js +17 -10
- package/dist/scan/discovery.js +11 -2
- package/dist/scan/render.js +28 -4
- package/dist/scan/scanner-report.js +15 -9
- package/dist/scan/staged.js +33 -9
- package/dist/scan-ui/LegacyApp.js +9 -13
- package/dist/scan-ui/alt-screen.js +5 -8
- package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
- package/dist/scan-ui/components/ProgressBar.js +3 -14
- package/dist/scan-ui/components/ProjectSelector.js +1 -1
- package/dist/scan-ui/components/ScoreHeader.js +2 -3
- package/dist/scan-ui/components/SetupBanner.js +0 -6
- package/dist/scan-ui/format-helpers.js +61 -5
- package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
- package/dist/scan-ui/hooks/useScan.js +19 -2
- package/dist/scan-ui/launch.js +7 -4
- package/dist/scan-ui/shims.js +11 -4
- package/dist/scripts/detect.js +7 -2
- package/dist/scripts/gate.js +1 -1
- package/dist/security/sanitize.js +8 -4
- package/dist/service/state.js +28 -9
- package/dist/service/trust-refresh.js +92 -0
- package/dist/service/trust-store.js +5 -9
- package/dist/service/worker.js +23 -1
- package/dist/setup/activate-shell.js +28 -0
- package/dist/setup/git-hook.js +49 -4
- package/dist/setup/plan.js +227 -49
- package/dist/setup/uninstall-standalone.js +25 -0
- package/dist/setup-ui/gate.js +39 -0
- package/dist/setup-ui/offer.js +42 -0
- package/dist/setup-ui/selector.js +27 -0
- package/dist/setup-ui/tasks.js +56 -0
- package/dist/setup-ui/wizard.js +233 -0
- package/dist/standalone/uninstall.mjs +2123 -0
- package/dist/state/cooldown-held.js +66 -0
- package/dist/state/index.js +1 -0
- package/dist/state/locks.js +4 -2
- package/dist/state/store.js +2 -1
- package/dist/util/external-tool.js +25 -0
- package/dist/util/git.js +10 -3
- package/dist/util/report-writer.js +57 -0
- package/dist/verify/local.js +242 -44
- package/dist/verify/package-check.js +87 -29
- package/dist/verify/preflight.js +242 -49
- package/dist/verify/render.js +15 -1
- package/npm-shrinkwrap.json +2383 -0
- package/package.json +7 -8
- package/NOTICE +0 -5
- package/dist/commands/completion.js +0 -117
- package/dist/commands/explain.js +0 -236
- package/dist/commands/unavailable.js +0 -11
- package/dist/scripts/rebuild.js +0 -28
- package/dist/telemetry/events.js +0 -40
package/dist/proxy/server.js
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
-
import { BufferBudgetError, collectBounded } from "./buffer-budget.js";
|
|
3
|
+
import { BufferBudgetError, collectBounded, maxArtifactBytes } from "./buffer-budget.js";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
|
-
import { createServer, request as httpRequest } from "node:http";
|
|
6
|
-
import { request as httpsRequest } from "node:https";
|
|
5
|
+
import { Agent as HttpAgent, createServer, request as httpRequest } from "node:http";
|
|
6
|
+
import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
|
|
7
7
|
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { dirname } from "node:path";
|
|
9
9
|
import { connect } from "node:net";
|
|
10
|
-
import {
|
|
10
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
11
|
+
import { createSecureContext, createServer as createTlsServer, connect as tlsConnect, rootCertificates } from "node:tls";
|
|
11
12
|
import { createEphemeralCertificateAuthority } from "./ca.js";
|
|
12
13
|
import { shouldMitmHost } from "./classify-host.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
14
|
+
import { generateProxyAuthToken, proxyAuthorizationValue, verifyProxyAuthorization, writeProxyAuthToken } from "./auth.js";
|
|
15
|
+
import { enforceProtectedInstall, noteEnforcementSideEffectFailure } from "./enforcement.js";
|
|
16
|
+
import { cooldownRequestParam, isCooldownExemptByDgFile } from "../policy/cooldown.js";
|
|
15
17
|
import { loadUserConfig } from "../config/settings.js";
|
|
16
18
|
import { artifactDisplayName, artifactUrlHash, extractRegistryMetadataIdentities, isRegistryIndexRequest, resolveArtifactIdentity } from "./metadata-map.js";
|
|
19
|
+
import { loadPreverifiedMap, preverifiedKey } from "./preverified.js";
|
|
17
20
|
import { authorityFor, connectViaUpstreamProxy, selectUpstreamProxy } from "./upstream-proxy.js";
|
|
18
21
|
import { redactSecrets } from "../launcher/output-redaction.js";
|
|
22
|
+
import { sanitize } from "../security/sanitize.js";
|
|
19
23
|
import { envAuthToken } from "../auth/env-token.js";
|
|
20
24
|
import { identityHeaders } from "../api/analyze.js";
|
|
21
25
|
import { dgVersion } from "../commands/version.js";
|
|
26
|
+
import { recordHeldPackage } from "../state/index.js";
|
|
22
27
|
export async function startProductionHttpProxy(options) {
|
|
23
|
-
const
|
|
28
|
+
const onCaRotate = options.onCaRotate;
|
|
29
|
+
const ca = createEphemeralCertificateAuthority(options.session.files.ca, onCaRotate ? { onRotate: (caCertPem) => safeCaRotateCallback(onCaRotate, caCertPem) } : {});
|
|
30
|
+
const authToken = generateProxyAuthToken();
|
|
31
|
+
writeProxyAuthToken(options.session.dir, authToken);
|
|
24
32
|
const state = {
|
|
25
33
|
ready: false,
|
|
26
34
|
port: 0,
|
|
@@ -32,6 +40,10 @@ export async function startProductionHttpProxy(options) {
|
|
|
32
40
|
};
|
|
33
41
|
const activeSockets = new Set();
|
|
34
42
|
const server = createServer((request, response) => {
|
|
43
|
+
if (!verifyProxyAuthorization(headerValue(request.headers["proxy-authorization"]), authToken)) {
|
|
44
|
+
sendProxyAuthRequired(response);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
35
47
|
handleProxyRequest(request, response, options, state).catch((error) => {
|
|
36
48
|
const decision = recordDecision(options, state, {
|
|
37
49
|
verdict: "block",
|
|
@@ -45,6 +57,10 @@ export async function startProductionHttpProxy(options) {
|
|
|
45
57
|
server.on("connection", (socket) => trackSocket(activeSockets, socket));
|
|
46
58
|
server.on("connect", (request, socket, head) => {
|
|
47
59
|
const clientSocket = socket;
|
|
60
|
+
if (!verifyProxyAuthorization(headerValue(request.headers["proxy-authorization"]), authToken)) {
|
|
61
|
+
clientSocket.end("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"dg\"\r\nConnection: close\r\n\r\n");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
48
64
|
handleConnectRequest(request, clientSocket, head, options, state, ca, activeSockets).catch((error) => {
|
|
49
65
|
const decision = recordDecision(options, state, {
|
|
50
66
|
verdict: "block",
|
|
@@ -65,9 +81,27 @@ export async function startProductionHttpProxy(options) {
|
|
|
65
81
|
writeProxyState(options.session, state);
|
|
66
82
|
return {
|
|
67
83
|
port: address.port,
|
|
84
|
+
proxyAuthorization: proxyAuthorizationValue(authToken),
|
|
68
85
|
close: () => closeServer(server, activeSockets)
|
|
69
86
|
};
|
|
70
87
|
}
|
|
88
|
+
function safeCaRotateCallback(onCaRotate, caCertPem) {
|
|
89
|
+
try {
|
|
90
|
+
onCaRotate(caCertPem);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
noteEnforcementSideEffectFailure(error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function sendProxyAuthRequired(response) {
|
|
97
|
+
const body = "Dependency Guardian proxy requires the per-session credential issued to the wrapped package manager.\n";
|
|
98
|
+
response.writeHead(407, {
|
|
99
|
+
"Proxy-Authenticate": "Basic realm=\"dg\"",
|
|
100
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
101
|
+
"Content-Length": Buffer.byteLength(body)
|
|
102
|
+
});
|
|
103
|
+
response.end(body);
|
|
104
|
+
}
|
|
71
105
|
export function readProxySessionState(session) {
|
|
72
106
|
try {
|
|
73
107
|
const parsed = JSON.parse(readFileSync(session.files.proxy, "utf8"));
|
|
@@ -136,7 +170,27 @@ async function handleConnectRequest(request, clientSocket, head, options, state,
|
|
|
136
170
|
clientSocket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
137
171
|
return;
|
|
138
172
|
}
|
|
173
|
+
if (isLinkLocalHost(target)) {
|
|
174
|
+
recordDecision(options, state, {
|
|
175
|
+
verdict: "block",
|
|
176
|
+
packageName: target.hostname,
|
|
177
|
+
cause: "policy",
|
|
178
|
+
reason: `refusing CONNECT to link-local/metadata address ${target.hostname}`
|
|
179
|
+
});
|
|
180
|
+
clientSocket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
139
183
|
if (!shouldMitmHost(target.hostname, options.env)) {
|
|
184
|
+
if (strictEgressEnabled(options.env)) {
|
|
185
|
+
recordDecision(options, state, {
|
|
186
|
+
verdict: "block",
|
|
187
|
+
packageName: target.hostname,
|
|
188
|
+
cause: "policy",
|
|
189
|
+
reason: `strict egress: refusing to tunnel to un-screened host ${target.hostname} (add it to DG_PROXY_MITM_HOSTS to screen it, or disable policy.strictEgress)`
|
|
190
|
+
});
|
|
191
|
+
clientSocket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
140
194
|
state.events = [...state.events, `tunnel:${redactSecrets(authorityFor(target))}`];
|
|
141
195
|
writeProxyState(options.session, state);
|
|
142
196
|
await blindTunnel(clientSocket, head, target, options, activeSockets);
|
|
@@ -147,22 +201,337 @@ async function handleConnectRequest(request, clientSocket, head, options, state,
|
|
|
147
201
|
await mitmTunnel(clientSocket, head, target, options, state, ca, activeSockets);
|
|
148
202
|
}
|
|
149
203
|
const REDIRECT_LIMIT = 5;
|
|
204
|
+
// Streaming tail-hold: an OPT-IN fast path for artifacts so large their raw
|
|
205
|
+
// download alone would outlast the client read timeout. It streams bytes while
|
|
206
|
+
// hashing and withholds only the final TAIL bytes until the verdict arrives —
|
|
207
|
+
// so on a block the client receives most of the (unverified) artifact and is
|
|
208
|
+
// relied on to reject the truncated body via its own size/integrity checks.
|
|
209
|
+
// That is a weaker guarantee than the default buffered path, which delivers
|
|
210
|
+
// ZERO bytes until a pass verdict. It is therefore DISABLED by default; the
|
|
211
|
+
// preferred fix for a slow verify is a generous client read timeout (set in
|
|
212
|
+
// buildProxyChildEnv), which keeps the strong buffered guarantee. Enable only
|
|
213
|
+
// for genuinely huge artifacts via DG_STREAM_THRESHOLD_BYTES=<bytes>.
|
|
214
|
+
const STREAM_TAIL_BYTES = 64 * 1024;
|
|
215
|
+
const STREAMING_DISABLED = Number.POSITIVE_INFINITY;
|
|
216
|
+
// A package install fetches dozens-to-hundreds of artifacts from the same few
|
|
217
|
+
// registry hosts. Without connection reuse each is a fresh TCP+TLS handshake
|
|
218
|
+
// (~100-250ms to registry.npmjs.org); keep-alive agents pool sockets per host
|
|
219
|
+
// so an N-tarball install pays one handshake, not N. Reuse is keyed by the full
|
|
220
|
+
// connection option-set (host, port, TLS params), so this never crosses hosts
|
|
221
|
+
// or mixes TLS trust. Purely transport-level — no effect on what gets verified.
|
|
222
|
+
const upstreamHttpAgent = new HttpAgent({ keepAlive: true, maxSockets: 64, scheduling: "fifo" });
|
|
223
|
+
const upstreamHttpsAgent = new HttpsAgent({ keepAlive: true, maxSockets: 64, scheduling: "fifo" });
|
|
224
|
+
// Opt-in (policy.strictEgress, default off): a CONNECT to a host dg doesn't MITM
|
|
225
|
+
// is blind-tunnelled, so its artifacts arrive unscanned. Managed/CI environments
|
|
226
|
+
// can fail those closed instead. A corrupt config reads as off so a broken config
|
|
227
|
+
// never blocks installs that the default would have allowed.
|
|
228
|
+
function strictEgressEnabled(env) {
|
|
229
|
+
try {
|
|
230
|
+
return loadUserConfig(env).policy.strictEgress;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function streamThresholdBytes(env) {
|
|
237
|
+
const raw = env.DG_STREAM_THRESHOLD_BYTES;
|
|
238
|
+
if (raw === undefined || raw === "") {
|
|
239
|
+
return STREAMING_DISABLED;
|
|
240
|
+
}
|
|
241
|
+
const parsed = Number(raw);
|
|
242
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : STREAMING_DISABLED;
|
|
243
|
+
}
|
|
244
|
+
function canStreamTarget(target, options) {
|
|
245
|
+
if (selectUpstreamProxy(target, options.env)) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
if (isRegistryIndexRequest(target)) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
return !hostMatchesList(target.hostname, options.env.DG_PRIVATE_REGISTRY_HOSTS ?? "");
|
|
252
|
+
}
|
|
253
|
+
function shouldStreamArtifactResponse(live, env) {
|
|
254
|
+
if (live.statusCode < 200 || live.statusCode >= 300 || live.statusCode === 206) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const contentType = headerValue(live.headers["content-type"]) ?? "";
|
|
258
|
+
if (/\bjson\b/i.test(contentType)) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
const contentLength = Number(headerValue(live.headers["content-length"]) ?? "");
|
|
262
|
+
return Number.isFinite(contentLength) && contentLength > streamThresholdBytes(env);
|
|
263
|
+
}
|
|
264
|
+
async function fetchUpstreamHeadersFollowingRedirects(request, target, env) {
|
|
265
|
+
let current = target;
|
|
266
|
+
let live = await fetchUpstreamHeaders(request, current, env);
|
|
267
|
+
for (let hop = 0; isRedirectStatus(live.statusCode); hop += 1) {
|
|
268
|
+
live.stream.resume();
|
|
269
|
+
if (hop >= REDIRECT_LIMIT) {
|
|
270
|
+
throw new Error(`registry redirect chain exceeded ${REDIRECT_LIMIT} hops for ${current.host}`);
|
|
271
|
+
}
|
|
272
|
+
const location = headerValue(live.headers.location);
|
|
273
|
+
if (!location) {
|
|
274
|
+
throw new Error(`registry returned a ${live.statusCode} redirect with no Location header for ${current.host}`);
|
|
275
|
+
}
|
|
276
|
+
const next = new URL(location, current);
|
|
277
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
278
|
+
throw new Error(`registry redirected to unsupported protocol ${next.protocol}`);
|
|
279
|
+
}
|
|
280
|
+
if (isPrivateNetworkHost(next) && !isPrivateNetworkHost(target)) {
|
|
281
|
+
throw new Error(`registry redirected a public artifact request into a private address (${next.hostname})`);
|
|
282
|
+
}
|
|
283
|
+
live = await fetchUpstreamHeaders(syntheticGetRequest(redirectHopHeaders(request, current, next)), next, env);
|
|
284
|
+
current = next;
|
|
285
|
+
}
|
|
286
|
+
return live;
|
|
287
|
+
}
|
|
288
|
+
function fetchUpstreamHeaders(request, target, env) {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
const isHttps = target.protocol === "https:";
|
|
291
|
+
const requester = isHttps ? httpsRequest : httpRequest;
|
|
292
|
+
let liveStream = null;
|
|
293
|
+
const upstream = requester({
|
|
294
|
+
hostname: upstreamHostname(target, env),
|
|
295
|
+
port: target.port ? Number(target.port) : isHttps ? 443 : 80,
|
|
296
|
+
path: `${target.pathname}${target.search}`,
|
|
297
|
+
method: request.method,
|
|
298
|
+
agent: isHttps ? upstreamHttpsAgent : upstreamHttpAgent,
|
|
299
|
+
headers: isHttps ? upstreamRequestHeaders(request, target) : stripProxyHopHeaders(request.headers),
|
|
300
|
+
...(isHttps ? upstreamTlsOptions(env) : {})
|
|
301
|
+
}, (upstreamResponse) => {
|
|
302
|
+
liveStream = upstreamResponse;
|
|
303
|
+
resolve({
|
|
304
|
+
statusCode: upstreamResponse.statusCode ?? 502,
|
|
305
|
+
headers: responseHeaders(upstreamResponse),
|
|
306
|
+
stream: upstreamResponse
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
applyUpstreamTimeBudget(upstream, target, env, (error) => {
|
|
310
|
+
liveStream?.destroy(error);
|
|
311
|
+
reject(error);
|
|
312
|
+
});
|
|
313
|
+
upstream.on("error", reject);
|
|
314
|
+
request.on("data", (chunk) => upstream.write(chunk));
|
|
315
|
+
request.on("end", () => upstream.end());
|
|
316
|
+
request.on("error", reject);
|
|
317
|
+
request.once("close", () => {
|
|
318
|
+
if (!request.complete) {
|
|
319
|
+
upstream.destroy();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
async function streamArtifactWithTailHold(response, target, options, state, live) {
|
|
325
|
+
const identityResolution = resolveArtifactIdentity(target, state.identities, options.classification);
|
|
326
|
+
if (identityResolution.kind === "ambiguous") {
|
|
327
|
+
live.stream.destroy();
|
|
328
|
+
const decision = recordDecision(options, state, {
|
|
329
|
+
verdict: "block",
|
|
330
|
+
packageName: identityResolution.packageName,
|
|
331
|
+
cause: "policy",
|
|
332
|
+
reason: identityResolution.reason
|
|
333
|
+
});
|
|
334
|
+
sendBlocked(response, decision);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const identity = identityResolution.identity;
|
|
338
|
+
const inflightName = artifactDisplayName(identity);
|
|
339
|
+
state.inflight = [...state.inflight, inflightName];
|
|
340
|
+
writeProxyState(options.session, state);
|
|
341
|
+
const limit = maxArtifactBytes(options.env);
|
|
342
|
+
const hash = createHash("sha256");
|
|
343
|
+
const tail = [];
|
|
344
|
+
let tailSize = 0;
|
|
345
|
+
let total = 0;
|
|
346
|
+
response.writeHead(live.statusCode, live.headers);
|
|
347
|
+
response.once("close", () => {
|
|
348
|
+
if (!response.writableEnded) {
|
|
349
|
+
live.stream.destroy();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
const finishInflight = () => {
|
|
353
|
+
state.inflight = removeFirst(state.inflight, inflightName);
|
|
354
|
+
};
|
|
355
|
+
try {
|
|
356
|
+
await new Promise((resolve, reject) => {
|
|
357
|
+
live.stream.on("data", (chunk) => {
|
|
358
|
+
total += chunk.length;
|
|
359
|
+
if (total > limit) {
|
|
360
|
+
reject(new BufferBudgetError(`artifact exceeded the ${limit}-byte streaming budget for ${target.host}`));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
hash.update(chunk);
|
|
364
|
+
tail.push(chunk);
|
|
365
|
+
tailSize += chunk.length;
|
|
366
|
+
while (tail.length > 1 && tailSize - (tail[0]?.length ?? 0) >= STREAM_TAIL_BYTES) {
|
|
367
|
+
const released = tail.shift();
|
|
368
|
+
if (released) {
|
|
369
|
+
tailSize -= released.length;
|
|
370
|
+
response.write(released);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
live.stream.once("end", () => resolve());
|
|
375
|
+
live.stream.once("error", reject);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
finishInflight();
|
|
380
|
+
live.stream.destroy();
|
|
381
|
+
recordDecision(options, state, {
|
|
382
|
+
verdict: "block",
|
|
383
|
+
packageName: artifactDisplayName(identity),
|
|
384
|
+
cause: error instanceof BufferBudgetError ? "proxy-setup-failure" : "registry-timeout",
|
|
385
|
+
reason: error instanceof Error ? error.message : "registry stream failed"
|
|
386
|
+
});
|
|
387
|
+
response.destroy();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const sha256 = hash.digest("hex");
|
|
391
|
+
state.hashes = [...state.hashes, {
|
|
392
|
+
url: redactSecrets(target.toString()),
|
|
393
|
+
sha256,
|
|
394
|
+
identity
|
|
395
|
+
}];
|
|
396
|
+
writeProxyState(options.session, state);
|
|
397
|
+
const verdict = await lookupVerdict(options, target, sha256, { statusCode: live.statusCode, headers: live.headers }, identity).catch((error) => ({
|
|
398
|
+
verdict: "block",
|
|
399
|
+
packageName: artifactDisplayName(identity),
|
|
400
|
+
cause: "api-timeout",
|
|
401
|
+
reason: error instanceof Error ? error.message : "Dependency Guardian API verdict lookup failed"
|
|
402
|
+
}));
|
|
403
|
+
finishInflight();
|
|
404
|
+
const decision = recordDecision(options, state, verdict);
|
|
405
|
+
if (decision.action === "block") {
|
|
406
|
+
response.destroy();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
for (const chunk of tail) {
|
|
410
|
+
response.write(chunk);
|
|
411
|
+
}
|
|
412
|
+
response.end();
|
|
413
|
+
}
|
|
150
414
|
function isRedirectStatus(statusCode) {
|
|
151
415
|
return [301, 302, 303, 307, 308].includes(statusCode);
|
|
152
416
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
417
|
+
// Node's WHATWG URL parser canonicalizes an IPv4-mapped IPv6 literal to the hex
|
|
418
|
+
// hextet form ([::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe]), so a guard that
|
|
419
|
+
// only string-matches the dotted-decimal form silently lets the cloud-metadata
|
|
420
|
+
// IP through as "public". Recover the embedded IPv4 (both forms) before
|
|
421
|
+
// classifying so the IPv4 ranges below cover the mapped address too.
|
|
422
|
+
function hextetsToIpv4(hi, lo) {
|
|
423
|
+
const h = parseInt(hi, 16);
|
|
424
|
+
const l = parseInt(lo, 16);
|
|
425
|
+
return `${(h >> 8) & 0xff}.${h & 0xff}.${(l >> 8) & 0xff}.${l & 0xff}`;
|
|
426
|
+
}
|
|
427
|
+
// Recover an IPv4 address embedded in an IPv6 literal so the IPv4 ranges below
|
|
428
|
+
// also cover it. Covers IPv4-mapped (::ffff:), IPv4-compatible (::), 6to4
|
|
429
|
+
// (2002::/16), and NAT64 (64:ff9b::/96) — each a way to smuggle 169.254.169.254
|
|
430
|
+
// past a guard that only string-matches the dotted-decimal form.
|
|
431
|
+
function recoverIpv4MappedHost(host) {
|
|
432
|
+
const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
|
|
433
|
+
if (mappedDotted?.[1])
|
|
434
|
+
return mappedDotted[1];
|
|
435
|
+
const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
436
|
+
if (mappedHex?.[1] && mappedHex[2])
|
|
437
|
+
return hextetsToIpv4(mappedHex[1], mappedHex[2]);
|
|
438
|
+
const compatDotted = /^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
|
|
439
|
+
if (compatDotted?.[1])
|
|
440
|
+
return compatDotted[1];
|
|
441
|
+
const compatHex = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
442
|
+
if (compatHex?.[1] && compatHex[2])
|
|
443
|
+
return hextetsToIpv4(compatHex[1], compatHex[2]);
|
|
444
|
+
const sixToFour = /^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/i.exec(host);
|
|
445
|
+
if (sixToFour?.[1] && sixToFour[2])
|
|
446
|
+
return hextetsToIpv4(sixToFour[1], sixToFour[2]);
|
|
447
|
+
const nat64Dotted = /^64:ff9b::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
|
|
448
|
+
if (nat64Dotted?.[1])
|
|
449
|
+
return nat64Dotted[1];
|
|
450
|
+
const nat64Hex = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
451
|
+
if (nat64Hex?.[1] && nat64Hex[2])
|
|
452
|
+
return hextetsToIpv4(nat64Hex[1], nat64Hex[2]);
|
|
453
|
+
return host;
|
|
454
|
+
}
|
|
455
|
+
// Block a RESOLVED address (the actual connect target), so a public hostname
|
|
456
|
+
// whose A/AAAA record points at an internal/metadata IP — DNS-rebinding SSRF —
|
|
457
|
+
// is refused at connect time, not just literal-IP targets.
|
|
458
|
+
export function isBlockedResolvedIp(ip) {
|
|
459
|
+
const host = recoverIpv4MappedHost(ip.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""));
|
|
460
|
+
if (host === "0.0.0.0" || host === "::" || host === "::1") {
|
|
156
461
|
return true;
|
|
157
462
|
}
|
|
158
|
-
if (
|
|
463
|
+
if (isPrivateIpv4(host)) {
|
|
159
464
|
return true;
|
|
160
465
|
}
|
|
161
|
-
|
|
466
|
+
return /^(fe80|f[cd][0-9a-f]{2}):/.test(host);
|
|
467
|
+
}
|
|
468
|
+
function guardedLookup(hostname, options, callback) {
|
|
469
|
+
const opts = typeof options === "object" && options !== null ? options : {};
|
|
470
|
+
dnsLookup(hostname, { ...opts, all: true }, (err, addresses) => {
|
|
471
|
+
if (err) {
|
|
472
|
+
callback(err, "", 0);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const list = addresses;
|
|
476
|
+
const blocked = list.find((entry) => isBlockedResolvedIp(entry.address));
|
|
477
|
+
if (blocked) {
|
|
478
|
+
callback(new Error(`refusing to connect to ${hostname}: resolves to blocked address ${blocked.address}`), "", 0);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (opts.all === true) {
|
|
482
|
+
callback(null, list);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const first = list[0];
|
|
486
|
+
if (!first) {
|
|
487
|
+
callback(new Error(`no address found for ${hostname}`), "", 0);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
callback(null, first.address, first.family);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
// The resolved-IP guard exists for DNS rebinding — a PUBLIC hostname whose A/AAAA
|
|
494
|
+
// record points at an internal IP. It must not fire for: an explicitly-configured
|
|
495
|
+
// private registry (DG_PRIVATE_REGISTRY_HOSTS) whose name legitimately resolves to
|
|
496
|
+
// a private IP; a test host-map target; or a target that is ALREADY a private/
|
|
497
|
+
// loopback literal (the user/test pointed there directly — not rebinding, and the
|
|
498
|
+
// link-local/metadata literal is still blocked at the artifact path).
|
|
499
|
+
function lookupForTarget(target, env) {
|
|
500
|
+
const host = target.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
501
|
+
if (hostMatchesList(target.hostname, env.DG_PRIVATE_REGISTRY_HOSTS ?? "")) {
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
if (testUpstreamHostMap(env).has(host)) {
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
if (isPrivateNetworkHost(target)) {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
return guardedLookup;
|
|
511
|
+
}
|
|
512
|
+
function isPrivateIpv4(host) {
|
|
513
|
+
return (/^(127|10|0)\./.test(host) ||
|
|
514
|
+
/^192\.168\./.test(host) ||
|
|
515
|
+
/^169\.254\./.test(host) ||
|
|
516
|
+
/^172\.(1[6-9]|2[0-9]|3[01])\./.test(host) ||
|
|
517
|
+
/^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./.test(host));
|
|
518
|
+
}
|
|
519
|
+
export function isPrivateNetworkHost(url) {
|
|
520
|
+
const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
521
|
+
const host = recoverIpv4MappedHost(raw);
|
|
522
|
+
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1") {
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
if (isPrivateIpv4(host)) {
|
|
162
526
|
return true;
|
|
163
527
|
}
|
|
164
528
|
return /^(fe80|f[cd][0-9a-f]{2}):/.test(host);
|
|
165
529
|
}
|
|
530
|
+
export function isLinkLocalHost(url) {
|
|
531
|
+
const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
532
|
+
const host = recoverIpv4MappedHost(raw);
|
|
533
|
+
return /^169\.254\./.test(host) || /^fe80:/.test(host);
|
|
534
|
+
}
|
|
166
535
|
function syntheticGetRequest(headers) {
|
|
167
536
|
const request = new EventEmitter();
|
|
168
537
|
request.method = "GET";
|
|
@@ -204,12 +573,40 @@ async function fetchUpstreamFollowingRedirects(request, target, env) {
|
|
|
204
573
|
}
|
|
205
574
|
const ARTIFACT_CONDITIONAL_HEADERS = ["range", "if-range", "if-none-match", "if-modified-since"];
|
|
206
575
|
async function handleArtifactRequest(request, response, target, options, state) {
|
|
576
|
+
if (isLinkLocalHost(target)) {
|
|
577
|
+
const decision = recordDecision(options, state, {
|
|
578
|
+
verdict: "block",
|
|
579
|
+
packageName: packageNameFromUrl(target),
|
|
580
|
+
cause: "policy",
|
|
581
|
+
reason: `refusing to fetch from link-local/metadata address ${target.hostname}`
|
|
582
|
+
});
|
|
583
|
+
sendBlocked(response, decision);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
207
586
|
if (!isRegistryIndexRequest(target)) {
|
|
208
587
|
for (const header of ARTIFACT_CONDITIONAL_HEADERS) {
|
|
209
588
|
delete request.headers[header];
|
|
210
589
|
}
|
|
211
590
|
}
|
|
212
|
-
|
|
591
|
+
let upstream;
|
|
592
|
+
try {
|
|
593
|
+
if (canStreamTarget(target, options)) {
|
|
594
|
+
const live = await fetchUpstreamHeadersFollowingRedirects(request, target, options.env);
|
|
595
|
+
if (shouldStreamArtifactResponse(live, options.env)) {
|
|
596
|
+
await streamArtifactWithTailHold(response, target, options, state, live);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
upstream = {
|
|
600
|
+
statusCode: live.statusCode,
|
|
601
|
+
headers: live.headers,
|
|
602
|
+
body: await collectBounded(live.stream, { label: target.toString() })
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
upstream = await fetchUpstreamFollowingRedirects(request, target, options.env);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
213
610
|
const message = error instanceof Error ? error.message : "registry request failed";
|
|
214
611
|
const decision = recordDecision(options, state, {
|
|
215
612
|
verdict: "block",
|
|
@@ -218,8 +615,8 @@ async function handleArtifactRequest(request, response, target, options, state)
|
|
|
218
615
|
reason: message
|
|
219
616
|
});
|
|
220
617
|
sendBlocked(response, decision);
|
|
221
|
-
return
|
|
222
|
-
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
223
620
|
if (!upstream) {
|
|
224
621
|
return;
|
|
225
622
|
}
|
|
@@ -289,7 +686,7 @@ async function handleArtifactRequest(request, response, target, options, state)
|
|
|
289
686
|
reason: error instanceof Error ? error.message : "Dependency Guardian API verdict lookup failed"
|
|
290
687
|
}));
|
|
291
688
|
state.inflight = removeFirst(state.inflight, inflightName);
|
|
292
|
-
const decision = recordDecision(options, state, verdict);
|
|
689
|
+
const decision = recordDecision(options, state, verdict, identity.identity);
|
|
293
690
|
if (decision.action === "block") {
|
|
294
691
|
sendBlocked(response, decision);
|
|
295
692
|
return;
|
|
@@ -301,7 +698,7 @@ async function blindTunnel(clientSocket, head, target, options, activeSockets) {
|
|
|
301
698
|
const upstreamProxy = selectUpstreamProxy(target, options.env);
|
|
302
699
|
const upstreamSocket = upstreamProxy
|
|
303
700
|
? await connectViaUpstreamProxy(target, upstreamProxy)
|
|
304
|
-
: await connectDirect(target);
|
|
701
|
+
: await connectDirect(target, options.env);
|
|
305
702
|
trackSocket(activeSockets, upstreamSocket);
|
|
306
703
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
307
704
|
if (head.length > 0) {
|
|
@@ -376,9 +773,9 @@ function parseConnectTarget(authority) {
|
|
|
376
773
|
return null;
|
|
377
774
|
}
|
|
378
775
|
}
|
|
379
|
-
function connectDirect(target) {
|
|
776
|
+
function connectDirect(target, env = process.env) {
|
|
380
777
|
return new Promise((resolve, reject) => {
|
|
381
|
-
const socket = connect(Number(target.port || "443"), upstreamHostname(target));
|
|
778
|
+
const socket = connect({ port: Number(target.port || "443"), host: upstreamHostname(target, env), lookup: lookupForTarget(target, env) });
|
|
382
779
|
socket.once("connect", () => resolve(socket));
|
|
383
780
|
socket.once("error", reject);
|
|
384
781
|
});
|
|
@@ -388,6 +785,9 @@ function upstreamHostname(target, env = process.env) {
|
|
|
388
785
|
return testUpstreamHostMap(env).get(hostname) ?? hostname;
|
|
389
786
|
}
|
|
390
787
|
function testUpstreamHostMap(env) {
|
|
788
|
+
if (env.NODE_ENV !== "test") {
|
|
789
|
+
return new Map();
|
|
790
|
+
}
|
|
391
791
|
const entries = (env.DG_TEST_UPSTREAM_HOST_MAP ?? "")
|
|
392
792
|
.split(",")
|
|
393
793
|
.map((entry) => entry.trim())
|
|
@@ -405,7 +805,7 @@ function testUpstreamHostMap(env) {
|
|
|
405
805
|
function fetchUpstream(request, target, env) {
|
|
406
806
|
const upstreamProxy = selectUpstreamProxy(target, env);
|
|
407
807
|
if (upstreamProxy && target.protocol === "http:") {
|
|
408
|
-
return fetchHttpViaProxy(request, target, upstreamProxy);
|
|
808
|
+
return fetchHttpViaProxy(request, target, upstreamProxy, env);
|
|
409
809
|
}
|
|
410
810
|
if (upstreamProxy && target.protocol === "https:") {
|
|
411
811
|
return fetchHttpsViaProxy(request, target, upstreamProxy, env);
|
|
@@ -419,7 +819,9 @@ function fetchUpstream(request, target, env) {
|
|
|
419
819
|
port: target.port ? Number(target.port) : 80,
|
|
420
820
|
path: `${target.pathname}${target.search}`,
|
|
421
821
|
method: request.method,
|
|
422
|
-
|
|
822
|
+
agent: upstreamHttpAgent,
|
|
823
|
+
lookup: lookupForTarget(target, env),
|
|
824
|
+
headers: stripProxyHopHeaders(request.headers)
|
|
423
825
|
}, (upstreamResponse) => {
|
|
424
826
|
collectBounded(upstreamResponse, { label: target.toString() })
|
|
425
827
|
.then((body) => resolve({
|
|
@@ -429,6 +831,7 @@ function fetchUpstream(request, target, env) {
|
|
|
429
831
|
}))
|
|
430
832
|
.catch(reject);
|
|
431
833
|
});
|
|
834
|
+
applyUpstreamTimeBudget(upstream, target, env, reject);
|
|
432
835
|
upstream.on("error", reject);
|
|
433
836
|
request.on("data", (chunk) => upstream.write(chunk));
|
|
434
837
|
request.on("end", () => upstream.end());
|
|
@@ -440,10 +843,10 @@ function fetchUpstream(request, target, env) {
|
|
|
440
843
|
});
|
|
441
844
|
});
|
|
442
845
|
}
|
|
443
|
-
function fetchHttpViaProxy(request, target, upstreamProxy) {
|
|
846
|
+
function fetchHttpViaProxy(request, target, upstreamProxy, env) {
|
|
444
847
|
return new Promise((resolve, reject) => {
|
|
445
848
|
const headers = {
|
|
446
|
-
...request.headers,
|
|
849
|
+
...stripProxyHopHeaders(request.headers),
|
|
447
850
|
...(upstreamProxy.authorizationHeader ? { "Proxy-Authorization": upstreamProxy.authorizationHeader } : {})
|
|
448
851
|
};
|
|
449
852
|
const upstream = httpRequest({
|
|
@@ -461,6 +864,7 @@ function fetchHttpViaProxy(request, target, upstreamProxy) {
|
|
|
461
864
|
}))
|
|
462
865
|
.catch(reject);
|
|
463
866
|
});
|
|
867
|
+
applyUpstreamTimeBudget(upstream, target, env, reject);
|
|
464
868
|
upstream.on("error", reject);
|
|
465
869
|
request.on("data", (chunk) => upstream.write(chunk));
|
|
466
870
|
request.on("end", () => upstream.end());
|
|
@@ -479,6 +883,8 @@ function fetchHttpsDirect(request, target, env) {
|
|
|
479
883
|
port: target.port ? Number(target.port) : 443,
|
|
480
884
|
path: `${target.pathname}${target.search}`,
|
|
481
885
|
method: request.method,
|
|
886
|
+
agent: upstreamHttpsAgent,
|
|
887
|
+
lookup: lookupForTarget(target, env),
|
|
482
888
|
headers: upstreamRequestHeaders(request, target),
|
|
483
889
|
...upstreamTlsOptions(env)
|
|
484
890
|
}, (upstreamResponse) => {
|
|
@@ -490,6 +896,7 @@ function fetchHttpsDirect(request, target, env) {
|
|
|
490
896
|
}))
|
|
491
897
|
.catch(reject);
|
|
492
898
|
});
|
|
899
|
+
applyUpstreamTimeBudget(upstream, target, env, reject);
|
|
493
900
|
upstream.on("error", reject);
|
|
494
901
|
request.on("data", (chunk) => upstream.write(chunk));
|
|
495
902
|
request.on("end", () => upstream.end());
|
|
@@ -516,9 +923,35 @@ function connectTlsOverSocket(socket, target, env) {
|
|
|
516
923
|
ALPNProtocols: ["http/1.1"],
|
|
517
924
|
...upstreamTlsOptions(env)
|
|
518
925
|
}, () => resolve(tlsSocket));
|
|
926
|
+
applyUpstreamTimeBudget(tlsSocket, target, env);
|
|
519
927
|
tlsSocket.once("error", reject);
|
|
520
928
|
});
|
|
521
929
|
}
|
|
930
|
+
function upstreamIdleTimeoutMs(env) {
|
|
931
|
+
return parsePositiveInteger(env.DG_UPSTREAM_IDLE_TIMEOUT_MS, 30_000);
|
|
932
|
+
}
|
|
933
|
+
function upstreamTotalTimeoutMs(env) {
|
|
934
|
+
return parsePositiveInteger(env.DG_UPSTREAM_TOTAL_TIMEOUT_MS, 600_000);
|
|
935
|
+
}
|
|
936
|
+
function applyUpstreamTimeBudget(upstream, target, env, onTimeout) {
|
|
937
|
+
const idleMs = upstreamIdleTimeoutMs(env);
|
|
938
|
+
const totalMs = upstreamTotalTimeoutMs(env);
|
|
939
|
+
const expire = (detail) => {
|
|
940
|
+
const error = new Error(`upstream registry request for ${target.host} timed out: ${detail}`);
|
|
941
|
+
onTimeout?.(error);
|
|
942
|
+
upstream.destroy(error);
|
|
943
|
+
};
|
|
944
|
+
upstream.setTimeout(idleMs, () => expire(`no data for ${idleMs}ms`));
|
|
945
|
+
const totalTimer = setTimeout(() => expire(`exceeded the ${totalMs}ms total budget`), totalMs);
|
|
946
|
+
totalTimer.unref();
|
|
947
|
+
upstream.once("close", () => clearTimeout(totalTimer));
|
|
948
|
+
}
|
|
949
|
+
function stripProxyHopHeaders(headers) {
|
|
950
|
+
const stripped = { ...headers };
|
|
951
|
+
delete stripped["proxy-authorization"];
|
|
952
|
+
delete stripped["proxy-connection"];
|
|
953
|
+
return stripped;
|
|
954
|
+
}
|
|
522
955
|
function writeRawHttpRequest(tlsSocket, request, target, requestBody) {
|
|
523
956
|
return new Promise((resolve, reject) => {
|
|
524
957
|
collectBounded(tlsSocket, { label: target.toString() }).then(resolve).catch(reject);
|
|
@@ -584,14 +1017,20 @@ function headerLines(key, value) {
|
|
|
584
1017
|
}
|
|
585
1018
|
return [`${key}: ${value}`];
|
|
586
1019
|
}
|
|
587
|
-
function upstreamTlsOptions(env) {
|
|
588
|
-
|
|
1020
|
+
export function upstreamTlsOptions(env) {
|
|
1021
|
+
// Trust an extra upstream CA only from DG_UPSTREAM_CA_CERT — an explicit,
|
|
1022
|
+
// dg-specific knob for corporate TLS-intercepting proxies / private mirrors.
|
|
1023
|
+
// The ambient NODE_EXTRA_CA_CERTS is deliberately NOT honored here: dg's own
|
|
1024
|
+
// agent routing sets that variable to dg's MITM CA for the CLIENT side, so
|
|
1025
|
+
// re-consuming it as an UPSTREAM trust anchor would let any process that can
|
|
1026
|
+
// set it MITM the real registry through dg's proxy.
|
|
1027
|
+
const caPath = env.DG_UPSTREAM_CA_CERT;
|
|
589
1028
|
if (!caPath) {
|
|
590
1029
|
return {};
|
|
591
1030
|
}
|
|
592
1031
|
try {
|
|
593
1032
|
return {
|
|
594
|
-
ca: readFileSync(caPath, "utf8")
|
|
1033
|
+
ca: [...rootCertificates, readFileSync(caPath, "utf8")]
|
|
595
1034
|
};
|
|
596
1035
|
}
|
|
597
1036
|
catch {
|
|
@@ -627,7 +1066,11 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
|
|
|
627
1066
|
if (shouldUseScanTarball(options, target, identity)) {
|
|
628
1067
|
return lookupScanTarballVerdict(options, target, sha256, upstream, identity);
|
|
629
1068
|
}
|
|
630
|
-
const
|
|
1069
|
+
const preverified = preverifiedVerdict(options, target, sha256, identity);
|
|
1070
|
+
if (preverified) {
|
|
1071
|
+
return preverified;
|
|
1072
|
+
}
|
|
1073
|
+
const cooldown = resolveCooldownRequest(options, identity);
|
|
631
1074
|
const controller = new AbortController();
|
|
632
1075
|
const timeout = setTimeout(() => controller.abort(), options.verdictTimeoutMs ?? installVerdictTimeoutMs(options.env));
|
|
633
1076
|
try {
|
|
@@ -641,7 +1084,7 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
|
|
|
641
1084
|
body: JSON.stringify({
|
|
642
1085
|
manager: options.classification.manager,
|
|
643
1086
|
action: options.classification.action,
|
|
644
|
-
url: target.toString(),
|
|
1087
|
+
url: redactSecrets(target.toString()),
|
|
645
1088
|
artifactUrlHash: artifactUrlHash(target),
|
|
646
1089
|
ecosystem: identity.ecosystem,
|
|
647
1090
|
name: identity.name,
|
|
@@ -662,7 +1105,7 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
|
|
|
662
1105
|
packageName: artifactDisplayName(identity),
|
|
663
1106
|
cause: "quota-exceeded",
|
|
664
1107
|
reason: "You've reached your monthly scan limit. Upgrade at westbayberry.com/pricing or wait for it to reset.",
|
|
665
|
-
...(typeof body.resetsAt === "string" ? { resetsAt: body.resetsAt } : {}),
|
|
1108
|
+
...(typeof body.resetsAt === "string" ? { resetsAt: sanitize(body.resetsAt) } : {}),
|
|
666
1109
|
quotaBehavior: body.quotaBehavior === "pass" ? "pass" : "block"
|
|
667
1110
|
};
|
|
668
1111
|
}
|
|
@@ -748,6 +1191,39 @@ async function lookupScanTarballVerdict(options, target, sha256, upstream, ident
|
|
|
748
1191
|
clearTimeout(timeout);
|
|
749
1192
|
}
|
|
750
1193
|
}
|
|
1194
|
+
// A preflight batch-analyze already verdicted this exact name@version (and the
|
|
1195
|
+
// user accepted any warns), so a per-artifact API round-trip would re-answer
|
|
1196
|
+
// the same question. Trust it only when the identity came from registry
|
|
1197
|
+
// metadata and the preflight applied the same cooldown gate the proxy would;
|
|
1198
|
+
// the synthesized verdict goes through normalizeVerdict so the scanned-SHA
|
|
1199
|
+
// cross-check blocks tampered bytes exactly like a server response would.
|
|
1200
|
+
function preverifiedVerdict(options, target, streamedSha256, identity) {
|
|
1201
|
+
if (identity.sourceKind !== "registry-metadata") {
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
const entry = loadPreverifiedMap(options.session.dir).get(preverifiedKey(identity.ecosystem, identity.name, identity.version));
|
|
1205
|
+
if (!entry) {
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
if (!entry.cooldownEvaluated && resolveCooldownRequest(options, identity) !== undefined) {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
if (!entry.scannedSha256) {
|
|
1212
|
+
// No byte-level fingerprint to cross-check against the streamed artifact, so
|
|
1213
|
+
// a preverified pass/warn can't prove the downloaded bytes match what was
|
|
1214
|
+
// screened (a metadata-only preflight, or a registry swap between preflight
|
|
1215
|
+
// and fetch). Fall through to a real scan of the streamed bytes — TOCTOU
|
|
1216
|
+
// defense — rather than honoring the verdict for whatever now arrives.
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
return normalizeVerdict({
|
|
1220
|
+
verdict: entry.action,
|
|
1221
|
+
cause: entry.action,
|
|
1222
|
+
packageName: `${identity.name}@${identity.version}`,
|
|
1223
|
+
reason: entry.reason ?? (entry.action === "pass" ? "verified before install" : "flagged for review"),
|
|
1224
|
+
...(entry.scannedSha256 ? { scannedSha256: entry.scannedSha256 } : {})
|
|
1225
|
+
}, target, identity, streamedSha256);
|
|
1226
|
+
}
|
|
751
1227
|
function normalizeVerdict(value, target, identity, streamedSha256) {
|
|
752
1228
|
if (!isRecord(value)) {
|
|
753
1229
|
throw new Error("Dependency Guardian API returned a malformed verdict");
|
|
@@ -767,22 +1243,38 @@ function normalizeVerdict(value, target, identity, streamedSha256) {
|
|
|
767
1243
|
}
|
|
768
1244
|
const cause = typeof value.cause === "string" ? value.cause : undefined;
|
|
769
1245
|
const cooldown = parseCooldownInfo(value.cooldown);
|
|
1246
|
+
const dashboardUrl = safeDashboardUrl(value.dashboardUrl);
|
|
770
1247
|
return {
|
|
771
1248
|
verdict,
|
|
772
|
-
packageName: typeof value.packageName === "string" ? value.packageName : artifactDisplayName(identity) || packageNameFromUrl(target),
|
|
1249
|
+
packageName: typeof value.packageName === "string" ? sanitize(value.packageName) : artifactDisplayName(identity) || packageNameFromUrl(target),
|
|
773
1250
|
...(isProxyCause(cause) ? { cause } : {}),
|
|
774
|
-
reason: typeof value.reason === "string" ? value.reason : `API verdict ${verdict}`,
|
|
775
|
-
...(
|
|
1251
|
+
reason: typeof value.reason === "string" ? sanitize(value.reason) : `API verdict ${verdict}`,
|
|
1252
|
+
...(dashboardUrl ? { dashboardUrl } : {}),
|
|
776
1253
|
...(value.unauthenticated === true ? { unauthenticated: true } : {}),
|
|
777
1254
|
...(cooldown ? { cooldown } : {})
|
|
778
1255
|
};
|
|
779
1256
|
}
|
|
780
|
-
function
|
|
1257
|
+
function safeDashboardUrl(value) {
|
|
1258
|
+
if (typeof value !== "string") {
|
|
1259
|
+
return undefined;
|
|
1260
|
+
}
|
|
1261
|
+
try {
|
|
1262
|
+
const url = new URL(sanitize(value));
|
|
1263
|
+
return url.protocol === "https:" ? url.toString() : undefined;
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
return undefined;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
function resolveCooldownRequest(options, identity) {
|
|
781
1270
|
if (identity.ecosystem === "unknown" || identity.version === "unknown") {
|
|
782
1271
|
return undefined;
|
|
783
1272
|
}
|
|
1273
|
+
if (isCooldownExemptByDgFile(identity.name, identity.ecosystem, options.cooldownExemptions ?? [])) {
|
|
1274
|
+
return undefined;
|
|
1275
|
+
}
|
|
784
1276
|
try {
|
|
785
|
-
return cooldownRequestParam(loadUserConfig(env), env, identity.ecosystem, identity.name);
|
|
1277
|
+
return cooldownRequestParam(loadUserConfig(options.env), options.env, identity.ecosystem, identity.name);
|
|
786
1278
|
}
|
|
787
1279
|
catch {
|
|
788
1280
|
return undefined;
|
|
@@ -795,8 +1287,8 @@ function parseCooldownInfo(value) {
|
|
|
795
1287
|
return {
|
|
796
1288
|
requiredDays: value.requiredDays,
|
|
797
1289
|
...(typeof value.ageDays === "number" && Number.isFinite(value.ageDays) ? { ageDays: value.ageDays } : {}),
|
|
798
|
-
...(typeof value.publishedAt === "string" ? { publishedAt: value.publishedAt } : {}),
|
|
799
|
-
...(typeof value.eligibleAt === "string" ? { eligibleAt: value.eligibleAt } : {})
|
|
1290
|
+
...(typeof value.publishedAt === "string" ? { publishedAt: sanitize(value.publishedAt) } : {}),
|
|
1291
|
+
...(typeof value.eligibleAt === "string" ? { eligibleAt: sanitize(value.eligibleAt) } : {})
|
|
800
1292
|
};
|
|
801
1293
|
}
|
|
802
1294
|
function normalizeScanTarballVerdict(value, target, identity, streamedSha256) {
|
|
@@ -857,7 +1349,7 @@ function mergeIdentities(existing, next) {
|
|
|
857
1349
|
}
|
|
858
1350
|
return merged;
|
|
859
1351
|
}
|
|
860
|
-
function recordDecision(options, state, verdict) {
|
|
1352
|
+
function recordDecision(options, state, verdict, identity) {
|
|
861
1353
|
const decision = enforceProtectedInstall({
|
|
862
1354
|
classification: options.classification,
|
|
863
1355
|
env: options.env,
|
|
@@ -867,10 +1359,28 @@ function recordDecision(options, state, verdict) {
|
|
|
867
1359
|
state.decisions = [...state.decisions, decision];
|
|
868
1360
|
state.events = [...state.events, `${decision.action}:${decision.cause}:${redactSecrets(decision.packageName)}`];
|
|
869
1361
|
writeProxyState(options.session, state);
|
|
1362
|
+
if (identity && decision.action === "block" && decision.cause === "cooldown" && decision.cooldown) {
|
|
1363
|
+
try {
|
|
1364
|
+
recordHeldPackage({
|
|
1365
|
+
ecosystem: identity.ecosystem,
|
|
1366
|
+
name: identity.name,
|
|
1367
|
+
version: identity.version,
|
|
1368
|
+
requiredDays: decision.cooldown.requiredDays,
|
|
1369
|
+
...(decision.cooldown.ageDays !== undefined ? { ageDays: decision.cooldown.ageDays } : {}),
|
|
1370
|
+
...(decision.cooldown.publishedAt ? { publishedAt: decision.cooldown.publishedAt } : {}),
|
|
1371
|
+
...(decision.cooldown.eligibleAt ? { eligibleAt: decision.cooldown.eligibleAt } : {}),
|
|
1372
|
+
manager: options.classification.manager
|
|
1373
|
+
}, options.env);
|
|
1374
|
+
}
|
|
1375
|
+
catch (error) {
|
|
1376
|
+
noteEnforcementSideEffectFailure(error);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
870
1379
|
return decision;
|
|
871
1380
|
}
|
|
872
1381
|
function sendBlocked(response, decision) {
|
|
873
|
-
const
|
|
1382
|
+
const breadcrumb = decision.cause === "cooldown" ? " · holds: dg cooldown" : "";
|
|
1383
|
+
const body = `Dependency Guardian blocked ${redactSecrets(decision.packageName)}: ${redactSecrets(decision.reason)}${breadcrumb}\n`;
|
|
874
1384
|
response.writeHead(403, {
|
|
875
1385
|
"Content-Type": "text/plain; charset=utf-8",
|
|
876
1386
|
"Content-Length": Buffer.byteLength(body)
|
|
@@ -903,24 +1413,29 @@ function packageNameFromUrl(url) {
|
|
|
903
1413
|
return name ? decodeURIComponent(name) : url.hostname;
|
|
904
1414
|
}
|
|
905
1415
|
function writeProxyState(session, state) {
|
|
906
|
-
mkdirSync(dirname(session.files.proxy), {
|
|
907
|
-
recursive: true,
|
|
908
|
-
mode: 0o700
|
|
909
|
-
});
|
|
910
|
-
const tempPath = `${session.files.proxy}.${process.pid}.${process.hrtime.bigint().toString(36)}.tmp`;
|
|
911
1416
|
try {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
mode: 0o600
|
|
1417
|
+
mkdirSync(dirname(session.files.proxy), {
|
|
1418
|
+
recursive: true,
|
|
1419
|
+
mode: 0o700
|
|
916
1420
|
});
|
|
917
|
-
|
|
1421
|
+
const tempPath = `${session.files.proxy}.${process.pid}.${process.hrtime.bigint().toString(36)}.tmp`;
|
|
1422
|
+
try {
|
|
1423
|
+
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, {
|
|
1424
|
+
encoding: "utf8",
|
|
1425
|
+
flag: "wx",
|
|
1426
|
+
mode: 0o600
|
|
1427
|
+
});
|
|
1428
|
+
renameSync(tempPath, session.files.proxy);
|
|
1429
|
+
}
|
|
1430
|
+
catch (error) {
|
|
1431
|
+
rmSync(tempPath, {
|
|
1432
|
+
force: true
|
|
1433
|
+
});
|
|
1434
|
+
throw error;
|
|
1435
|
+
}
|
|
918
1436
|
}
|
|
919
1437
|
catch (error) {
|
|
920
|
-
|
|
921
|
-
force: true
|
|
922
|
-
});
|
|
923
|
-
throw error;
|
|
1438
|
+
noteEnforcementSideEffectFailure(error);
|
|
924
1439
|
}
|
|
925
1440
|
}
|
|
926
1441
|
function removeFirst(items, value) {
|
|
@@ -933,7 +1448,7 @@ function removeFirst(items, value) {
|
|
|
933
1448
|
function isRecord(value) {
|
|
934
1449
|
return typeof value === "object" && value !== null;
|
|
935
1450
|
}
|
|
936
|
-
function isProxyCause(value) {
|
|
1451
|
+
export function isProxyCause(value) {
|
|
937
1452
|
return [
|
|
938
1453
|
"pass",
|
|
939
1454
|
"warn",
|