@westbayberry/dg 2.2.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/dist/agents/claude-code.js +10 -0
- package/dist/agents/codex.js +1 -1
- package/dist/agents/cursor.js +6 -1
- package/dist/agents/gate-posture.js +21 -0
- package/dist/agents/persistence.js +68 -2
- package/dist/agents/registry.js +4 -3
- package/dist/agents/routing.js +118 -0
- package/dist/agents/windsurf.js +1 -1
- package/dist/audit/rules.js +6 -2
- package/dist/auth/store.js +9 -2
- package/dist/commands/agents.js +45 -1
- package/dist/commands/audit.js +6 -0
- package/dist/commands/setup.js +1 -2
- package/dist/commands/uninstall.js +2 -1
- package/dist/config/settings.js +35 -4
- package/dist/install-ui/LiveInstall.js +3 -2
- package/dist/install-ui/block-render.js +17 -13
- package/dist/launcher/agent-check.js +455 -25
- package/dist/launcher/agent-hook-exec.js +1 -1
- package/dist/launcher/agent-hook-io.js +12 -4
- package/dist/launcher/classify.js +27 -1
- package/dist/launcher/env.js +39 -7
- package/dist/launcher/install-preflight.js +15 -6
- package/dist/launcher/live-install.js +4 -3
- package/dist/launcher/manifest-screen.js +171 -0
- package/dist/launcher/output-redaction.js +4 -1
- package/dist/launcher/preflight-prompt.js +4 -3
- package/dist/launcher/run.js +90 -18
- package/dist/project/dgfile.js +2 -1
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/metadata-map.js +29 -13
- package/dist/proxy/server.js +130 -14
- package/dist/runtime/first-run.js +2 -1
- package/dist/sbom-ui/inventory.js +5 -1
- package/dist/scan/staged.js +31 -8
- package/dist/scan-ui/hooks/useScan.js +4 -1
- package/dist/security/sanitize.js +8 -4
- package/dist/service/state.js +1 -1
- package/dist/service/trust-store.js +5 -9
- package/dist/service/worker.js +3 -3
- package/dist/setup/plan.js +156 -12
- package/dist/setup/uninstall-standalone.js +25 -0
- package/dist/setup-ui/wizard.js +9 -1
- package/dist/standalone/uninstall.mjs +2123 -0
- package/dist/verify/local.js +2 -2
- package/dist/verify/package-check.js +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from "node:zlib";
|
|
3
|
+
// decodeURIComponent throws on malformed percent-encoding (e.g. a stray % in a
|
|
4
|
+
// registry href); fall back to the raw value so one bad entry cannot throw out of
|
|
5
|
+
// the whole metadata-extraction pass and drop every identity.
|
|
6
|
+
function safeDecodeUriComponent(value) {
|
|
7
|
+
try {
|
|
8
|
+
return decodeURIComponent(value);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
3
14
|
export function extractRegistryMetadataIdentities(metadataUrl, response) {
|
|
4
15
|
const decoded = { headers: response.headers, body: decodeContentEncoding(response.headers, response.body) };
|
|
5
16
|
// PyPI Simple index (PEP 503 HTML or PEP 691 JSON) — the pip/uv/pipx flow.
|
|
@@ -18,6 +29,9 @@ export function extractRegistryMetadataIdentities(metadataUrl, response) {
|
|
|
18
29
|
...extractPypiIdentities(metadataUrl, parsed)
|
|
19
30
|
];
|
|
20
31
|
}
|
|
32
|
+
// A MITM'd registry can return a tiny gzip/brotli body that expands to gigabytes;
|
|
33
|
+
// cap the decoded size so a decompression bomb cannot exhaust proxy memory.
|
|
34
|
+
const MAX_METADATA_DECODED_BYTES = 32 * 1024 * 1024;
|
|
21
35
|
// Content-Encoding lists encodings in the order they were applied; decode in
|
|
22
36
|
// reverse. Any unknown token or decode failure returns the body untouched so
|
|
23
37
|
// the response falls back to artifact handling instead of crashing the proxy.
|
|
@@ -27,21 +41,22 @@ function decodeContentEncoding(headers, body) {
|
|
|
27
41
|
.split(",")
|
|
28
42
|
.map((token) => token.trim().toLowerCase())
|
|
29
43
|
.filter((token) => token.length > 0 && token !== "identity");
|
|
44
|
+
const cap = { maxOutputLength: MAX_METADATA_DECODED_BYTES };
|
|
30
45
|
let decoded = body;
|
|
31
46
|
for (const encoding of encodings.reverse()) {
|
|
32
47
|
try {
|
|
33
48
|
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
34
|
-
decoded = gunzipSync(decoded);
|
|
49
|
+
decoded = gunzipSync(decoded, cap);
|
|
35
50
|
}
|
|
36
51
|
else if (encoding === "br") {
|
|
37
|
-
decoded = brotliDecompressSync(decoded);
|
|
52
|
+
decoded = brotliDecompressSync(decoded, cap);
|
|
38
53
|
}
|
|
39
54
|
else if (encoding === "deflate") {
|
|
40
55
|
try {
|
|
41
|
-
decoded = inflateSync(decoded);
|
|
56
|
+
decoded = inflateSync(decoded, cap);
|
|
42
57
|
}
|
|
43
58
|
catch {
|
|
44
|
-
decoded = inflateRawSync(decoded);
|
|
59
|
+
decoded = inflateRawSync(decoded, cap);
|
|
45
60
|
}
|
|
46
61
|
}
|
|
47
62
|
else {
|
|
@@ -65,7 +80,8 @@ function decodeContentEncoding(headers, body) {
|
|
|
65
80
|
export function isRegistryIndexRequest(url) {
|
|
66
81
|
return (isPypiSimpleIndexUrl(url) ||
|
|
67
82
|
/\/pypi\/[^/]+\/json\/?$/i.test(url.pathname) ||
|
|
68
|
-
|
|
83
|
+
// PEP 658 sidecar: only `<wheel|sdist>.metadata`, not any path ending .metadata
|
|
84
|
+
/\.(whl|tar\.gz|tgz|zip)\.metadata$/i.test(url.pathname) ||
|
|
69
85
|
/^\/-\//.test(url.pathname));
|
|
70
86
|
}
|
|
71
87
|
function isPypiSimpleIndexUrl(url) {
|
|
@@ -103,7 +119,7 @@ function extractPypiSimpleIdentities(metadataUrl, response) {
|
|
|
103
119
|
catch {
|
|
104
120
|
continue;
|
|
105
121
|
}
|
|
106
|
-
const file = entry.filename ??
|
|
122
|
+
const file = entry.filename ?? safeDecodeUriComponent(absolute.pathname.split("/").filter(Boolean).at(-1) ?? "");
|
|
107
123
|
const parsed = parsePypiArtifactFilename(file);
|
|
108
124
|
if (!parsed) {
|
|
109
125
|
continue;
|
|
@@ -239,7 +255,7 @@ function extractPypiIdentities(metadataUrl, parsed) {
|
|
|
239
255
|
function fallbackIdentity(artifactUrl, classification) {
|
|
240
256
|
const ecosystem = ecosystemForManager(classification.manager);
|
|
241
257
|
const parsed = ecosystem === "pypi"
|
|
242
|
-
? parsePypiArtifactFilename(
|
|
258
|
+
? parsePypiArtifactFilename(safeDecodeUriComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "")) ?? parsePackageVersionFromUrl(artifactUrl)
|
|
243
259
|
: ecosystem === "cargo"
|
|
244
260
|
? parseCargoArtifactUrl(artifactUrl) ?? parsePackageVersionFromUrl(artifactUrl)
|
|
245
261
|
: parsePackageVersionFromUrl(artifactUrl);
|
|
@@ -261,11 +277,11 @@ function parseCargoArtifactUrl(artifactUrl) {
|
|
|
261
277
|
const downloadMatch = /^\/(?:api\/v1\/)?crates\/([^/]+)\/([^/]+)\/download\/?$/.exec(artifactUrl.pathname);
|
|
262
278
|
if (downloadMatch && downloadMatch[1] && downloadMatch[2]) {
|
|
263
279
|
return {
|
|
264
|
-
name:
|
|
265
|
-
version:
|
|
280
|
+
name: safeDecodeUriComponent(downloadMatch[1]),
|
|
281
|
+
version: safeDecodeUriComponent(downloadMatch[2])
|
|
266
282
|
};
|
|
267
283
|
}
|
|
268
|
-
const file =
|
|
284
|
+
const file = safeDecodeUriComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "");
|
|
269
285
|
if (/\.crate$/i.test(file)) {
|
|
270
286
|
const stem = file.slice(0, -".crate".length);
|
|
271
287
|
const match = /^(.+)-(\d[0-9A-Za-z.+-]*)$/.exec(stem);
|
|
@@ -276,7 +292,7 @@ function parseCargoArtifactUrl(artifactUrl) {
|
|
|
276
292
|
return null;
|
|
277
293
|
}
|
|
278
294
|
function parsePackageVersionFromUrl(artifactUrl) {
|
|
279
|
-
const parts = artifactUrl.pathname.split("/").filter(Boolean).map((part) =>
|
|
295
|
+
const parts = artifactUrl.pathname.split("/").filter(Boolean).map((part) => safeDecodeUriComponent(part));
|
|
280
296
|
const file = parts.at(-1) ?? artifactUrl.hostname;
|
|
281
297
|
const npmPackage = npmPackageFromTarballPath(parts);
|
|
282
298
|
const packageName = npmPackage ?? parts.at(-2) ?? file.replace(/\.(?:tgz|tar\.gz|zip|whl)$/i, "");
|
|
@@ -338,14 +354,14 @@ function parseNpmPackageSpec(spec) {
|
|
|
338
354
|
return { name, version };
|
|
339
355
|
}
|
|
340
356
|
function packageNameFromNpmMetadataPath(metadataUrl) {
|
|
341
|
-
const parts = metadataUrl.pathname.split("/").filter(Boolean).map((part) =>
|
|
357
|
+
const parts = metadataUrl.pathname.split("/").filter(Boolean).map((part) => safeDecodeUriComponent(part));
|
|
342
358
|
if (parts[0]?.startsWith("@") && parts[1]) {
|
|
343
359
|
return `${parts[0]}/${parts[1]}`;
|
|
344
360
|
}
|
|
345
361
|
return parts[0] ?? metadataUrl.hostname;
|
|
346
362
|
}
|
|
347
363
|
function packageNameFromPypiMetadataPath(metadataUrl) {
|
|
348
|
-
const parts = metadataUrl.pathname.split("/").filter(Boolean).map((part) =>
|
|
364
|
+
const parts = metadataUrl.pathname.split("/").filter(Boolean).map((part) => safeDecodeUriComponent(part));
|
|
349
365
|
const projectIndex = parts.findIndex((part) => part.toLowerCase() === "pypi");
|
|
350
366
|
return parts[projectIndex + 1] ?? parts[0] ?? metadataUrl.hostname;
|
|
351
367
|
}
|
package/dist/proxy/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
|
|
|
7
7
|
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { dirname } from "node:path";
|
|
9
9
|
import { connect } from "node:net";
|
|
10
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
10
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";
|
|
@@ -180,6 +181,16 @@ async function handleConnectRequest(request, clientSocket, head, options, state,
|
|
|
180
181
|
return;
|
|
181
182
|
}
|
|
182
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
|
+
}
|
|
183
194
|
state.events = [...state.events, `tunnel:${redactSecrets(authorityFor(target))}`];
|
|
184
195
|
writeProxyState(options.session, state);
|
|
185
196
|
await blindTunnel(clientSocket, head, target, options, activeSockets);
|
|
@@ -210,6 +221,18 @@ const STREAMING_DISABLED = Number.POSITIVE_INFINITY;
|
|
|
210
221
|
// or mixes TLS trust. Purely transport-level — no effect on what gets verified.
|
|
211
222
|
const upstreamHttpAgent = new HttpAgent({ keepAlive: true, maxSockets: 64, scheduling: "fifo" });
|
|
212
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
|
+
}
|
|
213
236
|
function streamThresholdBytes(env) {
|
|
214
237
|
const raw = env.DG_STREAM_THRESHOLD_BYTES;
|
|
215
238
|
if (raw === undefined || raw === "") {
|
|
@@ -396,18 +419,95 @@ function isRedirectStatus(statusCode) {
|
|
|
396
419
|
// only string-matches the dotted-decimal form silently lets the cloud-metadata
|
|
397
420
|
// IP through as "public". Recover the embedded IPv4 (both forms) before
|
|
398
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.
|
|
399
431
|
function recoverIpv4MappedHost(host) {
|
|
400
|
-
const
|
|
401
|
-
if (
|
|
402
|
-
return
|
|
432
|
+
const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
|
|
433
|
+
if (mappedDotted?.[1])
|
|
434
|
+
return mappedDotted[1];
|
|
435
|
+
const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
436
|
+
if (mappedHex?.[1] && mappedHex[2])
|
|
437
|
+
return hextetsToIpv4(mappedHex[1], mappedHex[2]);
|
|
438
|
+
const compatDotted = /^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
|
|
439
|
+
if (compatDotted?.[1])
|
|
440
|
+
return compatDotted[1];
|
|
441
|
+
const compatHex = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
442
|
+
if (compatHex?.[1] && compatHex[2])
|
|
443
|
+
return hextetsToIpv4(compatHex[1], compatHex[2]);
|
|
444
|
+
const sixToFour = /^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/i.exec(host);
|
|
445
|
+
if (sixToFour?.[1] && sixToFour[2])
|
|
446
|
+
return hextetsToIpv4(sixToFour[1], sixToFour[2]);
|
|
447
|
+
const nat64Dotted = /^64:ff9b::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
|
|
448
|
+
if (nat64Dotted?.[1])
|
|
449
|
+
return nat64Dotted[1];
|
|
450
|
+
const nat64Hex = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
451
|
+
if (nat64Hex?.[1] && nat64Hex[2])
|
|
452
|
+
return hextetsToIpv4(nat64Hex[1], nat64Hex[2]);
|
|
453
|
+
return host;
|
|
454
|
+
}
|
|
455
|
+
// Block a RESOLVED address (the actual connect target), so a public hostname
|
|
456
|
+
// whose A/AAAA record points at an internal/metadata IP — DNS-rebinding SSRF —
|
|
457
|
+
// is refused at connect time, not just literal-IP targets.
|
|
458
|
+
export function isBlockedResolvedIp(ip) {
|
|
459
|
+
const host = recoverIpv4MappedHost(ip.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""));
|
|
460
|
+
if (host === "0.0.0.0" || host === "::" || host === "::1") {
|
|
461
|
+
return true;
|
|
403
462
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const hi = parseInt(hex[1], 16);
|
|
407
|
-
const lo = parseInt(hex[2], 16);
|
|
408
|
-
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
463
|
+
if (isPrivateIpv4(host)) {
|
|
464
|
+
return true;
|
|
409
465
|
}
|
|
410
|
-
return host;
|
|
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;
|
|
411
511
|
}
|
|
412
512
|
function isPrivateIpv4(host) {
|
|
413
513
|
return (/^(127|10|0)\./.test(host) ||
|
|
@@ -598,7 +698,7 @@ async function blindTunnel(clientSocket, head, target, options, activeSockets) {
|
|
|
598
698
|
const upstreamProxy = selectUpstreamProxy(target, options.env);
|
|
599
699
|
const upstreamSocket = upstreamProxy
|
|
600
700
|
? await connectViaUpstreamProxy(target, upstreamProxy)
|
|
601
|
-
: await connectDirect(target);
|
|
701
|
+
: await connectDirect(target, options.env);
|
|
602
702
|
trackSocket(activeSockets, upstreamSocket);
|
|
603
703
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
604
704
|
if (head.length > 0) {
|
|
@@ -673,9 +773,9 @@ function parseConnectTarget(authority) {
|
|
|
673
773
|
return null;
|
|
674
774
|
}
|
|
675
775
|
}
|
|
676
|
-
function connectDirect(target) {
|
|
776
|
+
function connectDirect(target, env = process.env) {
|
|
677
777
|
return new Promise((resolve, reject) => {
|
|
678
|
-
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) });
|
|
679
779
|
socket.once("connect", () => resolve(socket));
|
|
680
780
|
socket.once("error", reject);
|
|
681
781
|
});
|
|
@@ -720,6 +820,7 @@ function fetchUpstream(request, target, env) {
|
|
|
720
820
|
path: `${target.pathname}${target.search}`,
|
|
721
821
|
method: request.method,
|
|
722
822
|
agent: upstreamHttpAgent,
|
|
823
|
+
lookup: lookupForTarget(target, env),
|
|
723
824
|
headers: stripProxyHopHeaders(request.headers)
|
|
724
825
|
}, (upstreamResponse) => {
|
|
725
826
|
collectBounded(upstreamResponse, { label: target.toString() })
|
|
@@ -783,6 +884,7 @@ function fetchHttpsDirect(request, target, env) {
|
|
|
783
884
|
path: `${target.pathname}${target.search}`,
|
|
784
885
|
method: request.method,
|
|
785
886
|
agent: upstreamHttpsAgent,
|
|
887
|
+
lookup: lookupForTarget(target, env),
|
|
786
888
|
headers: upstreamRequestHeaders(request, target),
|
|
787
889
|
...upstreamTlsOptions(env)
|
|
788
890
|
}, (upstreamResponse) => {
|
|
@@ -915,8 +1017,14 @@ function headerLines(key, value) {
|
|
|
915
1017
|
}
|
|
916
1018
|
return [`${key}: ${value}`];
|
|
917
1019
|
}
|
|
918
|
-
function upstreamTlsOptions(env) {
|
|
919
|
-
|
|
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;
|
|
920
1028
|
if (!caPath) {
|
|
921
1029
|
return {};
|
|
922
1030
|
}
|
|
@@ -1100,6 +1208,14 @@ function preverifiedVerdict(options, target, streamedSha256, identity) {
|
|
|
1100
1208
|
if (!entry.cooldownEvaluated && resolveCooldownRequest(options, identity) !== undefined) {
|
|
1101
1209
|
return null;
|
|
1102
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
|
+
}
|
|
1103
1219
|
return normalizeVerdict({
|
|
1104
1220
|
verdict: entry.action,
|
|
1105
1221
|
cause: entry.action,
|
|
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { dgVersion } from "../commands/version.js";
|
|
4
4
|
import { isCiEnv } from "../presentation/mode.js";
|
|
5
5
|
import { createTheme } from "../presentation/theme.js";
|
|
6
|
-
import { sweepLegacyPythonHooks } from "../setup/plan.js";
|
|
6
|
+
import { refreshSetupOnUpgrade, sweepLegacyPythonHooks } from "../setup/plan.js";
|
|
7
7
|
import { resolveDgPaths } from "../state/index.js";
|
|
8
8
|
const SKIP_COMMANDS = new Set([
|
|
9
9
|
"help",
|
|
@@ -42,6 +42,7 @@ export function sweepLegacyHooksOnVersionChange(env = process.env, version = dgV
|
|
|
42
42
|
return false;
|
|
43
43
|
}
|
|
44
44
|
sweepLegacyPythonHooks(resolveDgPaths(env).homeDir, [], []);
|
|
45
|
+
refreshSetupOnUpgrade(env);
|
|
45
46
|
mkdirSync(dirname(marker), { recursive: true, mode: 0o700 });
|
|
46
47
|
writeFileSync(marker, `${version}\n`, { encoding: "utf8", mode: 0o600 });
|
|
47
48
|
return true;
|
|
@@ -95,7 +95,11 @@ export function emptyFilterMessage(filter, query, phase, tally, scanError) {
|
|
|
95
95
|
return "no components";
|
|
96
96
|
}
|
|
97
97
|
function csvCell(value) {
|
|
98
|
-
|
|
98
|
+
// Neutralize spreadsheet formula injection: a cell starting with = + - @ (or
|
|
99
|
+
// tab/CR) is interpreted as a formula by Excel/Sheets, letting an attacker-named
|
|
100
|
+
// dependency run on open. Prefix a sentinel apostrophe so it is treated as text.
|
|
101
|
+
const guarded = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
|
|
102
|
+
return /[",\n\r]/.test(guarded) ? `"${guarded.replace(/"/g, '""')}"` : guarded;
|
|
99
103
|
}
|
|
100
104
|
export function componentsCsv(rows) {
|
|
101
105
|
const lines = ["name,version,ecosystem,license,verdict"];
|
package/dist/scan/staged.js
CHANGED
|
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { createTheme } from "../presentation/theme.js";
|
|
5
5
|
import { resolvePresentation } from "../presentation/mode.js";
|
|
6
|
-
import { loadUserConfig } from "../config/settings.js";
|
|
6
|
+
import { DEFAULT_CONFIG, loadUserConfig } from "../config/settings.js";
|
|
7
7
|
import { offerRememberSync } from "../decisions/remember-prompt.js";
|
|
8
8
|
import { packageKey } from "../decisions/apply.js";
|
|
9
9
|
import { loadDgFile, resolveAcceptedBy, warnUnreadableDgFile } from "../project/dgfile.js";
|
|
@@ -78,9 +78,10 @@ export function runStagedScan(options) {
|
|
|
78
78
|
if (!root) {
|
|
79
79
|
return notARepoResult();
|
|
80
80
|
}
|
|
81
|
+
const onIncomplete = gitHookOnIncomplete(env);
|
|
81
82
|
const lockfiles = stagedLockfilePaths(cwd, env);
|
|
82
83
|
if (lockfiles === null) {
|
|
83
|
-
return failOpen(theme, "could not read staged changes");
|
|
84
|
+
return failOpen(theme, "could not read staged changes", onIncomplete);
|
|
84
85
|
}
|
|
85
86
|
const scoped = scopeStagedPaths(lockfiles, root, cwd, options.targetPath ?? null);
|
|
86
87
|
if (scoped === null) {
|
|
@@ -97,11 +98,11 @@ export function runStagedScan(options) {
|
|
|
97
98
|
const { dir, count } = materializeStaged(scoped, cwd, env);
|
|
98
99
|
try {
|
|
99
100
|
if (count === 0) {
|
|
100
|
-
return failOpen(theme, "could not read the staged lockfile contents");
|
|
101
|
+
return failOpen(theme, "could not read the staged lockfile contents", onIncomplete);
|
|
101
102
|
}
|
|
102
103
|
const report = tryScannerScan(dir, emptyLocalReport(dir), env, usableDgFile);
|
|
103
104
|
if (!report || !report.scanner) {
|
|
104
|
-
return failOpen(theme, "could not reach the scanner");
|
|
105
|
+
return failOpen(theme, "could not reach the scanner", onIncomplete);
|
|
105
106
|
}
|
|
106
107
|
return decideStagedVerdict(report, env, options.hook, usableDgFile ? { root, file: usableDgFile } : undefined);
|
|
107
108
|
}
|
|
@@ -168,7 +169,7 @@ export function decideStagedVerdict(report, env = process.env, hook = false, dec
|
|
|
168
169
|
return { exitCode: 2, stdout: "", stderr: renderBlock(report.findings, theme) };
|
|
169
170
|
}
|
|
170
171
|
if (action === "error" || action === "unknown") {
|
|
171
|
-
return scannerUnavailable(theme);
|
|
172
|
+
return scannerUnavailable(theme, config.gitHook.onIncomplete);
|
|
172
173
|
}
|
|
173
174
|
if (action === "warn") {
|
|
174
175
|
if (acknowledgedCount > 0 && effective === "pass") {
|
|
@@ -279,14 +280,36 @@ function incompleteNotice(theme, blocked) {
|
|
|
279
280
|
}
|
|
280
281
|
return `${head} ${theme.paint("muted", "— proceeding")}\n`;
|
|
281
282
|
}
|
|
282
|
-
function
|
|
283
|
+
function gitHookOnIncomplete(env) {
|
|
284
|
+
try {
|
|
285
|
+
return loadUserConfig(env).gitHook.onIncomplete;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return DEFAULT_CONFIG.gitHook.onIncomplete;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function failOpen(theme, reason, onIncomplete) {
|
|
292
|
+
if (onIncomplete === "block") {
|
|
293
|
+
return {
|
|
294
|
+
exitCode: 1,
|
|
295
|
+
stdout: "",
|
|
296
|
+
stderr: ` ${theme.paint("unknown", "?")} ${theme.paint("muted", `DG could not verify (${reason}) — commit blocked (gitHook.onIncomplete=block). Use`)} ${theme.paint("accent", "git commit --no-verify")} ${theme.paint("muted", "to override.")}\n`
|
|
297
|
+
};
|
|
298
|
+
}
|
|
283
299
|
return {
|
|
284
300
|
exitCode: 0,
|
|
285
301
|
stdout: "",
|
|
286
|
-
stderr: ` ${theme.paint("unknown", "?")} ${theme.paint("muted", `DG could not verify (${reason}) — commit allowed`)}\n`
|
|
302
|
+
stderr: ` ${theme.paint("unknown", "?")} ${theme.paint("muted", `DG could not verify (${reason}) — commit allowed (gitHook.onIncomplete=allow)`)}\n`
|
|
287
303
|
};
|
|
288
304
|
}
|
|
289
|
-
function scannerUnavailable(theme) {
|
|
305
|
+
function scannerUnavailable(theme, onIncomplete) {
|
|
306
|
+
if (onIncomplete === "block") {
|
|
307
|
+
return {
|
|
308
|
+
exitCode: 1,
|
|
309
|
+
stdout: "",
|
|
310
|
+
stderr: ` ${theme.paint("unknown", "?")} ${theme.paint("muted", "dg: scanner unavailable — staged changes not verified; commit blocked (gitHook.onIncomplete=block). Use")} ${theme.paint("accent", "git commit --no-verify")} ${theme.paint("muted", "to override.")}\n`
|
|
311
|
+
};
|
|
312
|
+
}
|
|
290
313
|
return {
|
|
291
314
|
exitCode: 0,
|
|
292
315
|
stdout: "",
|
|
@@ -160,7 +160,10 @@ async function scanProjects(projects, dispatch, signal) {
|
|
|
160
160
|
const firstFailure = outcomes.find((outcome) => "error" in outcome);
|
|
161
161
|
if (responses.length > 0) {
|
|
162
162
|
const merged = mergeAnalyzeResponses(responses);
|
|
163
|
-
|
|
163
|
+
// A failed ecosystem means its verdict is unknown; escalate anything short of
|
|
164
|
+
// a confirmed block to analysis_incomplete so a warn from the succeeded
|
|
165
|
+
// ecosystem does not present as the whole verdict while the other is unscanned.
|
|
166
|
+
const base = firstFailure && merged.action !== "block" ? { ...merged, action: "analysis_incomplete" } : merged;
|
|
164
167
|
const ecoByKey = new Map();
|
|
165
168
|
for (const [ecosystem, packages] of entries) {
|
|
166
169
|
for (const pkg of packages) {
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
const CTRL_KEEP_NEWLINE = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
|
|
3
|
+
const CTRL_ALL = /[\x00-\x1F\x7F-\x9F]/g;
|
|
2
4
|
export function sanitize(s) {
|
|
3
|
-
return stripVTControlCharacters(s);
|
|
5
|
+
return stripVTControlCharacters(s).replace(CTRL_KEEP_NEWLINE, "");
|
|
6
|
+
}
|
|
7
|
+
export function sanitizeLine(s) {
|
|
8
|
+
return stripVTControlCharacters(s).replace(/[\r\n]+/g, " ").replace(CTRL_ALL, "");
|
|
4
9
|
}
|
|
5
10
|
export function sanitizeDeep(value) {
|
|
6
11
|
if (typeof value === "string") {
|
|
7
|
-
return
|
|
12
|
+
return sanitize(value);
|
|
8
13
|
}
|
|
9
14
|
if (value === null || value === undefined) {
|
|
10
15
|
return value;
|
|
@@ -16,8 +21,7 @@ export function sanitizeDeep(value) {
|
|
|
16
21
|
const src = value;
|
|
17
22
|
const out = {};
|
|
18
23
|
for (const k of Object.keys(src)) {
|
|
19
|
-
|
|
20
|
-
out[cleanKey] = sanitizeDeep(src[k]);
|
|
24
|
+
out[sanitizeLine(k)] = sanitizeDeep(src[k]);
|
|
21
25
|
}
|
|
22
26
|
return out;
|
|
23
27
|
}
|
package/dist/service/state.js
CHANGED
|
@@ -466,7 +466,7 @@ function trustStoreOperation(operation) {
|
|
|
466
466
|
throw error;
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
|
-
function withServiceLock(paths, run) {
|
|
469
|
+
export function withServiceLock(paths, run) {
|
|
470
470
|
const lock = acquireLockSync(paths.paths, SERVICE_LOCK, {
|
|
471
471
|
staleMs: SERVICE_LOCK_STALE_MS
|
|
472
472
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { X509Certificate } from "node:crypto";
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { writeJsonAtomic } from "../util/json-file.js";
|
|
6
7
|
export class TrustStoreError extends Error {
|
|
7
8
|
constructor(message) {
|
|
8
9
|
super(message);
|
|
@@ -191,14 +192,9 @@ export function readCertificateFingerprints(path) {
|
|
|
191
192
|
return readCertificateInfo(path);
|
|
192
193
|
}
|
|
193
194
|
export function writeServiceTrustRecord(path, record) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
});
|
|
198
|
-
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, {
|
|
199
|
-
encoding: "utf8",
|
|
200
|
-
mode: 0o600
|
|
201
|
-
});
|
|
195
|
+
// Atomic write so a torn write can't drop the trustInstalled state while the OS
|
|
196
|
+
// still trusts the CA.
|
|
197
|
+
writeJsonAtomic(path, record, { fileMode: 0o600, dirMode: 0o700 });
|
|
202
198
|
}
|
|
203
199
|
function readCertificateInfo(path) {
|
|
204
200
|
try {
|
package/dist/service/worker.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { startProductionHttpProxy } from "../proxy/server.js";
|
|
4
|
-
import { resolveServicePaths, TRUST_SENTINEL } from "./state.js";
|
|
4
|
+
import { resolveServicePaths, TRUST_SENTINEL, withServiceLock } from "./state.js";
|
|
5
5
|
import { refreshServiceTrustAfterCaRotation } from "./trust-refresh.js";
|
|
6
6
|
const sessionPath = process.argv[2];
|
|
7
7
|
const apiBaseUrl = process.argv[3];
|
|
@@ -57,13 +57,13 @@ proxy = await startProductionHttpProxy({
|
|
|
57
57
|
apiBaseUrl: requiredApiBaseUrl,
|
|
58
58
|
classification,
|
|
59
59
|
env: process.env,
|
|
60
|
-
onCaRotate: () => refreshServiceTrustAfterCaRotation({
|
|
60
|
+
onCaRotate: () => withServiceLock(servicePaths, () => refreshServiceTrustAfterCaRotation({
|
|
61
61
|
serviceDir: servicePaths.serviceDir,
|
|
62
62
|
trustRecordPath: servicePaths.trustRecordPath,
|
|
63
63
|
sentinel: TRUST_SENTINEL,
|
|
64
64
|
caPath: session.files.ca,
|
|
65
65
|
env: process.env
|
|
66
|
-
})
|
|
66
|
+
}))
|
|
67
67
|
});
|
|
68
68
|
healthServer = await startHealthServer(proxy.port);
|
|
69
69
|
const healthAddress = healthServer.address();
|