@cdxgen/cdxgen 13.2.0 → 13.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +28 -12
  2. package/bin/cdxgen.js +227 -119
  3. package/bin/evinse.js +5 -0
  4. package/bin/sign.js +88 -14
  5. package/bin/tracebom.js +64 -4
  6. package/bin/validate.js +29 -7
  7. package/bin/verify.js +79 -39
  8. package/data/remediations.json +24 -0
  9. package/lib/cli/bomAssembly.js +105 -15
  10. package/lib/cli/cliOptions.js +93 -1
  11. package/lib/cli/index.js +43 -4
  12. package/lib/cli/jsBom.js +49 -24
  13. package/lib/cli/jvmBom.js +46 -12
  14. package/lib/cli/managedBom.js +112 -41
  15. package/lib/cli/nativeBom.js +125 -14
  16. package/lib/core/env.js +5 -6
  17. package/lib/core/fs.js +174 -6
  18. package/lib/core/metadataProperties.js +97 -0
  19. package/lib/core/projectConfig.js +31 -4
  20. package/lib/ecosystems/bunutils.js +269 -101
  21. package/lib/ecosystems/caxa.js +434 -2
  22. package/lib/ecosystems/cmakeResolver.js +66 -0
  23. package/lib/ecosystems/cppEvidence.js +64 -31
  24. package/lib/ecosystems/ecosystems.js +19 -8
  25. package/lib/ecosystems/gradleutils.js +42 -7
  26. package/lib/ecosystems/jsEvidence.js +19 -20
  27. package/lib/ecosystems/kosi.js +10 -3
  28. package/lib/ecosystems/npmutils.js +110 -9
  29. package/lib/ecosystems/parsers-dotnet.js +51 -10
  30. package/lib/ecosystems/parsers-go.js +47 -9
  31. package/lib/ecosystems/parsers-js.js +171 -50
  32. package/lib/ecosystems/parsers-misc.js +747 -161
  33. package/lib/ecosystems/parsers-python.js +26 -14
  34. package/lib/ecosystems/parsers-rust.js +24 -18
  35. package/lib/ecosystems/rubyutils.js +1426 -166
  36. package/lib/ecosystems/rusi.js +32 -1
  37. package/lib/evinser/evinser.js +436 -128
  38. package/lib/evinser/swiftsem.js +1914 -302
  39. package/lib/helpers/bomSigner.js +652 -221
  40. package/lib/inventory/atomUtils.js +568 -31
  41. package/lib/inventory/cdxrs.js +103 -12
  42. package/lib/inventory/depsUtils.js +183 -11
  43. package/lib/inventory/display.js +4 -5
  44. package/lib/inventory/dosai.js +550 -13
  45. package/lib/inventory/dynamic.js +39 -1
  46. package/lib/inventory/evidenceUtils.js +14 -6
  47. package/lib/inventory/fetchBatch.js +116 -17
  48. package/lib/inventory/protobom.js +8 -1
  49. package/lib/inventory/purl.js +152 -3
  50. package/lib/inventory/sbomDocument.js +2 -70
  51. package/lib/inventory/source.js +37 -19
  52. package/lib/inventory/traceRunner.js +210 -21
  53. package/lib/managers/binary.js +115 -24
  54. package/lib/managers/docker.js +440 -20
  55. package/lib/managers/oci.js +39 -1
  56. package/lib/parsers/cmakeLists.js +452 -0
  57. package/lib/parsers/largeJson.js +454 -0
  58. package/lib/parsers/npmrc.js +19 -1
  59. package/lib/stages/postgen/postgen.js +68 -15
  60. package/lib/validator/bomValidator.js +6 -16
  61. package/lib/validator/index.js +48 -10
  62. package/lib/validator/reporters/console.js +3 -0
  63. package/lib/validator/reporters/json.js +1 -0
  64. package/lib/validator/reporters/sarif.js +7 -0
  65. package/package.json +20 -20
  66. package/types/lib/cli/bomAssembly.d.ts +2 -1
  67. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  68. package/types/lib/cli/cliOptions.d.ts +33 -1
  69. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  70. package/types/lib/cli/index.d.ts.map +1 -1
  71. package/types/lib/cli/jsBom.d.ts.map +1 -1
  72. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  73. package/types/lib/cli/managedBom.d.ts.map +1 -1
  74. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  75. package/types/lib/core/env.d.ts.map +1 -1
  76. package/types/lib/core/fs.d.ts +55 -0
  77. package/types/lib/core/fs.d.ts.map +1 -1
  78. package/types/lib/core/metadataProperties.d.ts +39 -0
  79. package/types/lib/core/metadataProperties.d.ts.map +1 -0
  80. package/types/lib/core/projectConfig.d.ts +6 -2
  81. package/types/lib/core/projectConfig.d.ts.map +1 -1
  82. package/types/lib/ecosystems/bunutils.d.ts +12 -5
  83. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  84. package/types/lib/ecosystems/caxa.d.ts +14 -0
  85. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  86. package/types/lib/ecosystems/cmakeResolver.d.ts +23 -0
  87. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  88. package/types/lib/ecosystems/cppEvidence.d.ts +1 -1
  89. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  90. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  91. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  92. package/types/lib/ecosystems/gradleutils.d.ts +20 -4
  93. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  94. package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
  95. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  96. package/types/lib/ecosystems/kosi.d.ts +13 -0
  97. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  98. package/types/lib/ecosystems/npmutils.d.ts +42 -0
  99. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  100. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  101. package/types/lib/ecosystems/parsers-go.d.ts +10 -0
  102. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  103. package/types/lib/ecosystems/parsers-js.d.ts +10 -4
  104. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  105. package/types/lib/ecosystems/parsers-misc.d.ts +67 -3
  106. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  107. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  108. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  109. package/types/lib/ecosystems/pythonutils.d.ts +1 -1
  110. package/types/lib/ecosystems/rubyutils.d.ts +143 -2
  111. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  112. package/types/lib/ecosystems/rusi.d.ts +13 -0
  113. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  114. package/types/lib/evinser/evinser.d.ts +59 -9
  115. package/types/lib/evinser/evinser.d.ts.map +1 -1
  116. package/types/lib/evinser/swiftsem.d.ts +280 -15
  117. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  118. package/types/lib/helpers/bomSigner.d.ts +106 -13
  119. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  120. package/types/lib/inventory/atomUtils.d.ts +145 -3
  121. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  122. package/types/lib/inventory/cdxrs.d.ts +8 -3
  123. package/types/lib/inventory/cdxrs.d.ts.map +1 -1
  124. package/types/lib/inventory/depsUtils.d.ts +15 -0
  125. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  126. package/types/lib/inventory/display.d.ts.map +1 -1
  127. package/types/lib/inventory/dosai.d.ts +64 -1
  128. package/types/lib/inventory/dosai.d.ts.map +1 -1
  129. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  130. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  131. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  132. package/types/lib/inventory/plugins.d.ts.map +1 -1
  133. package/types/lib/inventory/protobom.d.ts.map +1 -1
  134. package/types/lib/inventory/purl.d.ts +71 -2
  135. package/types/lib/inventory/purl.d.ts.map +1 -1
  136. package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
  137. package/types/lib/inventory/source.d.ts.map +1 -1
  138. package/types/lib/inventory/traceRunner.d.ts +47 -1
  139. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  140. package/types/lib/managers/binary.d.ts +30 -1
  141. package/types/lib/managers/binary.d.ts.map +1 -1
  142. package/types/lib/managers/docker.d.ts +29 -0
  143. package/types/lib/managers/docker.d.ts.map +1 -1
  144. package/types/lib/managers/oci.d.ts +14 -0
  145. package/types/lib/managers/oci.d.ts.map +1 -1
  146. package/types/lib/parsers/cmakeLists.d.ts +76 -0
  147. package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
  148. package/types/lib/parsers/largeJson.d.ts +52 -0
  149. package/types/lib/parsers/largeJson.d.ts.map +1 -0
  150. package/types/lib/parsers/npmrc.d.ts +5 -0
  151. package/types/lib/parsers/npmrc.d.ts.map +1 -1
  152. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  153. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  154. package/types/lib/validator/index.d.ts +10 -1
  155. package/types/lib/validator/index.d.ts.map +1 -1
  156. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  157. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  158. package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
@@ -70,7 +70,6 @@ import {
70
70
  } from "../inventory/fetchBatch.js";
71
71
  import {
72
72
  applyPurl,
73
- encodeForPurl,
74
73
  genericPurl,
75
74
  nugetPurl,
76
75
  pypiPurl,
@@ -2500,8 +2499,8 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2500
2499
  }
2501
2500
  }
2502
2501
  const apkg = {
2503
- group: group ? encodeForPurl(group) : "",
2504
- name: name ? encodeForPurl(name) : "",
2502
+ group: group || "",
2503
+ name: name || "",
2505
2504
  version,
2506
2505
  purl,
2507
2506
  evidence: {
@@ -2631,16 +2630,28 @@ const METADATA_VERSION_PROP = "cdx:nuget:metadata_version";
2631
2630
 
2632
2631
  async function getNugetUrl() {
2633
2632
  const req = "https://api.nuget.org/v3/index.json";
2634
- const res = await cdxgenAgent.get(req, {
2635
- responseType: "json",
2636
- });
2637
- const urls = res.body.resources;
2633
+ const fallbackUrl = "https://api.nuget.org/v3/registration3/";
2634
+ let res;
2635
+ try {
2636
+ res = await cdxgenAgent.get(req, {
2637
+ responseType: "json",
2638
+ });
2639
+ } catch (err) {
2640
+ // Package lookups already degrade to unenriched components on error. An
2641
+ // unreachable service index used to reject here instead and abort the
2642
+ // whole BOM, so fall back to the well-known registration base.
2643
+ thoughtLog(
2644
+ `Unable to read the NuGet service index (${err.code || err.message}). Using ${fallbackUrl}.`,
2645
+ );
2646
+ return fallbackUrl;
2647
+ }
2648
+ const urls = res?.body?.resources || [];
2638
2649
  for (const resource of urls) {
2639
2650
  if (resource["@type"] === "RegistrationsBaseUrl/3.6.0") {
2640
2651
  return resource["@id"];
2641
2652
  }
2642
2653
  }
2643
- return "https://api.nuget.org/v3/registration3/";
2654
+ return fallbackUrl;
2644
2655
  }
2645
2656
 
2646
2657
  /**
@@ -41,19 +41,33 @@ function isJvmToolPinned() {
41
41
  /**
42
42
  * Method to return the gradle command to use.
43
43
  *
44
+ * Project-local wrapper scripts (`gradlew`) are attacker-controlled files in
45
+ * the scanned project, so they are only used when dependency tooling is
46
+ * allowed (`options.installDeps !== false`); otherwise the system Gradle is
47
+ * used.
48
+ *
44
49
  * @param {string} srcPath Path to look for gradlew wrapper
45
50
  * @param {string|null} rootPath Root directory to look for gradlew wrapper
51
+ * @param {Object} [options] CLI options (`installDeps` gates wrapper use)
46
52
  */
47
- export function getGradleCommand(srcPath, rootPath) {
53
+ export function getGradleCommand(srcPath, rootPath, options = {}) {
48
54
  let gradleCmd = "gradle";
49
55
  const pinnedToolSelected = isJvmToolPinned();
56
+ // Introspection mode exists to observe the project's own toolchain, so it
57
+ // keeps using wrappers even when dependency installation is disabled.
58
+ const allowProjectWrappers =
59
+ options.installDeps !== false || options.introspect === true;
50
60
 
51
61
  let findGradleFile = "gradlew";
52
62
  if (platform() === "win32") {
53
63
  findGradleFile = "gradlew.bat";
54
64
  }
55
65
 
56
- if (!pinnedToolSelected && safeExistsSync(join(srcPath, findGradleFile))) {
66
+ if (
67
+ allowProjectWrappers &&
68
+ !pinnedToolSelected &&
69
+ safeExistsSync(join(srcPath, findGradleFile))
70
+ ) {
57
71
  // Use local gradle wrapper if available
58
72
  // Enable execute permission
59
73
  try {
@@ -72,6 +86,7 @@ export function getGradleCommand(srcPath, rootPath) {
72
86
  reason: `Selected project-local Gradle wrapper ${gradleCmd}.`,
73
87
  });
74
88
  } else if (
89
+ allowProjectWrappers &&
75
90
  !pinnedToolSelected &&
76
91
  rootPath &&
77
92
  safeExistsSync(join(rootPath, findGradleFile))
@@ -380,8 +395,9 @@ export function executeParallelGradleProperties(
380
395
  dir,
381
396
  allProjectsStr,
382
397
  extraArgs = [],
398
+ options = {},
383
399
  ) {
384
- const gradleCmd = getGradleCommand(dir, null);
400
+ const gradleCmd = getGradleCommand(dir, null, options);
385
401
  const gradleArgs = buildGradleCommandArguments(
386
402
  extraArgs.concat(
387
403
  readEnvironmentVariable("GRADLE_ARGS")
@@ -1205,11 +1221,16 @@ export async function collectGradleDependencies(
1205
1221
  /**
1206
1222
  * Method to return the mill command to use.
1207
1223
  *
1224
+ * The project-local `mill` script is an attacker-controlled file in the
1225
+ * scanned project, so it is only used when dependency tooling is allowed
1226
+ * (`options.installDeps !== false`).
1227
+ *
1208
1228
  * @param {string} srcPath Path to look for mill wrapper
1229
+ * @param {Object} [options] CLI options (`installDeps` gates wrapper use)
1209
1230
  */
1210
- export function getMillCommand(srcPath) {
1231
+ export function getMillCommand(srcPath, options = {}) {
1211
1232
  let millCmd = `mill${platform() === "win32" ? ".bat" : ""}`;
1212
- if (safeExistsSync(join(srcPath, millCmd))) {
1233
+ if (options.installDeps !== false && safeExistsSync(join(srcPath, millCmd))) {
1213
1234
  // Use local mill wrapper if available
1214
1235
  // Enable execute permission
1215
1236
  try {
@@ -1226,15 +1247,24 @@ export function getMillCommand(srcPath) {
1226
1247
  /**
1227
1248
  * Method to return the maven command to use.
1228
1249
  *
1250
+ * Project-local wrapper scripts (`mvnw`) are attacker-controlled files in the
1251
+ * scanned project, so they are only used when dependency tooling is allowed
1252
+ * (`options.installDeps !== false`); otherwise the installed Maven is used.
1253
+ *
1229
1254
  * @param {string} srcPath Path to look for maven wrapper
1230
1255
  * @param {string} rootPath Root directory to look for maven wrapper
1256
+ * @param {Object} [options] CLI options (`installDeps` gates wrapper use)
1231
1257
  */
1232
- export function getMavenCommand(srcPath, rootPath) {
1258
+ export function getMavenCommand(srcPath, rootPath, options = {}) {
1233
1259
  let mavenCmd = "mvn";
1234
1260
  // Check if the wrapper script is both available and functional
1235
1261
  let isWrapperReady = false;
1236
1262
  let isWrapperFound = false;
1237
1263
  const pinnedToolSelected = isJvmToolPinned();
1264
+ // Introspection mode exists to observe the project's own toolchain, so it
1265
+ // keeps using wrappers even when dependency installation is disabled.
1266
+ const allowProjectWrappers =
1267
+ options.installDeps !== false || options.introspect === true;
1238
1268
  let findMavenFile = "mvnw";
1239
1269
  let mavenWrapperCmd = null;
1240
1270
  if (platform() === "win32") {
@@ -1247,7 +1277,11 @@ export function getMavenCommand(srcPath, rootPath) {
1247
1277
  }
1248
1278
  }
1249
1279
 
1250
- if (!pinnedToolSelected && safeExistsSync(join(srcPath, findMavenFile))) {
1280
+ if (
1281
+ allowProjectWrappers &&
1282
+ !pinnedToolSelected &&
1283
+ safeExistsSync(join(srcPath, findMavenFile))
1284
+ ) {
1251
1285
  // Use local maven wrapper if available
1252
1286
  // Enable execute permission
1253
1287
  try {
@@ -1267,6 +1301,7 @@ export function getMavenCommand(srcPath, rootPath) {
1267
1301
  reason: `Found Maven wrapper candidate ${mavenWrapperCmd}.`,
1268
1302
  });
1269
1303
  } else if (
1304
+ allowProjectWrappers &&
1270
1305
  !pinnedToolSelected &&
1271
1306
  rootPath &&
1272
1307
  safeExistsSync(join(rootPath, findMavenFile))
@@ -8,9 +8,9 @@
8
8
 
9
9
  import { join, resolve } from "node:path";
10
10
 
11
- import { DEBUG_MODE } from "../core/activity.js";
12
11
  import { safeExistsSync } from "../core/fs.js";
13
12
  import { createOccurrenceEvidence } from "../inventory/evidenceUtils.js";
13
+ import { isPkgTypeOnlyImport, setNpmTypeOnlyProperty } from "./npmutils.js";
14
14
  import { parsePkgJson } from "./parsers-js.js";
15
15
 
16
16
  const NPM_BIN_IMPORT_PREFIX = "cdx:npm:bin/";
@@ -109,8 +109,11 @@ function applyNpmBinCommandEvidence(pkg, allImports) {
109
109
  *
110
110
  * For each component, resolves its module aliases (including the deno jsr
111
111
  * specifier), records imported/exported modules as properties, attaches
112
- * occurrence evidence, and adjusts the component scope to "required" when the
113
- * package is used or "optional" when it is not.
112
+ * occurrence evidence, and promotes the component scope to "required" when the
113
+ * package is used. A package with no observed usage keeps the scope its
114
+ * manifest parser assigned: static import analysis cannot see packages loaded
115
+ * from configuration, bundler entrypoints, stylesheets, or plugins, so a
116
+ * missing import is not evidence that a package is optional.
114
117
  *
115
118
  * @param {Array<object>} pkgList Package components to enrich.
116
119
  * @param {object} allImports Map of import specifier to usage evidence objects.
@@ -128,10 +131,6 @@ export async function addEvidenceForImports(
128
131
  const impPkgs = Object.keys(allImports);
129
132
  const exportedPkgs = Object.keys(allExports);
130
133
  for (const pkg of pkgList) {
131
- if (impPkgs?.length) {
132
- // Assume that all packages are optional until we see an evidence
133
- pkg.scope = "optional";
134
- }
135
134
  const { group, name } = pkg;
136
135
  // Evidence belonging to a type must be associated with the package
137
136
  if (group === "@types") {
@@ -155,7 +154,6 @@ export async function addEvidenceForImports(
155
154
  aliases.push(jsrSpecifier);
156
155
  }
157
156
  }
158
- let isImported = false;
159
157
  for (const alias of aliases) {
160
158
  const isWasmAlias = /\.wasm([?#].*)?$/i.test(alias);
161
159
  const all_includes = impPkgs.filter(
@@ -213,11 +211,22 @@ export async function addEvidenceForImports(
213
211
  }
214
212
  // Identify all the imported modules of a component
215
213
  if (impPkgs.includes(alias) || all_includes.length) {
216
- isImported = true;
217
214
  let importedModules = new Set();
218
215
  let wasmExportedModules = new Set();
219
216
  const seenOccurrenceLocations = new Set();
220
- pkg.scope = "required";
217
+ // A package imported only for its types is erased at compile time.
218
+ // Scoping it excluded here, rather than when the BOM is assembled,
219
+ // keeps propagateRequiredScopeFromDependencies from promoting what it
220
+ // depends on. A package whose bin command is used is still required.
221
+ if (
222
+ isPkgTypeOnlyImport(allImports, group, name) &&
223
+ !getNpmBinCommandEvidenceKeys(allImports, pkg).length
224
+ ) {
225
+ pkg.scope = "excluded";
226
+ setNpmTypeOnlyProperty(pkg);
227
+ } else {
228
+ pkg.scope = "required";
229
+ }
221
230
  for (const subevidence of all_includes) {
222
231
  const evidences = allImports[subevidence];
223
232
  for (const evidence of evidences) {
@@ -273,16 +282,6 @@ export async function addEvidenceForImports(
273
282
  }
274
283
  break;
275
284
  }
276
- if (
277
- impPkgs?.length > 0 &&
278
- !isImported &&
279
- DEBUG_MODE &&
280
- pkg?.scope !== "optional"
281
- ) {
282
- console.debug(
283
- `\x1b[1;35mNotice: Package ${pkg.name} has no usage in code. Check if it is needed.\x1b[0m`,
284
- );
285
- }
286
285
  // Capture metadata such as description from local node_modules in deep mode
287
286
  if (deep && !pkg.description && pkg.properties) {
288
287
  let localNodeModulesPath;
@@ -11,7 +11,11 @@ import {
11
11
  safeSpawnSync,
12
12
  } from "../core/fs.js";
13
13
  import { resolvePluginBinary } from "../inventory/plugins.js";
14
- import { tryBuildPurl, tryParsePurl } from "../inventory/purl.js";
14
+ import {
15
+ parsePurlLoose,
16
+ tryBuildPurl,
17
+ tryParsePurl,
18
+ } from "../inventory/purl.js";
15
19
 
16
20
  /**
17
21
  * kosi — JS bridge to the Kotlin-native evidence binary (thirdparty/kosi in
@@ -46,7 +50,10 @@ export function kosiWorkspaceComponent(workspaceModule) {
46
50
  if (!workspaceModule?.purl) {
47
51
  return undefined;
48
52
  }
49
- const version = workspaceModule.purl.split("@")[1] || "unspecified";
53
+ // The version is read through the purl parser: cutting the text after `@`
54
+ // kept its escaping (`1.0.0%2Bbuild`) and any `?type=jar` qualifiers.
55
+ const version =
56
+ parsePurlLoose(workspaceModule.purl)?.version || "unspecified";
50
57
  const cleanName = (workspaceModule.name || "").replace(/^['"]|['"]+$/g, "");
51
58
  const purl =
52
59
  tryParsePurl(workspaceModule.purl) ||
@@ -59,7 +66,7 @@ export function kosiWorkspaceComponent(workspaceModule) {
59
66
  name: cleanName || workspaceModule.name,
60
67
  version,
61
68
  purl,
62
- "bom-ref": purl,
69
+ "bom-ref": decodeURIComponent(purl),
63
70
  };
64
71
  }
65
72
 
@@ -2,8 +2,10 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
 
4
4
  import { build, Purl } from "@cdxgen/cdx-purl";
5
+ import { clean } from "semver";
5
6
 
6
7
  import { safeExistsSync } from "../core/fs.js";
8
+ import { parsePurlLoose } from "../inventory/purl.js";
7
9
  import { parseNpmrc, parseNpmrcFromEnv } from "../parsers/npmrc.js";
8
10
 
9
11
  const npmPackageHydrationFields = [
@@ -84,6 +86,89 @@ export function setNpmPeerProperty(pkg) {
84
86
  addComponentProperty(pkg, "cdx:npm:package:peer", "true");
85
87
  }
86
88
 
89
+ /**
90
+ * Marks an npm component as imported only for its TypeScript types.
91
+ *
92
+ * @param {object} pkg Component object to annotate
93
+ * @returns {void}
94
+ */
95
+ export function setNpmTypeOnlyProperty(pkg) {
96
+ addComponentProperty(pkg, "cdx:npm:package:type-only", "true");
97
+ }
98
+
99
+ /**
100
+ * Clean an npm version the way `npm publish` does before it records a release.
101
+ *
102
+ * Publishing a directory runs the manifest through `npm pkg fix`
103
+ * (`fixVersionField` in `@npmcli/package-json`, which calls `semver.clean` in
104
+ * loose mode) and then through libnpmpublish, which calls `semver.clean` again
105
+ * in strict mode and records the result as the packument version, the
106
+ * dist-tag, the `_id` and the tarball file name. Semver build metadata
107
+ * (`1.0.0+build.5`), a leading `v` or `=`, surrounding whitespace and leading
108
+ * zeros are dropped; a prerelease is kept.
109
+ *
110
+ * The `package.json` inside the published tarball keeps the original string,
111
+ * and so do the lockfiles npm, pnpm, yarn and bun write for a project, a
112
+ * workspace member or a git, file or link dependency. The cleaned form is the
113
+ * version the npm registry knows the package by.
114
+ *
115
+ * @param {string} version Version as a manifest or lockfile records it
116
+ * @returns {string|null} The cleaned version, or null when npm would reject `version` as invalid semver
117
+ */
118
+ export function cleanNpmVersion(version) {
119
+ if (typeof version !== "string" || !version.trim()) {
120
+ return null;
121
+ }
122
+ const looseClean = clean(version, { loose: true });
123
+ if (!looseClean) {
124
+ return null;
125
+ }
126
+ return clean(looseClean);
127
+ }
128
+
129
+ /**
130
+ * Record the version npm would publish when it differs from the recorded one.
131
+ *
132
+ * Sets `cdx:npm:cleanedVersion` on an npm component whose version is valid
133
+ * semver but not in the form {@link cleanNpmVersion} produces, for example
134
+ * `1.0.0+build.5` (published as `1.0.0`). The component's `version` and `purl`
135
+ * keep the recorded string: that is what the manifest and lockfile say. A
136
+ * component whose version is already clean, or not semver at all (a git
137
+ * commit, a `file:` path), is left without the property, and a stale value from
138
+ * an earlier pass is replaced.
139
+ *
140
+ * @param {object} component Component or package object with `version` and `purl`
141
+ * @returns {object} The same component, for chaining
142
+ */
143
+ export function setNpmCleanedVersionProperty(component) {
144
+ if (!component || typeof component !== "object") {
145
+ return component;
146
+ }
147
+ const purl = component.purl;
148
+ if (typeof purl !== "string" || !purl.startsWith("pkg:npm/")) {
149
+ return component;
150
+ }
151
+ const cleaned = cleanNpmVersion(component.version);
152
+ const wanted = cleaned && cleaned !== component.version ? cleaned : undefined;
153
+ const existing = component.properties?.filter(
154
+ (property) => property?.name === "cdx:npm:cleanedVersion",
155
+ );
156
+ if (
157
+ (!existing?.length && !wanted) ||
158
+ (existing?.length === 1 && existing[0].value === wanted)
159
+ ) {
160
+ return component;
161
+ }
162
+ const properties = (component.properties || []).filter(
163
+ (property) => property?.name !== "cdx:npm:cleanedVersion",
164
+ );
165
+ if (wanted) {
166
+ properties.push({ name: "cdx:npm:cleanedVersion", value: wanted });
167
+ }
168
+ component.properties = properties;
169
+ return component;
170
+ }
171
+
87
172
  /**
88
173
  * Helper function to create a properly encoded workspace PURL
89
174
  *
@@ -130,14 +215,18 @@ export function findMatchingNpmWorkspace(workspacePackages, packageName) {
130
215
  return undefined;
131
216
  }
132
217
 
133
- const expectedEncodedPurl = createNpmWorkspacePurl(packageName);
134
- const simplePurl = `pkg:npm/${packageName}`;
135
-
136
- return workspacePackages.find(
137
- (workspacePackage) =>
138
- workspacePackage.startsWith(expectedEncodedPurl) ||
139
- workspacePackage.startsWith(simplePurl),
140
- );
218
+ // Compare the parsed coordinates: a prefix match on the purl text would let
219
+ // `ws` match `wsmember`, and the reference may be escaped or decoded.
220
+ return workspacePackages.find((workspacePackage) => {
221
+ const parts = parsePurlLoose(workspacePackage);
222
+ if (parts?.type !== "npm") {
223
+ return false;
224
+ }
225
+ const fullName = parts.namespace
226
+ ? `${parts.namespace}/${parts.name}`
227
+ : parts.name;
228
+ return fullName === packageName;
229
+ });
141
230
  }
142
231
 
143
232
  /**
@@ -201,6 +290,12 @@ export function collectNpmManifestSources(node) {
201
290
  return manifestSources;
202
291
  }
203
292
  for (const edge of node.edgesIn) {
293
+ // Arborist adds a `workspace` edge from the root to every member, with an
294
+ // absolute `file:` spec it builds from the checkout path. No manifest
295
+ // declares it, and copying it would put the local path into the BOM.
296
+ if (edge?.workspace || edge?.type === "workspace") {
297
+ continue;
298
+ }
204
299
  const manifestSource = classifyNpmManifestSource(edge?.spec);
205
300
  if (!manifestSource) {
206
301
  continue;
@@ -520,7 +615,9 @@ export function parsePnpmGitLockKey(lockKey) {
520
615
  * @returns {Object<string, object>} Map of lookup key to git package reference.
521
616
  */
522
617
  export function buildPnpmGitPkgRefs(packages, snapshots, npmrcConfig = {}) {
523
- const gitPkgRefs = {};
618
+ // A null prototype keeps inherited Object properties (such as `constructor`)
619
+ // from masquerading as lookup hits for keys that were never registered.
620
+ const gitPkgRefs = Object.create(null);
524
621
  const registerEntry = (lockKey, packageNode) => {
525
622
  const resolution = packageNode?.resolution;
526
623
  const parsed = parsePnpmGitLockKey(lockKey);
@@ -531,6 +628,10 @@ export function buildPnpmGitPkgRefs(packages, snapshots, npmrcConfig = {}) {
531
628
  return;
532
629
  }
533
630
  const { group, name, gitSpec, fullName, packageName } = parsed;
631
+ if (name === "__proto__" || packageName === "__proto__") {
632
+ // A crafted lock key cannot become a lookup entry under this name.
633
+ return;
634
+ }
534
635
  const version = packageNode?.version || resolution?.commit || "";
535
636
  const repo = resolution?.repo || "";
536
637
  const commit = resolution?.commit || "";
@@ -466,6 +466,21 @@ export function getPropertyGroupTextNodes(propsFiles) {
466
466
  return matches;
467
467
  }
468
468
 
469
+ /**
470
+ * Read the trimmed text of an MSBuild property from a parsed PropertyGroup.
471
+ *
472
+ * @param {Object} propertyGroup PropertyGroup parsed by xml2js in compact mode
473
+ * @param {string} key Property name
474
+ * @returns {string|undefined} The text, or undefined when absent or empty
475
+ */
476
+ function propertyGroupText(propertyGroup, key) {
477
+ const text = propertyGroup?.[key]?.[0]?._?.[0];
478
+ if (typeof text !== "string" || !text.trim().length) {
479
+ return undefined;
480
+ }
481
+ return text.trim();
482
+ }
483
+
469
484
  /**
470
485
  * Method to parse .csproj like xml files
471
486
  *
@@ -514,6 +529,35 @@ export function parseCsProjData(
514
529
  return pkgList;
515
530
  }
516
531
  const project = projects[0];
532
+ // SDK-style projects declare their version through the MSBuild properties
533
+ // that `dotnet pack` reads: PackageVersion, which defaults to Version, which
534
+ // in turn defaults to VersionPrefix plus an optional `-VersionSuffix`. A
535
+ // property may be set in any PropertyGroup, so they are collected first.
536
+ const sdkVersionProps = {};
537
+ for (const apg of project?.PropertyGroup || []) {
538
+ for (const key of [
539
+ "PackageVersion",
540
+ "Version",
541
+ "VersionPrefix",
542
+ "VersionSuffix",
543
+ ]) {
544
+ const value = propertyGroupText(apg, key);
545
+ if (value !== undefined) {
546
+ sdkVersionProps[key] = value;
547
+ }
548
+ }
549
+ }
550
+ let sdkVersion =
551
+ sdkVersionProps.PackageVersion ||
552
+ sdkVersionProps.Version ||
553
+ (sdkVersionProps.VersionPrefix
554
+ ? `${sdkVersionProps.VersionPrefix}${sdkVersionProps.VersionSuffix ? `-${sdkVersionProps.VersionSuffix}` : ""}`
555
+ : undefined);
556
+ // An MSBuild property reference such as $(BuildNumber) is only known at
557
+ // build time, so a version that still contains one is not reported.
558
+ if (sdkVersion?.includes("$(")) {
559
+ sdkVersion = undefined;
560
+ }
517
561
  // First make up a parentcomponent name based on the .csproj file name
518
562
  if (projFile) {
519
563
  parentComponent.name = basename(projFile).replaceAll(
@@ -571,10 +615,6 @@ export function parseCsProjData(
571
615
  if (outputType === "Library") {
572
616
  parentComponent.type = "library";
573
617
  }
574
- applyPurl(
575
- parentComponent,
576
- nugetPurl(parentComponent.name, parentComponent.version || "latest"),
577
- );
578
618
  // The MSBuild OutputType (Exe, WinExe, …) used to ride along as an
579
619
  // `output_type` purl qualifier, which nuget does not define — every such
580
620
  // purl was invalid. It is project metadata, so it belongs in properties.
@@ -924,21 +964,22 @@ export function parseCsProjData(
924
964
  }
925
965
  }
926
966
  }
927
- // If the parent still lacks a purl, add one based on the name and version
928
- if (parentComponent && !parentComponent.purl && parentComponent.name) {
967
+ if (sdkVersion) {
968
+ parentComponent.version = sdkVersion;
969
+ }
970
+ // The purl is built once every PropertyGroup has been read, so a version
971
+ // declared after OutputType is not lost.
972
+ if (parentComponent.name) {
929
973
  applyPurl(
930
974
  parentComponent,
931
975
  nugetPurl(parentComponent.name, parentComponent.version || "latest"),
932
976
  );
933
977
  }
934
- if (parentComponent?.purl) {
935
- parentComponent["bom-ref"] = parentComponent.purl;
936
- }
937
978
  let dependencies = [];
938
979
  if (parentComponent?.["bom-ref"]) {
939
980
  dependencies = [
940
981
  {
941
- ref: parentComponent.purl,
982
+ ref: parentComponent["bom-ref"],
942
983
  dependsOn: [...new Set(pkgList.map((p) => p["bom-ref"]))].sort(),
943
984
  },
944
985
  ];
@@ -7,7 +7,12 @@ import { Purl } from "@cdxgen/cdx-purl";
7
7
  import { DEBUG_MODE } from "../core/activity.js";
8
8
  import { shouldFetchLicense, shouldFetchVCS } from "../core/env.js";
9
9
  import { safeExistsSync } from "../core/fs.js";
10
- import { applyPurl, tryBuildPurl, tryParsePurl } from "../inventory/purl.js";
10
+ import {
11
+ applyPurl,
12
+ canonicalPurlFromLooseString,
13
+ tryBuildPurl,
14
+ tryParsePurl,
15
+ } from "../inventory/purl.js";
11
16
  import {
12
17
  _clearMetadataCache,
13
18
  getGoPkgLicense,
@@ -443,6 +448,23 @@ function _addGoComponentEvidence(component, goModFile, confidence = 0.8) {
443
448
  return component;
444
449
  }
445
450
 
451
+ /**
452
+ * Build the purl of one `module@version` token from `go mod graph` output.
453
+ *
454
+ * The version is written the way Go prints it, so `+incompatible` and the build
455
+ * metadata of a pseudo-version are unescaped. Reading the token strictly would
456
+ * reject it and leave a reference that keeps the module path's upper-case
457
+ * letters, which then never matches the lower-cased component and drops the
458
+ * dependency edge.
459
+ *
460
+ * @param {string} token Module path, optionally followed by `@version`
461
+ * @returns {Purl|null} Parsed purl, or null when the token forms no valid golang purl
462
+ */
463
+ function goModGraphTokenPurl(token) {
464
+ const canonical = canonicalPurlFromLooseString(`pkg:golang/${token}`);
465
+ return canonical ? Purl.parse(canonical) : null;
466
+ }
467
+
446
468
  /**
447
469
  * Parse go mod graph
448
470
  *
@@ -512,22 +534,20 @@ export async function parseGoModGraph(
512
534
  // cdx-purl requires a namespace. Use the raw ref when parsing fails.
513
535
  let sourceRefString;
514
536
  let sourceName = tmpA[0].split("@")[0];
515
- let sourcePurl = null;
516
- try {
517
- sourcePurl = Purl.parse(`pkg:golang/${tmpA[0]}`);
537
+ const sourcePurl = goModGraphTokenPurl(tmpA[0]);
538
+ if (sourcePurl) {
518
539
  sourceRefString = decodeURIComponent(sourcePurl.toString());
519
540
  sourceName = `${sourcePurl.namespace ? `${sourcePurl.namespace}/` : ""}${sourcePurl.name}`;
520
- } catch {
541
+ } else {
521
542
  sourceRefString = `pkg:golang/${tmpA[0]}`;
522
543
  }
523
544
  let dependsRefString;
524
545
  let dependsName = tmpA[1].split("@")[0];
525
- let dependsPurl = null;
526
- try {
527
- dependsPurl = Purl.parse(`pkg:golang/${tmpA[1]}`);
546
+ const dependsPurl = goModGraphTokenPurl(tmpA[1]);
547
+ if (dependsPurl) {
528
548
  dependsRefString = decodeURIComponent(dependsPurl.toString());
529
549
  dependsName = `${dependsPurl.namespace ? `${dependsPurl.namespace}/` : ""}${dependsPurl.name}`;
530
- } catch {
550
+ } else {
531
551
  dependsRefString = `pkg:golang/${tmpA[1]}`;
532
552
  }
533
553
  // Since go mod graph over-reports direct dependencies we use the existing list
@@ -683,6 +703,24 @@ export function parseGoModWhy(rawOutput) {
683
703
  return undefined;
684
704
  }
685
705
 
706
+ /**
707
+ * Reports whether `go mod why -m` output says the main module does not need
708
+ * the module. go prints `(main module does not need module X)`, or
709
+ * `(main module does not need to vendor module X)` with `-vendor`, and exits
710
+ * with status 0 either way.
711
+ *
712
+ * @param {string} rawOutput Output from go mod why
713
+ * @returns {boolean} True when go reports the module as not needed
714
+ */
715
+ export function isGoModWhyNotNeeded(rawOutput) {
716
+ if (typeof rawOutput !== "string") {
717
+ return false;
718
+ }
719
+ return rawOutput
720
+ .split("\n")
721
+ .some((line) => line.trim().startsWith("(main module does not need "));
722
+ }
723
+
686
724
  /**
687
725
  * Parse go sum data
688
726
  * @param {string} gosumData Content of go.sum