@westbayberry/dg 2.3.2 → 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 +95 -208
  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 +4 -2
@@ -15,19 +15,19 @@ import { markSecurityNotesShown, securityNotesShown } from "../setup-ui/gate.js"
15
15
  export const setupCommand = {
16
16
  name: "setup",
17
17
  summary: "Protect this machine — shell installs, AI-agent installs, and a repo's commits.",
18
- usage: "dg setup [--print] [--yes] [--agents] [--guard-commit] [--shell <auto|zsh|bash|fish>] [--service]",
18
+ usage: "dg setup [--print] [--yes] [--agents] [--commit-guard] [--shell <auto|zsh|bash|fish>] [--service]",
19
19
  flags: [
20
20
  { flag: "--print", summary: "Preview the exact write plan and change nothing." },
21
- { flag: "--yes", summary: "Apply the shell firewall only, without prompts (add --agents / --guard-commit to opt in the rest)." },
21
+ { flag: "--yes", summary: "Apply the shell firewall only, without prompts (add --agents / --commit-guard to opt in the rest)." },
22
22
  { flag: "--agents", summary: "With --yes: also route every detected AI agent's installs through dg." },
23
- { flag: "--guard-commit", summary: "With --yes: also scan this repo's commits (per-repo)." },
23
+ { flag: "--commit-guard", summary: "With --yes: also scan this repo's commits (per-repo)." },
24
24
  { flag: "--shell", value: "<auto|zsh|bash|fish>", summary: "Target shell rc to write (default: auto-detect)." },
25
- { flag: "--service", summary: "Set up service mode (Pro/Team; persistent proxy + managed CA)." }
25
+ { flag: "--service", summary: "Set up service mode (Pro/Team; long-running local proxy for this login session + managed CA)." }
26
26
  ],
27
27
  examples: ["dg setup", "dg setup --print", "dg setup --yes --agents", "dg setup --shell zsh --yes"],
28
28
  details: [
29
29
  "In a terminal it shows one consent screen listing everything it will protect — your shell's installs plus every detected AI agent, with the exact files it writes — and applies them on a single yes. Commits in the current repo are a separate per-repo question.",
30
- "Non-interactively, --yes applies just the shell firewall; add --agents and/or --guard-commit to opt those in. --print previews. Writes only dg-owned reversible files; undo it all with dg uninstall."
30
+ "Non-interactively, --yes applies just the shell firewall; add --agents and/or --commit-guard to opt those in. --print previews. Writes only dg-owned reversible files; undo it all with dg uninstall."
31
31
  ],
32
32
  handler: (context) => setupHandler(context.args)
33
33
  };
@@ -234,7 +234,7 @@ async function applyRequestedSurfaces(parsed) {
234
234
  }
235
235
  }
236
236
  }
237
- if (parsed.guardCommit) {
237
+ if (parsed.commitGuard) {
238
238
  const repo = commitGuardOffer();
239
239
  if (repo) {
240
240
  try {
@@ -254,7 +254,7 @@ function printableSurfaceOffers() {
254
254
  lines.push(`would offer: route ${offer.label} installs through dg (--agents)`);
255
255
  }
256
256
  if (commitGuardOffer()) {
257
- lines.push("would offer: scan this repo's commits (--guard-commit)");
257
+ lines.push("would offer: scan this repo's commits (--commit-guard)");
258
258
  }
259
259
  return lines.length > 0 ? `\n${lines.join("\n")}\n` : "";
260
260
  }
@@ -335,7 +335,7 @@ function parseSetupArgs(args) {
335
335
  let shell = "auto";
336
336
  let service = false;
337
337
  let agents = false;
338
- let guardCommit = false;
338
+ let commitGuard = false;
339
339
  for (let index = 0; index < args.length; index += 1) {
340
340
  const arg = args[index];
341
341
  if (arg === "--print") {
@@ -350,8 +350,8 @@ function parseSetupArgs(args) {
350
350
  else if (arg === "--agents") {
351
351
  agents = true;
352
352
  }
353
- else if (arg === "--guard-commit") {
354
- guardCommit = true;
353
+ else if (arg === "--commit-guard") {
354
+ commitGuard = true;
355
355
  }
356
356
  else if (arg === "--python-hook" || arg?.startsWith("--python-hook=")) {
357
357
  return {
@@ -362,7 +362,7 @@ function parseSetupArgs(args) {
362
362
  else if (arg === "--git-hooks") {
363
363
  return {
364
364
  exitCode: EXIT_USAGE,
365
- error: "dg setup --git-hooks moved — run 'dg guard-commit' inside the repo you want to protect.\n"
365
+ error: "dg setup --git-hooks moved — run 'dg commit-guard' inside the repo you want to protect.\n"
366
366
  };
367
367
  }
368
368
  else if (arg === "--shell") {
@@ -419,7 +419,7 @@ function parseSetupArgs(args) {
419
419
  shell,
420
420
  service,
421
421
  agents,
422
- guardCommit
422
+ commitGuard
423
423
  };
424
424
  }
425
425
  function parseShell(value) {
@@ -64,7 +64,7 @@ async function buildStatusReport(io) {
64
64
  async function fetchStatusUsage(connected, io) {
65
65
  const token = connected ? resolveStatusToken() : undefined;
66
66
  if (!token) {
67
- return { state: "anonymous" };
67
+ return { state: "signed-out" };
68
68
  }
69
69
  const account = await fetchAccountStatus(token, process.env, io.fetchImpl ?? fetch, io.usageTimeoutMs ?? STATUS_USAGE_TIMEOUT_MS);
70
70
  if (!account || account.scansUsed === null) {
@@ -131,7 +131,7 @@ function renderStatus(report, theme) {
131
131
  return `${lines.join("\n")}\n`;
132
132
  }
133
133
  function usageLine(report, theme) {
134
- if (report.usage.state === "anonymous") {
134
+ if (report.usage.state === "signed-out") {
135
135
  return theme.paint("muted", "sign in to see usage");
136
136
  }
137
137
  if (report.usage.state === "unavailable") {
@@ -147,7 +147,7 @@ function commitGuardLine(state, theme) {
147
147
  return `${theme.paint("pass", "✓")} this repo's commits are scanned`;
148
148
  }
149
149
  if (state === "dead") {
150
- return `${theme.paint("warn", "⚠")} installed but git isn't using it — run ${theme.paint("accent", "dg guard-commit --check")}`;
150
+ return `${theme.paint("warn", "⚠")} installed but git isn't using it — run ${theme.paint("accent", "dg commit-guard --check")}`;
151
151
  }
152
- return `${theme.paint("warn", "⚠")} off — run ${theme.paint("accent", "dg guard-commit")}`;
152
+ return `${theme.paint("warn", "⚠")} off — run ${theme.paint("accent", "dg commit-guard")}`;
153
153
  }
@@ -8,7 +8,7 @@ export const uninstallCommand = {
8
8
  usage: "dg uninstall [--yes] [--service] [--all] [--keep-config]",
9
9
  flags: [
10
10
  { flag: "--yes", summary: "Remove without the confirmation prompt." },
11
- { flag: "--keep-config", summary: "Keep ~/.dg config and cache." },
11
+ { flag: "--keep-config", summary: "Keep the saved login too (the default removes it; config.json is kept either way unless --all)." },
12
12
  { flag: "--all", summary: "Also remove the config directory — a full wipe." },
13
13
  { flag: "--service", summary: "Reverse only service-mode writes." }
14
14
  ],
@@ -1,7 +1,24 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { toolInvocation } from "../util/external-tool.js";
5
+ import { resolveDgPaths } from "../state/index.js";
3
6
  import { EXIT_USAGE } from "./types.js";
4
7
  import { dgVersion } from "./version.js";
8
+ // `dg update` only prints the npm command; the user runs the upgrade manually,
9
+ // during which dg is briefly unavailable. Drop a freshness-stamped sentinel the
10
+ // shim's fail-open self-cleanup honors (15-min window) so a slow `npm install -g`
11
+ // is not mistaken for an uninstall and self-cleaned mid-upgrade.
12
+ function markUpgradeWindow() {
13
+ try {
14
+ const dgHome = join(resolveDgPaths().homeDir, ".dg");
15
+ mkdirSync(dgHome, { recursive: true, mode: 0o700 });
16
+ writeFileSync(join(dgHome, ".upgrading"), "", { encoding: "utf8", mode: 0o600 });
17
+ }
18
+ catch {
19
+ // best-effort: a missing sentinel only restores the prior 60s-debounce behavior
20
+ }
21
+ }
5
22
  const PACKAGE_NAME = "@westbayberry/dg";
6
23
  const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/u;
7
24
  export const updateCommand = {
@@ -24,6 +41,9 @@ function runUpdateCommand(args, commandName) {
24
41
  }
25
42
  const latestVersion = readLatestVersion();
26
43
  const report = buildUpdateReport(latestVersion);
44
+ if (report.updateCommand) {
45
+ markUpgradeWindow();
46
+ }
27
47
  if (parsed.format === "json") {
28
48
  return {
29
49
  exitCode: report.status === "unknown" ? 1 : 0,
@@ -1,4 +1,4 @@
1
- import { EXIT_ANALYSIS_INCOMPLETE, EXIT_UNAVAILABLE, EXIT_USAGE_VERDICT } from "./types.js";
1
+ import { EXIT_ANALYSIS_INCOMPLETE, EXIT_TOOL_ERROR, EXIT_UNAVAILABLE, EXIT_USAGE_VERDICT } from "./types.js";
2
2
  import { writeFileSync } from "node:fs";
3
3
  import { existsSync } from "node:fs";
4
4
  import { resolve } from "node:path";
@@ -22,8 +22,8 @@ export const verifyCommand = {
22
22
  ],
23
23
  examples: ["dg verify npm:react", "dg verify pypi:requests@2.31.0", "dg verify ./pkg.tgz --verbose", "dg verify package-lock.json --json"],
24
24
  details: [
25
- "dg verify npm:react (or pypi:requests, with an optional @version — defaults to latest) runs a real scanner check on a published package before you install it. Signed-out runs show the verdict and top reasons; dg login unlocks full findings, license info, --json, and --output.",
26
- "Local paths, workspaces, tgz/zip/wheel artifacts, and lockfiles are verified offline as advisory preflight (free); proxy enforcement remains authoritative for network artifact fetches."
25
+ "dg verify npm:react (or pypi:requests, with an optional @version — defaults to latest) runs a real scanner check on a published package before you install it. Registry checks require sign-in run dg login.",
26
+ "Local paths, workspaces, tgz/zip/wheel artifacts, and lockfiles are verified offline as advisory preflight (no account needed); proxy enforcement remains authoritative for network artifact fetches."
27
27
  ],
28
28
  handler: (context) => {
29
29
  const parsed = parseVerifyArgs(context.args);
@@ -52,7 +52,7 @@ export const verifyCommand = {
52
52
  }
53
53
  catch (error) {
54
54
  return {
55
- exitCode: EXIT_ANALYSIS_INCOMPLETE,
55
+ exitCode: EXIT_TOOL_ERROR,
56
56
  stdout: "",
57
57
  stderr: `dg verify could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n`
58
58
  };
@@ -11,8 +11,10 @@ export const CONFIG_KEYS = Object.freeze([
11
11
  "policy.scriptHardening",
12
12
  "policy.shimFailClosed",
13
13
  "policy.strictEgress",
14
+ "policy.allowNonVendorApiHost",
15
+ "policy.provenanceDowngrade",
14
16
  "scriptGate.mode",
15
- "scriptGate.observe",
17
+ "scriptGate.persistObservations",
16
18
  "gitHook.onWarn",
17
19
  "gitHook.onIncomplete",
18
20
  "cooldown.age",
@@ -37,11 +39,13 @@ export const DEFAULT_CONFIG = Object.freeze({
37
39
  allowForceOverride: true,
38
40
  scriptHardening: false,
39
41
  shimFailClosed: false,
40
- strictEgress: false
42
+ strictEgress: false,
43
+ allowNonVendorApiHost: false,
44
+ provenanceDowngrade: "off"
41
45
  },
42
46
  scriptGate: {
43
47
  mode: "observe",
44
- observe: false
48
+ persistObservations: false
45
49
  },
46
50
  gitHook: {
47
51
  onWarn: "prompt",
@@ -146,11 +150,17 @@ export function getConfigValue(config, key) {
146
150
  if (key === "policy.strictEgress") {
147
151
  return String(config.policy.strictEgress);
148
152
  }
153
+ if (key === "policy.allowNonVendorApiHost") {
154
+ return String(config.policy.allowNonVendorApiHost);
155
+ }
156
+ if (key === "policy.provenanceDowngrade") {
157
+ return config.policy.provenanceDowngrade;
158
+ }
149
159
  if (key === "scriptGate.mode") {
150
160
  return config.scriptGate.mode;
151
161
  }
152
- if (key === "scriptGate.observe") {
153
- return String(config.scriptGate.observe);
162
+ if (key === "scriptGate.persistObservations") {
163
+ return String(config.scriptGate.persistObservations);
154
164
  }
155
165
  if (key === "gitHook.onWarn") {
156
166
  return config.gitHook.onWarn;
@@ -183,7 +193,8 @@ export const ADVANCED_CONFIG_KEYS = new Set([
183
193
  "policy.scriptHardening",
184
194
  "policy.shimFailClosed",
185
195
  "policy.strictEgress",
186
- "scriptGate.observe",
196
+ "policy.provenanceDowngrade",
197
+ "scriptGate.persistObservations",
187
198
  "cooldown.npm.age",
188
199
  "cooldown.pypi.age",
189
200
  "cooldown.cargo.age"
@@ -199,7 +210,7 @@ export function setConfigValue(config, key, rawValue) {
199
210
  return {
200
211
  ...config,
201
212
  api: {
202
- baseUrl: parseUrl(rawValue)
213
+ baseUrl: assertVendorApiBaseUrl(parseUrl(rawValue), config.policy.allowNonVendorApiHost)
203
214
  }
204
215
  };
205
216
  }
@@ -235,6 +246,25 @@ export function setConfigValue(config, key, rawValue) {
235
246
  if (key === "policy.strictEgress") {
236
247
  return withPolicyBoolean(config, "strictEgress", rawValue);
237
248
  }
249
+ if (key === "policy.allowNonVendorApiHost") {
250
+ const next = withPolicyBoolean(config, "allowNonVendorApiHost", rawValue);
251
+ if (!next.policy.allowNonVendorApiHost) {
252
+ const host = new URL(next.api.baseUrl).hostname;
253
+ if (!isVendorApiHost(host)) {
254
+ throw new ConfigError(`cannot disable policy.allowNonVendorApiHost while api.baseUrl points at the self-hosted host ${host}; reset api.baseUrl to a vendor host (or unset it) first`);
255
+ }
256
+ }
257
+ return next;
258
+ }
259
+ if (key === "policy.provenanceDowngrade") {
260
+ return {
261
+ ...config,
262
+ policy: {
263
+ ...config.policy,
264
+ provenanceDowngrade: parseProvenanceDowngradeMode(rawValue)
265
+ }
266
+ };
267
+ }
238
268
  if (key === "scriptGate.mode") {
239
269
  return {
240
270
  ...config,
@@ -244,12 +274,12 @@ export function setConfigValue(config, key, rawValue) {
244
274
  }
245
275
  };
246
276
  }
247
- if (key === "scriptGate.observe") {
277
+ if (key === "scriptGate.persistObservations") {
248
278
  return {
249
279
  ...config,
250
280
  scriptGate: {
251
281
  ...config.scriptGate,
252
- observe: parseBoolean(rawValue, key)
282
+ persistObservations: parseBoolean(rawValue, key)
253
283
  }
254
284
  };
255
285
  }
@@ -317,7 +347,7 @@ function normalizeConfig(raw) {
317
347
  const cooldown = fieldObject(raw, "cooldown");
318
348
  const audit = fieldObject(raw, "audit");
319
349
  const scriptHardening = fieldBoolean(policy, "policy.scriptHardening", "scriptHardening") ?? DEFAULT_CONFIG.policy.scriptHardening;
320
- return {
350
+ const normalized = {
321
351
  version: 1,
322
352
  api: {
323
353
  baseUrl: parseUrl(fieldString(api, "api.baseUrl", "baseUrl") ?? DEFAULT_CONFIG.api.baseUrl)
@@ -331,11 +361,15 @@ function normalizeConfig(raw) {
331
361
  allowForceOverride: fieldBoolean(policy, "policy.allowForceOverride", "allowForceOverride") ?? DEFAULT_CONFIG.policy.allowForceOverride,
332
362
  scriptHardening,
333
363
  shimFailClosed: fieldBoolean(policy, "policy.shimFailClosed", "shimFailClosed") ?? DEFAULT_CONFIG.policy.shimFailClosed,
334
- strictEgress: fieldBoolean(policy, "policy.strictEgress", "strictEgress") ?? DEFAULT_CONFIG.policy.strictEgress
364
+ strictEgress: fieldBoolean(policy, "policy.strictEgress", "strictEgress") ?? DEFAULT_CONFIG.policy.strictEgress,
365
+ allowNonVendorApiHost: fieldBoolean(policy, "policy.allowNonVendorApiHost", "allowNonVendorApiHost") ?? DEFAULT_CONFIG.policy.allowNonVendorApiHost,
366
+ provenanceDowngrade: parseProvenanceDowngradeMode(fieldString(policy, "policy.provenanceDowngrade", "provenanceDowngrade") ?? DEFAULT_CONFIG.policy.provenanceDowngrade)
335
367
  },
336
368
  scriptGate: {
337
369
  mode: parseScriptGateMode(fieldString(scriptGate, "scriptGate.mode", "mode") ?? (scriptHardening ? "enforce" : DEFAULT_CONFIG.scriptGate.mode)),
338
- observe: fieldBoolean(scriptGate, "scriptGate.observe", "observe") ?? DEFAULT_CONFIG.scriptGate.observe
370
+ persistObservations: fieldBoolean(scriptGate, "scriptGate.persistObservations", "persistObservations") ??
371
+ fieldBoolean(scriptGate, "scriptGate.observe", "observe") ??
372
+ DEFAULT_CONFIG.scriptGate.persistObservations
339
373
  },
340
374
  gitHook: {
341
375
  onWarn: parseOnWarn(fieldString(gitHook, "gitHook.onWarn", "onWarn") ?? DEFAULT_CONFIG.gitHook.onWarn),
@@ -353,6 +387,8 @@ function normalizeConfig(raw) {
353
387
  upload: fieldBoolean(audit, "audit.upload", "upload") ?? DEFAULT_CONFIG.audit.upload
354
388
  }
355
389
  };
390
+ assertVendorApiBaseUrl(normalized.api.baseUrl, normalized.policy.allowNonVendorApiHost);
391
+ return normalized;
356
392
  }
357
393
  function fieldObject(root, field) {
358
394
  const value = root[field];
@@ -417,6 +453,12 @@ function parsePolicyMode(value) {
417
453
  }
418
454
  throw new ConfigError("policy.mode must be one of: off, warn, block, strict");
419
455
  }
456
+ function parseProvenanceDowngradeMode(value) {
457
+ if (value === "off" || value === "warn" || value === "block") {
458
+ return value;
459
+ }
460
+ throw new ConfigError("policy.provenanceDowngrade must be one of: off, warn, block");
461
+ }
420
462
  function parseScriptGateMode(value) {
421
463
  if (value === "observe" || value === "enforce" || value === "off") {
422
464
  return value;
@@ -500,3 +542,30 @@ export function parseUrl(value) {
500
542
  throw new ConfigError("api.baseUrl must be an absolute URL");
501
543
  }
502
544
  }
545
+ // The vendor API host the CLI ships to — derived from the default so there is one
546
+ // source of truth, never a duplicated literal. localhost stays usable for dev.
547
+ export const VENDOR_API_HOST = new URL(DEFAULT_CONFIG.api.baseUrl).hostname;
548
+ export function isVendorApiHost(host) {
549
+ return host === VENDOR_API_HOST || host === "localhost" || host === "127.0.0.1";
550
+ }
551
+ // The CLI attaches a Bearer token to and trusts scan verdicts from this base URL,
552
+ // so a non-vendor host (a tampered config / auth file, an env-set login base, an
553
+ // unpinned MITM) would leak the token and could return action:pass for malware.
554
+ // Refuse it unless the operator opted into a self-hosted API in their own global
555
+ // config (policy.allowNonVendorApiHost) — a project/repo cannot set that field.
556
+ export function assertVendorApiBaseUrl(baseUrl, allowNonVendor) {
557
+ if (allowNonVendor) {
558
+ return baseUrl;
559
+ }
560
+ let host;
561
+ try {
562
+ host = new URL(baseUrl).hostname;
563
+ }
564
+ catch {
565
+ throw new ConfigError("api.baseUrl must be an absolute URL");
566
+ }
567
+ if (!isVendorApiHost(host)) {
568
+ throw new ConfigError(`api.baseUrl host ${host} is not a Dependency Guardian vendor host; set policy.allowNonVendorApiHost=true to use a self-hosted API`);
569
+ }
570
+ return baseUrl;
571
+ }
@@ -6,7 +6,7 @@ import { existsSync, readdirSync } from "node:fs";
6
6
  import { basename, join, resolve, sep } from "node:path";
7
7
  import { exportDestinations, resolveExportPath, userHomeDir, writeReportAtomic } from "../util/report-writer.js";
8
8
  export function loginRequiredToast() {
9
- return `${chalk.yellow("Sign in to export:")} ${chalk.cyan.bold("dg login")} ${chalk.dim("(free account)")}`;
9
+ return `${chalk.yellow("Sign in to export:")} ${chalk.cyan.bold("dg login")}`;
10
10
  }
11
11
  const PATH_DISPLAY_MAX = 48;
12
12
  function cursorBeforeExtension(name) {
@@ -0,0 +1,22 @@
1
+ import { cooldownRequestParam, isCooldownExemptByDgFile } from "../policy/cooldown.js";
2
+ import { DEFAULT_CONFIG, loadUserConfig } from "../config/settings.js";
3
+ export function resolveCooldownRequest(options, identity) {
4
+ if (identity.ecosystem === "unknown" || identity.version === "unknown") {
5
+ return undefined;
6
+ }
7
+ if (isCooldownExemptByDgFile(identity.name, identity.ecosystem, options.cooldownExemptions ?? [])) {
8
+ return undefined;
9
+ }
10
+ // A corrupt user config must not silently disable the cooldown gate — fall back
11
+ // to defaults (24h age + onUnknown=block) so the gate still applies, consistent
12
+ // with the rest of the policy stack. undefined stays reserved for the legitimate
13
+ // disabled/exempt cases cooldownRequestParam itself returns.
14
+ let config;
15
+ try {
16
+ config = loadUserConfig(options.env);
17
+ }
18
+ catch {
19
+ config = DEFAULT_CONFIG;
20
+ }
21
+ return cooldownRequestParam(config, options.env, identity.ecosystem, identity.name);
22
+ }
@@ -0,0 +1,185 @@
1
+ import { lookup as dnsLookup } from "node:dns";
2
+ import { hostMatchesList, testUpstreamHostMap } from "./host-match.js";
3
+ // Node's WHATWG URL parser canonicalizes an IPv4-mapped IPv6 literal to the hex
4
+ // hextet form ([::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe]), so a guard that
5
+ // only string-matches the dotted-decimal form silently lets the cloud-metadata
6
+ // IP through as "public". Recover the embedded IPv4 (both forms) before
7
+ // classifying so the IPv4 ranges below cover the mapped address too.
8
+ function hextetsToIpv4(hi, lo) {
9
+ const h = parseInt(hi, 16);
10
+ const l = parseInt(lo, 16);
11
+ return `${(h >> 8) & 0xff}.${h & 0xff}.${(l >> 8) & 0xff}.${l & 0xff}`;
12
+ }
13
+ // Recover an IPv4 address embedded in an IPv6 literal so the IPv4 ranges below
14
+ // also cover it. Covers IPv4-mapped (::ffff:), IPv4-compatible (::), 6to4
15
+ // (2002::/16), and NAT64 (64:ff9b::/96) — each a way to smuggle 169.254.169.254
16
+ // past a guard that only string-matches the dotted-decimal form.
17
+ function recoverIpv4MappedHost(host) {
18
+ const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
19
+ if (mappedDotted?.[1])
20
+ return mappedDotted[1];
21
+ const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
22
+ if (mappedHex?.[1] && mappedHex[2])
23
+ return hextetsToIpv4(mappedHex[1], mappedHex[2]);
24
+ const compatDotted = /^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
25
+ if (compatDotted?.[1])
26
+ return compatDotted[1];
27
+ const compatHex = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
28
+ if (compatHex?.[1] && compatHex[2])
29
+ return hextetsToIpv4(compatHex[1], compatHex[2]);
30
+ const sixToFour = /^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/i.exec(host);
31
+ if (sixToFour?.[1] && sixToFour[2])
32
+ return hextetsToIpv4(sixToFour[1], sixToFour[2]);
33
+ const nat64Dotted = /^64:ff9b::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
34
+ if (nat64Dotted?.[1])
35
+ return nat64Dotted[1];
36
+ const nat64Hex = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
37
+ if (nat64Hex?.[1] && nat64Hex[2])
38
+ return hextetsToIpv4(nat64Hex[1], nat64Hex[2]);
39
+ return host;
40
+ }
41
+ // Block a RESOLVED address (the actual connect target), so a public hostname
42
+ // whose A/AAAA record points at an internal/metadata IP — DNS-rebinding SSRF —
43
+ // is refused at connect time, not just literal-IP targets.
44
+ export function isBlockedResolvedIp(ip) {
45
+ const host = recoverIpv4MappedHost(ip.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""));
46
+ if (host === "0.0.0.0" || host === "::" || host === "::1") {
47
+ return true;
48
+ }
49
+ if (isPrivateIpv4(host)) {
50
+ return true;
51
+ }
52
+ return /^(fe80|f[cd][0-9a-f]{2}|ff[0-9a-f]{2}):/.test(host);
53
+ }
54
+ function guardedLookup(hostname, options, callback) {
55
+ const opts = typeof options === "object" && options !== null ? options : {};
56
+ dnsLookup(hostname, { ...opts, all: true }, (err, addresses) => {
57
+ if (err) {
58
+ callback(err, "", 0);
59
+ return;
60
+ }
61
+ const list = addresses;
62
+ const blocked = list.find((entry) => isBlockedResolvedIp(entry.address));
63
+ if (blocked) {
64
+ callback(new Error(`refusing to connect to ${hostname}: resolves to blocked address ${blocked.address}. If this is a private/tailnet registry you trust, add its host to DG_PRIVATE_REGISTRY_HOSTS.`), "", 0);
65
+ return;
66
+ }
67
+ if (opts.all === true) {
68
+ callback(null, list);
69
+ return;
70
+ }
71
+ const first = list[0];
72
+ if (!first) {
73
+ callback(new Error(`no address found for ${hostname}`), "", 0);
74
+ return;
75
+ }
76
+ callback(null, first.address, first.family);
77
+ });
78
+ }
79
+ // The resolved-IP guard exists for DNS rebinding — a PUBLIC hostname whose A/AAAA
80
+ // record points at an internal IP. It must not fire for: an explicitly-configured
81
+ // private registry (DG_PRIVATE_REGISTRY_HOSTS) whose name legitimately resolves to
82
+ // a private IP; a test host-map target; or a target that is ALREADY a private/
83
+ // loopback literal (the user/test pointed there directly — not rebinding, and the
84
+ // link-local/metadata literal is still blocked at the artifact path).
85
+ export function lookupForTarget(target, env) {
86
+ const host = target.hostname.replace(/^\[(.*)\]$/, "$1");
87
+ if (hostMatchesList(target.hostname, env.DG_PRIVATE_REGISTRY_HOSTS ?? "")) {
88
+ return undefined;
89
+ }
90
+ if (testUpstreamHostMap(env).has(host)) {
91
+ return undefined;
92
+ }
93
+ if (isPrivateNetworkHost(target)) {
94
+ return undefined;
95
+ }
96
+ return guardedLookup;
97
+ }
98
+ // The direct connect paths run the resolve-and-classify rebind guard via
99
+ // lookupForTarget's custom lookup. When an upstream HTTP/HTTPS proxy is used the
100
+ // proxy resolves the target itself, so that guard never runs — pre-resolve here
101
+ // and refuse a public target that resolves into a blocked range, mirroring the
102
+ // same exemptions (private registry / test host-map / already-private literal).
103
+ // Best-effort: a resolution failure surfaces later as the real connect error.
104
+ export async function assertTargetEgressAllowed(target, env) {
105
+ if (lookupForTarget(target, env) === undefined) {
106
+ return;
107
+ }
108
+ const host = target.hostname.replace(/^\[(.*)\]$/, "$1");
109
+ const addresses = await new Promise((resolve) => {
110
+ dnsLookup(host, { all: true }, (err, list) => {
111
+ resolve(err ? [] : list);
112
+ });
113
+ });
114
+ const blocked = addresses.find((entry) => isBlockedResolvedIp(entry.address));
115
+ if (blocked) {
116
+ throw new Error(`refusing to connect to ${host} via the upstream proxy: resolves to blocked address ${blocked.address}. If this is a private/tailnet registry you trust, add its host to DG_PRIVATE_REGISTRY_HOSTS.`);
117
+ }
118
+ }
119
+ // Non-globally-routable / special-purpose IPv4 ranges. None is ever a legitimate
120
+ // public registry, so a public hostname resolving into one is SSRF, and a direct
121
+ // literal target there is the user's own choice (handled by the callers). Covers
122
+ // RFC1918 + loopback + link-local + CGNAT, plus the special-purpose ranges that
123
+ // are still connectable: 198.18/15 benchmarking, 192.0.0/24 IETF, the TEST-NET
124
+ // documentation blocks, and multicast / reserved 224.0.0.0/3 (224-255).
125
+ function isPrivateIpv4(host) {
126
+ return (/^(127|10|0)\./.test(host) ||
127
+ /^192\.168\./.test(host) ||
128
+ /^169\.254\./.test(host) ||
129
+ /^172\.(1[6-9]|2[0-9]|3[01])\./.test(host) ||
130
+ /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./.test(host) ||
131
+ /^198\.1[89]\./.test(host) ||
132
+ /^192\.0\.0\./.test(host) ||
133
+ /^192\.0\.2\./.test(host) ||
134
+ /^198\.51\.100\./.test(host) ||
135
+ /^203\.0\.113\./.test(host) ||
136
+ /^(22[4-9]|23[0-9]|24[0-9]|25[0-5])\./.test(host));
137
+ }
138
+ export function isPrivateNetworkHost(url) {
139
+ const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
140
+ const host = recoverIpv4MappedHost(raw);
141
+ if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1") {
142
+ return true;
143
+ }
144
+ if (isPrivateIpv4(host)) {
145
+ return true;
146
+ }
147
+ return /^(fe80|f[cd][0-9a-f]{2}|ff[0-9a-f]{2}):/.test(host);
148
+ }
149
+ // Zero-expand a compressed IPv6 literal to its eight bare-hextet form so an
150
+ // exact metadata-address compare matches regardless of "::" placement. Returns
151
+ // the input unchanged for anything that is not a plain IPv6 literal.
152
+ function normalizeIpv6(host) {
153
+ if (host.includes(".") || !host.includes(":") || !/^[0-9a-f:]+$/i.test(host)) {
154
+ return host;
155
+ }
156
+ const sides = host.split("::");
157
+ if (sides.length > 2) {
158
+ return host;
159
+ }
160
+ const head = sides[0] ? sides[0].split(":") : [];
161
+ const tail = sides.length === 2 && sides[1] ? sides[1].split(":") : [];
162
+ let parts;
163
+ if (sides.length === 2) {
164
+ const missing = 8 - head.length - tail.length;
165
+ if (missing < 0)
166
+ return host;
167
+ parts = [...head, ...Array(missing).fill("0"), ...tail];
168
+ }
169
+ else {
170
+ parts = head;
171
+ }
172
+ if (parts.length !== 8) {
173
+ return host;
174
+ }
175
+ return parts.map((part) => parseInt(part, 16).toString(16)).join(":");
176
+ }
177
+ // The AWS IPv6 IMDS endpoint is a ULA (fd00::/8), so it is not caught by the
178
+ // fe80 link-local check; match the exact metadata literal only — broadening to
179
+ // all of fd00::/8 would refuse legitimate private/tailnet registries.
180
+ const IPV6_METADATA_HOST = "fd00:ec2:0:0:0:0:0:254";
181
+ export function isLinkLocalHost(url) {
182
+ const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
183
+ const host = recoverIpv4MappedHost(raw);
184
+ return /^169\.254\./.test(host) || /^fe80:/.test(host) || normalizeIpv6(host) === IPV6_METADATA_HOST;
185
+ }
@@ -0,0 +1,48 @@
1
+ export function hostMatchesList(host, rawList) {
2
+ const normalized = normalizeHost(host);
3
+ return rawList.split(",").map((entry) => entry.trim()).filter(Boolean).some((pattern) => hostMatchesPattern(normalized, pattern));
4
+ }
5
+ function hostMatchesPattern(host, pattern) {
6
+ const normalized = normalizeHost(pattern);
7
+ if (normalized.startsWith("*.")) {
8
+ const suffix = normalized.slice(1);
9
+ return host.endsWith(suffix) && host.length > suffix.length;
10
+ }
11
+ return host === normalized;
12
+ }
13
+ function normalizeHost(host) {
14
+ return host.replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "").toLowerCase();
15
+ }
16
+ export function testUpstreamHostMap(env) {
17
+ if (env.NODE_ENV !== "test") {
18
+ return new Map();
19
+ }
20
+ const entries = (env.DG_TEST_UPSTREAM_HOST_MAP ?? "")
21
+ .split(",")
22
+ .map((entry) => entry.trim())
23
+ .filter(Boolean)
24
+ .map((entry) => {
25
+ const separator = entry.indexOf("=");
26
+ if (separator <= 0) {
27
+ return null;
28
+ }
29
+ const target = entry.slice(separator + 1);
30
+ // The seam only exists to point a public host at a LOOPBACK mock in tests.
31
+ // Refusing any non-loopback target stops an attacker who can set
32
+ // NODE_ENV=test from redirecting a registry fetch to an external/internal
33
+ // host (SSRF) and from disabling the egress DNS guard for it.
34
+ if (!isLoopbackHost(target)) {
35
+ return null;
36
+ }
37
+ return [entry.slice(0, separator), target];
38
+ })
39
+ .filter((entry) => entry !== null);
40
+ return new Map(entries);
41
+ }
42
+ export function isLoopbackHost(value) {
43
+ const host = normalizeHost(value);
44
+ // Only the 127.0.0.0/8 IPv4 literal range is loopback — anchor to a full dotted
45
+ // quad so a hostname like 127.evil.com (which resolves anywhere) is NOT treated
46
+ // as loopback and cannot slip past the SSRF confinement.
47
+ return host === "localhost" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?$/.test(host);
48
+ }