@westbayberry/dg 2.3.3 → 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +94 -217
  2. package/dist/agents/copilot-cli.js +7 -2
  3. package/dist/agents/persistence.js +83 -8
  4. package/dist/agents/registry.js +5 -2
  5. package/dist/agents/routing.js +31 -14
  6. package/dist/api/analyze.js +138 -41
  7. package/dist/audit/deep.js +2 -1
  8. package/dist/audit/detectors.js +23 -2
  9. package/dist/audit/rules.js +4 -1
  10. package/dist/audit-ui/export.js +5 -4
  11. package/dist/auth/device-login.js +33 -11
  12. package/dist/auth/login-app.js +17 -12
  13. package/dist/auth/store.js +65 -9
  14. package/dist/bin/dg.js +1 -1
  15. package/dist/commands/audit.js +12 -20
  16. package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
  17. package/dist/commands/cooldown.js +2 -1
  18. package/dist/commands/decisions.js +8 -7
  19. package/dist/commands/help.js +11 -3
  20. package/dist/commands/licenses.js +17 -8
  21. package/dist/commands/login.js +58 -21
  22. package/dist/commands/router.js +12 -2
  23. package/dist/commands/sbom.js +20 -5
  24. package/dist/commands/scan.js +5 -4
  25. package/dist/commands/service.js +1 -1
  26. package/dist/commands/setup.js +12 -12
  27. package/dist/commands/status.js +4 -4
  28. package/dist/commands/uninstall.js +1 -1
  29. package/dist/commands/update.js +20 -0
  30. package/dist/commands/verify.js +4 -4
  31. package/dist/config/settings.js +81 -12
  32. package/dist/export-ui/ExportDialog.js +1 -1
  33. package/dist/gate/cooldown-request.js +22 -0
  34. package/dist/gate/egress-guard.js +185 -0
  35. package/dist/gate/host-match.js +48 -0
  36. package/dist/gate/verdict-core.js +101 -0
  37. package/dist/gate/verdict-fetch.js +214 -0
  38. package/dist/launcher/agent-check.js +1096 -42
  39. package/dist/launcher/agent-hook-exec.js +8 -1
  40. package/dist/launcher/agent-hook-io.js +5 -3
  41. package/dist/launcher/classify.js +110 -26
  42. package/dist/launcher/env.js +84 -18
  43. package/dist/launcher/install-preflight.js +26 -3
  44. package/dist/launcher/live-install.js +4 -2
  45. package/dist/launcher/manifest-screen.js +88 -7
  46. package/dist/launcher/output-redaction.js +153 -15
  47. package/dist/launcher/preflight-prompt.js +1 -1
  48. package/dist/launcher/resolve-real-binary.js +8 -3
  49. package/dist/launcher/run.js +237 -40
  50. package/dist/origin/artifact-cache.js +55 -0
  51. package/dist/origin/cargo-config.js +12 -0
  52. package/dist/origin/gate-mode.js +7 -0
  53. package/dist/origin/platform-paths.js +14 -0
  54. package/dist/origin/precedence.js +345 -0
  55. package/dist/origin/server.js +611 -0
  56. package/dist/origin/spike-worker.js +35 -0
  57. package/dist/origin/worker.js +116 -0
  58. package/dist/presentation/package-page.js +21 -1
  59. package/dist/presentation/safe-version.js +11 -0
  60. package/dist/presentation/sarif.js +109 -0
  61. package/dist/project/dgfile.js +54 -17
  62. package/dist/project/override-trust.js +0 -0
  63. package/dist/proxy/enforcement.js +27 -7
  64. package/dist/proxy/metadata-map.js +33 -7
  65. package/dist/proxy/preverified.js +0 -3
  66. package/dist/proxy/server.js +104 -446
  67. package/dist/proxy/upstream-proxy.js +23 -4
  68. package/dist/proxy/worker.js +3 -2
  69. package/dist/publish-set/collect.js +6 -0
  70. package/dist/runtime/fatal.js +2 -1
  71. package/dist/runtime/first-run.js +5 -1
  72. package/dist/runtime/node-version.js +51 -0
  73. package/dist/runtime/nudges.js +27 -1
  74. package/dist/sbom/cyclonedx.js +120 -33
  75. package/dist/sbom/graph.js +236 -0
  76. package/dist/sbom/spdx.js +53 -0
  77. package/dist/sbom-ui/SbomApp.js +5 -3
  78. package/dist/sbom-ui/inventory.js +8 -10
  79. package/dist/scan/analyze-worker.js +3 -1
  80. package/dist/scan/collect.js +38 -8
  81. package/dist/scan/command.js +77 -27
  82. package/dist/scan/discovery.js +18 -7
  83. package/dist/scan/installed.js +202 -0
  84. package/dist/scan/manifest-coverage.js +128 -0
  85. package/dist/scan/render.js +125 -40
  86. package/dist/scan/scanner-report.js +92 -22
  87. package/dist/scan/staged.js +68 -15
  88. package/dist/scan/types.js +7 -1
  89. package/dist/scan-ui/LegacyApp.js +3 -3
  90. package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
  91. package/dist/scan-ui/hooks/useScan.js +37 -11
  92. package/dist/scan-ui/launch.js +2 -2
  93. package/dist/scan-ui/logo.js +6 -2
  94. package/dist/scan-ui/shims.js +6 -6
  95. package/dist/scripts/gate.js +53 -35
  96. package/dist/security/csv.js +9 -0
  97. package/dist/security/sanitize.js +11 -2
  98. package/dist/service/state.js +120 -37
  99. package/dist/service/trust-refresh.js +12 -1
  100. package/dist/service/trust-store.js +36 -5
  101. package/dist/service/worker.js +5 -7
  102. package/dist/setup/git-hook.js +2 -2
  103. package/dist/setup/install-method.js +36 -0
  104. package/dist/setup/plan.js +119 -57
  105. package/dist/setup/uninstall-standalone.js +7 -2
  106. package/dist/setup-ui/gate.js +1 -8
  107. package/dist/setup-ui/offer.js +14 -7
  108. package/dist/setup-ui/selector.js +1 -1
  109. package/dist/setup-ui/wizard.js +73 -78
  110. package/dist/standalone/uninstall.mjs +1114 -386
  111. package/dist/state/cleanup-registry.js +14 -3
  112. package/dist/state/cooldown-held.js +36 -13
  113. package/dist/state/locks.js +239 -34
  114. package/dist/state/sessions.js +2 -2
  115. package/dist/util/sh-escape.js +6 -0
  116. package/dist/util/tty-prompt.js +13 -4
  117. package/dist/verify/local.js +60 -10
  118. package/dist/verify/package-check.js +35 -49
  119. package/dist/verify/preflight.js +59 -33
  120. package/dist/verify/render.js +11 -12
  121. package/npm-shrinkwrap.json +714 -213
  122. package/package.json +3 -2
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { buildAgentRoutingEnv } from "../launcher/env.js";
4
4
  import { readServiceState } from "../service/state.js";
5
5
  import { resolveDgPaths } from "../state/index.js";
6
- import { readSettings, writeSettingsAtomic } from "./persistence.js";
6
+ import { readSettings, writeSettingsAtomic, writeFileAtomicSafe } from "./persistence.js";
7
7
  import { getAgent } from "./registry.js";
8
8
  // The agent's install hook screens statically; this routes the agent's ACTUAL
9
9
  // fetches through dg's proxy so a wrapped/dynamic install (eval, $VAR, python -m
@@ -29,30 +29,47 @@ function stripCodexBlock(content) {
29
29
  const block = new RegExp(`\\n?${escapeRegex(CODEX_BEGIN)}[\\s\\S]*?${escapeRegex(CODEX_END)}\\n?`, "g");
30
30
  return content.replace(block, "\n").replace(/\n{3,}/g, "\n\n");
31
31
  }
32
+ function loadBackupPrior(backup) {
33
+ if (!existsSync(backup)) {
34
+ return {};
35
+ }
36
+ try {
37
+ return JSON.parse(readFileSync(backup, "utf8")).prior ?? {};
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
32
43
  function applyClaudeRouting(settingsPath, routing, backup) {
33
44
  const { settings } = readSettings(settingsPath);
34
45
  const envObj = typeof settings.env === "object" && settings.env !== null ? { ...settings.env } : {};
35
- const prior = {};
46
+ // The backup must hold the user's PRE-dg values so removal can restore them. On
47
+ // a re-apply the `k in prior` guard preserves the snapshot captured on first
48
+ // apply, so dg's own values are never recorded as the user's prior. We only
49
+ // collapse a value that exactly equals what dg is writing now — never one that
50
+ // merely *looks* like dg's (a loopback host), which would silently delete the
51
+ // user's own local proxy / CA on `dg agents off`.
52
+ const prior = loadBackupPrior(backup);
36
53
  for (const [k, v] of Object.entries(routing)) {
37
- prior[k] = k in envObj ? String(envObj[k]) : null;
54
+ if (!(k in prior)) {
55
+ const current = k in envObj ? String(envObj[k]) : null;
56
+ prior[k] = current === v ? null : current;
57
+ }
38
58
  envObj[k] = v;
39
59
  }
40
- settings.env = envObj;
41
- writeSettingsAtomic(settingsPath, settings);
60
+ // Write the restore backup BEFORE mutating the agent settings: an IO failure on
61
+ // the settings write then leaves a recoverable state, never settings routed
62
+ // through dg with no backup to undo them.
42
63
  mkdirSync(dirname(backup), { recursive: true, mode: 0o700 });
43
64
  writeFileSync(backup, `${JSON.stringify({ kind: "claude-env", path: settingsPath, prior }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
65
+ settings.env = envObj;
66
+ writeSettingsAtomic(settingsPath, settings);
44
67
  }
45
68
  function removeClaudeRouting(settingsPath, backup) {
46
69
  if (!existsSync(backup)) {
47
70
  return;
48
71
  }
49
- let prior = {};
50
- try {
51
- prior = JSON.parse(readFileSync(backup, "utf8")).prior ?? {};
52
- }
53
- catch {
54
- prior = {};
55
- }
72
+ const prior = loadBackupPrior(backup);
56
73
  if (existsSync(settingsPath)) {
57
74
  const { settings } = readSettings(settingsPath);
58
75
  const envObj = typeof settings.env === "object" && settings.env !== null ? { ...settings.env } : {};
@@ -82,14 +99,14 @@ function applyCodexRouting(configPath, routing) {
82
99
  const set = Object.entries(routing).map(([k, v]) => `${k} = ${JSON.stringify(v)}`).join(", ");
83
100
  const block = `${CODEX_BEGIN}\n[shell_environment_policy]\nset = { ${set} }\n${CODEX_END}\n`;
84
101
  const next = content === "" || content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
85
- writeFileSync(configPath, next, { encoding: "utf8", mode: 0o600 });
102
+ writeFileAtomicSafe(configPath, next);
86
103
  return { applied: true, detail: configPath };
87
104
  }
88
105
  function removeCodexRouting(configPath) {
89
106
  if (!existsSync(configPath)) {
90
107
  return;
91
108
  }
92
- writeFileSync(configPath, stripCodexBlock(readFileSync(configPath, "utf8")), { encoding: "utf8" });
109
+ writeFileAtomicSafe(configPath, stripCodexBlock(readFileSync(configPath, "utf8")));
93
110
  }
94
111
  function codexConfigPath(home) {
95
112
  return join(home, ".codex", "config.toml");
@@ -1,11 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
2
  import { readAuthStateOrWarn } from "../auth/store.js";
5
3
  import { envAuthToken } from "../auth/env-token.js";
6
- import { loadUserConfig } from "../config/settings.js";
4
+ import { assertVendorApiBaseUrl, loadUserConfig } from "../config/settings.js";
7
5
  import { sanitize, sanitizeResponse } from "../security/sanitize.js";
8
- import { resolveDgPaths } from "../state/index.js";
9
6
  import { dgVersion } from "../commands/version.js";
10
7
  export class AnalyzeError extends Error {
11
8
  statusCode;
@@ -95,16 +92,19 @@ const DEFAULT_TIMEOUT_MS = 180_000;
95
92
  const BATCH_CONCURRENCY = Math.max(1, Number(process.env.DG_ANALYZE_CONCURRENCY) || 4);
96
93
  export async function analyzePackages(packages, options) {
97
94
  const env = options.env ?? process.env;
95
+ if (options.bill === false || (options.publicFallback && !resolveToken(env))) {
96
+ return analyzePublicVerdicts(packages, options, env);
97
+ }
98
98
  const baseUrl = resolveApiBaseUrl(env);
99
99
  const context = {
100
100
  url: `${baseUrl}${ANALYZE_PATHS[options.ecosystem]}`,
101
101
  token: resolveToken(env),
102
- deviceId: getOrCreateDeviceId(env),
103
102
  scanId: options.scanId ?? randomUUID(),
104
103
  fetchImpl: options.fetchImpl ?? fetch,
105
104
  timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
106
105
  ...(options.signal ? { signal: options.signal } : {}),
107
- ...(options.cooldown ? { cooldown: options.cooldown } : {})
106
+ ...(options.cooldown ? { cooldown: options.cooldown } : {}),
107
+ ...(options.installTreeHash ? { installTreeHash: options.installTreeHash } : {})
108
108
  };
109
109
  const total = packages.length;
110
110
  const batches = [];
@@ -138,7 +138,107 @@ export async function analyzePackages(packages, options) {
138
138
  };
139
139
  const workers = Math.min(BATCH_CONCURRENCY, Math.max(1, batches.length));
140
140
  await Promise.all(Array.from({ length: workers }, () => runWorker()));
141
- return mergeAnalyzeResponses(responses);
141
+ const merged = mergeAnalyzeResponses(responses);
142
+ // Tag every result with the ecosystem this call resolved, so consumers can tell
143
+ // a name@version apart across ecosystems (the authed batch response omits it).
144
+ return {
145
+ ...merged,
146
+ packages: merged.packages.map((pkg) => (pkg.ecosystem ? pkg : { ...pkg, ecosystem: options.ecosystem }))
147
+ };
148
+ }
149
+ const PUBLIC_VERDICT_PATH = "/v1/install-verdict";
150
+ // Account-free per-package verdict path for the install-time firewall when no
151
+ // API token is present. The authed batch endpoints require a Bearer key, but
152
+ // the firewall must keep blocking known-malicious packages for signed-out
153
+ // users, so each package is resolved through the public install-verdict
154
+ // endpoint (the same verdict the package pages show) and reshaped into the
155
+ // AnalyzeResponse the firewall callers already consume.
156
+ async function analyzePublicVerdicts(packages, options, env) {
157
+ const url = `${resolveApiBaseUrl(env)}${PUBLIC_VERDICT_PATH}`;
158
+ const fetchImpl = options.fetchImpl ?? fetch;
159
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
160
+ const total = packages.length;
161
+ options.onProgress?.({ done: 0, total, batchIndex: 0, batchCount: 1 });
162
+ const results = new Array(packages.length);
163
+ let done = 0;
164
+ let cursor = 0;
165
+ const runWorker = async () => {
166
+ for (let i = cursor++; i < packages.length; i = cursor++) {
167
+ const entry = packages[i];
168
+ if (!entry)
169
+ continue;
170
+ results[i] = await fetchPublicVerdict(url, options.ecosystem, entry, fetchImpl, timeoutMs, options);
171
+ done += 1;
172
+ options.onProgress?.({ done, total, batchIndex: 0, batchCount: 1 });
173
+ }
174
+ };
175
+ const workers = Math.min(BATCH_CONCURRENCY, Math.max(1, packages.length));
176
+ await Promise.all(Array.from({ length: workers }, () => runWorker()));
177
+ const rank = { pass: 0, analysis_incomplete: 1, warn: 2, block: 3 };
178
+ let action = "pass";
179
+ for (const pkg of results) {
180
+ const a = pkg.action ?? "analysis_incomplete";
181
+ if (rank[a] > rank[action])
182
+ action = a;
183
+ }
184
+ return { score: 0, action, packages: results, durationMs: 0 };
185
+ }
186
+ async function fetchPublicVerdict(url, ecosystem, entry, fetchImpl, timeoutMs, options) {
187
+ const controller = new AbortController();
188
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
189
+ const onAbort = () => controller.abort();
190
+ options.signal?.addEventListener("abort", onAbort, { once: true });
191
+ if (options.signal?.aborted) {
192
+ controller.abort();
193
+ }
194
+ try {
195
+ const response = await fetchImpl(url, {
196
+ method: "POST",
197
+ headers: {
198
+ "Content-Type": "application/json",
199
+ "X-Dg-Version": dgVersion()
200
+ },
201
+ body: JSON.stringify({
202
+ ecosystem,
203
+ name: entry.name,
204
+ version: entry.version,
205
+ ...(options.cooldown ? { cooldown: options.cooldown } : {})
206
+ }),
207
+ signal: controller.signal
208
+ });
209
+ if (!response.ok) {
210
+ const body = await response.json().catch(() => undefined);
211
+ throw analyzeErrorFromResponse(response.status, body);
212
+ }
213
+ const payload = await response.json().catch(() => {
214
+ throw new AnalyzeError("scanner returned an unreadable response", response.status, undefined, "invalid_response");
215
+ });
216
+ return publicVerdictToPackage(ecosystem, entry, payload);
217
+ }
218
+ finally {
219
+ clearTimeout(timer);
220
+ options.signal?.removeEventListener("abort", onAbort);
221
+ }
222
+ }
223
+ function publicVerdictToPackage(ecosystem, entry, payload) {
224
+ const v = (payload && typeof payload === "object" ? payload : {});
225
+ const action = v.cause === "analysis-incomplete"
226
+ ? "analysis_incomplete"
227
+ : normalizeAction(v.verdict);
228
+ const reason = typeof v.reason === "string" && v.reason.length > 0 ? sanitize(v.reason) : undefined;
229
+ return {
230
+ name: entry.name,
231
+ version: entry.version,
232
+ score: 0,
233
+ action,
234
+ findings: [],
235
+ reasons: reason ? [reason] : [],
236
+ ...(reason ? { recommendation: reason } : {}),
237
+ cached: false,
238
+ ecosystem,
239
+ ...(typeof v.scannedSha256 === "string" ? { artifactSha256: v.scannedSha256 } : {}),
240
+ ...(v.provenance && typeof v.provenance === "object" ? { provenance: v.provenance } : {})
241
+ };
142
242
  }
143
243
  const MAX_BATCH_ATTEMPTS = 3;
144
244
  async function analyzeBatchWithRetry(context, batch, onBatchProgress) {
@@ -179,7 +279,7 @@ function delay(ms) {
179
279
  return new Promise((resolve) => setTimeout(resolve, ms));
180
280
  }
181
281
  async function analyzeBatch(context, batch, onBatchProgress) {
182
- const { url, token, deviceId, scanId, fetchImpl, timeoutMs, signal, cooldown } = context;
282
+ const { url, token, scanId, fetchImpl, timeoutMs, signal, cooldown, installTreeHash } = context;
183
283
  const controller = new AbortController();
184
284
  let timedOut = false;
185
285
  let silenceTimer;
@@ -202,9 +302,9 @@ async function analyzeBatch(context, batch, onBatchProgress) {
202
302
  headers: {
203
303
  "Content-Type": "application/json",
204
304
  "Accept": "application/x-ndjson",
205
- "X-Device-Id": deviceId,
206
305
  "X-Scan-Id": scanId,
207
306
  "X-Dg-Version": dgVersion(),
307
+ ...(installTreeHash ? { "X-Install-Tree-Hash": installTreeHash } : {}),
208
308
  ...(token ? { Authorization: `Bearer ${token}` } : {})
209
309
  },
210
310
  body: JSON.stringify({
@@ -255,6 +355,7 @@ function analyzeErrorFromResponse(status, body) {
255
355
  }
256
356
  return probe;
257
357
  }
358
+ const MAX_STREAM_LINE_BYTES = 32 * 1024 * 1024;
258
359
  async function consumeAnalyzeStream(body, onBatchProgress, onActivity) {
259
360
  const reader = body.getReader();
260
361
  const decoder = new TextDecoder();
@@ -293,6 +394,11 @@ async function consumeAnalyzeStream(body, onBatchProgress, onActivity) {
293
394
  handleLine(buffer.slice(0, newline).trim());
294
395
  buffer = buffer.slice(newline + 1);
295
396
  }
397
+ // A newline-less / header-less stream would grow the buffer without bound;
398
+ // a single NDJSON event is never this large, so cap it and fail closed.
399
+ if (buffer.length > MAX_STREAM_LINE_BYTES) {
400
+ throw new AnalyzeError("scanner stream line exceeded the size limit", 0);
401
+ }
296
402
  }
297
403
  handleLine(buffer.trim());
298
404
  }
@@ -316,9 +422,9 @@ export function normalizeAnalyzeResponse(raw) {
316
422
  ...entry,
317
423
  action: entry.action === undefined ? undefined : normalizeAction(entry.action),
318
424
  findings: Array.isArray(entry.findings) ? entry.findings : [],
319
- reasons: Array.isArray(entry.reasons) ? entry.reasons : []
425
+ reasons: Array.isArray(entry.reasons) ? entry.reasons : [],
426
+ safeVersion: normalizeSafeVersion(entry.safeVersion)
320
427
  })),
321
- safeVersions: candidate.safeVersions ?? {},
322
428
  durationMs: typeof candidate.durationMs === "number" ? candidate.durationMs : 0,
323
429
  ...(candidate.freeScansRemaining !== undefined ? { freeScansRemaining: candidate.freeScansRemaining } : {}),
324
430
  ...(candidate.usage !== undefined ? { usage: candidate.usage } : {})
@@ -330,6 +436,16 @@ function normalizeAction(action) {
330
436
  }
331
437
  return "analysis_incomplete";
332
438
  }
439
+ function normalizeSafeVersion(value) {
440
+ if (!value || typeof value !== "object")
441
+ return undefined;
442
+ const v = value;
443
+ if (typeof v.version !== "string" || v.version.length === 0)
444
+ return undefined;
445
+ if (v.direction !== "upgrade" && v.direction !== "downgrade")
446
+ return undefined;
447
+ return { version: sanitize(v.version), direction: v.direction, crossesMajor: v.crossesMajor === true };
448
+ }
333
449
  export function mergeAnalyzeResponses(responses) {
334
450
  const first = responses[0];
335
451
  if (!first) {
@@ -337,7 +453,6 @@ export function mergeAnalyzeResponses(responses) {
337
453
  score: 0,
338
454
  action: "pass",
339
455
  packages: [],
340
- safeVersions: {},
341
456
  durationMs: 0
342
457
  };
343
458
  }
@@ -354,7 +469,6 @@ export function mergeAnalyzeResponses(responses) {
354
469
  score: Math.max(merged.score, next.score),
355
470
  action: rank[next.action] > rank[merged.action] ? next.action : merged.action,
356
471
  packages: [...merged.packages, ...next.packages],
357
- safeVersions: { ...merged.safeVersions, ...next.safeVersions },
358
472
  durationMs: merged.durationMs + next.durationMs,
359
473
  ...(next.freeScansRemaining !== undefined
360
474
  ? { freeScansRemaining: next.freeScansRemaining }
@@ -365,38 +479,21 @@ export function mergeAnalyzeResponses(responses) {
365
479
  }));
366
480
  }
367
481
  function resolveApiBaseUrl(env) {
368
- const auth = readAuthStateOrWarn(env);
369
- if (auth?.apiBaseUrl) {
370
- return auth.apiBaseUrl;
371
- }
372
- return loadUserConfig(env).api.baseUrl;
482
+ const config = loadUserConfig(env);
483
+ // An env-provided token (resolveToken prefers it) belongs to the endpoint
484
+ // config points at, NOT the apiBaseUrl pinned in auth.json — which may be a
485
+ // different instance from a prior `dg login`. Pairing an env token with the
486
+ // file-pinned URL would send that credential to the wrong host. The file's
487
+ // apiBaseUrl is honored only when the file token is the one in use.
488
+ const baseUrl = envAuthToken(env)
489
+ ? config.api.baseUrl
490
+ : readAuthStateOrWarn(env)?.apiBaseUrl ?? config.api.baseUrl;
491
+ return assertVendorApiBaseUrl(baseUrl, config.policy.allowNonVendorApiHost);
373
492
  }
374
493
  function resolveToken(env) {
375
494
  return envAuthToken(env) ?? readAuthStateOrWarn(env)?.token;
376
495
  }
377
496
  export function identityHeaders(env) {
378
- const headers = { "X-Device-Id": getOrCreateDeviceId(env) };
379
497
  const token = resolveToken(env);
380
- if (token) {
381
- headers.Authorization = `Bearer ${token}`;
382
- }
383
- return headers;
384
- }
385
- function getOrCreateDeviceId(env) {
386
- const path = join(resolveDgPaths(env).stateDir, "device-id");
387
- try {
388
- if (existsSync(path)) {
389
- const existing = readFileSync(path, "utf8").trim();
390
- if (existing) {
391
- return existing;
392
- }
393
- }
394
- const id = randomUUID();
395
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
396
- writeFileSync(path, `${id}\n`, { encoding: "utf8", mode: 0o600 });
397
- return id;
398
- }
399
- catch {
400
- return "anonymous";
401
- }
498
+ return token ? { Authorization: `Bearer ${token}` } : {};
402
499
  }
@@ -2,6 +2,7 @@ import { authStatus, readAuthState } from "../auth/store.js";
2
2
  import { envAuthToken } from "../auth/env-token.js";
3
3
  import { loadUserConfig } from "../config/settings.js";
4
4
  import { packNpmArtifact } from "../publish-set/pack.js";
5
+ import { sanitize } from "../security/sanitize.js";
5
6
  export function deepDecision(scope, local, env = process.env) {
6
7
  if (local) {
7
8
  return { upload: false, reason: "local mode (--local)" };
@@ -99,7 +100,7 @@ export async function runDeepUpload(scope, packageJson, deps = {}) {
99
100
  const body = await safeJson(response);
100
101
  const verdict = body?.verdict;
101
102
  if (verdict === "pass" || verdict === "warn" || verdict === "block") {
102
- return { ran: true, action: verdict, reason: typeof body?.reason === "string" ? body.reason : `behavioral verdict: ${verdict}` };
103
+ return { ran: true, action: verdict, reason: typeof body?.reason === "string" ? sanitize(body.reason) : `behavioral verdict: ${verdict}` };
103
104
  }
104
105
  return { ran: true, action: "analysis_incomplete", reason: "scanner returned an incomplete verdict" };
105
106
  }
@@ -1,6 +1,17 @@
1
1
  import { BIDI_OVERRIDE_RE, CONTENT_RULES, DANGEROUS_SCRIPT_RE, FILENAME_RULES, INVISIBLE_UNICODE_RE, RISKY_SCRIPT_NAMES } from "./rules.js";
2
+ import { sanitizeLine } from "../security/sanitize.js";
3
+ // Untrusted audit strings (file paths, titles built from package.json script
4
+ // keys, evidence snippets) reach the terminal and exported md/txt — strip
5
+ // control bytes at the shared accessors so a hostile repo cannot inject ANSI/OSC
6
+ // or forge table rows.
2
7
  export function findingLocation(finding) {
3
- return finding.line ? `${finding.location}:${finding.line}` : finding.location;
8
+ return sanitizeLine(finding.line ? `${finding.location}:${finding.line}` : finding.location);
9
+ }
10
+ export function findingTitleSafe(finding) {
11
+ return sanitizeLine(finding.title);
12
+ }
13
+ export function findingEvidenceSafe(finding) {
14
+ return sanitizeLine(finding.evidence);
4
15
  }
5
16
  function lineAt(body, index) {
6
17
  let line = 1;
@@ -21,7 +32,17 @@ export function detectFindings(files, context) {
21
32
  return;
22
33
  }
23
34
  seen.add(key);
24
- findings.push(finding);
35
+ // Sanitize at the single point findings leave the detector layer so EVERY
36
+ // consumer (the TUI, the text report, the md/txt/JSON export) is safe — these
37
+ // strings embed package.json script keys and file content, which a hostile
38
+ // repo can fill with ANSI/OSC control sequences.
39
+ findings.push({
40
+ ...finding,
41
+ title: sanitizeLine(finding.title),
42
+ recommendation: sanitizeLine(finding.recommendation),
43
+ evidence: sanitizeLine(finding.evidence),
44
+ location: sanitizeLine(finding.location),
45
+ });
25
46
  };
26
47
  for (const file of files) {
27
48
  structuralFileChecks(file, push);
@@ -426,5 +426,8 @@ export const RISKY_SCRIPT_NAMES = [
426
426
  // node -e, python -c, base64 -d, curl|sh, AND download-then-execute where the fetch
427
427
  // is chained (&& / ;) to a shell or a freshly-downloaded file.
428
428
  export const DANGEROUS_SCRIPT_RE = /\|\s*(?:sh|bash|zsh)\b|\beval\s|node\s+-e\b|python[0-9.]*\s+-c\b|base64\s+-d|(?:curl|wget|fetch)\b[^\n]*\|\s*\w*sh\b|(?:curl|wget|fetch)\b[^\n]*(?:&&|;)\s*(?:sh|bash|zsh|source|\.\/)/iu;
429
- export const INVISIBLE_UNICODE_RE = /[\u200B-\u200F\u2060-\u2064\u202A-\u202E\u2066-\u2069\uFEFF]/u;
429
+ // Single source of truth lives in security/sanitize.ts (the strip set the
430
+ // sanitizers apply); re-exported here so the audit detector's invisible-char
431
+ // check and the strip set can never drift apart.
432
+ export { INVISIBLE_UNICODE_RE } from "../security/sanitize.js";
430
433
  export const BIDI_OVERRIDE_RE = /[\u202A-\u202E\u2066-\u2069]/u;
@@ -1,4 +1,5 @@
1
- import { findingLocation } from "../audit/detectors.js";
1
+ import { findingEvidenceSafe, findingLocation, findingTitleSafe } from "../audit/detectors.js";
2
+ import { sanitizeLine } from "../security/sanitize.js";
2
3
  import { deepSummary } from "../audit/deep.js";
3
4
  import { severityKind, WARN_GLYPH } from "./format.js";
4
5
  function countSummaryLine(findings) {
@@ -43,7 +44,7 @@ function buildMd(input) {
43
44
  for (const finding of input.findings) {
44
45
  const kind = severityKind(finding.severity);
45
46
  const sev = kind === "block" ? "BLOCK" : kind === "warn" ? "WARN" : "NOTE";
46
- const cell = (value) => value.replace(/\|/g, "\\|").replace(/\n/g, " ");
47
+ const cell = (value) => sanitizeLine(value).replace(/\|/g, "\\|");
47
48
  lines.push(`| ${sev} | ${cell(findingLocation(finding))} | ${cell(finding.title)} | ${cell(finding.evidence)} | ${cell(finding.recommendation)} |`);
48
49
  }
49
50
  lines.push("");
@@ -61,9 +62,9 @@ function buildTxt(input) {
61
62
  const kind = severityKind(finding.severity);
62
63
  const tag = kind === "block" ? "✘" : kind === "warn" ? WARN_GLYPH : "·";
63
64
  lines.push(` ${tag} ${findingLocation(finding)}`);
64
- lines.push(` ${finding.title}`);
65
+ lines.push(` ${findingTitleSafe(finding)}`);
65
66
  if (finding.evidence && finding.evidence !== `path: ${finding.location}` && finding.evidence !== finding.location) {
66
- lines.push(` ${finding.evidence}`);
67
+ lines.push(` ${findingEvidenceSafe(finding)}`);
67
68
  }
68
69
  lines.push(` → ${finding.recommendation}`);
69
70
  lines.push("");
@@ -1,19 +1,36 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { closeSync, openSync, readSync } from "node:fs";
3
3
  import { displayTier, writeAuthState } from "./store.js";
4
- import { loadUserConfig } from "../config/settings.js";
4
+ import { loadUserConfig, VENDOR_API_HOST } from "../config/settings.js";
5
5
  import { createTheme } from "../presentation/theme.js";
6
6
  import { resolvePresentation } from "../presentation/mode.js";
7
7
  const DEFAULT_WEB_BASE = "https://westbayberry.com";
8
8
  export const POLL_INTERVAL_MS = 2000;
9
9
  export const POLL_TIMEOUT_MS = 5 * 60_000;
10
+ const VENDOR_WEB_HOST = VENDOR_API_HOST.replace(/^api\./, "");
11
+ function isVendorWebHost(host) {
12
+ return host === VENDOR_WEB_HOST || host.endsWith(`.${VENDOR_WEB_HOST}`) || host === "localhost" || host === "127.0.0.1";
13
+ }
14
+ function allowsNonVendorHost(env) {
15
+ try {
16
+ return loadUserConfig(env).policy.allowNonVendorApiHost;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
10
22
  export function resolveWebBase(env) {
11
23
  const override = env.DG_AUTH_BASE;
12
24
  if (override) {
13
25
  try {
14
26
  const url = new URL(override);
15
27
  const localhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
16
- if (url.protocol === "https:" || (url.protocol === "http:" && localhost)) {
28
+ // The login browser opens this base and the polled token is stored as the
29
+ // user's credential, so an attacker-set DG_AUTH_BASE would harvest it. Pin
30
+ // it to the vendor web host (or localhost) unless the operator opted into a
31
+ // self-hosted host in their own global config — same anchor as the api pin.
32
+ if ((url.protocol === "https:" || (url.protocol === "http:" && localhost))
33
+ && (isVendorWebHost(url.hostname) || allowsNonVendorHost(env))) {
17
34
  return override.replace(/\/$/, "");
18
35
  }
19
36
  }
@@ -43,8 +60,9 @@ export function assertTrustedVerifyUrl(verifyUrl, webBase) {
43
60
  catch {
44
61
  throw new Error("login server returned an invalid verify URL");
45
62
  }
46
- if (verify.protocol !== "https:" && verify.protocol !== "http:") {
47
- throw new Error("login server returned an unsupported verify URL");
63
+ const isLocalhost = verify.hostname === "localhost" || verify.hostname === "127.0.0.1";
64
+ if (verify.protocol !== "https:" && !(verify.protocol === "http:" && isLocalhost)) {
65
+ throw new Error("login server returned an insecure verify URL (https required)");
48
66
  }
49
67
  const baseHost = new URL(webBase).hostname;
50
68
  if (!isSameOrSubdomain(verify.hostname, baseHost)) {
@@ -136,13 +154,16 @@ export function openBrowser(url) {
136
154
  function delay(ms) {
137
155
  return new Promise((resolve) => setTimeout(resolve, ms));
138
156
  }
157
+ // Returns true only when the user actually pressed Enter on the controlling
158
+ // terminal. A false return (no /dev/tty, or EOF) means we could NOT confirm, so
159
+ // the caller must not auto-open a browser on the user's behalf.
139
160
  function waitForEnter() {
140
161
  let tty;
141
162
  try {
142
163
  tty = openSync("/dev/tty", "rs");
143
164
  }
144
165
  catch {
145
- return;
166
+ return false;
146
167
  }
147
168
  try {
148
169
  const byte = Buffer.alloc(1);
@@ -155,14 +176,14 @@ function waitForEnter() {
155
176
  if (error.code === "EAGAIN") {
156
177
  continue;
157
178
  }
158
- break;
179
+ return false;
159
180
  }
160
181
  if (read === 0) {
161
- break;
182
+ return false;
162
183
  }
163
184
  const char = byte.toString("utf8");
164
185
  if (char === "\n" || char === "\r") {
165
- break;
186
+ return true;
166
187
  }
167
188
  }
168
189
  }
@@ -226,9 +247,10 @@ export async function runDeviceLogin(io = {}) {
226
247
  stderr: `dg login: ${error instanceof Error ? error.message : "could not start login"}.\n`
227
248
  };
228
249
  }
229
- stderr.write(`\n Sign in at:\n ${accent(session.verifyUrl)}\n\n ${muted("Press Enter to open it in your browser…")}`);
230
- confirm();
231
- open(session.verifyUrl);
250
+ stderr.write(`\n Sign in at:\n ${accent(session.verifyUrl)}\n\n ${muted("Press Enter to open it in your browser, or open the link above yourself…")}`);
251
+ if (confirm()) {
252
+ open(session.verifyUrl);
253
+ }
232
254
  stderr.write(`\n ${muted("Waiting for you to approve in the browser…")}\n\n`);
233
255
  const deadline = now() + POLL_TIMEOUT_MS;
234
256
  for (;;) {
@@ -9,7 +9,8 @@ const SUCCESS_MAX_MS = 2500;
9
9
  export function useLogin(webBase, env) {
10
10
  const [state, setState] = useState({ phase: "creating", verifyUrl: "", email: "", plan: "", message: "" });
11
11
  const sessionId = useRef("");
12
- const started = useRef(false);
12
+ const verifyUrl = useRef("");
13
+ const pollStarted = useRef(false);
13
14
  const cancelled = useRef(false);
14
15
  const tierSettled = useRef(false);
15
16
  const pollTimer = useRef(undefined);
@@ -20,6 +21,7 @@ export function useLogin(webBase, env) {
20
21
  if (sessionCancelled)
21
22
  return;
22
23
  sessionId.current = session.sessionId;
24
+ verifyUrl.current = session.verifyUrl;
23
25
  setState((prev) => ({ ...prev, phase: "ready", verifyUrl: session.verifyUrl }));
24
26
  })
25
27
  .catch((error) => {
@@ -41,12 +43,12 @@ export function useLogin(webBase, env) {
41
43
  }
42
44
  };
43
45
  }, []);
44
- const openAndPoll = () => {
45
- if (started.current)
46
+ // Polling starts as soon as the session is ready, so signing in works whether
47
+ // the user lets us open the browser (Enter) or opens the link themselves.
48
+ useEffect(() => {
49
+ if (state.phase !== "ready" || pollStarted.current)
46
50
  return;
47
- started.current = true;
48
- openBrowser(state.verifyUrl);
49
- setState((prev) => ({ ...prev, phase: "waiting" }));
51
+ pollStarted.current = true;
50
52
  void (async () => {
51
53
  const deadline = Date.now() + POLL_TIMEOUT_MS;
52
54
  while (!cancelled.current) {
@@ -85,11 +87,16 @@ export function useLogin(webBase, env) {
85
87
  });
86
88
  }
87
89
  })();
90
+ }, [state.phase, webBase, env]);
91
+ const openInBrowser = () => {
92
+ if (verifyUrl.current) {
93
+ openBrowser(verifyUrl.current);
94
+ }
88
95
  };
89
- return { state, tierSettled, openAndPoll };
96
+ return { state, tierSettled, openInBrowser };
90
97
  }
91
98
  const LoginApp = ({ webBase, env }) => {
92
- const { state, tierSettled, openAndPoll } = useLogin(webBase, env);
99
+ const { state, tierSettled, openInBrowser } = useLogin(webBase, env);
93
100
  const { exit } = useApp();
94
101
  useEffect(() => {
95
102
  if (state.phase === "expired" || state.phase === "error") {
@@ -122,7 +129,7 @@ const LoginApp = ({ webBase, env }) => {
122
129
  }, [state.phase, exit, tierSettled]);
123
130
  useInput((_input, key) => {
124
131
  if (state.phase === "ready" && key.return) {
125
- openAndPoll();
132
+ openInBrowser();
126
133
  }
127
134
  else if (state.phase === "success") {
128
135
  exit();
@@ -132,9 +139,7 @@ const LoginApp = ({ webBase, env }) => {
132
139
  case "creating":
133
140
  return (_jsx(Box, { paddingLeft: 1, paddingTop: 1, children: _jsx(Spinner, { label: "Creating login session\u2026" }) }));
134
141
  case "ready":
135
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingTop: 1, children: [_jsx(Text, { children: "Sign in at:" }), _jsx(Text, { color: "cyan", children: state.verifyUrl }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Press Enter to open it in your browser\u2026" })] }));
136
- case "waiting":
137
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingTop: 1, children: [_jsx(Text, { children: "Sign in at:" }), _jsx(Text, { color: "cyan", children: state.verifyUrl }), _jsx(Text, { children: " " }), _jsx(Spinner, { label: "Waiting for you to approve in the browser\u2026" })] }));
142
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingTop: 1, children: [_jsx(Text, { children: "Sign in at:" }), _jsx(Text, { color: "cyan", children: state.verifyUrl }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Press Enter to open it in your browser, or open the link yourself." }), _jsx(Text, { children: " " }), _jsx(Spinner, { label: "Waiting for you to approve\u2026" })] }));
138
143
  case "success":
139
144
  return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingTop: 1, children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 Logged in", state.email ? ` as ${state.email}` : "", state.plan ? ` (${displayTier(state.plan)} plan)` : ""] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Run `dg scan` to check your dependencies." })] }));
140
145
  case "expired":