@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
@@ -22,13 +22,32 @@ export function selectUpstreamProxy(target, env) {
22
22
  redactedUrl: redactSecrets(url.toString())
23
23
  };
24
24
  }
25
+ // A stalled upstream proxy (TCP connect or CONNECT response never completing)
26
+ // would otherwise hang the whole install with no bound; cap the handshake.
27
+ const CONNECT_HANDSHAKE_TIMEOUT_MS = 30_000;
25
28
  export function connectViaUpstreamProxy(target, upstream, extraHeaders = []) {
26
29
  return new Promise((resolve, reject) => {
27
30
  const socket = connect(Number(upstream.url.port || "80"), upstream.url.hostname);
28
31
  const chunks = [];
32
+ let settled = false;
33
+ const timer = setTimeout(() => {
34
+ if (settled)
35
+ return;
36
+ settled = true;
37
+ socket.destroy();
38
+ reject(new Error("upstream proxy CONNECT handshake timed out"));
39
+ }, CONNECT_HANDSHAKE_TIMEOUT_MS);
40
+ timer.unref?.();
41
+ const settle = (action) => {
42
+ if (settled)
43
+ return;
44
+ settled = true;
45
+ clearTimeout(timer);
46
+ action();
47
+ };
29
48
  const fail = (error) => {
30
49
  socket.destroy();
31
- reject(error);
50
+ settle(() => reject(error));
32
51
  };
33
52
  socket.once("error", fail);
34
53
  socket.once("connect", () => {
@@ -50,7 +69,7 @@ export function connectViaUpstreamProxy(target, upstream, extraHeaders = []) {
50
69
  if (buffered.length > 64 * 1024) {
51
70
  socket.off("data", onData);
52
71
  socket.destroy();
53
- reject(new Error("upstream proxy CONNECT response exceeded the 64KiB header limit"));
72
+ settle(() => reject(new Error("upstream proxy CONNECT response exceeded the 64KiB header limit")));
54
73
  return;
55
74
  }
56
75
  const headerEnd = buffered.indexOf("\r\n\r\n");
@@ -63,13 +82,13 @@ export function connectViaUpstreamProxy(target, upstream, extraHeaders = []) {
63
82
  const statusLine = buffered.subarray(0, headerEnd).toString("latin1").split("\r\n")[0] ?? "";
64
83
  if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) {
65
84
  socket.destroy();
66
- reject(new Error(`upstream proxy CONNECT failed: ${statusLine}`));
85
+ settle(() => reject(new Error(`upstream proxy CONNECT failed: ${statusLine}`)));
67
86
  return;
68
87
  }
69
88
  if (head.length > 0) {
70
89
  socket.unshift(head);
71
90
  }
72
- resolve(socket);
91
+ settle(() => resolve(socket));
73
92
  });
74
93
  });
75
94
  }
@@ -3,6 +3,7 @@ import { startProductionHttpProxy } from "./server.js";
3
3
  import { parseForceOverrideRequest } from "./enforcement.js";
4
4
  import { cleanupSessionSync } from "../state/index.js";
5
5
  import { COOLDOWN_EXEMPTIONS_ENV, loadCooldownExemptionsFile } from "./cooldown-exemptions-file.js";
6
+ import { redactSecrets } from "../launcher/output-redaction.js";
6
7
  const PARENT_POLL_MS = 500;
7
8
  const sessionPath = process.argv[2];
8
9
  const apiBaseUrl = process.argv[3];
@@ -35,7 +36,7 @@ function shutdown() {
35
36
  function exitOnFatal(error) {
36
37
  const message = error instanceof Error ? error.message : String(error);
37
38
  try {
38
- process.stderr.write(`dg proxy worker: unexpected error — ${message}\n`);
39
+ process.stderr.write(`dg proxy worker: unexpected error — ${redactSecrets(message)}\n`);
39
40
  }
40
41
  catch {
41
42
  // stderr is gone; nothing left to report to.
@@ -43,7 +44,7 @@ function exitOnFatal(error) {
43
44
  close().finally(() => process.exit(1));
44
45
  }
45
46
  process.on("uncaughtException", exitOnFatal);
46
- process.on("unhandledRejection", exitOnFatal);
47
+ process.on("unhandledRejection", (reason) => exitOnFatal(reason));
47
48
  process.stdin.resume();
48
49
  process.stdin.on("end", shutdown);
49
50
  process.stdin.on("error", shutdown);
@@ -33,6 +33,12 @@ export function buildAuditFile(root, relPath) {
33
33
  symlinkEscapes,
34
34
  mode: stat.mode,
35
35
  read: () => {
36
+ // A symlink whose target escapes the package root would, if read, leak an
37
+ // arbitrary host file (e.g. /etc/passwd) into the audit — and into any
38
+ // redacted excerpt shown. Flag it, never follow it.
39
+ if (isSymlink && symlinkEscapes) {
40
+ return null;
41
+ }
36
42
  try {
37
43
  return readFileSync(absolute);
38
44
  }
@@ -1,5 +1,6 @@
1
1
  import { EXIT_TOOL_ERROR } from "../commands/types.js";
2
2
  import { leaveTui, tuiIsActive } from "../scan-ui/alt-screen.js";
3
+ import { redactSecrets } from "../launcher/output-redaction.js";
3
4
  const defaultIo = {
4
5
  writeStderr: (text) => {
5
6
  process.stderr.write(text);
@@ -22,7 +23,7 @@ export function exitOnFatal(error, io = defaultIo) {
22
23
  // screen restore must never mask the fatal report
23
24
  }
24
25
  try {
25
- io.writeStderr(`dg: unexpected error — ${message}\nRun 'dg doctor' to check your installation; remove ~/.dg/config.json if it is corrupted.\n`);
26
+ io.writeStderr(`dg: unexpected error — ${redactSecrets(message)}\nRun 'dg doctor' to check your installation; remove ~/.dg/config.json if it is corrupted.\n`);
26
27
  }
27
28
  catch {
28
29
  // stderr is gone; nothing left to report to.
@@ -42,7 +42,11 @@ export function sweepLegacyHooksOnVersionChange(env = process.env, version = dgV
42
42
  return false;
43
43
  }
44
44
  sweepLegacyPythonHooks(resolveDgPaths(env).homeDir, [], []);
45
- refreshSetupOnUpgrade(env);
45
+ if (refreshSetupOnUpgrade(env) === "lock-busy") {
46
+ // Don't record this version as refreshed; retry on the next run once the
47
+ // setup lock is free, so a momentary contention can't skip the upgrade.
48
+ return false;
49
+ }
46
50
  mkdirSync(dirname(marker), { recursive: true, mode: 0o700 });
47
51
  writeFileSync(marker, `${version}\n`, { encoding: "utf8", mode: 0o600 });
48
52
  return true;
@@ -71,11 +71,62 @@ export async function resolveShimFallback(env = process.env, argv = process.argv
71
71
  return null;
72
72
  }
73
73
  }
74
+ // An agent invokes `dg hook-exec <agent>` directly (no shim), so the shim
75
+ // fallback never applies. Exiting 1 with empty stdout reads as ALLOW to the
76
+ // agents we integrate, which would let an install run unscanned on an old-Node
77
+ // shell. Emit this agent's deny shape instead, so the firewall fails CLOSED.
78
+ export async function agentHookNodeGuardDeny(version, argv = process.argv) {
79
+ if (argv[2] !== "hook-exec") {
80
+ return null;
81
+ }
82
+ const agent = argv[3];
83
+ const blockedStderr = `dg hook: Node ${version} is below the required >=22.14.0; install blocked\n`;
84
+ try {
85
+ const [{ isAgentId, getAgent }, { ESCALATE }] = await Promise.all([
86
+ import("../agents/registry.js"),
87
+ import("../launcher/agent-check.js")
88
+ ]);
89
+ if (agent && isAgentId(agent)) {
90
+ const emitted = getAgent(agent).emitDecision({
91
+ decision: "deny",
92
+ reason: `dg cannot run the install firewall on Node ${version} (requires >=22.14.0), so the install is blocked. Upgrade Node, then retry. ${ESCALATE}`
93
+ });
94
+ return {
95
+ stdout: emitted.stdout,
96
+ stderr: blockedStderr,
97
+ exitCode: emitted.exitCode || 2
98
+ };
99
+ }
100
+ return {
101
+ stdout: "",
102
+ stderr: `dg hook-exec: Node ${version} is below >=22.14.0 and agent '${agent ?? ""}' is unknown; install blocked under the firewall — upgrade Node and dg\n`,
103
+ exitCode: 2
104
+ };
105
+ }
106
+ catch {
107
+ // The agent modules failed to load on this runtime. Still refuse: a non-zero
108
+ // exit with a block payload keeps the hook fail-closed rather than letting an
109
+ // unhandled rejection surface as a silent allow.
110
+ return {
111
+ stdout: JSON.stringify({ decision: "block", reason: `dg requires Node.js >=22.14.0; install blocked on Node ${version}` }),
112
+ stderr: blockedStderr,
113
+ exitCode: 2
114
+ };
115
+ }
116
+ }
74
117
  export async function assertCurrentNode() {
75
118
  const version = currentNodeVersion();
76
119
  if (isSupportedNode(version)) {
77
120
  return;
78
121
  }
122
+ const hookDeny = await agentHookNodeGuardDeny(version);
123
+ if (hookDeny) {
124
+ if (hookDeny.stdout) {
125
+ process.stdout.write(hookDeny.stdout);
126
+ }
127
+ process.stderr.write(hookDeny.stderr);
128
+ process.exit(hookDeny.exitCode);
129
+ }
79
130
  const fallback = await resolveShimFallback();
80
131
  if (fallback) {
81
132
  process.stderr.write(fallback.warning);
@@ -7,6 +7,7 @@ import { isCiEnv } from "../presentation/mode.js";
7
7
  import { createTheme } from "../presentation/theme.js";
8
8
  import { resolveDgPaths } from "../state/index.js";
9
9
  const UPDATE_THROTTLE_MS = 24 * 60 * 60 * 1000;
10
+ const UPDATE_FAILURE_BACKOFF_MS = 5 * 60 * 1000;
10
11
  const LOGIN_THROTTLE_MS = 7 * 24 * 60 * 60 * 1000;
11
12
  const LATEST_LOOKUP_TIMEOUT_MS = 1500;
12
13
  const SKIP_COMMANDS = new Set([
@@ -47,16 +48,21 @@ export function maybeShowNudges(args, options = {}) {
47
48
  const theme = createTheme(true);
48
49
  const lines = [];
49
50
  let stateChanged = false;
50
- if (due(state.updateCheckedAt, UPDATE_THROTTLE_MS, now)) {
51
+ if (due(state.updateCheckedAt, UPDATE_THROTTLE_MS, now) && due(state.updateCheckFailedAt, UPDATE_FAILURE_BACKOFF_MS, now)) {
51
52
  const latest = readLatestVersion(LATEST_LOOKUP_TIMEOUT_MS);
52
53
  if (latest) {
53
54
  state.updateCheckedAt = now.toISOString();
55
+ delete state.updateCheckFailedAt;
54
56
  state.updateLatest = latest;
55
57
  stateChanged = true;
56
58
  if (compareVersions(latest, dgVersion()) > 0) {
57
59
  lines.push(`${theme.paint("warn", "⚠")} ${theme.paint("muted", `Update available: ${dgVersion()} → ${latest}. Run`)} ${theme.paint("accent", "dg update")}${theme.paint("muted", ".")}`);
58
60
  }
59
61
  }
62
+ else {
63
+ state.updateCheckFailedAt = now.toISOString();
64
+ stateChanged = true;
65
+ }
60
66
  }
61
67
  if (due(state.loginNudgedAt, LOGIN_THROTTLE_MS, now) && !isAuthenticated(env)) {
62
68
  state.loginNudgedAt = now.toISOString();
@@ -108,6 +114,26 @@ export function pendingUpdate(env = process.env) {
108
114
  }
109
115
  return { current: dgVersion(), latest };
110
116
  }
117
+ // Surface the cached pending update on a non-TTY path (agent hook, install
118
+ // wrapper) where maybeShowNudges' live lookup never runs. Reads the cache only —
119
+ // no network — and throttles to once per window so it doesn't print on every
120
+ // screened command. Returns "" when there's nothing to show.
121
+ export function maybeUpdateNudge(options = {}) {
122
+ const env = options.env ?? process.env;
123
+ const now = options.now ?? new Date();
124
+ const update = pendingUpdate(env);
125
+ if (!update) {
126
+ return "";
127
+ }
128
+ const statePath = nudgeStatePath(env);
129
+ const state = readState(statePath);
130
+ if (!due(state.updateNudgedAt, UPDATE_THROTTLE_MS, now)) {
131
+ return "";
132
+ }
133
+ state.updateNudgedAt = now.toISOString();
134
+ writeState(statePath, state);
135
+ return `\n dg ${update.current} → ${update.latest} available — run dg update\n`;
136
+ }
111
137
  function isAuthenticated(env) {
112
138
  try {
113
139
  return authStatus(env).authenticated;
@@ -1,7 +1,9 @@
1
1
  import { join } from "node:path";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { parseLockfilePackages } from "../verify/preflight.js";
4
+ import { sanitizeLine } from "../security/sanitize.js";
4
5
  import { normalizePypiName } from "../policy/cooldown.js";
6
+ import { canonicalSpdxId } from "./spdx.js";
5
7
  export function collectSbomComponents(projects) {
6
8
  const seen = new Set();
7
9
  const droppedSeen = new Set();
@@ -11,7 +13,7 @@ export function collectSbomComponents(projects) {
11
13
  const parsed = parseLockfilePackages(join(project.path, project.depFile));
12
14
  for (const identity of parsed.packages) {
13
15
  if (identity.ecosystem === "unknown" || !identity.version) {
14
- const label = `${identity.ecosystem}:${identity.name}`;
16
+ const label = sanitizeLine(`${identity.ecosystem}:${identity.name}`);
15
17
  if (identity.name.length > 0 && !droppedSeen.has(label)) {
16
18
  droppedSeen.add(label);
17
19
  dropped.push(label);
@@ -30,21 +32,76 @@ export function collectSbomComponents(projects) {
30
32
  return { components, dropped };
31
33
  }
32
34
  export function readRootComponent(root) {
35
+ return npmRootComponent(root) ?? cargoRootComponent(root) ?? pyprojectRootComponent(root) ?? {};
36
+ }
37
+ // name/version reach the SBOM summary subject on the terminal; strip control bytes
38
+ // so a hostile repo manifest can't inject ANSI into the rendered subject line.
39
+ function npmRootComponent(root) {
33
40
  const manifest = join(root, "package.json");
34
41
  if (!existsSync(manifest)) {
35
- return {};
42
+ return null;
36
43
  }
37
44
  try {
38
45
  const parsed = JSON.parse(readFileSync(manifest, "utf8"));
39
46
  if (typeof parsed.name !== "string" || parsed.name.length === 0) {
40
47
  return {};
41
48
  }
42
- return { component: { name: parsed.name, ...(typeof parsed.version === "string" ? { version: parsed.version } : {}) } };
49
+ return {
50
+ component: {
51
+ name: sanitizeLine(parsed.name),
52
+ ecosystem: "npm",
53
+ ...(typeof parsed.version === "string" ? { version: sanitizeLine(parsed.version) } : {}),
54
+ ...(typeof parsed.license === "string" && parsed.license.length > 0 ? { license: parsed.license } : {})
55
+ }
56
+ };
43
57
  }
44
58
  catch {
45
59
  return { malformed: true };
46
60
  }
47
61
  }
62
+ function cargoRootComponent(root) {
63
+ return tomlManifestComponent(join(root, "Cargo.toml"), "package", "cargo");
64
+ }
65
+ function pyprojectRootComponent(root) {
66
+ const manifest = join(root, "pyproject.toml");
67
+ if (!existsSync(manifest)) {
68
+ return null;
69
+ }
70
+ return tomlManifestComponent(manifest, "project", "pypi") ?? tomlManifestComponent(manifest, "tool.poetry", "pypi");
71
+ }
72
+ function tomlManifestComponent(manifest, table, ecosystem) {
73
+ if (!existsSync(manifest)) {
74
+ return null;
75
+ }
76
+ let section;
77
+ try {
78
+ section = tomlTable(readFileSync(manifest, "utf8"), table);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ const name = section ? tomlField(section, "name") : null;
84
+ if (!section || !name) {
85
+ return null;
86
+ }
87
+ const version = tomlField(section, "version");
88
+ const license = tomlField(section, "license");
89
+ return {
90
+ component: {
91
+ name: sanitizeLine(name),
92
+ ecosystem,
93
+ ...(version ? { version: sanitizeLine(version) } : {}),
94
+ ...(license ? { license } : {})
95
+ }
96
+ };
97
+ }
98
+ function tomlTable(text, table) {
99
+ const escaped = table.replace(/[.]/gu, "\\.");
100
+ return new RegExp(`^\\s*\\[${escaped}\\]([\\s\\S]*?)(?=^\\s*\\[|$)`, "mu").exec(text)?.[1] ?? null;
101
+ }
102
+ function tomlField(section, key) {
103
+ return new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "mu").exec(section)?.[1] ?? null;
104
+ }
48
105
  export function buildCycloneDxSbom(components, opts) {
49
106
  const byRef = new Map();
50
107
  for (const component of components) {
@@ -54,26 +111,36 @@ export function buildCycloneDxSbom(components, opts) {
54
111
  }
55
112
  }
56
113
  const mapped = [...byRef.values()].sort((a, b) => compareCodePoints(a["bom-ref"], b["bom-ref"]));
114
+ const dependencies = opts.dependencies ?? [];
57
115
  return {
116
+ $schema: "http://cyclonedx.org/schema/bom-1.5.schema.json",
58
117
  bomFormat: "CycloneDX",
59
118
  specVersion: "1.5",
60
119
  serialNumber: opts.serialNumber,
61
120
  version: 1,
62
121
  metadata: {
63
122
  ...(opts.timestamp ? { timestamp: opts.timestamp } : {}),
123
+ lifecycles: [{ phase: "build" }],
64
124
  tools: [{ vendor: "WestBayBerry", name: "dg", version: opts.toolVersion }],
65
- ...(opts.rootComponent
66
- ? {
67
- component: {
68
- type: "application",
69
- "bom-ref": "root",
70
- name: opts.rootComponent.name,
71
- ...(opts.rootComponent.version ? { version: opts.rootComponent.version } : {})
72
- }
73
- }
74
- : {})
125
+ authors: [{ name: "dg (WestBayBerry)" }],
126
+ supplier: { name: "WestBayBerry", url: ["https://westbayberry.com"] },
127
+ ...(opts.rootComponent ? { component: rootComponentOf(opts.rootComponent) } : {})
75
128
  },
76
- components: mapped
129
+ components: mapped,
130
+ ...(dependencies.length > 0 ? { dependencies } : {}),
131
+ ...(opts.completeness ? { compositions: [{ aggregate: opts.completeness }] } : {})
132
+ };
133
+ }
134
+ function rootComponentOf(root) {
135
+ const purl = root.ecosystem && root.version ? purlForParts(root.ecosystem, root.name, root.version, null) : null;
136
+ const licenses = licensesFor(root.license);
137
+ return {
138
+ type: "application",
139
+ "bom-ref": "root",
140
+ name: root.name,
141
+ ...(root.version ? { version: root.version } : {}),
142
+ ...(purl ? { purl } : {}),
143
+ ...(licenses ? { licenses } : {})
77
144
  };
78
145
  }
79
146
  function compareCodePoints(a, b) {
@@ -95,31 +162,51 @@ function canonicalPurlName(ecosystem, name) {
95
162
  }
96
163
  return name;
97
164
  }
98
- // The declared license string is emitted verbatim as a CycloneDX `license.name`,
99
- // which is always schema-valid for any free text. We deliberately do NOT classify
100
- // it into the SPDX `expression` form: correctly distinguishing a valid SPDX
101
- // expression from free text requires validating operands against the SPDX license
102
- // registry, and any heuristic shortcut emits schema-invalid expressions for some
103
- // inputs (unbalanced parens, operator-only strings, unregistered ids).
104
165
  function toCycloneDxComponent(component) {
105
166
  const purl = purlFor(component);
106
167
  const hashes = hashesFor(component.integrity);
107
- const license = component.license?.trim();
108
- const licenses = license ? [{ license: { name: license } }] : undefined;
168
+ const licenses = licensesFor(component.license);
169
+ const externalReferences = distributionReference(component.resolvedUrl);
109
170
  return {
110
171
  type: "library",
111
172
  "bom-ref": bomRefOf(component),
112
- name: component.name,
113
- version: component.version,
173
+ name: sanitizeLine(component.name),
174
+ version: sanitizeLine(component.version),
114
175
  ...(purl ? { purl } : {}),
115
176
  ...(licenses ? { licenses } : {}),
116
- ...(hashes.length > 0 ? { hashes } : {})
177
+ ...(hashes.length > 0 ? { hashes } : {}),
178
+ ...(externalReferences ? { externalReferences } : {})
117
179
  };
118
180
  }
181
+ // An exact-match SPDX id becomes the machine-readable license.id; everything else
182
+ // stays free-text license.name. A compound expression is never synthesized: only
183
+ // validating operands against the SPDX registry avoids emitting an invalid id, so
184
+ // the conservative id-or-name split keeps every license object schema-valid. name
185
+ // is lockfile-controlled so it is sanitized; a promoted id is from our allowlist.
186
+ function licensesFor(declared) {
187
+ const license = declared?.trim();
188
+ if (!license) {
189
+ return undefined;
190
+ }
191
+ const id = canonicalSpdxId(license);
192
+ return id ? [{ license: { id } }] : [{ license: { name: sanitizeLine(license) } }];
193
+ }
194
+ function distributionReference(resolvedUrl) {
195
+ if (!resolvedUrl || !/^https?:\/\//iu.test(resolvedUrl)) {
196
+ return undefined;
197
+ }
198
+ return [{ type: "distribution", url: sanitizeLine(resolvedUrl) }];
199
+ }
119
200
  export function purlFor(component) {
120
- const version = encodeURIComponent(component.version);
121
- if (component.ecosystem === "npm") {
122
- const lower = component.name.toLowerCase();
201
+ return purlForParts(component.ecosystem, component.name, component.version, component.resolvedUrl);
202
+ }
203
+ export function componentRef(parts) {
204
+ return purlForParts(parts.ecosystem, parts.name, parts.version, parts.resolvedUrl ?? null) ?? `${parts.ecosystem}:${parts.name}@${parts.version}`;
205
+ }
206
+ function purlForParts(ecosystem, name, rawVersion, resolvedUrl) {
207
+ const version = encodeURIComponent(rawVersion);
208
+ if (ecosystem === "npm") {
209
+ const lower = name.toLowerCase();
123
210
  if (lower.startsWith("@") && lower.includes("/")) {
124
211
  const slash = lower.indexOf("/");
125
212
  const scope = lower.slice(1, slash);
@@ -128,12 +215,12 @@ export function purlFor(component) {
128
215
  }
129
216
  return `pkg:npm/${encodeURIComponent(lower)}@${version}`;
130
217
  }
131
- if (component.ecosystem === "pypi") {
132
- return `pkg:pypi/${encodeURIComponent(normalizePypiName(component.name))}@${version}`;
218
+ if (ecosystem === "pypi") {
219
+ return `pkg:pypi/${encodeURIComponent(normalizePypiName(name))}@${version}`;
133
220
  }
134
- if (component.ecosystem === "cargo") {
135
- const base = `pkg:cargo/${encodeURIComponent(component.name.toLowerCase())}@${version}`;
136
- const repository = cargoAlternateRegistry(component.resolvedUrl);
221
+ if (ecosystem === "cargo") {
222
+ const base = `pkg:cargo/${encodeURIComponent(name.toLowerCase())}@${version}`;
223
+ const repository = cargoAlternateRegistry(resolvedUrl);
137
224
  return repository ? `${base}?repository_url=${encodeURIComponent(repository)}` : base;
138
225
  }
139
226
  return null;