@westbayberry/dg 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/agents/claude-code.js +10 -0
  2. package/dist/agents/codex.js +1 -1
  3. package/dist/agents/cursor.js +6 -1
  4. package/dist/agents/gate-posture.js +21 -0
  5. package/dist/agents/persistence.js +68 -2
  6. package/dist/agents/registry.js +4 -3
  7. package/dist/agents/routing.js +118 -0
  8. package/dist/agents/windsurf.js +1 -1
  9. package/dist/audit/rules.js +6 -2
  10. package/dist/auth/store.js +9 -2
  11. package/dist/commands/agents.js +45 -1
  12. package/dist/commands/audit.js +6 -0
  13. package/dist/commands/setup.js +1 -2
  14. package/dist/commands/uninstall.js +2 -1
  15. package/dist/config/settings.js +35 -4
  16. package/dist/install-ui/LiveInstall.js +3 -2
  17. package/dist/install-ui/block-render.js +17 -13
  18. package/dist/launcher/agent-check.js +455 -25
  19. package/dist/launcher/agent-hook-exec.js +1 -1
  20. package/dist/launcher/agent-hook-io.js +12 -4
  21. package/dist/launcher/classify.js +27 -1
  22. package/dist/launcher/env.js +39 -7
  23. package/dist/launcher/install-preflight.js +15 -6
  24. package/dist/launcher/live-install.js +4 -3
  25. package/dist/launcher/manifest-screen.js +171 -0
  26. package/dist/launcher/output-redaction.js +4 -1
  27. package/dist/launcher/preflight-prompt.js +4 -3
  28. package/dist/launcher/run.js +90 -18
  29. package/dist/project/dgfile.js +2 -1
  30. package/dist/project/override-trust.js +0 -0
  31. package/dist/proxy/metadata-map.js +29 -13
  32. package/dist/proxy/server.js +130 -14
  33. package/dist/runtime/first-run.js +2 -1
  34. package/dist/sbom-ui/inventory.js +5 -1
  35. package/dist/scan/staged.js +31 -8
  36. package/dist/scan-ui/hooks/useScan.js +4 -1
  37. package/dist/security/sanitize.js +8 -4
  38. package/dist/service/state.js +1 -1
  39. package/dist/service/trust-store.js +5 -9
  40. package/dist/service/worker.js +3 -3
  41. package/dist/setup/plan.js +156 -12
  42. package/dist/setup/uninstall-standalone.js +25 -0
  43. package/dist/setup-ui/wizard.js +9 -1
  44. package/dist/standalone/uninstall.mjs +2123 -0
  45. package/dist/verify/local.js +2 -2
  46. package/dist/verify/package-check.js +3 -1
  47. package/npm-shrinkwrap.json +2 -2
  48. 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
- /\.metadata$/i.test(url.pathname) ||
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 ?? decodeURIComponent(absolute.pathname.split("/").filter(Boolean).at(-1) ?? "");
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(decodeURIComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "")) ?? parsePackageVersionFromUrl(artifactUrl)
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: decodeURIComponent(downloadMatch[1]),
265
- version: decodeURIComponent(downloadMatch[2])
280
+ name: safeDecodeUriComponent(downloadMatch[1]),
281
+ version: safeDecodeUriComponent(downloadMatch[2])
266
282
  };
267
283
  }
268
- const file = decodeURIComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "");
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) => decodeURIComponent(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) => decodeURIComponent(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) => decodeURIComponent(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
  }
@@ -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 dotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
401
- if (dotted?.[1]) {
402
- return dotted[1];
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
- const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
405
- if (hex?.[1] && hex?.[2]) {
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
- const caPath = env.DG_UPSTREAM_CA_CERT ?? env.NODE_EXTRA_CA_CERTS;
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
- return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
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"];
@@ -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 failOpen(theme, reason) {
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
- const base = firstFailure && merged.action === "pass" ? { ...merged, action: "analysis_incomplete" } : merged;
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 stripVTControlCharacters(value);
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
- const cleanKey = stripVTControlCharacters(k);
20
- out[cleanKey] = sanitizeDeep(src[k]);
24
+ out[sanitizeLine(k)] = sanitizeDeep(src[k]);
21
25
  }
22
26
  return out;
23
27
  }
@@ -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, writeFileSync } from "node:fs";
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
- mkdirSync(dirname(path), {
195
- recursive: true,
196
- mode: 0o700
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 {
@@ -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();