@cdxgen/cdxgen 13.6.0 → 13.7.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 (264) hide show
  1. package/README.md +15 -8
  2. package/bin/audit.js +7 -4
  3. package/bin/cdxgen.js +256 -37
  4. package/bin/convert.js +146 -22
  5. package/bin/evinse.js +25 -3
  6. package/bin/repl.js +47 -3
  7. package/bin/sign.js +211 -34
  8. package/bin/tracebom.js +18 -6
  9. package/bin/validate.js +18 -3
  10. package/bin/verify.js +113 -5
  11. package/data/behavior-taxonomy.schema.json +1491 -0
  12. package/data/cyclonedx-2.0-bundled.schema.json +18606 -5528
  13. package/data/cyclonedx-2.0-cryptography-defs.schema.json +857 -0
  14. package/data/pqc-migration.json +239 -0
  15. package/data/remediations.json +52 -0
  16. package/data/rules/cbom-security.yaml +4 -3
  17. package/data/rules/package-integrity.yaml +28 -0
  18. package/lib/audit/index.js +25 -3
  19. package/lib/cli/bomAssembly.js +80 -1
  20. package/lib/cli/cliOptions.js +121 -17
  21. package/lib/cli/index.js +609 -898
  22. package/lib/cli/jsBom.js +3 -0
  23. package/lib/cli/jvmBom.js +365 -40
  24. package/lib/cli/managedBom.js +19 -3
  25. package/lib/cli/nativeBom.js +10 -0
  26. package/lib/core/env.js +26 -0
  27. package/lib/core/fs.js +126 -3
  28. package/lib/core/globPatterns.js +218 -0
  29. package/lib/core/processTree.js +99 -12
  30. package/lib/core/projectConfig.js +332 -25
  31. package/lib/core/toolRegistry.js +15 -1
  32. package/lib/ecosystems/denoutils.js +2 -2
  33. package/lib/ecosystems/golem.js +3 -8
  34. package/lib/ecosystems/gradleutils.js +1082 -6
  35. package/lib/ecosystems/jsEvidence.js +90 -38
  36. package/lib/ecosystems/kosi.js +3 -5
  37. package/lib/ecosystems/npmVersionResolution.js +376 -0
  38. package/lib/ecosystems/parsers-python.js +89 -28
  39. package/lib/ecosystems/parsers-rust.js +117 -15
  40. package/lib/ecosystems/remote/huggingface.js +22 -0
  41. package/lib/ecosystems/rusi.js +2 -4
  42. package/lib/ecosystems/scalasem.js +2 -1
  43. package/lib/evinser/evinser.js +196 -34
  44. package/lib/evinser/scalasem.js +5 -1
  45. package/lib/helpers/agenticToolutils.js +112 -1
  46. package/lib/helpers/bomSigner.js +43 -9
  47. package/lib/helpers/jssSigner.js +1276 -0
  48. package/lib/inventory/agentBlueprint.js +193 -0
  49. package/lib/inventory/agentToolNames.js +145 -0
  50. package/lib/inventory/aiCollector.js +153 -7
  51. package/lib/inventory/aiInventory.js +44 -8
  52. package/lib/inventory/aiProviders.js +324 -0
  53. package/lib/inventory/bomUtils.js +8 -1
  54. package/lib/inventory/ciParsers/githubActionsLock.js +821 -0
  55. package/lib/inventory/citations.js +31 -2
  56. package/lib/inventory/communityAiConfigParser.js +978 -80
  57. package/lib/inventory/cyclonedx20.js +152 -0
  58. package/lib/inventory/cyclonedx20Common.js +286 -0
  59. package/lib/inventory/cyclonedx20Derived.js +159 -0
  60. package/lib/inventory/cyclonedx20Parties.js +552 -0
  61. package/lib/inventory/cyclonedx20Read.js +768 -0
  62. package/lib/inventory/cyclonedx20ReadCommon.js +374 -0
  63. package/lib/inventory/cyclonedx20ReadComponents.js +692 -0
  64. package/lib/inventory/cyclonedx20ReadContext.js +380 -0
  65. package/lib/inventory/cyclonedx20ReadEvidence.js +466 -0
  66. package/lib/inventory/cyclonedx20ReadSections.js +599 -0
  67. package/lib/inventory/cyclonedx20Render.js +766 -0
  68. package/lib/inventory/cyclonedx20RenderComponents.js +716 -0
  69. package/lib/inventory/cyclonedx20RenderDefinitions.js +405 -0
  70. package/lib/inventory/cyclonedx20RenderEvidence.js +726 -0
  71. package/lib/inventory/cyclonedx20RenderHardware.js +203 -0
  72. package/lib/inventory/cyclonedx20RenderPatents.js +174 -0
  73. package/lib/inventory/cyclonedx20RenderRefIds.js +314 -0
  74. package/lib/inventory/cyclonedx20RenderSections.js +499 -0
  75. package/lib/inventory/cyclonedx20Tables.js +594 -0
  76. package/lib/inventory/deps.js +106 -13
  77. package/lib/inventory/dosai.js +236 -1
  78. package/lib/inventory/dosaiParsers.js +3 -6
  79. package/lib/inventory/dotnetRuntime.js +164 -14
  80. package/lib/inventory/ecosystemFormulationParsers.js +979 -0
  81. package/lib/inventory/envcontext.js +132 -38
  82. package/lib/inventory/evidenceUtils.js +2 -1
  83. package/lib/inventory/formulationParsers.js +95 -13
  84. package/lib/inventory/hbom.js +45 -6
  85. package/lib/inventory/hbomLoader.js +166 -0
  86. package/lib/inventory/mcpConfigParser.js +5 -0
  87. package/lib/inventory/occurrenceKey.js +90 -0
  88. package/lib/inventory/pqcMigration.js +426 -0
  89. package/lib/inventory/rustFormulationParser.js +21 -10
  90. package/lib/inventory/source.js +65 -6
  91. package/lib/parsers/huggingfaceManifest.js +9 -1
  92. package/lib/parsers/huggingfaceUtils.js +18 -0
  93. package/lib/parsers/kiroPermissions.js +166 -0
  94. package/lib/server/server.js +36 -34
  95. package/lib/stages/postgen/postgen.js +41 -1
  96. package/lib/stages/postgen/specVersionCompat.js +76 -0
  97. package/lib/validator/bomValidator.js +8 -1
  98. package/lib/validator/complianceEngine.js +30 -3
  99. package/lib/validator/complianceRules.js +1416 -4
  100. package/lib/validator/generated/validate-2.0.mjs +57554 -13851
  101. package/lib/validator/index.js +48 -7
  102. package/lib/validator/reporters/json.js +6 -0
  103. package/package.json +9 -9
  104. package/types/bin/repl.d.ts.map +1 -1
  105. package/types/lib/audit/index.d.ts +6 -2
  106. package/types/lib/audit/index.d.ts.map +1 -1
  107. package/types/lib/cli/bomAssembly.d.ts +16 -0
  108. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  109. package/types/lib/cli/cliOptions.d.ts +6 -0
  110. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  111. package/types/lib/cli/index.d.ts.map +1 -1
  112. package/types/lib/cli/jsBom.d.ts +2 -1
  113. package/types/lib/cli/jsBom.d.ts.map +1 -1
  114. package/types/lib/cli/jvmBom.d.ts +15 -0
  115. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  116. package/types/lib/cli/managedBom.d.ts.map +1 -1
  117. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  118. package/types/lib/core/boundedFile.d.ts +30 -0
  119. package/types/lib/core/boundedFile.d.ts.map +1 -1
  120. package/types/lib/core/env.d.ts +10 -0
  121. package/types/lib/core/env.d.ts.map +1 -1
  122. package/types/lib/core/fs.d.ts +4 -0
  123. package/types/lib/core/fs.d.ts.map +1 -1
  124. package/types/lib/core/globPatterns.d.ts +70 -0
  125. package/types/lib/core/globPatterns.d.ts.map +1 -0
  126. package/types/lib/core/paths.d.ts +9 -0
  127. package/types/lib/core/paths.d.ts.map +1 -1
  128. package/types/lib/core/processTree.d.ts +16 -5
  129. package/types/lib/core/processTree.d.ts.map +1 -1
  130. package/types/lib/core/projectConfig.d.ts +47 -8
  131. package/types/lib/core/projectConfig.d.ts.map +1 -1
  132. package/types/lib/core/toolRegistry.d.ts.map +1 -1
  133. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  134. package/types/lib/ecosystems/gradleutils.d.ts +248 -8
  135. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  136. package/types/lib/ecosystems/jsEvidence.d.ts +14 -1
  137. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  138. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  139. package/types/lib/ecosystems/npmVersionResolution.d.ts +119 -0
  140. package/types/lib/ecosystems/npmVersionResolution.d.ts.map +1 -0
  141. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  142. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  143. package/types/lib/ecosystems/pythonutils.d.ts +1 -1
  144. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  145. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  146. package/types/lib/ecosystems/scalasem.d.ts +58 -16
  147. package/types/lib/ecosystems/scalasem.d.ts.map +1 -1
  148. package/types/lib/evinser/evinser.d.ts +61 -9
  149. package/types/lib/evinser/evinser.d.ts.map +1 -1
  150. package/types/lib/evinser/scalasem.d.ts.map +1 -1
  151. package/types/lib/helpers/agenticToolutils.d.ts +53 -19
  152. package/types/lib/helpers/agenticToolutils.d.ts.map +1 -1
  153. package/types/lib/helpers/bomSigner.d.ts +20 -3
  154. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  155. package/types/lib/helpers/jssSigner.d.ts +288 -0
  156. package/types/lib/helpers/jssSigner.d.ts.map +1 -0
  157. package/types/lib/helpers/vsixutils.d.ts +303 -19
  158. package/types/lib/helpers/vsixutils.d.ts.map +1 -1
  159. package/types/lib/inventory/agentBlueprint.d.ts +70 -0
  160. package/types/lib/inventory/agentBlueprint.d.ts.map +1 -0
  161. package/types/lib/inventory/agentToolNames.d.ts +51 -0
  162. package/types/lib/inventory/agentToolNames.d.ts.map +1 -0
  163. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  164. package/types/lib/inventory/aiInventory.d.ts.map +1 -1
  165. package/types/lib/inventory/aiProviders.d.ts +85 -0
  166. package/types/lib/inventory/aiProviders.d.ts.map +1 -0
  167. package/types/lib/inventory/bomUtils.d.ts.map +1 -1
  168. package/types/lib/inventory/ciParsers/githubActionsLock.d.ts +101 -0
  169. package/types/lib/inventory/ciParsers/githubActionsLock.d.ts.map +1 -0
  170. package/types/lib/inventory/citations.d.ts +5 -0
  171. package/types/lib/inventory/citations.d.ts.map +1 -1
  172. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  173. package/types/lib/inventory/cyclonedx20.d.ts +99 -0
  174. package/types/lib/inventory/cyclonedx20.d.ts.map +1 -0
  175. package/types/lib/inventory/cyclonedx20Common.d.ts +102 -0
  176. package/types/lib/inventory/cyclonedx20Common.d.ts.map +1 -0
  177. package/types/lib/inventory/cyclonedx20Derived.d.ts +74 -0
  178. package/types/lib/inventory/cyclonedx20Derived.d.ts.map +1 -0
  179. package/types/lib/inventory/cyclonedx20Parties.d.ts +91 -0
  180. package/types/lib/inventory/cyclonedx20Parties.d.ts.map +1 -0
  181. package/types/lib/inventory/cyclonedx20Read.d.ts +51 -0
  182. package/types/lib/inventory/cyclonedx20Read.d.ts.map +1 -0
  183. package/types/lib/inventory/cyclonedx20ReadCommon.d.ts +69 -0
  184. package/types/lib/inventory/cyclonedx20ReadCommon.d.ts.map +1 -0
  185. package/types/lib/inventory/cyclonedx20ReadComponents.d.ts +77 -0
  186. package/types/lib/inventory/cyclonedx20ReadComponents.d.ts.map +1 -0
  187. package/types/lib/inventory/cyclonedx20ReadContext.d.ts +118 -0
  188. package/types/lib/inventory/cyclonedx20ReadContext.d.ts.map +1 -0
  189. package/types/lib/inventory/cyclonedx20ReadEvidence.d.ts +40 -0
  190. package/types/lib/inventory/cyclonedx20ReadEvidence.d.ts.map +1 -0
  191. package/types/lib/inventory/cyclonedx20ReadSections.d.ts +45 -0
  192. package/types/lib/inventory/cyclonedx20ReadSections.d.ts.map +1 -0
  193. package/types/lib/inventory/cyclonedx20Render.d.ts +53 -0
  194. package/types/lib/inventory/cyclonedx20Render.d.ts.map +1 -0
  195. package/types/lib/inventory/cyclonedx20RenderComponents.d.ts +73 -0
  196. package/types/lib/inventory/cyclonedx20RenderComponents.d.ts.map +1 -0
  197. package/types/lib/inventory/cyclonedx20RenderDefinitions.d.ts +29 -0
  198. package/types/lib/inventory/cyclonedx20RenderDefinitions.d.ts.map +1 -0
  199. package/types/lib/inventory/cyclonedx20RenderEvidence.d.ts +110 -0
  200. package/types/lib/inventory/cyclonedx20RenderEvidence.d.ts.map +1 -0
  201. package/types/lib/inventory/cyclonedx20RenderHardware.d.ts +42 -0
  202. package/types/lib/inventory/cyclonedx20RenderHardware.d.ts.map +1 -0
  203. package/types/lib/inventory/cyclonedx20RenderPatents.d.ts +26 -0
  204. package/types/lib/inventory/cyclonedx20RenderPatents.d.ts.map +1 -0
  205. package/types/lib/inventory/cyclonedx20RenderRefIds.d.ts +55 -0
  206. package/types/lib/inventory/cyclonedx20RenderRefIds.d.ts.map +1 -0
  207. package/types/lib/inventory/cyclonedx20RenderSections.d.ts +45 -0
  208. package/types/lib/inventory/cyclonedx20RenderSections.d.ts.map +1 -0
  209. package/types/lib/inventory/cyclonedx20Tables.d.ts +134 -0
  210. package/types/lib/inventory/cyclonedx20Tables.d.ts.map +1 -0
  211. package/types/lib/inventory/deps.d.ts +35 -2
  212. package/types/lib/inventory/deps.d.ts.map +1 -1
  213. package/types/lib/inventory/dosai.d.ts +19 -0
  214. package/types/lib/inventory/dosai.d.ts.map +1 -1
  215. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  216. package/types/lib/inventory/dotnetRuntime.d.ts +51 -4
  217. package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -1
  218. package/types/lib/inventory/ecosystemFormulationParsers.d.ts +73 -0
  219. package/types/lib/inventory/ecosystemFormulationParsers.d.ts.map +1 -0
  220. package/types/lib/inventory/envcontext.d.ts +22 -6
  221. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  222. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  223. package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
  224. package/types/lib/inventory/hbom.d.ts +11 -0
  225. package/types/lib/inventory/hbom.d.ts.map +1 -1
  226. package/types/lib/inventory/hbomLoader.d.ts +54 -0
  227. package/types/lib/inventory/hbomLoader.d.ts.map +1 -1
  228. package/types/lib/inventory/jvmLocalRepos.d.ts +5 -1
  229. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -1
  230. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  231. package/types/lib/inventory/occurrenceKey.d.ts +52 -0
  232. package/types/lib/inventory/occurrenceKey.d.ts.map +1 -0
  233. package/types/lib/inventory/pqcMigration.d.ts +84 -0
  234. package/types/lib/inventory/pqcMigration.d.ts.map +1 -0
  235. package/types/lib/inventory/rustFormulationParser.d.ts +7 -2
  236. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  237. package/types/lib/inventory/scalaCoords.d.ts.map +1 -1
  238. package/types/lib/inventory/scalaDedupe.d.ts +43 -0
  239. package/types/lib/inventory/scalaDedupe.d.ts.map +1 -0
  240. package/types/lib/inventory/source.d.ts +15 -0
  241. package/types/lib/inventory/source.d.ts.map +1 -1
  242. package/types/lib/parsers/huggingfaceManifest.d.ts +10 -1
  243. package/types/lib/parsers/huggingfaceManifest.d.ts.map +1 -1
  244. package/types/lib/parsers/huggingfaceUtils.d.ts +12 -0
  245. package/types/lib/parsers/huggingfaceUtils.d.ts.map +1 -1
  246. package/types/lib/parsers/kiroPermissions.d.ts +62 -0
  247. package/types/lib/parsers/kiroPermissions.d.ts.map +1 -0
  248. package/types/lib/parsers/vscodeExtensionId.d.ts +48 -0
  249. package/types/lib/parsers/vscodeExtensionId.d.ts.map +1 -0
  250. package/types/lib/server/server.d.ts.map +1 -1
  251. package/types/lib/stages/postgen/postgen.d.ts +7 -0
  252. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  253. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  254. package/types/lib/stages/postgen/specVersionCompat.d.ts +25 -0
  255. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  256. package/types/lib/validator/bomValidator.d.ts +5 -1
  257. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  258. package/types/lib/validator/complianceEngine.d.ts +4 -4
  259. package/types/lib/validator/complianceEngine.d.ts.map +1 -1
  260. package/types/lib/validator/complianceRules.d.ts +14 -2
  261. package/types/lib/validator/complianceRules.d.ts.map +1 -1
  262. package/types/lib/validator/index.d.ts +10 -0
  263. package/types/lib/validator/index.d.ts.map +1 -1
  264. package/types/lib/validator/reporters/json.d.ts.map +1 -1
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { chmodSync, readdirSync, readFileSync } from "node:fs";
3
3
  import { homedir, platform } from "node:os";
4
- import { basename, join, resolve } from "node:path";
4
+ import { basename, dirname, join, relative, resolve } from "node:path";
5
5
  import process from "node:process";
6
6
 
7
7
  import { build } from "@cdxgen/cdx-purl";
@@ -12,6 +12,7 @@ import {
12
12
  readEnvironmentVariable,
13
13
  recordDecisionActivity,
14
14
  } from "../core/activity.js";
15
+ import { readRegularTextFile } from "../core/boundedFile.js";
15
16
  import {
16
17
  commandOutputText,
17
18
  isLedgerEnabled,
@@ -30,10 +31,13 @@ import {
30
31
  noteBuildToolRateLimit,
31
32
  } from "../inventory/buildToolRateLimit.js";
32
33
  import { collectJarNS } from "../inventory/deps.js";
34
+ import { splitArtifactFileName } from "../inventory/jvmLocalRepos.js";
33
35
  import { minimumGradleVersionForJava } from "../inventory/jvmToolEnv.js";
34
36
  import { gradleMirrorInitScriptArgs } from "../inventory/mavenCentralMirror.js";
35
- import { applyPurl, tryBuildPurl } from "../inventory/purl.js";
37
+ import { applyPurl, mavenPurl, tryBuildPurl } from "../inventory/purl.js";
38
+ import { xml2js } from "../parsers/xml.js";
36
39
  import { parsePkgJson } from "./parsers-js.js";
40
+ import { isInVendoredDir } from "./vendoredCode.js";
37
41
 
38
42
  /**
39
43
  * True when the pre-generation stage pinned an exact JVM build tool version
@@ -1223,7 +1227,12 @@ function parseGradleTreeLine(line) {
1223
1227
  }
1224
1228
  }
1225
1229
  const [requested, selected] = text.split(" -> ");
1226
- if (requested.startsWith("project ")) {
1230
+ // Gradle 9.6 and later print the root project as `root project '<name>'`
1231
+ // where earlier versions print `project :`. Either way it is the root
1232
+ // project, whose path is `:`.
1233
+ if (requested.startsWith("root project ")) {
1234
+ parsed.projectPath = ":";
1235
+ } else if (requested.startsWith("project ")) {
1227
1236
  parsed.projectPath = gradleProjectPath(requested.slice("project ".length));
1228
1237
  } else {
1229
1238
  const [group, name, ...versionParts] = requested.split(":");
@@ -1235,7 +1244,11 @@ function parseGradleTreeLine(line) {
1235
1244
  parsed.requestedVersion = versionParts.join(":") || undefined;
1236
1245
  parsed.version = gradleRequestedVersion(parsed.requestedVersion);
1237
1246
  }
1238
- if (selected?.startsWith("project ")) {
1247
+ if (selected?.startsWith("root project ")) {
1248
+ // A module substituted by the root project, as with
1249
+ // `substitute module('org.ex:main') using project(':')`.
1250
+ parsed.projectPath = ":";
1251
+ } else if (selected?.startsWith("project ")) {
1239
1252
  parsed.projectPath = gradleProjectPath(selected.slice("project ".length));
1240
1253
  } else if (selected?.includes(":")) {
1241
1254
  // The selected module replaces the requested one, version and all.
@@ -1244,8 +1257,12 @@ function parseGradleTreeLine(line) {
1244
1257
  parsed.name = name;
1245
1258
  parsed.version = version;
1246
1259
  parsed.requestedVersion = undefined;
1247
- } else if (selected) {
1260
+ } else if (selected && !/\s/u.test(selected)) {
1261
+ // A selected version has no whitespace. Anything else is a form of the
1262
+ // report this parser does not know, and must not become a version.
1248
1263
  parsed.version = selected;
1264
+ } else if (selected) {
1265
+ return { level: parsed.level, unknown: true };
1249
1266
  }
1250
1267
  if (parsed.projectPath === undefined && !parsed.group) {
1251
1268
  return { level: parsed.level, unknown: true };
@@ -2433,6 +2450,14 @@ function catalogCoordinate(entry) {
2433
2450
  return undefined;
2434
2451
  }
2435
2452
 
2453
+ /**
2454
+ * Gradle rich-version syntax that names no single version: a bracketed range,
2455
+ * an interval, a `!!` or `{strictly}` marker, a `->` transition, or a `..`
2456
+ * separator. A version text matching this is a declaration about versions, not
2457
+ * one version.
2458
+ */
2459
+ const GRADLE_RICH_VERSION_PATTERN = /[[\](),!]|->|\.\./u;
2460
+
2436
2461
  /**
2437
2462
  * Resolve the version of one catalog entry.
2438
2463
  *
@@ -2455,8 +2480,1059 @@ function resolveCatalogVersion(entry, versions) {
2455
2480
  if (typeof raw !== "string" || !raw) {
2456
2481
  return undefined;
2457
2482
  }
2458
- if (/[[\](),!]|->|\.\./u.test(raw)) {
2483
+ if (GRADLE_RICH_VERSION_PATTERN.test(raw)) {
2459
2484
  return undefined;
2460
2485
  }
2461
2486
  return raw;
2462
2487
  }
2488
+
2489
+ /**
2490
+ * The largest Gradle lockfile read. Real lockfiles of large multi-project
2491
+ * builds stay well under a megabyte; the file is untrusted input, so a larger
2492
+ * one is skipped whole rather than read in part.
2493
+ */
2494
+ const GRADLE_LOCKFILE_MAX_BYTES = 8 * 1024 * 1024;
2495
+
2496
+ /**
2497
+ * The most lines read from one Gradle lockfile. Lines past the cap are
2498
+ * ignored.
2499
+ */
2500
+ const GRADLE_LOCKFILE_MAX_LINES = 100000;
2501
+
2502
+ /**
2503
+ * Gradle 6+ lockfiles that lock a buildscript classpath rather than the
2504
+ * project's own configurations.
2505
+ */
2506
+ const GRADLE_BUILD_TOOLING_LOCKFILES = new Set([
2507
+ "buildscript-gradle.lockfile",
2508
+ "settings-gradle.lockfile",
2509
+ ]);
2510
+
2511
+ const GRADLE_LOCKFILE_SUFFIX = ".lockfile";
2512
+
2513
+ /**
2514
+ * Work out which kind of Gradle lockfile a path names.
2515
+ *
2516
+ * Gradle 6 and later write one `gradle.lockfile` per project, plus
2517
+ * `buildscript-gradle.lockfile` and `settings-gradle.lockfile` for the
2518
+ * buildscript classpaths. Gradle 4.8 to 5.x wrote one
2519
+ * `gradle/dependency-locks/<configuration>.lockfile` per configuration, with
2520
+ * the buildscript configurations prefixed `buildscript-`.
2521
+ *
2522
+ * @param {string} lockFile Path to the lockfile
2523
+ * @returns {{legacy: boolean, buildTooling: boolean, configuration: (string|undefined)}|undefined}
2524
+ * The kind, or undefined when the name is not a lockfile name
2525
+ */
2526
+ function gradleLockfileKind(lockFile) {
2527
+ const fileName = basename(lockFile);
2528
+ if (!fileName.endsWith(GRADLE_LOCKFILE_SUFFIX)) {
2529
+ return undefined;
2530
+ }
2531
+ const parentDir = dirname(lockFile);
2532
+ if (
2533
+ basename(parentDir) === "dependency-locks" &&
2534
+ basename(dirname(parentDir)) === "gradle"
2535
+ ) {
2536
+ const configuration = fileName.slice(0, -GRADLE_LOCKFILE_SUFFIX.length);
2537
+ if (!configuration) {
2538
+ return undefined;
2539
+ }
2540
+ return {
2541
+ legacy: true,
2542
+ buildTooling: configuration.startsWith("buildscript-"),
2543
+ configuration,
2544
+ };
2545
+ }
2546
+ return {
2547
+ legacy: false,
2548
+ buildTooling: GRADLE_BUILD_TOOLING_LOCKFILES.has(fileName),
2549
+ configuration: undefined,
2550
+ };
2551
+ }
2552
+
2553
+ /**
2554
+ * Read a `group:name:version` coordinate from a lockfile entry.
2555
+ *
2556
+ * Gradle writes only resolved versions to a lockfile, so a version that is
2557
+ * blank, a range, a dynamic selector (`1.+`, `latest.release`) or any other
2558
+ * rich-version syntax means the line was not written by Gradle and is
2559
+ * skipped.
2560
+ *
2561
+ * @param {string} text Coordinate text, already trimmed
2562
+ * @returns {{group: string, name: string, version: string}|undefined}
2563
+ * The coordinate, or undefined for anything else
2564
+ */
2565
+ function parseGradleLockCoordinate(text) {
2566
+ if (!text || /[\s=]/u.test(text)) {
2567
+ return undefined;
2568
+ }
2569
+ const parts = text.split(":");
2570
+ if (parts.length !== 3 || parts.some((part) => !part)) {
2571
+ return undefined;
2572
+ }
2573
+ const [group, name, version] = parts;
2574
+ // Gradle writes only resolved versions to a lockfile, so a rich version or
2575
+ // a dynamic selector (`1.+`, `2.*`, `latest.release`) means the line was
2576
+ // not written by Gradle. The rich-version rejection is the one
2577
+ // `resolveCatalogVersion` applies; a trailing `+` and a `*` are the
2578
+ // dynamic selectors.
2579
+ if (
2580
+ version.startsWith("latest.") ||
2581
+ version.endsWith("+") ||
2582
+ version.includes("*") ||
2583
+ GRADLE_RICH_VERSION_PATTERN.test(version)
2584
+ ) {
2585
+ return undefined;
2586
+ }
2587
+ return { group, name, version };
2588
+ }
2589
+
2590
+ /**
2591
+ * Parse one Gradle dependency lockfile.
2592
+ *
2593
+ * Both layouts are read. A Gradle 6+ `gradle.lockfile` has one
2594
+ * `group:name:version=configuration,configuration` line per module and an
2595
+ * `empty=` line naming the configurations that resolved nothing. A legacy
2596
+ * `gradle/dependency-locks/<configuration>.lockfile` has one
2597
+ * `group:name:version` line per module, and the file name is the
2598
+ * configuration.
2599
+ *
2600
+ * The scope of a module is the strongest that `gradleConfigurationScope`
2601
+ * gives any of its configurations, so a module locked for both
2602
+ * `testRuntimeClasspath` and `runtimeClasspath` is `required`. Everything in
2603
+ * a buildscript or settings lockfile is build tooling and `excluded`.
2604
+ *
2605
+ * A lockfile records resolved versions, so components carry a purl identity
2606
+ * with confidence 1 and the lockfile path as the evidence value. The purl and
2607
+ * bom-ref match what `parseGradleDep` builds for the same coordinate, so a
2608
+ * component reads the same whether Gradle ran or not. A lockfile is flat: it
2609
+ * says nothing about which module depends on which, so no dependency graph is
2610
+ * returned.
2611
+ *
2612
+ * The file is untrusted input. It is read only when it is a regular file no
2613
+ * larger than 8 MiB, at most 100000 lines are read, and a malformed line
2614
+ * costs only itself. Line content never reaches a property.
2615
+ *
2616
+ * @param {string} lockFile Path to the lockfile
2617
+ * @param {Object} [options] Parse options
2618
+ * @param {string} [options.rootDir] Directory the lockfile must resolve inside
2619
+ * @returns {Object[]} Components, one per locked module
2620
+ */
2621
+ export function parseGradleLockfile(lockFile, options = {}) {
2622
+ const kind = gradleLockfileKind(lockFile);
2623
+ if (!kind) {
2624
+ return [];
2625
+ }
2626
+ const text = readRegularTextFile(
2627
+ lockFile,
2628
+ GRADLE_LOCKFILE_MAX_BYTES,
2629
+ options?.rootDir,
2630
+ );
2631
+ if (text === undefined) {
2632
+ if (safeExistsSync(lockFile)) {
2633
+ console.warn(
2634
+ `Skipping ${lockFile}: it is not a regular file inside the project, or is larger than ${GRADLE_LOCKFILE_MAX_BYTES} bytes.`,
2635
+ );
2636
+ }
2637
+ return [];
2638
+ }
2639
+ const body = text.startsWith("\uFEFF") ? text.slice(1) : text;
2640
+ const pkgsByRef = new Map();
2641
+ let skippedLines = 0;
2642
+ let lineStart = 0;
2643
+ let lineCount = 0;
2644
+ while (lineStart <= body.length) {
2645
+ if (lineCount === GRADLE_LOCKFILE_MAX_LINES) {
2646
+ if (lineStart < body.length) {
2647
+ thoughtLog(
2648
+ `${lockFile} has more than ${GRADLE_LOCKFILE_MAX_LINES} lines; only the first ${GRADLE_LOCKFILE_MAX_LINES} are read.`,
2649
+ );
2650
+ }
2651
+ break;
2652
+ }
2653
+ let lineEnd = body.indexOf("\n", lineStart);
2654
+ if (lineEnd === -1) {
2655
+ lineEnd = body.length;
2656
+ }
2657
+ const line = body.slice(lineStart, lineEnd).trim();
2658
+ lineStart = lineEnd + 1;
2659
+ lineCount++;
2660
+ if (!line || line.startsWith("#")) {
2661
+ continue;
2662
+ }
2663
+ let coordinateText = line;
2664
+ let configurations = [kind.configuration];
2665
+ if (!kind.legacy) {
2666
+ const separator = line.indexOf("=");
2667
+ if (separator === -1) {
2668
+ skippedLines++;
2669
+ continue;
2670
+ }
2671
+ coordinateText = line.slice(0, separator).trim();
2672
+ if (coordinateText === "empty") {
2673
+ continue;
2674
+ }
2675
+ configurations = line
2676
+ .slice(separator + 1)
2677
+ .split(",")
2678
+ .map((configuration) => configuration.trim())
2679
+ .filter(Boolean);
2680
+ }
2681
+ const coordinate = parseGradleLockCoordinate(coordinateText);
2682
+ if (!coordinate || !configurations.length) {
2683
+ skippedLines++;
2684
+ continue;
2685
+ }
2686
+ const { group, name, version } = coordinate;
2687
+ const purl = mavenPurl(group, name, version, { type: "jar" });
2688
+ if (!purl) {
2689
+ skippedLines++;
2690
+ continue;
2691
+ }
2692
+ let scope = "excluded";
2693
+ if (!kind.buildTooling) {
2694
+ for (const configuration of configurations) {
2695
+ const configurationScope = gradleConfigurationScope(configuration);
2696
+ if (gradleScopeRank(configurationScope) > gradleScopeRank(scope)) {
2697
+ scope = configurationScope;
2698
+ }
2699
+ }
2700
+ }
2701
+ const bomRef = decodeURIComponent(purl);
2702
+ const existing = pkgsByRef.get(bomRef);
2703
+ if (existing) {
2704
+ if (gradleScopeRank(scope) > gradleScopeRank(existing.scope)) {
2705
+ existing.scope = scope;
2706
+ }
2707
+ continue;
2708
+ }
2709
+ pkgsByRef.set(bomRef, {
2710
+ group,
2711
+ name,
2712
+ version,
2713
+ qualifiers: { type: "jar" },
2714
+ type: "library",
2715
+ scope,
2716
+ purl,
2717
+ "bom-ref": bomRef,
2718
+ properties: [{ name: "internal:SrcFile", value: lockFile }],
2719
+ evidence: {
2720
+ identity: {
2721
+ field: "purl",
2722
+ confidence: 1,
2723
+ concludedValue: purl,
2724
+ methods: [
2725
+ {
2726
+ technique: "manifest-analysis",
2727
+ confidence: 1,
2728
+ value: lockFile,
2729
+ },
2730
+ ],
2731
+ },
2732
+ },
2733
+ });
2734
+ }
2735
+ if (skippedLines) {
2736
+ thoughtLog(
2737
+ `Skipped ${skippedLines} malformed or unresolved line(s) in ${lockFile}.`,
2738
+ );
2739
+ }
2740
+ return [...pkgsByRef.values()];
2741
+ }
2742
+
2743
+ /**
2744
+ * Parse several Gradle lockfiles of one build and merge the result.
2745
+ *
2746
+ * Every project of a multi-project build has its own lockfile, so the same
2747
+ * module often appears in several of them with different configurations. The
2748
+ * copies collapse into one component with the strongest scope any lockfile
2749
+ * gave it (see `reconcileGradleComponents`), and its evidence lists every
2750
+ * lockfile that locked it. The first lockfile that locked a module stays its
2751
+ * `internal:SrcFile`.
2752
+ *
2753
+ * @param {string[]} lockFiles Paths to the lockfiles
2754
+ * @param {Object} [options] Parse options, as for `parseGradleLockfile`
2755
+ * @returns {Object[]} Components, one per locked module
2756
+ */
2757
+ export function parseGradleLockfiles(lockFiles, options = {}) {
2758
+ const allPkgs = [];
2759
+ for (const lockFile of lockFiles || []) {
2760
+ allPkgs.push(...parseGradleLockfile(lockFile, options));
2761
+ }
2762
+ reconcileGradleComponents(allPkgs);
2763
+ const pkgsByRef = new Map();
2764
+ for (const pkg of allPkgs) {
2765
+ const existing = pkgsByRef.get(pkg["bom-ref"]);
2766
+ if (!existing) {
2767
+ pkgsByRef.set(pkg["bom-ref"], pkg);
2768
+ continue;
2769
+ }
2770
+ const methods = existing.evidence.identity.methods;
2771
+ for (const method of pkg.evidence.identity.methods) {
2772
+ if (!methods.some((m) => m.value === method.value)) {
2773
+ methods.push(method);
2774
+ }
2775
+ }
2776
+ }
2777
+ return [...pkgsByRef.values()];
2778
+ }
2779
+
2780
+ /**
2781
+ * The build files that mark a directory as a Gradle project of its own. A
2782
+ * lockfile is accepted only beside one of these, so a lockfile copied into a
2783
+ * notes folder or a build's output is not read as an inventory source.
2784
+ */
2785
+ const GRADLE_PROJECT_MARKERS = [
2786
+ "build.gradle",
2787
+ "build.gradle.kts",
2788
+ "settings.gradle",
2789
+ "settings.gradle.kts",
2790
+ ];
2791
+
2792
+ /**
2793
+ * Find the Gradle dependency lockfiles of one build.
2794
+ *
2795
+ * Both layouts are searched below `rootPath`, recursively even when
2796
+ * `options.multiProject` is off: every subproject of a Gradle build locks its
2797
+ * own configurations, and the subprojects belong to the same build. The
2798
+ * Gradle 6+ names are `gradle.lockfile`, `buildscript-gradle.lockfile` and
2799
+ * `settings-gradle.lockfile`; the Gradle 4.8 to 5.x layout is
2800
+ * `gradle/dependency-locks/<configuration>.lockfile`.
2801
+ *
2802
+ * A lockfile is accepted only when the project directory that owns it holds a
2803
+ * Gradle build file, and never when it sits under `build/`, `.gradle/`,
2804
+ * `node_modules/` or a vendored directory. `getAllFiles` already leaves
2805
+ * `.git`, `.hg`, `.chen` and `node_modules` out; `build/` and `.gradle/` are
2806
+ * Gradle's own output and cache directories, which a lockfile in them cannot
2807
+ * belong to the project's source of truth.
2808
+ *
2809
+ * Custom `dependencyLocking.lockFile` paths, the Nebula `dependencies.lock`
2810
+ * and Palantir `versions.lock` formats are not looked for.
2811
+ *
2812
+ * @param {string} rootPath Directory to search
2813
+ * @param {Object} [options] CLI options
2814
+ * @returns {string[]} Lockfile paths, in a stable order
2815
+ */
2816
+ export function findGradleLockfiles(rootPath, options = {}) {
2817
+ const lockFiles = new Set();
2818
+ for (const pattern of [
2819
+ "**/{gradle,buildscript-gradle,settings-gradle}.lockfile",
2820
+ "**/gradle/dependency-locks/*.lockfile",
2821
+ ]) {
2822
+ for (const lockFile of getAllFiles(rootPath, pattern, options)) {
2823
+ // Only the segments below the scan root count: a scan root that sits
2824
+ // under a directory named `build` still has lockfiles of its own.
2825
+ const segments = relative(rootPath, lockFile).split(/[\\/]/);
2826
+ if (
2827
+ segments.some((segment) =>
2828
+ ["build", ".gradle", "node_modules"].includes(segment),
2829
+ )
2830
+ ) {
2831
+ continue;
2832
+ }
2833
+ if (isInVendoredDir(rootPath, lockFile)) {
2834
+ continue;
2835
+ }
2836
+ const kind = gradleLockfileKind(lockFile);
2837
+ if (!kind) {
2838
+ continue;
2839
+ }
2840
+ // The project directory is the lockfile's own directory, and the
2841
+ // directory above `gradle` for a legacy dependency-locks file.
2842
+ const projectDir = kind.legacy
2843
+ ? dirname(dirname(dirname(lockFile)))
2844
+ : dirname(lockFile);
2845
+ if (
2846
+ !GRADLE_PROJECT_MARKERS.some((marker) =>
2847
+ safeExistsSync(join(projectDir, marker)),
2848
+ )
2849
+ ) {
2850
+ continue;
2851
+ }
2852
+ lockFiles.add(lockFile);
2853
+ }
2854
+ }
2855
+ return [...lockFiles].sort();
2856
+ }
2857
+
2858
+ /**
2859
+ * The most bytes read from a settings script when looking for the root
2860
+ * project name.
2861
+ */
2862
+ const GRADLE_SETTINGS_MAX_BYTES = 1024 * 1024;
2863
+
2864
+ /**
2865
+ * Read the root project name a Gradle settings script assigns with
2866
+ * `rootProject.name = '<name>'` or `rootProject.name = "<name>"`, without
2867
+ * running Gradle.
2868
+ *
2869
+ * `settings.gradle` is read before `settings.gradle.kts`, and the last
2870
+ * assignment in the file wins, as it does in Gradle. Only a plain string
2871
+ * literal is accepted: a value built from a variable, a template
2872
+ * (`"${x}"`) or a method call is not something a static read can resolve.
2873
+ *
2874
+ * @param {string} gradleRootPath Root directory of the Gradle build
2875
+ * @returns {string|undefined} The root project name, or undefined when no
2876
+ * settings script assigns one as a plain literal
2877
+ */
2878
+ export function readGradleRootProjectName(gradleRootPath) {
2879
+ for (const settingsName of ["settings.gradle", "settings.gradle.kts"]) {
2880
+ const settingsFile = join(gradleRootPath, settingsName);
2881
+ if (!safeExistsSync(settingsFile)) {
2882
+ continue;
2883
+ }
2884
+ const text = readRegularTextFile(
2885
+ settingsFile,
2886
+ GRADLE_SETTINGS_MAX_BYTES,
2887
+ gradleRootPath,
2888
+ );
2889
+ if (!text) {
2890
+ continue;
2891
+ }
2892
+ let rootName;
2893
+ for (const rawLine of text.split("\n")) {
2894
+ const line = rawLine.trim();
2895
+ if (!line.startsWith("rootProject.name")) {
2896
+ continue;
2897
+ }
2898
+ let rest = line.slice("rootProject.name".length).trim();
2899
+ if (!rest.startsWith("=")) {
2900
+ continue;
2901
+ }
2902
+ rest = rest.slice(1).trim();
2903
+ const commentIndex = rest.indexOf("//");
2904
+ if (commentIndex !== -1) {
2905
+ rest = rest.slice(0, commentIndex).trim();
2906
+ }
2907
+ if (rest.endsWith(";")) {
2908
+ rest = rest.slice(0, -1).trim();
2909
+ }
2910
+ const quote = rest.charAt(0);
2911
+ if (
2912
+ rest.length < 3 ||
2913
+ !["'", '"'].includes(quote) ||
2914
+ !rest.endsWith(quote)
2915
+ ) {
2916
+ continue;
2917
+ }
2918
+ const value = rest.slice(1, -1).trim();
2919
+ if (
2920
+ !value ||
2921
+ [..."'\"$\\"].some((ch) => value.includes(ch)) ||
2922
+ value.includes("/")
2923
+ ) {
2924
+ continue;
2925
+ }
2926
+ rootName = value;
2927
+ }
2928
+ if (rootName) {
2929
+ return rootName;
2930
+ }
2931
+ }
2932
+ return undefined;
2933
+ }
2934
+
2935
+ /**
2936
+ * The largest gradle/verification-metadata.xml read. Real files of large
2937
+ * builds stay around half a megabyte; the file is untrusted input, so a
2938
+ * larger one is skipped whole rather than read in part.
2939
+ */
2940
+ const GRADLE_VERIFICATION_METADATA_MAX_BYTES = 32 * 1024 * 1024;
2941
+
2942
+ /**
2943
+ * The most components read from one verification-metadata.xml. Components
2944
+ * past the cap are ignored.
2945
+ */
2946
+ const GRADLE_VERIFICATION_METADATA_MAX_COMPONENTS = 200000;
2947
+
2948
+ /**
2949
+ * The most artifacts read for one component. A real entry names the jar, the
2950
+ * pom and the module metadata; a coordinate listing many more artifacts was
2951
+ * not written by Gradle.
2952
+ */
2953
+ const GRADLE_VERIFICATION_METADATA_MAX_ARTIFACTS = 64;
2954
+
2955
+ /**
2956
+ * Checksum element of the verification schema, the CycloneDX hash algorithm
2957
+ * it maps to, and the exact hex digest length that algorithm produces. A
2958
+ * digest of any other length was not written by Gradle.
2959
+ */
2960
+ const VERIFICATION_HASH_ALGORITHMS = [
2961
+ { element: "sha512", alg: "SHA-512", length: 128 },
2962
+ { element: "sha256", alg: "SHA-256", length: 64 },
2963
+ { element: "sha1", alg: "SHA-1", length: 40 },
2964
+ { element: "md5", alg: "MD5", length: 32 },
2965
+ ];
2966
+
2967
+ const HEX_DIGEST_PATTERN = /^[0-9a-f]+$/iu;
2968
+
2969
+ /**
2970
+ * A `pgp value` is either a 16-character long key id or a 40-character v4
2971
+ * fingerprint. Anything else is not a key id.
2972
+ */
2973
+ const PGP_KEY_ID_LENGTHS = new Set([16, 40]);
2974
+
2975
+ /**
2976
+ * The build stamp a timestamped SNAPSHOT file carries in place of
2977
+ * `-SNAPSHOT`, for example `-20240401.123456-1`.
2978
+ */
2979
+ const SNAPSHOT_STAMP_PATTERN = /^-\d{8}\.\d{6}-\d+/u;
2980
+
2981
+ /**
2982
+ * Children of one element in the compact XML form. A child that occurs once
2983
+ * parses as an object and a repeated child as an array; this accepts both.
2984
+ *
2985
+ * @param {object|object[]|undefined} value Compact-form child or children
2986
+ * @returns {object[]} The children as a list
2987
+ */
2988
+ function verificationElementList(value) {
2989
+ if (value === undefined || value === null) {
2990
+ return [];
2991
+ }
2992
+ return Array.isArray(value) ? value : [value];
2993
+ }
2994
+
2995
+ /**
2996
+ * One named child of an element in the compact XML form.
2997
+ *
2998
+ * A document whose root element carries a namespace prefix keeps that prefix
2999
+ * on the name of every element below it, so the child name is looked up with
3000
+ * the root's prefix in front of it. Attributes are never prefixed: the
3001
+ * schema's attributes are unqualified even when the elements are not.
3002
+ *
3003
+ * @param {object|undefined} element Compact-form element
3004
+ * @param {string} name Child element name without a prefix
3005
+ * @param {string} [prefix] Namespace prefix of the root element, with its
3006
+ * colon
3007
+ * @returns {object[]} The children as a list
3008
+ */
3009
+ function verificationChildList(element, name, prefix = "") {
3010
+ return verificationElementList(element?.[`${prefix}${name}`]);
3011
+ }
3012
+
3013
+ /**
3014
+ * Read the `true` or `false` text of a configuration flag. A flag that is
3015
+ * absent, or whose text is neither, reads as undefined so the caller cannot
3016
+ * tell a written `false` from an unreadable one.
3017
+ *
3018
+ * @param {object|undefined} element Compact-form flag element
3019
+ * @returns {boolean|undefined} The flag value, or undefined
3020
+ */
3021
+ function verificationBoolean(element) {
3022
+ const value = typeof element?._ === "string" ? element._.trim() : "";
3023
+ if (value === "true") {
3024
+ return true;
3025
+ }
3026
+ if (value === "false") {
3027
+ return false;
3028
+ }
3029
+ return undefined;
3030
+ }
3031
+
3032
+ /**
3033
+ * Split an artifact file name into its classifier and extension, accepting
3034
+ * the timestamped file names Gradle writes for SNAPSHOT versions, for which
3035
+ * the build stamp replaces `-SNAPSHOT` in the file name only.
3036
+ *
3037
+ * @param {string} fileName File name from the verification metadata
3038
+ * @param {string} name Artifact ID of the coordinate
3039
+ * @param {string} version Version of the coordinate
3040
+ * @returns {{classifier: string, extension: string}|undefined} Undefined when
3041
+ * the name does not belong to the coordinate
3042
+ */
3043
+ function splitVerificationArtifactName(fileName, name, version) {
3044
+ const split = splitArtifactFileName(fileName, name, version);
3045
+ if (split) {
3046
+ return split;
3047
+ }
3048
+ if (!version.endsWith("-SNAPSHOT")) {
3049
+ return undefined;
3050
+ }
3051
+ const base = version.slice(0, -"-SNAPSHOT".length);
3052
+ if (!fileName.startsWith(`${name}-${base}`)) {
3053
+ return undefined;
3054
+ }
3055
+ const stamp = SNAPSHOT_STAMP_PATTERN.exec(
3056
+ fileName.substring(`${name}-${base}`.length),
3057
+ );
3058
+ if (!stamp) {
3059
+ return undefined;
3060
+ }
3061
+ return splitArtifactFileName(fileName, name, `${base}${stamp[0]}`);
3062
+ }
3063
+
3064
+ /**
3065
+ * Keep a checksum value only when it is a hex digest of the exact length its
3066
+ * algorithm produces. The `origin` and `reason` attributes, and the
3067
+ * `also-trust` alternates, are never read.
3068
+ *
3069
+ * @param {string} alg CycloneDX hash algorithm name
3070
+ * @param {number} length Expected hex digest length
3071
+ * @param {object|undefined} element Compact-form checksum element
3072
+ * @returns {{alg: string, content: string}|undefined} The hash, or undefined
3073
+ */
3074
+ function verificationHash(alg, length, element) {
3075
+ const value = element?.$?.value;
3076
+ if (typeof value !== "string" || value.length !== length) {
3077
+ return undefined;
3078
+ }
3079
+ if (!HEX_DIGEST_PATTERN.test(value)) {
3080
+ return undefined;
3081
+ }
3082
+ return { alg, content: value.toLowerCase() };
3083
+ }
3084
+
3085
+ /**
3086
+ * Keep a `pgp value` only when it is a long key id or a v4 fingerprint, in
3087
+ * the upper-case form the verification schema itself uses.
3088
+ *
3089
+ * @param {object|undefined} element Compact-form pgp element
3090
+ * @returns {string|undefined} The key id, or undefined
3091
+ */
3092
+ function verificationPgpKeyId(element) {
3093
+ const value = element?.$?.value;
3094
+ if (typeof value !== "string") {
3095
+ return undefined;
3096
+ }
3097
+ const keyId = value.trim();
3098
+ if (
3099
+ !PGP_KEY_ID_LENGTHS.has(keyId.length) ||
3100
+ !HEX_DIGEST_PATTERN.test(keyId)
3101
+ ) {
3102
+ return undefined;
3103
+ }
3104
+ return keyId.toUpperCase();
3105
+ }
3106
+
3107
+ /**
3108
+ * Parse a `gradle/verification-metadata.xml` file.
3109
+ *
3110
+ * The file is untrusted input. It records what Gradle verified when
3111
+ * `gradle --write-verification-metadata` last ran, which does not prove that
3112
+ * the artifacts in this BOM match: the file travels with the source, and
3113
+ * nothing here re-checks the digests. Trust rules (`trusted-artifacts`,
3114
+ * `trusted-keys`, `ignored-keys`) may be regular expressions, so they are
3115
+ * only counted, never evaluated, and no free text of the file (an `origin`, a
3116
+ * `reason`, a key-server URI) reaches the result. Only checksum digests,
3117
+ * PGP key ids and the configuration flags survive.
3118
+ *
3119
+ * The read is bounded: the file must be a regular file no larger than
3120
+ * 32 MiB inside `options.rootDir`, at most 200000 components and 64
3121
+ * artifacts per component are read, and a malformed file yields undefined
3122
+ * rather than an error.
3123
+ *
3124
+ * @param {string} metadataFile Path to verification-metadata.xml
3125
+ * @param {Object} [options] Parse options
3126
+ * @param {string} [options.rootDir] Directory the file must resolve inside
3127
+ * @returns {{configuration: {verifyMetadata: (boolean|undefined), verifySignatures: (boolean|undefined), trustedKeyCount: number, trustedArtifactRuleCount: number, ignoredKeyCount: number, keyServerCount: number}, components: Map<string, {group: string, name: string, version: string, artifacts: {fileName: string, classifier: string, extension: string, hashes: {alg: string, content: string}[], pgpKeyIds: string[]}[]}>}|undefined}
3128
+ * The parsed metadata, or undefined when the file could not be read
3129
+ */
3130
+ export function parseGradleVerificationMetadata(metadataFile, options = {}) {
3131
+ const text = readRegularTextFile(
3132
+ metadataFile,
3133
+ GRADLE_VERIFICATION_METADATA_MAX_BYTES,
3134
+ options?.rootDir,
3135
+ );
3136
+ if (text === undefined) {
3137
+ if (safeExistsSync(metadataFile)) {
3138
+ console.warn(
3139
+ `Skipping ${metadataFile}: it is not a regular file inside the project, or is larger than ${GRADLE_VERIFICATION_METADATA_MAX_BYTES} bytes.`,
3140
+ );
3141
+ }
3142
+ return undefined;
3143
+ }
3144
+ const body = text.startsWith("\uFEFF") ? text.slice(1) : text;
3145
+ let parsed;
3146
+ try {
3147
+ parsed = xml2js(body, {
3148
+ compact: true,
3149
+ ignoreComment: true,
3150
+ ignoreDoctype: true,
3151
+ ignoreDeclaration: true,
3152
+ textKey: "_",
3153
+ attributesKey: "$",
3154
+ });
3155
+ } catch (err) {
3156
+ thoughtLog(
3157
+ `${metadataFile} is not well-formed XML, so it was skipped: ${
3158
+ err?.message || err
3159
+ }`,
3160
+ );
3161
+ return undefined;
3162
+ }
3163
+ // Gradle writes the root element in its own namespace, which a default
3164
+ // namespace leaves off element names and a prefix keeps; both are accepted.
3165
+ // A prefixed document keeps the prefix on the name of every element below
3166
+ // the root, so the prefix is carried into every child lookup below.
3167
+ const rootKey = Object.keys(parsed).find(
3168
+ (key) =>
3169
+ key === "verification-metadata" || key.endsWith(":verification-metadata"),
3170
+ );
3171
+ const root = verificationElementList(
3172
+ rootKey ? parsed[rootKey] : undefined,
3173
+ )[0];
3174
+ if (!root) {
3175
+ thoughtLog(
3176
+ `${metadataFile} has no verification-metadata root element, so it was skipped.`,
3177
+ );
3178
+ return undefined;
3179
+ }
3180
+ const prefix = rootKey?.includes(":")
3181
+ ? rootKey.slice(0, rootKey.indexOf(":") + 1)
3182
+ : "";
3183
+ const configurationElement = verificationChildList(
3184
+ root,
3185
+ "configuration",
3186
+ prefix,
3187
+ )[0];
3188
+ const componentsElement = verificationChildList(
3189
+ root,
3190
+ "components",
3191
+ prefix,
3192
+ )[0];
3193
+ const trustedKeysElement = verificationChildList(
3194
+ configurationElement,
3195
+ "trusted-keys",
3196
+ prefix,
3197
+ )[0];
3198
+ const trustedArtifactsElement = verificationChildList(
3199
+ configurationElement,
3200
+ "trusted-artifacts",
3201
+ prefix,
3202
+ )[0];
3203
+ const ignoredKeysElement = verificationChildList(
3204
+ configurationElement,
3205
+ "ignored-keys",
3206
+ prefix,
3207
+ )[0];
3208
+ const keyServersElement = verificationChildList(
3209
+ configurationElement,
3210
+ "key-servers",
3211
+ prefix,
3212
+ )[0];
3213
+ const components = new Map();
3214
+ let skippedArtifacts = 0;
3215
+ const componentList = verificationChildList(
3216
+ componentsElement,
3217
+ "component",
3218
+ prefix,
3219
+ );
3220
+ for (const componentElement of componentList) {
3221
+ if (components.size === GRADLE_VERIFICATION_METADATA_MAX_COMPONENTS) {
3222
+ thoughtLog(
3223
+ `${metadataFile} lists more than ${GRADLE_VERIFICATION_METADATA_MAX_COMPONENTS} components; only the first ${GRADLE_VERIFICATION_METADATA_MAX_COMPONENTS} are read.`,
3224
+ );
3225
+ break;
3226
+ }
3227
+ const attributes = componentElement?.$ || {};
3228
+ const group =
3229
+ typeof attributes.group === "string" ? attributes.group.trim() : "";
3230
+ const name =
3231
+ typeof attributes.name === "string" ? attributes.name.trim() : "";
3232
+ const version =
3233
+ typeof attributes.version === "string" ? attributes.version.trim() : "";
3234
+ // A coordinate without all three parts, or one no valid purl can be
3235
+ // built for, can never match a component of this BOM.
3236
+ if (!group || !name || !version) {
3237
+ continue;
3238
+ }
3239
+ if (!mavenPurl(group, name, version)) {
3240
+ continue;
3241
+ }
3242
+ const artifacts = [];
3243
+ const artifactList = verificationChildList(
3244
+ componentElement,
3245
+ "artifact",
3246
+ prefix,
3247
+ );
3248
+ if (artifactList.length > GRADLE_VERIFICATION_METADATA_MAX_ARTIFACTS) {
3249
+ skippedArtifacts +=
3250
+ artifactList.length - GRADLE_VERIFICATION_METADATA_MAX_ARTIFACTS;
3251
+ }
3252
+ for (const artifactElement of artifactList.slice(
3253
+ 0,
3254
+ GRADLE_VERIFICATION_METADATA_MAX_ARTIFACTS,
3255
+ )) {
3256
+ const fileName =
3257
+ typeof artifactElement?.$?.name === "string"
3258
+ ? artifactElement.$.name.trim()
3259
+ : "";
3260
+ const split = fileName
3261
+ ? splitVerificationArtifactName(fileName, name, version)
3262
+ : undefined;
3263
+ if (!split) {
3264
+ skippedArtifacts++;
3265
+ continue;
3266
+ }
3267
+ const hashes = [];
3268
+ for (const { element, alg, length } of VERIFICATION_HASH_ALGORITHMS) {
3269
+ const hash = verificationHash(
3270
+ alg,
3271
+ length,
3272
+ verificationChildList(artifactElement, element, prefix)[0],
3273
+ );
3274
+ if (hash) {
3275
+ hashes.push(hash);
3276
+ }
3277
+ }
3278
+ const pgpKeyIds = [];
3279
+ for (const pgpElement of verificationChildList(
3280
+ artifactElement,
3281
+ "pgp",
3282
+ prefix,
3283
+ )) {
3284
+ const keyId = verificationPgpKeyId(pgpElement);
3285
+ if (keyId && !pgpKeyIds.includes(keyId)) {
3286
+ pgpKeyIds.push(keyId);
3287
+ }
3288
+ }
3289
+ pgpKeyIds.sort();
3290
+ artifacts.push({
3291
+ fileName,
3292
+ classifier: split.classifier,
3293
+ extension: split.extension,
3294
+ hashes,
3295
+ pgpKeyIds,
3296
+ });
3297
+ }
3298
+ const key = `${group}:${name}:${version}`;
3299
+ if (!components.has(key)) {
3300
+ components.set(key, { group, name, version, artifacts });
3301
+ }
3302
+ }
3303
+ if (skippedArtifacts) {
3304
+ thoughtLog(
3305
+ `Skipped ${skippedArtifacts} artifact element(s) whose file name does not belong to their coordinate in ${metadataFile}.`,
3306
+ );
3307
+ }
3308
+ return {
3309
+ configuration: {
3310
+ verifyMetadata: verificationBoolean(
3311
+ verificationChildList(
3312
+ configurationElement,
3313
+ "verify-metadata",
3314
+ prefix,
3315
+ )[0],
3316
+ ),
3317
+ verifySignatures: verificationBoolean(
3318
+ verificationChildList(
3319
+ configurationElement,
3320
+ "verify-signatures",
3321
+ prefix,
3322
+ )[0],
3323
+ ),
3324
+ trustedKeyCount: verificationChildList(
3325
+ trustedKeysElement,
3326
+ "trusted-key",
3327
+ prefix,
3328
+ ).length,
3329
+ trustedArtifactRuleCount: verificationChildList(
3330
+ trustedArtifactsElement,
3331
+ "trust",
3332
+ prefix,
3333
+ ).length,
3334
+ ignoredKeyCount: verificationChildList(
3335
+ ignoredKeysElement,
3336
+ "ignored-key",
3337
+ prefix,
3338
+ ).length,
3339
+ keyServerCount: verificationChildList(
3340
+ keyServersElement,
3341
+ "key-server",
3342
+ prefix,
3343
+ ).length,
3344
+ },
3345
+ components,
3346
+ };
3347
+ }
3348
+
3349
+ /**
3350
+ * Find the gradle/verification-metadata.xml of one Gradle build.
3351
+ *
3352
+ * Gradle reads one verification file per build, from the root build's
3353
+ * `gradle` directory; included builds' copies are ignored, so nothing is
3354
+ * searched below the root. The file is accepted only when the directory is a
3355
+ * Gradle project of its own, so a file copied into a nested folder is not
3356
+ * read as an inventory source.
3357
+ *
3358
+ * @param {string} gradleRootPath Root directory of the Gradle build
3359
+ * @returns {string|undefined} The metadata file path, or undefined
3360
+ */
3361
+ export function findGradleVerificationMetadata(gradleRootPath) {
3362
+ const metadataFile = join(
3363
+ gradleRootPath,
3364
+ "gradle",
3365
+ "verification-metadata.xml",
3366
+ );
3367
+ if (!safeExistsSync(metadataFile)) {
3368
+ return undefined;
3369
+ }
3370
+ if (
3371
+ !GRADLE_PROJECT_MARKERS.some((marker) =>
3372
+ safeExistsSync(join(gradleRootPath, marker)),
3373
+ )
3374
+ ) {
3375
+ return undefined;
3376
+ }
3377
+ return metadataFile;
3378
+ }
3379
+
3380
+ /**
3381
+ * The artifact of a coordinate whose hashes describe the component itself:
3382
+ * the `.pom` artifact for a platform or BOM component, else the main jar,
3383
+ * else the main aar. Sources, javadoc and other classifiers, and the
3384
+ * `.module` and `.pom` hashes of a jar component, are never used.
3385
+ *
3386
+ * @param {object} component Parsed verification metadata component
3387
+ * @param {boolean} wantsPom True for a component with purl qualifier type=pom
3388
+ * @returns {object|undefined} The artifact, or undefined
3389
+ */
3390
+ export function gradleVerificationMainArtifact(component, wantsPom) {
3391
+ const wanted = wantsPom ? "pom" : "jar";
3392
+ const main = component.artifacts.find(
3393
+ (artifact) => !artifact.classifier && artifact.extension === wanted,
3394
+ );
3395
+ if (main || wantsPom) {
3396
+ return main;
3397
+ }
3398
+ return component.artifacts.find(
3399
+ (artifact) => !artifact.classifier && artifact.extension === "aar",
3400
+ );
3401
+ }
3402
+
3403
+ /**
3404
+ * Merge the checksums and PGP key ids a verification-metadata.xml records
3405
+ * into the components of a package list.
3406
+ *
3407
+ * A digest of the artifact itself, computed from the jar in the local cache
3408
+ * or found by another collector, is the stronger claim, so the recorded
3409
+ * digests only fill the algorithms a component still lacks. Where the file
3410
+ * records a different digest for an algorithm the component already has,
3411
+ * the recorded digest never wins: the component keeps its own, and the
3412
+ * component gains the cdx:gradle:verificationMismatch=true property so the
3413
+ * disagreement is visible. A released Maven version does not change after
3414
+ * publishing, so a stale file cannot explain a mismatch: a stale file names
3415
+ * old versions, which match nothing. The likely causes are an artifact
3416
+ * republished on a mirror or internal repository, or a corrupted or
3417
+ * tampered cache. SNAPSHOT versions and digests that are not hex are not
3418
+ * compared.
3419
+ *
3420
+ * Every matched component also gains two properties naming the verification
3421
+ * methods and the PGP key ids of its artifact. The evidence of the
3422
+ * component is left alone: a component found by a Gradle run or a lockfile
3423
+ * keeps the identity evidence of that source, and only the components the
3424
+ * verification-metadata fallback built carry the file as their evidence.
3425
+ *
3426
+ * @param {Object[]} pkgList Components to enrich in place
3427
+ * @param {Object} metadata Parsed metadata, as parseGradleVerificationMetadata
3428
+ * returns
3429
+ * @param {string} metadataFile Path to the verification-metadata.xml, named
3430
+ * in the debug reasoning
3431
+ * @returns {number} The number of components this call changed
3432
+ */
3433
+ export function applyGradleVerificationMetadata(
3434
+ pkgList,
3435
+ metadata,
3436
+ metadataFile,
3437
+ ) {
3438
+ if (!metadata?.components?.size) {
3439
+ return 0;
3440
+ }
3441
+ let changedCount = 0;
3442
+ for (const pkg of pkgList || []) {
3443
+ if (!pkg?.purl?.startsWith("pkg:maven/")) {
3444
+ continue;
3445
+ }
3446
+ const component = metadata.components.get(
3447
+ `${pkg.group}:${pkg.name}:${pkg.version}`,
3448
+ );
3449
+ if (!component) {
3450
+ continue;
3451
+ }
3452
+ const artifact = gradleVerificationMainArtifact(
3453
+ component,
3454
+ pkg.qualifiers?.type === "pom",
3455
+ );
3456
+ if (!artifact) {
3457
+ continue;
3458
+ }
3459
+ const hashesBefore = pkg.hashes;
3460
+ const propertiesBefore = pkg.properties;
3461
+ // A SNAPSHOT version names a moving artifact: the file can record
3462
+ // several timestamped builds of it while the cache holds the newest, so
3463
+ // a differing digest there says nothing about tampering.
3464
+ const comparable = !String(pkg.version || "").endsWith("-SNAPSHOT");
3465
+ // The merged list keeps the order the component's own hashes came in
3466
+ // and appends the recorded ones in the schema's algorithm order.
3467
+ const hashes = Array.isArray(pkg.hashes) ? [...pkg.hashes] : [];
3468
+ let mismatch = false;
3469
+ for (const recorded of artifact.hashes) {
3470
+ const existing = hashes.find((hash) => hash.alg === recorded.alg);
3471
+ if (existing) {
3472
+ // Other collectors may carry an upper-case or base64 digest; only a
3473
+ // hex digest can be compared with the recorded one.
3474
+ const existingContent =
3475
+ typeof existing.content === "string" ? existing.content : "";
3476
+ if (
3477
+ comparable &&
3478
+ HEX_DIGEST_PATTERN.test(existingContent) &&
3479
+ existingContent.toLowerCase() !== recorded.content
3480
+ ) {
3481
+ mismatch = true;
3482
+ thoughtLog(
3483
+ `${metadataFile} records a different ${recorded.alg} digest for ${pkg.group}:${pkg.name}:${pkg.version} than the artifact carries; the recorded digest is flagged, not applied.`,
3484
+ );
3485
+ }
3486
+ continue;
3487
+ }
3488
+ hashes.push({ alg: recorded.alg, content: recorded.content });
3489
+ }
3490
+ if (hashes.length && hashes.length !== (pkg.hashes?.length || 0)) {
3491
+ pkg.hashes = hashes;
3492
+ }
3493
+ const methods = new Set();
3494
+ for (const { element, alg } of VERIFICATION_HASH_ALGORITHMS) {
3495
+ if (artifact.hashes.some((hash) => hash.alg === alg)) {
3496
+ methods.add(element);
3497
+ }
3498
+ }
3499
+ if (artifact.pgpKeyIds.length) {
3500
+ methods.add("pgp");
3501
+ }
3502
+ const properties = pkg.properties || [];
3503
+ if (
3504
+ methods.size &&
3505
+ !properties.some((p) => p.name === "cdx:gradle:verificationMethods")
3506
+ ) {
3507
+ properties.push({
3508
+ name: "cdx:gradle:verificationMethods",
3509
+ value: [...methods].sort().join(","),
3510
+ });
3511
+ }
3512
+ if (
3513
+ artifact.pgpKeyIds.length &&
3514
+ !properties.some((p) => p.name === "cdx:gradle:pgpKeyIds")
3515
+ ) {
3516
+ properties.push({
3517
+ name: "cdx:gradle:pgpKeyIds",
3518
+ value: [...new Set(artifact.pgpKeyIds)].sort().slice(0, 10).join(","),
3519
+ });
3520
+ }
3521
+ if (
3522
+ mismatch &&
3523
+ !properties.some((p) => p.name === "cdx:gradle:verificationMismatch")
3524
+ ) {
3525
+ properties.push({
3526
+ name: "cdx:gradle:verificationMismatch",
3527
+ value: "true",
3528
+ });
3529
+ }
3530
+ if (properties.length !== (pkg.properties?.length || 0)) {
3531
+ pkg.properties = properties;
3532
+ }
3533
+ if (pkg.hashes !== hashesBefore || pkg.properties !== propertiesBefore) {
3534
+ changedCount++;
3535
+ }
3536
+ }
3537
+ return changedCount;
3538
+ }