@cdxgen/cdxgen 13.2.0 → 13.4.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 (311) hide show
  1. package/README.md +33 -14
  2. package/bin/cdxgen.js +310 -138
  3. package/bin/evinse.js +22 -0
  4. package/bin/repl.js +86 -0
  5. package/bin/sign.js +119 -15
  6. package/bin/tracebom.js +69 -5
  7. package/bin/validate.js +107 -19
  8. package/bin/verify.js +172 -63
  9. package/data/helpers/init.gradle +46 -21
  10. package/data/remediations.json +85 -0
  11. package/data/rules/dependency-sources.yaml +67 -0
  12. package/data/rules/scala-evidence.yaml +115 -0
  13. package/lib/audit/index.js +38 -6
  14. package/lib/cli/bomAssembly.js +105 -15
  15. package/lib/cli/cliOptions.js +93 -1
  16. package/lib/cli/index.js +156 -58
  17. package/lib/cli/jsBom.js +99 -53
  18. package/lib/cli/jvmBom.js +708 -147
  19. package/lib/cli/managedBom.js +415 -224
  20. package/lib/cli/nativeBom.js +264 -44
  21. package/lib/core/activity.js +12 -0
  22. package/lib/core/env.js +22 -15
  23. package/lib/core/fs.js +176 -7
  24. package/lib/core/httpClient.js +159 -24
  25. package/lib/core/metadataProperties.js +97 -0
  26. package/lib/core/pluginRun.js +184 -0
  27. package/lib/core/processTree.js +100 -0
  28. package/lib/core/projectConfig.js +31 -4
  29. package/lib/core/runState.js +39 -0
  30. package/lib/core/toml.js +72 -0
  31. package/lib/ecosystems/asarutils.js +4 -2
  32. package/lib/ecosystems/bunutils.js +274 -108
  33. package/lib/ecosystems/cargoCache.js +231 -8
  34. package/lib/ecosystems/caxa.js +434 -2
  35. package/lib/ecosystems/cmakeResolver.js +76 -21
  36. package/lib/ecosystems/cppBuildContext.js +631 -0
  37. package/lib/ecosystems/cppEvidence.js +399 -51
  38. package/lib/ecosystems/cppIncludeResolver.js +294 -0
  39. package/lib/ecosystems/denoutils.js +93 -66
  40. package/lib/ecosystems/ecosystems.js +1983 -793
  41. package/lib/ecosystems/golem.js +28 -12
  42. package/lib/ecosystems/gradleutils.js +356 -37
  43. package/lib/ecosystems/jsEvidence.js +104 -44
  44. package/lib/ecosystems/kosi.js +31 -7
  45. package/lib/ecosystems/npmutils.js +174 -9
  46. package/lib/ecosystems/parsers-dotnet.js +616 -159
  47. package/lib/ecosystems/parsers-elm.js +46 -28
  48. package/lib/ecosystems/parsers-gleam.js +3 -4
  49. package/lib/ecosystems/parsers-go.js +371 -70
  50. package/lib/ecosystems/parsers-js.js +263 -76
  51. package/lib/ecosystems/parsers-julia.js +2 -3
  52. package/lib/ecosystems/parsers-jvm.js +204 -12
  53. package/lib/ecosystems/parsers-misc.js +929 -216
  54. package/lib/ecosystems/parsers-python.js +167 -24
  55. package/lib/ecosystems/parsers-rust.js +28 -22
  56. package/lib/ecosystems/parsers-terraform.js +1573 -19
  57. package/lib/ecosystems/pythonutils.js +102 -6
  58. package/lib/ecosystems/remote/huggingface.js +3 -0
  59. package/lib/ecosystems/rubyutils.js +1492 -182
  60. package/lib/ecosystems/rusi.js +54 -10
  61. package/lib/ecosystems/sbtutils.js +243 -187
  62. package/lib/ecosystems/scalacliutils.js +342 -0
  63. package/lib/ecosystems/scalasem.js +2618 -0
  64. package/lib/ecosystems/terraformRegistry.js +288 -0
  65. package/lib/ecosystems/utils.js +2 -0
  66. package/lib/ecosystems/vendoredCode.js +156 -0
  67. package/lib/evinser/evinser.js +1008 -191
  68. package/lib/evinser/swiftsem.js +1914 -302
  69. package/lib/helpers/agenticToolutils.js +2 -2
  70. package/lib/helpers/bomSigner.js +1101 -222
  71. package/lib/inventory/aiCollector.js +4 -1
  72. package/lib/inventory/analyzer.js +110 -127
  73. package/lib/inventory/atomUtils.js +703 -43
  74. package/lib/inventory/auditCategories.js +7 -0
  75. package/lib/inventory/buildToolRateLimit.js +144 -0
  76. package/lib/inventory/cacheCommands.js +2 -1
  77. package/lib/inventory/cdxrs.js +103 -12
  78. package/lib/inventory/cmakeBuildDirs.js +254 -0
  79. package/lib/inventory/deps.js +501 -124
  80. package/lib/inventory/depsUtils.js +183 -11
  81. package/lib/inventory/display.js +4 -5
  82. package/lib/inventory/dosai.js +798 -57
  83. package/lib/inventory/dosaiParsers.js +310 -15
  84. package/lib/inventory/dynamic.js +90 -6
  85. package/lib/inventory/evidenceUtils.js +166 -113
  86. package/lib/inventory/fetchBatch.js +690 -55
  87. package/lib/inventory/fetchDiskCache.js +597 -0
  88. package/lib/inventory/forgeEnricher.js +14 -5
  89. package/lib/inventory/jvmLocalRepos.js +917 -0
  90. package/lib/inventory/mcpConfigParser.js +2 -3
  91. package/lib/inventory/parentSelection.js +146 -0
  92. package/lib/inventory/protobom.js +8 -1
  93. package/lib/inventory/purl.js +152 -3
  94. package/lib/inventory/rustFormulationParser.js +3 -4
  95. package/lib/inventory/sbomDocument.js +2 -70
  96. package/lib/inventory/scalaCoords.js +153 -0
  97. package/lib/inventory/source.js +45 -27
  98. package/lib/inventory/spdx.js +101 -0
  99. package/lib/inventory/traceRunner.js +295 -25
  100. package/lib/inventory/tracedCrypto.js +294 -0
  101. package/lib/managers/binary.js +115 -24
  102. package/lib/managers/docker.js +467 -46
  103. package/lib/managers/oci.js +63 -3
  104. package/lib/parsers/cmakeCache.js +69 -0
  105. package/lib/parsers/cmakeLists.js +466 -0
  106. package/lib/parsers/cmakePresets.js +400 -0
  107. package/lib/parsers/compileCommands.js +557 -0
  108. package/lib/parsers/largeJson.js +454 -0
  109. package/lib/parsers/npmrc.js +19 -1
  110. package/lib/parsers/strictJson.js +322 -0
  111. package/lib/parsers/terraformConfig.js +1073 -0
  112. package/lib/parsers/terraformSource.js +652 -0
  113. package/lib/server/server.js +23 -0
  114. package/lib/stages/postgen/introspection/markers.js +1 -1
  115. package/lib/stages/postgen/postgen.js +83 -16
  116. package/lib/stages/postgen/specVersionCompat.js +32 -0
  117. package/lib/validator/bomValidator.js +6 -16
  118. package/lib/validator/index.js +64 -11
  119. package/lib/validator/reporters/console.js +3 -0
  120. package/lib/validator/reporters/json.js +2 -0
  121. package/lib/validator/reporters/sarif.js +7 -0
  122. package/package.json +23 -23
  123. package/types/lib/audit/index.d.ts.map +1 -1
  124. package/types/lib/cli/bomAssembly.d.ts +2 -1
  125. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  126. package/types/lib/cli/cliOptions.d.ts +33 -1
  127. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  128. package/types/lib/cli/index.d.ts.map +1 -1
  129. package/types/lib/cli/jsBom.d.ts.map +1 -1
  130. package/types/lib/cli/jvmBom.d.ts +24 -1
  131. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  132. package/types/lib/cli/managedBom.d.ts +7 -4
  133. package/types/lib/cli/managedBom.d.ts.map +1 -1
  134. package/types/lib/cli/nativeBom.d.ts +3 -1
  135. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  136. package/types/lib/core/activity.d.ts.map +1 -1
  137. package/types/lib/core/env.d.ts +9 -0
  138. package/types/lib/core/env.d.ts.map +1 -1
  139. package/types/lib/core/fs.d.ts +55 -0
  140. package/types/lib/core/fs.d.ts.map +1 -1
  141. package/types/lib/core/httpClient.d.ts +13 -8
  142. package/types/lib/core/httpClient.d.ts.map +1 -1
  143. package/types/lib/core/metadataProperties.d.ts +39 -0
  144. package/types/lib/core/metadataProperties.d.ts.map +1 -0
  145. package/types/lib/core/pluginRun.d.ts +97 -0
  146. package/types/lib/core/pluginRun.d.ts.map +1 -0
  147. package/types/lib/core/processTree.d.ts +36 -0
  148. package/types/lib/core/processTree.d.ts.map +1 -0
  149. package/types/lib/core/projectConfig.d.ts +6 -2
  150. package/types/lib/core/projectConfig.d.ts.map +1 -1
  151. package/types/lib/core/runState.d.ts +29 -0
  152. package/types/lib/core/runState.d.ts.map +1 -0
  153. package/types/lib/core/toml.d.ts +20 -0
  154. package/types/lib/core/toml.d.ts.map +1 -0
  155. package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
  156. package/types/lib/ecosystems/bunutils.d.ts +12 -5
  157. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  158. package/types/lib/ecosystems/cargoCache.d.ts +12 -0
  159. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
  160. package/types/lib/ecosystems/caxa.d.ts +14 -0
  161. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  162. package/types/lib/ecosystems/cmakeResolver.d.ts +28 -3
  163. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  164. package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
  165. package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
  166. package/types/lib/ecosystems/cppEvidence.d.ts +17 -1
  167. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  168. package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
  169. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
  170. package/types/lib/ecosystems/denoutils.d.ts +9 -3
  171. package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
  172. package/types/lib/ecosystems/ecosystems.d.ts +62 -13
  173. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  174. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  175. package/types/lib/ecosystems/gradleutils.d.ts +70 -4
  176. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  177. package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
  178. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  179. package/types/lib/ecosystems/kosi.d.ts +13 -0
  180. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  181. package/types/lib/ecosystems/npmutils.d.ts +57 -0
  182. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  183. package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
  184. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  185. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
  186. package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
  187. package/types/lib/ecosystems/parsers-go.d.ts +54 -2
  188. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  189. package/types/lib/ecosystems/parsers-js.d.ts +16 -5
  190. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  191. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
  192. package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
  193. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  194. package/types/lib/ecosystems/parsers-misc.d.ts +70 -4
  195. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  196. package/types/lib/ecosystems/parsers-python.d.ts +53 -3
  197. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  198. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  199. package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
  200. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
  201. package/types/lib/ecosystems/pythonutils.d.ts +27 -2
  202. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  203. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  204. package/types/lib/ecosystems/rubyutils.d.ts +164 -3
  205. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  206. package/types/lib/ecosystems/rusi.d.ts +13 -0
  207. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  208. package/types/lib/ecosystems/sbtutils.d.ts +88 -2
  209. package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
  210. package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
  211. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
  212. package/types/lib/ecosystems/scalasem.d.ts +311 -0
  213. package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
  214. package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
  215. package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
  216. package/types/lib/ecosystems/utils.d.ts +2 -2
  217. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  218. package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
  219. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
  220. package/types/lib/evinser/evinser.d.ts +132 -15
  221. package/types/lib/evinser/evinser.d.ts.map +1 -1
  222. package/types/lib/evinser/swiftsem.d.ts +280 -15
  223. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  224. package/types/lib/helpers/bomSigner.d.ts +182 -13
  225. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  226. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  227. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  228. package/types/lib/inventory/atomUtils.d.ts +219 -5
  229. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  230. package/types/lib/inventory/auditCategories.d.ts +3 -0
  231. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  232. package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
  233. package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
  234. package/types/lib/inventory/cacheCommands.d.ts +2 -1
  235. package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
  236. package/types/lib/inventory/cdxrs.d.ts +8 -3
  237. package/types/lib/inventory/cdxrs.d.ts.map +1 -1
  238. package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
  239. package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
  240. package/types/lib/inventory/deps.d.ts +59 -1
  241. package/types/lib/inventory/deps.d.ts.map +1 -1
  242. package/types/lib/inventory/depsUtils.d.ts +15 -0
  243. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  244. package/types/lib/inventory/display.d.ts.map +1 -1
  245. package/types/lib/inventory/dosai.d.ts +121 -9
  246. package/types/lib/inventory/dosai.d.ts.map +1 -1
  247. package/types/lib/inventory/dosaiParsers.d.ts +53 -7
  248. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  249. package/types/lib/inventory/dynamic.d.ts +10 -0
  250. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  251. package/types/lib/inventory/evidenceUtils.d.ts +19 -4
  252. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  253. package/types/lib/inventory/fetchBatch.d.ts +83 -14
  254. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  255. package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
  256. package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
  257. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  258. package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
  259. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
  260. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  261. package/types/lib/inventory/parentSelection.d.ts +34 -0
  262. package/types/lib/inventory/parentSelection.d.ts.map +1 -0
  263. package/types/lib/inventory/plugins.d.ts.map +1 -1
  264. package/types/lib/inventory/protobom.d.ts.map +1 -1
  265. package/types/lib/inventory/purl.d.ts +71 -2
  266. package/types/lib/inventory/purl.d.ts.map +1 -1
  267. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  268. package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
  269. package/types/lib/inventory/scalaCoords.d.ts +80 -0
  270. package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
  271. package/types/lib/inventory/source.d.ts.map +1 -1
  272. package/types/lib/inventory/spdx.d.ts +9 -0
  273. package/types/lib/inventory/spdx.d.ts.map +1 -1
  274. package/types/lib/inventory/traceRunner.d.ts +59 -1
  275. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  276. package/types/lib/inventory/tracedCrypto.d.ts +42 -0
  277. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
  278. package/types/lib/managers/binary.d.ts +30 -1
  279. package/types/lib/managers/binary.d.ts.map +1 -1
  280. package/types/lib/managers/docker.d.ts +29 -0
  281. package/types/lib/managers/docker.d.ts.map +1 -1
  282. package/types/lib/managers/oci.d.ts +17 -1
  283. package/types/lib/managers/oci.d.ts.map +1 -1
  284. package/types/lib/parsers/cmakeCache.d.ts +16 -0
  285. package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
  286. package/types/lib/parsers/cmakeLists.d.ts +77 -0
  287. package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
  288. package/types/lib/parsers/cmakePresets.d.ts +78 -0
  289. package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
  290. package/types/lib/parsers/compileCommands.d.ts +115 -0
  291. package/types/lib/parsers/compileCommands.d.ts.map +1 -0
  292. package/types/lib/parsers/largeJson.d.ts +52 -0
  293. package/types/lib/parsers/largeJson.d.ts.map +1 -0
  294. package/types/lib/parsers/npmrc.d.ts +5 -0
  295. package/types/lib/parsers/npmrc.d.ts.map +1 -1
  296. package/types/lib/parsers/strictJson.d.ts +46 -0
  297. package/types/lib/parsers/strictJson.d.ts.map +1 -0
  298. package/types/lib/parsers/terraformConfig.d.ts +70 -0
  299. package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
  300. package/types/lib/parsers/terraformSource.d.ts +66 -0
  301. package/types/lib/parsers/terraformSource.d.ts.map +1 -0
  302. package/types/lib/server/server.d.ts.map +1 -1
  303. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  304. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  305. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  306. package/types/lib/validator/index.d.ts +18 -1
  307. package/types/lib/validator/index.d.ts.map +1 -1
  308. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  309. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  310. package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
  311. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
@@ -1,24 +1,17 @@
1
1
  import { Buffer } from "node:buffer";
2
- import {
3
- constants,
4
- existsSync,
5
- lstatSync,
6
- readdirSync,
7
- readFileSync,
8
- statSync,
9
- } from "node:fs";
10
- import { homedir } from "node:os";
2
+ import { constants, lstatSync, readFileSync, statSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
11
4
  import {
12
5
  delimiter as _delimiter,
13
6
  sep as _sep,
14
7
  basename,
15
- dirname,
8
+ isAbsolute,
16
9
  join,
17
10
  relative,
11
+ resolve,
18
12
  } from "node:path";
19
13
 
20
14
  import { build } from "@cdxgen/cdx-purl";
21
- import { globSync } from "glob";
22
15
  import StreamZip from "node-stream-zip";
23
16
  import {
24
17
  clean,
@@ -32,49 +25,68 @@ import {
32
25
  import {
33
26
  cdxgenAgent,
34
27
  DEBUG_MODE,
28
+ isSecureMode,
35
29
  readEnvironmentVariable,
36
30
  } from "../core/activity.js";
37
31
  import {
38
32
  SEARCH_MAVEN_ORG,
39
33
  shouldFetchLicense,
40
34
  shouldFetchPackageMetadata,
35
+ shouldFetchRegistryProvenance,
41
36
  shouldFetchVCS,
42
37
  } from "../core/env.js";
43
38
  import {
44
39
  checksumFile,
45
40
  getAllFiles,
41
+ getTmpDir,
46
42
  multiChecksumFile,
47
43
  safeCopyFileSync,
48
44
  safeExistsSync,
49
45
  safeExtractArchive,
46
+ safeMkdtempSync,
50
47
  safeRmSync,
51
48
  } from "../core/fs.js";
52
49
  import { thoughtLog } from "../core/logger.js";
53
50
  import { mapWithConcurrency } from "../core/parallel.js";
54
51
  import { isWin } from "../core/paths.js";
52
+ import { registerRunReset } from "../core/runState.js";
55
53
  import { vendorAliases } from "../core/state.js";
56
- import { isGitHubUrl, modulePathHost } from "../core/urls.js";
54
+ import { isGitHubUrl, modulePathHost, parseUrl } from "../core/urls.js";
57
55
  import {
58
56
  collectJarNS,
59
57
  getPomPropertiesFromMavenDir,
60
58
  inferJarGroupFromManifest,
59
+ isDocumentationJar,
61
60
  parseJarManifest,
61
+ parseManifestLicenseList,
62
62
  parsePomXml,
63
+ readEmbeddedMavenDescriptor,
63
64
  trimJarGroupSuffix,
64
65
  } from "../inventory/deps.js";
65
66
  import {
67
+ gatedGet,
68
+ isHostCircuitOpen,
69
+ openHostCircuit,
66
70
  prefetchEnabled,
67
71
  prefetchedResponse,
68
72
  prefetchJson,
69
73
  withHostRateLimit,
70
74
  } from "../inventory/fetchBatch.js";
75
+ import {
76
+ findLocalMavenArtifact,
77
+ findMavenCoordinatesBySha1,
78
+ inferMavenCoordinatesFromPath,
79
+ } from "../inventory/jvmLocalRepos.js";
71
80
  import {
72
81
  applyPurl,
73
- encodeForPurl,
74
82
  genericPurl,
75
83
  nugetPurl,
76
84
  pypiPurl,
77
85
  } from "../inventory/purl.js";
86
+ import {
87
+ publishedArtifactId,
88
+ scalaCoordinateOfPurl,
89
+ } from "../inventory/scalaCoords.js";
78
90
  import {
79
91
  findLicenseId,
80
92
  guessLicenseId,
@@ -86,6 +98,9 @@ import {
86
98
  localCargoMetadataEnabled,
87
99
  readCargoCacheMetadata,
88
100
  } from "./cargoCache.js";
101
+ import { normalizeNpmRegistryUrl, resolveNpmLicense } from "./npmutils.js";
102
+ import { parseNuspecData } from "./parsers-dotnet.js";
103
+ import { isDefaultPypiRegistry } from "./pylockutils.js";
89
104
  import {
90
105
  applyPypiClassifierMetadata,
91
106
  applyPypiModuleNames,
@@ -96,26 +111,63 @@ import {
96
111
  collectPypiRegistryProvenanceProperties,
97
112
  } from "./registryProvenance.js";
98
113
 
99
- // Metadata cache
100
- export let metadata_cache = {};
114
+ /**
115
+ * No longer used. Registry answers are kept per ecosystem and for one run; this
116
+ * object stays exported for callers that imported it.
117
+ *
118
+ * @deprecated
119
+ */
120
+ export const metadata_cache = {};
121
+
122
+ // Registry answers kept for the rest of a run, one map per ecosystem, so that a
123
+ // package several lockfiles name is asked about once and one ecosystem's parser
124
+ // cannot empty another's. They are emptied when the run ends.
125
+ /** npm packument URL to the packument. */
126
+ const npmDocuments = new Map();
127
+ /** pkg.go.dev page URL to the licences or the repository URL read from it. */
128
+ const goPkgAnswers = new Map();
129
+ /** `name|version` to the NuGet catalog leaf, or `{error}` for a failed lookup. */
130
+ const nugetEntries = new Map();
131
+
132
+ registerRunReset("ecosystems:registry-answers", () => {
133
+ npmDocuments.clear();
134
+ goPkgAnswers.clear();
135
+ nugetEntries.clear();
136
+ repoLicensePrefetch = new Map();
137
+ goPkgPrefetch = new Map();
138
+ });
101
139
 
102
140
  // Speed up lookup namespaces for a given jar
103
141
 
104
- // circuit breaker for search maven.org
105
- let search_maven_org_errors = 0;
106
- const MAX_SEARCH_MAVEN_ORG_ERRORS = 1;
142
+ // Maven Central's search API, used to identify a jar by its SHA-1.
143
+ const MAVEN_SEARCH_HOST = "central.sonatype.com";
107
144
 
108
- // circuit breaker for get repo license
145
+ // circuit breaker for get repo license. After five failures the lookups pause
146
+ // for fifteen minutes, so a long-running server recovers once the API does.
109
147
  let get_repo_license_errors = 0;
148
+ let get_repo_license_paused_until = 0;
110
149
  const MAX_GET_REPO_LICENSE_ERRORS = 5;
150
+ const GET_REPO_LICENSE_PAUSE_MS = 15 * 60 * 1000;
111
151
 
112
152
  /**
113
- * Internal helper to reset metadata_cache. Used by parseGoModData (still in
114
- * utils.js until batch 6) because ESM forbids reassigning an imported binding.
115
- * NOT re-exported through the utils.js barrel.
153
+ * Whether repository licence lookups are currently paused.
154
+ *
155
+ * @returns {boolean}
116
156
  */
117
- export function _clearMetadataCache() {
118
- metadata_cache = {};
157
+ function repoLicenseLookupsPaused() {
158
+ if (get_repo_license_errors < MAX_GET_REPO_LICENSE_ERRORS) {
159
+ return false;
160
+ }
161
+ if (!get_repo_license_paused_until) {
162
+ get_repo_license_paused_until = Date.now() + GET_REPO_LICENSE_PAUSE_MS;
163
+ return true;
164
+ }
165
+ if (Date.now() < get_repo_license_paused_until) {
166
+ return true;
167
+ }
168
+ get_repo_license_errors = 0;
169
+ get_repo_license_paused_until = 0;
170
+ return false;
119
171
  }
120
172
 
121
173
  /**
@@ -183,75 +235,286 @@ function npmRegistryKey(p) {
183
235
  return p.name;
184
236
  }
185
237
 
238
+ // The hosts of the public npm registry. A lockfile `resolved` URL on any other
239
+ // host was served by a mirror or a private registry.
240
+ const DEFAULT_NPM_REGISTRY_HOSTS = new Set([
241
+ "registry.npmjs.org",
242
+ "registry.npmjs.com",
243
+ "registry.yarnpkg.com",
244
+ ]);
245
+
246
+ /**
247
+ * Whether a URL points at the public npm registry or at the registry the
248
+ * caller resolves against.
249
+ *
250
+ * @param {string} url Registry or tarball URL.
251
+ * @param {string} defaultRegistryUrl The registry the caller resolves against.
252
+ * @returns {boolean}
253
+ */
254
+ function isDefaultNpmRegistry(url, defaultRegistryUrl) {
255
+ const host = parseUrl(url)?.host;
256
+ return (
257
+ !!host &&
258
+ (DEFAULT_NPM_REGISTRY_HOSTS.has(host) ||
259
+ host === parseUrl(defaultRegistryUrl)?.host)
260
+ );
261
+ }
262
+
263
+ /**
264
+ * The registry that served a tarball. Registries lay tarballs out as
265
+ * `<registry>/<name>/-/<file>`, so the registry is everything before the name.
266
+ *
267
+ * @param {string} tarballUrl Lockfile `resolved` URL.
268
+ * @param {string} fullName Package name, with its scope.
269
+ * @returns {string|undefined} Registry base URL, or undefined when the URL has
270
+ * another layout.
271
+ */
272
+ function npmTarballRegistry(tarballUrl, fullName) {
273
+ if (!tarballUrl.startsWith("https://") && !tarballUrl.startsWith("http://")) {
274
+ return undefined;
275
+ }
276
+ const index = tarballUrl.indexOf(`/${fullName}/-/`);
277
+ return index > 0 ? tarballUrl.slice(0, index) : undefined;
278
+ }
279
+
280
+ /**
281
+ * Whether a lockfile `resolved` URL names a git source rather than a tarball.
282
+ *
283
+ * @param {string} url Resolved URL.
284
+ * @returns {boolean}
285
+ */
286
+ function isNpmGitSource(url) {
287
+ return (
288
+ url.startsWith("git+") ||
289
+ url.startsWith("git://") ||
290
+ url.startsWith("git@") ||
291
+ url.startsWith("ssh://")
292
+ );
293
+ }
294
+
295
+ /**
296
+ * The registry to look an npm package up at, or undefined when no registry
297
+ * lookup may be made for it.
298
+ *
299
+ * A public registry cannot hold the project itself, a workspace or link
300
+ * member, a file or git source, or a package another registry serves. Sending
301
+ * those names out leaks them and imports a look-alike's metadata, so the
302
+ * component keeps whatever the lockfile and the manifests already say.
303
+ *
304
+ * A scope that .npmrc maps to another registry, or whose tarball another
305
+ * registry served, is looked up at that registry. In secure mode cdxgen does
306
+ * not contact a host the scanned project names, so such a package is not
307
+ * looked up at all.
308
+ *
309
+ * @param {Object} p Package object carrying the properties the npm parsers record.
310
+ * @param {string} defaultRegistryUrl The registry the caller resolves against.
311
+ * @param {Object} [npmrcConfig] Merged .npmrc configuration, for scope mappings.
312
+ * @param {boolean} [secureMode] Whether secure mode is on.
313
+ * @returns {string|undefined} Registry base URL, or undefined to skip.
314
+ */
315
+ function npmLookupRegistry(p, defaultRegistryUrl, npmrcConfig, secureMode) {
316
+ if (p.type === "application") {
317
+ return undefined;
318
+ }
319
+ const props = {};
320
+ for (const prop of p.properties || []) {
321
+ props[prop.name] = prop.value;
322
+ }
323
+ if (
324
+ props["cdx:npm:isWorkspace"] === "true" ||
325
+ props["cdx:npm:isLink"] === "true" ||
326
+ props["cdx:npm:isRegistryDependency"] === "false" ||
327
+ props["cdx:npm:resolvedPath"]
328
+ ) {
329
+ return undefined;
330
+ }
331
+ const manifestSourceType = props["cdx:npm:manifestSourceType"];
332
+ if (manifestSourceType && manifestSourceType !== "registry") {
333
+ return undefined;
334
+ }
335
+ const resolvedUrl =
336
+ props["internal:ResolvedUrl"] ||
337
+ p.externalReferences?.find((ref) => ref.type === "distribution")?.url;
338
+ if (resolvedUrl && isNpmGitSource(resolvedUrl)) {
339
+ return undefined;
340
+ }
341
+ if (p.group) {
342
+ const scope = p.group.startsWith("@") ? p.group : `@${p.group}`;
343
+ const scopeRegistry = normalizeNpmRegistryUrl(
344
+ npmrcConfig?.[`${scope}:registry`],
345
+ );
346
+ let registry;
347
+ if (scopeRegistry) {
348
+ registry = scopeRegistry;
349
+ } else if (resolvedUrl) {
350
+ registry = isDefaultNpmRegistry(resolvedUrl, defaultRegistryUrl)
351
+ ? defaultRegistryUrl
352
+ : npmTarballRegistry(resolvedUrl, `${scope}/${p.name}`);
353
+ }
354
+ if (registry && !isDefaultNpmRegistry(registry, defaultRegistryUrl)) {
355
+ return secureMode ? undefined : registry;
356
+ }
357
+ if (resolvedUrl && !registry) {
358
+ // Served by another registry, but not in a layout that names it.
359
+ return undefined;
360
+ }
361
+ }
362
+ return defaultRegistryUrl;
363
+ }
364
+
365
+ /**
366
+ * Whether every field the registry is asked for is already on the component.
367
+ *
368
+ * A lockfile or an installed package.json that already states the licence, the
369
+ * description and the repository has answered the question a packument would be
370
+ * asked, so the package is not looked up at all. A licence the project gave is
371
+ * never replaced by the registry's view even when a lookup happens for the
372
+ * other fields.
373
+ *
374
+ * @param {Object} p Package object
375
+ * @returns {boolean}
376
+ */
377
+ function npmMetadataComplete(p) {
378
+ // A lockfile records the repository as a vcs external reference rather than
379
+ // the repository field the registry path sets, so both count.
380
+ const hasRepository =
381
+ p.repository?.url ||
382
+ (typeof p.repository === "string" && p.repository) ||
383
+ (p.externalReferences || []).some((ref) => ref.type === "vcs" && ref.url);
384
+ return Boolean(p.license && p.description && hasRepository);
385
+ }
386
+
387
+ /**
388
+ * The license an npm registry document declares for a version, read like the
389
+ * manifest it came from: the registry still serves the legacy `licenses` array
390
+ * of packages such as fuzzy 0.1.3 (issue 4466). The version's own manifest
391
+ * wins over the document-level field.
392
+ *
393
+ * @param {Object} body Registry document (packument).
394
+ * @param {string} version Version of the package.
395
+ * @returns {string|Object|Array|undefined} The declared license, if any.
396
+ */
397
+ function npmRegistryLicense(body, version) {
398
+ return (
399
+ resolveNpmLicense(body?.versions?.[version]) || resolveNpmLicense(body)
400
+ );
401
+ }
402
+
186
403
  /**
187
404
  * Method to retrieve metadata for npm packages by querying npmjs
188
405
  *
406
+ * Packages a public registry cannot hold are skipped: the project itself,
407
+ * workspace and link members, file and git sources, and scopes served by
408
+ * another registry. See {@link npmLookupRegistry}. A package whose licence,
409
+ * description and repository the project or lockfile already supplied is left
410
+ * alone as well: the registry answers only what is still missing, and never
411
+ * overwrites a licence that is already set. The registry's licence is read
412
+ * with its legacy `licenses` field, and only a package for which neither the
413
+ * package nor the registry names one falls back to its repository's licence.
414
+ *
189
415
  * @param {Array} pkgList Package list
416
+ * @param {string} [registryUrl] Registry to query instead of NPM_URL
417
+ * @param {Object} [options] Options
418
+ * @param {Object} [options.npmrcConfig] Merged .npmrc configuration, used to
419
+ * resolve a scope's own registry
420
+ * @param {boolean} [options.secureMode] Whether secure mode is on. Defaults to
421
+ * the process-wide setting.
190
422
  */
191
- export async function getNpmMetadata(pkgList, registryUrl) {
423
+ export async function getNpmMetadata(pkgList, registryUrl, options = {}) {
192
424
  const NPM_URL =
193
425
  registryUrl ||
194
426
  readEnvironmentVariable("NPM_URL") ||
195
427
  "https://registry.npmjs.org/";
428
+ const secureMode = options.secureMode ?? isSecureMode;
196
429
  const cdepList = [];
430
+ // The registry for every package is decided up front, so the batch and the
431
+ // loop below cannot disagree about who is skipped and who is asked where.
432
+ const lookupUrls = new Map();
433
+ // Provenance is never held locally, so when it is wanted every package is
434
+ // still asked about; the local fields survive the round either way.
435
+ const wantsProvenance = shouldFetchRegistryProvenance();
436
+ for (const p of pkgList) {
437
+ if (!wantsProvenance && npmMetadataComplete(p)) {
438
+ continue;
439
+ }
440
+ const registry = npmLookupRegistry(
441
+ p,
442
+ NPM_URL,
443
+ options.npmrcConfig,
444
+ secureMode,
445
+ );
446
+ if (registry) {
447
+ const base = registry.endsWith("/") ? registry : `${registry}/`;
448
+ lookupUrls.set(p, base + npmRegistryKey(p));
449
+ }
450
+ }
197
451
  // Every URL this loop needs is known up front, so they are fetched
198
452
  // concurrently before the loop rather than one at a time inside it. The
199
453
  // derivation below is unchanged and still reads `body`; only where `body`
200
- // comes from differs.
454
+ // comes from differs. A packument this run already holds, from another
455
+ // lockfile or an earlier pass, is not fetched again.
201
456
  const prefetched = await prefetchJson(
202
457
  prefetchEnabled()
203
- ? pkgList.map((p) => ({ url: NPM_URL + npmRegistryKey(p) }))
458
+ ? [...lookupUrls.values()]
459
+ .filter((url) => !npmDocuments.has(url))
460
+ .map((url) => ({ url }))
204
461
  : [],
205
462
  );
206
463
  // The licence fallback calls getRepoLicense with a URL that only exists once
207
464
  // the registry document has arrived, so it needs a second batched round. The
208
465
  // documents are already in hand here, which is why this can be done without a
209
466
  // second pass over the network for the registry itself.
210
- if (prefetched.size) {
467
+ if (prefetched.size || npmDocuments.size) {
211
468
  const repoUrls = [];
212
469
  for (const p of pkgList) {
213
- const entry = prefetched.get(NPM_URL + npmRegistryKey(p));
214
- if (!entry?.ok) {
470
+ const url = lookupUrls.get(p);
471
+ const entry = url ? prefetched.get(url) : undefined;
472
+ const body = entry?.ok ? entry.body : npmDocuments.get(url);
473
+ if (!body) {
215
474
  continue;
216
475
  }
217
- const body = entry.body;
218
- const versionLicense = body?.versions?.[p.version]?.license;
219
- if (!versionLicense && !body?.license && body?.repository?.url) {
476
+ if (
477
+ !p.license &&
478
+ !npmRegistryLicense(body, p.version) &&
479
+ body?.repository?.url
480
+ ) {
220
481
  repoUrls.push(body.repository.url);
221
482
  }
222
483
  }
223
484
  await prefetchRepoLicenses(repoUrls);
224
485
  }
225
486
  for (const p of pkgList) {
487
+ const url = lookupUrls.get(p);
488
+ if (!url) {
489
+ cdepList.push(p);
490
+ continue;
491
+ }
226
492
  try {
227
- const key = npmRegistryKey(p);
228
493
  // Namespace the cache by registry so packages resolved from a
229
494
  // non-default registry (e.g. jsr's npm mirror at npm.jsr.io) do not
230
495
  // collide with identically-named packages on the default registry.
231
- const cacheKey = `${NPM_URL}${key}`;
232
- let body = {};
233
- if (metadata_cache[cacheKey]) {
234
- body = metadata_cache[cacheKey];
235
- } else {
236
- const url = NPM_URL + key;
496
+ let body = npmDocuments.get(url);
497
+ if (!body) {
237
498
  const res =
238
499
  prefetchedResponse(prefetched, url) ||
239
- (await cdxgenAgent.get(url, {
240
- responseType: "json",
241
- }));
500
+ (await gatedGet(url, { responseType: "json" }));
242
501
  body = res.body;
243
- metadata_cache[cacheKey] = body;
502
+ npmDocuments.set(url, body);
244
503
  }
504
+ // The registry fills only what the project, the lockfile or an
505
+ // installed manifest left open, so a local answer survives.
245
506
  p.description =
246
- body.versions?.[p.version]?.description || body.description;
507
+ p.description ||
508
+ body.versions?.[p.version]?.description ||
509
+ body.description;
247
510
  p.license =
248
- body.versions?.[p.version]?.license ||
249
- body.license ||
511
+ p.license ||
512
+ npmRegistryLicense(body, p.version) ||
250
513
  (await getRepoLicense(body.repository?.url, undefined));
251
- if (body.repository?.url) {
514
+ if (body.repository?.url && !p.repository) {
252
515
  p.repository = { url: body.repository.url };
253
516
  }
254
- if (body.homepage) {
517
+ if (body.homepage && !p.homepage) {
255
518
  p.homepage = { url: body.homepage };
256
519
  }
257
520
  // Capture the resolved tarball as a distribution external reference when
@@ -296,445 +559,604 @@ export async function getNpmMetadata(pkgList, registryUrl) {
296
559
  * @returns {Object|null} Object containing jarPath, sha1, and pomPath, or null
297
560
  */
298
561
  export function findLocalMvnArtifact(group, name, version) {
299
- if (!group || !name || !version) {
562
+ const found = findLocalMavenArtifact(group, name, version);
563
+ if (!found) {
300
564
  return null;
301
565
  }
566
+ return {
567
+ jarPath: found.jarPath || null,
568
+ sha1: found.sha1 || null,
569
+ pomPath: found.pomPath || null,
570
+ };
571
+ }
302
572
 
303
- // 1. Gradle cache
304
- const gradleUserHome = readEnvironmentVariable("GRADLE_USER_HOME");
305
- const gradleCacheDir =
306
- readEnvironmentVariable("GRADLE_CACHE_DIR") ||
307
- (gradleUserHome
308
- ? join(gradleUserHome, "caches", "modules-2", "files-2.1")
309
- : join(homedir(), ".gradle", "caches", "modules-2", "files-2.1"));
573
+ // Options for reading a POM with xml2js, shared by every POM reader here.
574
+ const POM_XML_OPTIONS = {
575
+ compact: true,
576
+ spaces: 4,
577
+ textKey: "_",
578
+ attributesKey: "$",
579
+ commentKey: "value",
580
+ };
310
581
 
311
- if (existsSync(gradleCacheDir)) {
312
- const gradlePath = join(gradleCacheDir, group, name, version);
313
- if (existsSync(gradlePath)) {
314
- try {
315
- const subdirs = readdirSync(gradlePath);
316
- let jarPath = null;
317
- let sha1 = null;
318
- let pomPath = null;
319
- for (const subdir of subdirs) {
320
- const subdirPath = join(gradlePath, subdir);
321
- if (statSync(subdirPath).isDirectory()) {
322
- const files = readdirSync(subdirPath);
323
- for (const file of files) {
324
- if (
325
- file.endsWith(".jar") &&
326
- !file.endsWith("-sources.jar") &&
327
- !file.endsWith("-javadoc.jar")
328
- ) {
329
- jarPath = join(subdirPath, file);
330
- sha1 = subdir;
331
- } else if (file.endsWith(".pom")) {
332
- pomPath = join(subdirPath, file);
333
- }
334
- }
335
- }
336
- }
337
- if (jarPath || pomPath) {
338
- return { jarPath, sha1, pomPath };
339
- }
340
- } catch (_err) {
341
- // ignore
342
- }
343
- }
344
- }
582
+ // A POM chain is walked at most this deep. Real hierarchies stop well short.
583
+ const MAX_POM_PARENT_DEPTH = 8;
345
584
 
346
- // 2. Maven local repo
347
- const mavenLocal = join(homedir(), ".m2", "repository");
348
- const groupPath = group.replace(/\./g, "/");
349
- const mavenPath = join(mavenLocal, groupPath, name, version);
350
- if (existsSync(mavenPath)) {
351
- const jarPath = join(mavenPath, `${name}-${version}.jar`);
352
- const pomPath = join(mavenPath, `${name}-${version}.pom`);
353
- let hasFiles = false;
354
- const result = {};
355
- if (existsSync(jarPath)) {
356
- result.jarPath = jarPath;
357
- hasFiles = true;
358
- }
359
- if (existsSync(pomPath)) {
360
- result.pomPath = pomPath;
361
- hasFiles = true;
362
- }
363
- if (hasFiles) {
364
- return result;
365
- }
585
+ // Groups published to Google's Maven repository rather than Maven Central.
586
+ const GOOGLE_MAVEN_GROUP_PREFIXES = [
587
+ "android.arch.",
588
+ "androidx.",
589
+ "com.android.",
590
+ "com.google.ads.mediation",
591
+ "com.google.android.",
592
+ "com.google.ar",
593
+ "com.google.firebase",
594
+ "com.google.gms",
595
+ "com.google.mlkit",
596
+ "com.google.testing.platform",
597
+ ];
598
+
599
+ /**
600
+ * A repository base URL with exactly one trailing slash, ready for
601
+ * {@link composePomXmlUrl}.
602
+ *
603
+ * @param {string} url Repository URL.
604
+ * @returns {string} Normalised URL.
605
+ */
606
+ function withTrailingSlash(url) {
607
+ return url.endsWith("/") ? url : `${url}/`;
608
+ }
609
+
610
+ /**
611
+ * The repository a package's POM should be fetched from, or undefined when no
612
+ * public repository can hold it: snapshots never reach Maven Central, the
613
+ * project's own modules are not published, and a file:// repository is local.
614
+ *
615
+ * @param {Object} p Package.
616
+ * @param {{MAVEN_CENTRAL_URL: string, ANDROID_MAVEN_URL: string}} repos Repository base URLs.
617
+ * @param {{skipPurls?: Set<string>}} context Packages the caller knows are its own.
618
+ * @returns {string|undefined} Repository base URL.
619
+ */
620
+ function mavenRepositoryFor(p, repos, context) {
621
+ const group = p.group || "";
622
+ if (!group || !p.name || !p.version || p.version.endsWith("-SNAPSHOT")) {
623
+ return undefined;
624
+ }
625
+ if (
626
+ context?.skipPurls?.has(p.purl) ||
627
+ context?.skipPurls?.has(p["bom-ref"])
628
+ ) {
629
+ return undefined;
630
+ }
631
+ let repositoryUrl;
632
+ const query = p.purl?.includes("?") ? p.purl.split("?")[1] : "";
633
+ if (query) {
634
+ repositoryUrl = new URLSearchParams(query.split("#")[0]).get(
635
+ "repository_url",
636
+ );
366
637
  }
638
+ if (repositoryUrl?.startsWith("file:")) {
639
+ return undefined;
640
+ }
641
+ let repositoryHost;
642
+ try {
643
+ repositoryHost = repositoryUrl ? new URL(repositoryUrl).hostname : "";
644
+ } catch (_err) {
645
+ repositoryHost = "";
646
+ }
647
+ if (
648
+ ["maven.google.com", "dl.google.com"].includes(repositoryHost) ||
649
+ group.includes("android") ||
650
+ GOOGLE_MAVEN_GROUP_PREFIXES.some(
651
+ (prefix) => group === prefix || group.startsWith(prefix),
652
+ )
653
+ ) {
654
+ return repos.ANDROID_MAVEN_URL;
655
+ }
656
+ return repos.MAVEN_CENTRAL_URL;
657
+ }
367
658
 
368
- // 3. Coursier cache (SBT)
369
- let cacheRoot = readEnvironmentVariable("COURSIER_CACHE");
370
- if (!cacheRoot) {
371
- if (process.platform === "darwin") {
372
- cacheRoot = join(homedir(), "Library", "Caches", "Coursier", "v1");
373
- } else if (process.platform === "win32") {
374
- const localAppData = readEnvironmentVariable("LOCALAPPDATA");
375
- if (localAppData) {
376
- cacheRoot = join(localAppData, "Coursier", "Cache", "v1");
377
- } else {
378
- cacheRoot = join(
379
- homedir(),
380
- "AppData",
381
- "Local",
382
- "Coursier",
383
- "Cache",
384
- "v1",
385
- );
386
- }
387
- } else {
388
- cacheRoot = join(homedir(), ".cache", "coursier", "v1");
389
- }
659
+ /**
660
+ * Whether a package already carries licence information, in either the
661
+ * `license` field cdxgen fills or the CycloneDX `licenses` array that the
662
+ * cyclonedx-maven-plugin and Quarkus emit.
663
+ *
664
+ * @param {Object} p Package.
665
+ * @returns {boolean}
666
+ */
667
+ function hasMavenLicense(p) {
668
+ if (Array.isArray(p.license)) {
669
+ return p.license.some(Boolean);
390
670
  }
671
+ return Boolean(p.license) || Boolean(p.licenses?.length);
672
+ }
391
673
 
392
- if (existsSync(cacheRoot)) {
393
- const pattern = `**/${groupPath}/${name}/${version}/*`;
394
- try {
395
- const matches = globSync(pattern, { cwd: cacheRoot });
396
- if (matches && matches.length > 0) {
397
- let jarPath = null;
398
- let pomPath = null;
399
- for (const m of matches) {
400
- const match = m.replace(/\\/g, "/");
401
- if (
402
- match.endsWith(".jar") &&
403
- !match.endsWith("-sources.jar") &&
404
- !match.endsWith("-javadoc.jar")
405
- ) {
406
- const localPath = join(cacheRoot, match);
407
- if (existsSync(localPath)) {
408
- jarPath = localPath;
409
- }
410
- } else if (match.endsWith(".pom")) {
411
- const localPath = join(cacheRoot, match);
412
- if (existsSync(localPath)) {
413
- pomPath = localPath;
414
- }
415
- }
416
- }
417
- if (jarPath || pomPath) {
418
- return { jarPath, pomPath };
419
- }
420
- }
421
- } catch (_err) {
422
- // ignore
423
- }
674
+ /**
675
+ * Set the licences a jar manifest's Bundle-License names on a package: one
676
+ * value as is, several as a list.
677
+ *
678
+ * @param {Object} p Package.
679
+ * @param {string[]|undefined} licenses Values from parseManifestLicenseList.
680
+ */
681
+ function applyManifestLicenses(p, licenses) {
682
+ if (licenses?.length === 1) {
683
+ p.license = licenses[0];
684
+ } else if (licenses?.length > 1) {
685
+ p.license = licenses;
424
686
  }
687
+ }
425
688
 
426
- return null;
689
+ /**
690
+ * Collapse the whitespace a POM description carries over from XML indentation.
691
+ *
692
+ * @param {string} description Raw description.
693
+ * @returns {string} Tidied description.
694
+ */
695
+ function tidyPomDescription(description) {
696
+ return description
697
+ .replace(/[ \t]+/g, " ")
698
+ .replace(/^[ \t]+|[ \t]+$/gm, "")
699
+ .replace(/\n\s*\n/g, "\n")
700
+ .trim();
427
701
  }
428
702
 
429
703
  /**
430
- * Method to retrieve metadata for maven packages by querying maven central
704
+ * The licence a POM names in a comment ahead of its project element, as some
705
+ * projects do instead of declaring `<licenses>`.
431
706
  *
432
- * @param {Array} pkgList Package list
433
- * @param {Object} jarNSMapping Jar Namespace mapping object
434
- * @param {Boolean} force Force fetching of license
707
+ * @param {string} pomXml POM text.
708
+ * @returns {string|undefined} Licence id.
709
+ */
710
+ function licenseFromPomComment(pomXml) {
711
+ const match = /<!--([\s\S]*?)-->[\s\n]*<project/m.exec(pomXml || "");
712
+ return match?.[1] ? findLicenseId(match[1].trim()) : undefined;
713
+ }
714
+
715
+ /**
716
+ * Fill publisher, description, repository and licence from a merged POM,
717
+ * keeping any value the package already has.
435
718
  *
436
- * @returns {Array} Updated package list
719
+ * @param {Object} p Package.
720
+ * @param {Object} pomJson Merged POM (xml2js compact form).
721
+ * @param {string} [ownPomXml] The package's own POM text, for a licence comment.
722
+ * @returns {void}
437
723
  */
438
- export async function getMvnMetadata(
439
- pkgList,
440
- jarNSMapping = {},
441
- force = false,
442
- ) {
443
- const MAVEN_CENTRAL_URL =
444
- readEnvironmentVariable("MAVEN_CENTRAL_URL") ||
445
- "https://repo1.maven.org/maven2/";
446
- const ANDROID_MAVEN_URL =
447
- readEnvironmentVariable("ANDROID_MAVEN_URL") || "https://maven.google.com/";
448
- const cdepList = [];
449
- if (!pkgList?.length) {
450
- return pkgList;
724
+ function applyPomMetadata(p, pomJson, ownPomXml) {
725
+ if (!pomJson) {
726
+ return;
451
727
  }
452
- if (DEBUG_MODE && shouldFetchLicense()) {
453
- console.log(`About to query maven for ${pkgList.length} packages`);
728
+ if (!p.publisher && pomJson.organization?.name?._) {
729
+ p.publisher = pomJson.organization.name._;
454
730
  }
455
- // Collect the direct POM URLs the loop is going to need and fetch them in
456
- // one batched round. Only the direct POMs are prefetched: parent POMs (walked
457
- // inside fetchPomXmlAsJson) depend on the direct POM's content and stay
458
- // serial. The conditions below mirror the loop's skip checks so the batch
459
- // does not request anything the loop would skip.
460
- const batchUrls = [];
461
- const seenPomUrls = new Set();
462
- for (const p of pkgList) {
463
- const group = p.group || "";
464
- if (
465
- !p.version ||
466
- (group && p.name && p.version && !shouldFetchLicense() && !force) ||
467
- (group && p.name && p.version && p.license)
468
- ) {
469
- continue;
470
- }
471
- if (!group || group === "") {
472
- continue;
473
- }
474
- const urlPrefix =
475
- group.indexOf("android") !== -1 ? ANDROID_MAVEN_URL : MAVEN_CENTRAL_URL;
476
- const pomUrl = composePomXmlUrl({
477
- urlPrefix,
478
- group,
479
- name: p.name,
480
- version: p.version,
481
- });
482
- if (!seenPomUrls.has(pomUrl)) {
483
- seenPomUrls.add(pomUrl);
484
- batchUrls.push({ url: pomUrl, responseType: "text" });
485
- }
731
+ if (!p.description && pomJson.description?._) {
732
+ p.description = tidyPomDescription(pomJson.description._);
486
733
  }
487
- const ownPomUrls = [];
488
- if (batchUrls.length) {
489
- const fetched = await prefetchJson(batchUrls);
490
- const repoUrls = [];
491
- for (const [key, value] of fetched) {
492
- prefetchedPoms.set(key, value);
493
- ownPomUrls.push(key);
494
- const scmUrl = pomScmUrlNeedingLicenseLookup(value);
495
- if (scmUrl) {
496
- repoUrls.push(scmUrl);
497
- }
734
+ if (!p.repository && pomJson.scm?.url?._) {
735
+ p.repository = { url: pomJson.scm.url._ };
736
+ }
737
+ if (!hasMavenLicense(p)) {
738
+ const license =
739
+ parseLicenseEntryOrArrayFromPomXml(pomJson.licenses?.license) ||
740
+ licenseFromPomComment(ownPomXml);
741
+ if (license) {
742
+ p.license = license;
498
743
  }
499
- // The POMs are in hand, so the licence fallback's repository URLs are known
500
- // without a second network round for the POMs themselves — the same second
501
- // round getNpmMetadata does. Only a POM that declares no licence and no
502
- // licence comment reaches getRepoLicense, and only its own scm element is
503
- // read here, so this never fetches a licence the serial path would not.
504
- await prefetchRepoLicenses(repoUrls);
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Read a POM from the local Maven, Gradle or Coursier cache.
749
+ *
750
+ * @param {string} group groupId.
751
+ * @param {string} name artifactId.
752
+ * @param {string} version Version.
753
+ * @returns {string|undefined} POM text.
754
+ */
755
+ function readLocalPom(group, name, version) {
756
+ const pomPath = findLocalMavenArtifact(group, name, version)?.pomPath;
757
+ if (!pomPath) {
758
+ return undefined;
505
759
  }
506
760
  try {
507
- return await enrichMvnPackages(pkgList, cdepList, jarNSMapping, force, {
508
- MAVEN_CENTRAL_URL,
509
- ANDROID_MAVEN_URL,
510
- });
511
- } finally {
512
- // Each body is read once, by the loop below. Holding them past that only
513
- // grows the process, which matters in server mode where one process serves
514
- // many scans. Only the documents this call fetched are dropped, so a scan
515
- // running alongside this one keeps its own.
516
- for (const pomUrl of ownPomUrls) {
517
- prefetchedPoms.delete(pomUrl);
518
- }
761
+ return readFileSync(pomPath, "utf-8");
762
+ } catch (_err) {
763
+ return undefined;
519
764
  }
520
765
  }
521
766
 
522
767
  /**
523
- * Enrich Maven packages from their POMs, reading any document already fetched
524
- * by {@link getMvnMetadata}'s batch round.
768
+ * Load a POM and its parents and merge them child over parent.
525
769
  *
526
- * @param {Array} pkgList Packages to enrich.
527
- * @param {Array} cdepList Accumulator for the enriched packages.
528
- * @param {Object} jarNSMapping Namespace and hash data from jar analysis.
529
- * @param {Boolean} force Whether to query even when metadata is present.
530
- * @param {{MAVEN_CENTRAL_URL: string, ANDROID_MAVEN_URL: string}} repos Repository
531
- * base URLs resolved by the caller from the environment.
532
- * @returns {Promise<Array>} The enriched package list.
770
+ * Every level is read from the local caches first; a build has always
771
+ * downloaded the parents it needed. A level missing locally is fetched only
772
+ * when `remote` is set, and at most `remoteParents` parent levels are fetched,
773
+ * so a cache miss costs no more requests than it always did. The walk stops
774
+ * once licence, organization and scm are all known. A missing parent ends the
775
+ * walk without discarding what the child and nearer parents declared.
776
+ *
777
+ * @param {{urlPrefix?: string, group: string, name: string, version: string}} coordinates
778
+ * @param {{remote?: boolean, remoteParents?: number}} [opts]
779
+ * @returns {Promise<{merged: Object, ownXml: string}|undefined>}
533
780
  */
534
- async function enrichMvnPackages(
535
- pkgList,
536
- cdepList,
537
- jarNSMapping,
538
- force,
539
- { MAVEN_CENTRAL_URL, ANDROID_MAVEN_URL },
781
+ async function loadPomChain(
782
+ coordinates,
783
+ { remote = false, remoteParents = 1 } = {},
540
784
  ) {
541
- for (const p of pkgList) {
542
- // Reuse any namespace and hashes data from jarNSMapping
543
- if (jarNSMapping && p.purl && jarNSMapping[p.purl]) {
544
- if (jarNSMapping[p.purl].jarFile) {
545
- p.evidence = {
546
- identity: {
547
- field: "purl",
548
- confidence: 0.8,
549
- methods: [
550
- {
551
- technique: "binary-analysis",
552
- confidence: 0.8,
553
- value: jarNSMapping[p.purl].jarFile,
554
- },
555
- ],
556
- },
557
- };
558
- }
559
- if (jarNSMapping[p.purl].hashes && !p?.hashes?.length) {
560
- p.hashes = jarNSMapping[p.purl].hashes;
561
- }
562
- if (
563
- jarNSMapping[p.purl].namespaces &&
564
- jarNSMapping[p.purl].namespaces.length
565
- ) {
566
- if (!p.properties) {
567
- p.properties = [];
568
- }
569
- p.properties.push({
570
- name: "internal:Namespaces",
571
- value: jarNSMapping[p.purl].namespaces.join("\n"),
572
- });
785
+ const chain = [];
786
+ let current = coordinates;
787
+ let remoteParentAttempts = 0;
788
+ for (let depth = 0; current && depth < MAX_POM_PARENT_DEPTH; depth++) {
789
+ let xml = readLocalPom(current.group, current.name, current.version);
790
+ if (
791
+ !xml &&
792
+ remote &&
793
+ coordinates.urlPrefix &&
794
+ (depth === 0 || remoteParentAttempts < remoteParents)
795
+ ) {
796
+ if (depth > 0) {
797
+ remoteParentAttempts++;
573
798
  }
799
+ xml = await fetchRemotePomXml({
800
+ urlPrefix: coordinates.urlPrefix,
801
+ group: current.group,
802
+ name: current.name,
803
+ version: current.version,
804
+ });
574
805
  }
575
- const localArtifact = findLocalMvnArtifact(p.group, p.name, p.version);
576
- if (localArtifact) {
577
- if (localArtifact.jarPath && (!p.hashes || p.hashes.length === 0)) {
578
- try {
579
- const hashValues = await multiChecksumFile(
580
- ["md5", "sha1", "sha256", "sha512"],
581
- localArtifact.jarPath,
582
- );
583
- p.hashes = [
584
- { alg: "MD5", content: hashValues["md5"] },
585
- { alg: "SHA-1", content: hashValues["sha1"] },
586
- { alg: "SHA-256", content: hashValues["sha256"] },
587
- { alg: "SHA-512", content: hashValues["sha512"] },
588
- ];
589
- } catch (_err) {
590
- if (localArtifact.sha1) {
591
- p.hashes = [{ alg: "SHA-1", content: localArtifact.sha1 }];
592
- }
593
- }
594
- }
595
- if (localArtifact.pomPath) {
596
- try {
597
- const xmlData = readFileSync(localArtifact.pomPath, "utf-8");
598
- const pomData = parsePomXml(xmlData);
599
- if (pomData) {
600
- if (!p.publisher && pomData.organization?.name?._) {
601
- p.publisher = pomData.organization.name._;
602
- }
603
- if (!p.description && pomData.description) {
604
- p.description = pomData.description
605
- .replace(/[ \t]+/g, " ")
606
- .replace(/^[ \t]+|[ \t]+$/gm, "")
607
- .replace(/\n\s*\n/g, "\n")
608
- .trim();
609
- }
610
- if (!p.repository && pomData.scm) {
611
- p.repository = { url: pomData.scm };
612
- }
613
- if (!p.license) {
614
- const parsedLicense = parseLicenseEntryOrArrayFromPomXml(
615
- pomData.licenses,
616
- );
617
- if (parsedLicense) {
618
- p.license = parsedLicense;
619
- } else {
620
- const licenseRegex = /<!--([\s\S]*?)-->[\s\n]*<project/m;
621
- const match = licenseRegex.exec(xmlData);
622
- if (match?.[1]) {
623
- const commentLicense = findLicenseId(match[1].trim());
624
- if (commentLicense) {
625
- p.license = commentLicense;
626
- }
627
- }
628
- }
629
- }
630
- }
631
- } catch (_err) {
632
- // ignore
633
- }
634
- }
806
+ if (!xml) {
807
+ break;
635
808
  }
636
- const group = p.group || "";
637
- // If the package already has key metadata skip querying maven
809
+ let json;
810
+ try {
811
+ json = xml2js(xml, POM_XML_OPTIONS).project;
812
+ } catch (_err) {
813
+ json = undefined;
814
+ }
815
+ if (!json) {
816
+ break;
817
+ }
818
+ chain.push({ json, xml });
819
+ const merged = mergePomChain(chain);
638
820
  if (
639
- !p.version ||
640
- (group && p.name && p.version && !shouldFetchLicense() && !force) ||
641
- (group && p.name && p.version && p.license)
821
+ parseLicenseEntryOrArrayFromPomXml(merged.licenses?.license) &&
822
+ merged.organization?.name?._ &&
823
+ merged.scm?.url?._
642
824
  ) {
643
- cdepList.push(p);
825
+ break;
826
+ }
827
+ const parent = json.parent;
828
+ current =
829
+ parent?.groupId?._ && parent?.artifactId?._ && parent?.version?._
830
+ ? {
831
+ group: parent.groupId._,
832
+ name: parent.artifactId._,
833
+ version: parent.version._,
834
+ }
835
+ : undefined;
836
+ }
837
+ if (!chain.length) {
838
+ return undefined;
839
+ }
840
+ return { merged: mergePomChain(chain), ownXml: chain[0].xml };
841
+ }
842
+
843
+ /**
844
+ * Merge POM levels so that nearer levels win, as Maven inheritance does for
845
+ * the elements cdxgen reads.
846
+ *
847
+ * @param {Array<{json: Object}>} chain Levels, child first.
848
+ * @returns {Object} Merged POM.
849
+ */
850
+ function mergePomChain(chain) {
851
+ let merged = {};
852
+ for (let i = chain.length - 1; i >= 0; i--) {
853
+ merged = { ...merged, ...chain[i].json };
854
+ }
855
+ return merged;
856
+ }
857
+
858
+ /**
859
+ * A lookup of the jar namespace entry for a package. The jar data is keyed by the
860
+ * POM coordinates of the jar, while sbt (and the Mill and scala-cli paths)
861
+ * emit components with the Scala binary suffix stripped and a repository_url
862
+ * qualifier. Both spellings resolve to the same qualifier-free coordinate
863
+ * key, with the exact purl still winning when it matches. A cache can hold
864
+ * the same library for several Scala versions (`foo_2.13` and `foo_3`); the
865
+ * jar whose binary version matches the component's cdx:scala:compilerVersion
866
+ * speaks for it, and when that cannot be told only the namespaces, which the
867
+ * cross builds share, are taken.
868
+ *
869
+ * @param {Object} jarNSMapping Namespace and hash data from jar analysis.
870
+ * @returns {(p: Object) => {entry?: Object, exact?: Boolean}} Entry lookup.
871
+ */
872
+ function scalaJarEntryLookup(jarNSMapping) {
873
+ const coordinateIndex = new Map();
874
+ for (const apurl of Object.keys(jarNSMapping || {})) {
875
+ const coordinate = scalaCoordinateOfPurl(apurl);
876
+ if (!coordinate) {
644
877
  continue;
645
878
  }
646
- let urlPrefix = MAVEN_CENTRAL_URL;
647
- // Ideally we should try one resolver after the other. But it increases the time taken
648
- if (group.indexOf("android") !== -1) {
649
- urlPrefix = ANDROID_MAVEN_URL;
879
+ if (!coordinateIndex.has(coordinate.key)) {
880
+ coordinateIndex.set(coordinate.key, new Map());
881
+ }
882
+ const byBinaryVersion = coordinateIndex.get(coordinate.key);
883
+ const binaryVersion = coordinate.binaryVersion || "";
884
+ if (!byBinaryVersion.has(binaryVersion)) {
885
+ byBinaryVersion.set(binaryVersion, apurl);
886
+ }
887
+ }
888
+ return (pkg) => {
889
+ if (!pkg.purl) {
890
+ return undefined;
891
+ }
892
+ if (jarNSMapping[pkg.purl]) {
893
+ return { entry: jarNSMapping[pkg.purl], exact: true };
894
+ }
895
+ const byBinaryVersion = coordinateIndex.get(
896
+ scalaCoordinateOfPurl(pkg.purl)?.key,
897
+ );
898
+ if (!byBinaryVersion) {
899
+ return undefined;
900
+ }
901
+ const wanted = pkg.properties?.find(
902
+ (prop) => prop.name === "cdx:scala:compilerVersion",
903
+ )?.value;
904
+ if (wanted && byBinaryVersion.has(wanted)) {
905
+ return { entry: jarNSMapping[byBinaryVersion.get(wanted)], exact: true };
906
+ }
907
+ const candidates = [...byBinaryVersion.values()];
908
+ return {
909
+ entry: jarNSMapping[candidates[0]],
910
+ exact: candidates.length === 1,
911
+ };
912
+ };
913
+ }
914
+
915
+ /**
916
+ * Enrich one package from data already on disk: the jar namespace map, the
917
+ * POM that jar analysis parsed, the local jar's hashes and the local POM
918
+ * chain. No request is made.
919
+ *
920
+ * @param {Object} p Package.
921
+ * @param {Function} jarNSEntryFor Jar namespace entry lookup for a package.
922
+ * @returns {Promise<void>}
923
+ */
924
+ async function enrichMvnPackageLocally(p, jarNSEntryFor) {
925
+ const { entry: nsEntry, exact } = jarNSEntryFor(p) || {};
926
+ if (nsEntry) {
927
+ // A jar copied into a temporary directory, as Maven --deep copies the
928
+ // dependencies, is deleted with the scan, and its path would make the BOM
929
+ // differ between two runs. The identity the dependency tree gave the
930
+ // package stands.
931
+ if (nsEntry.jarFile && exact && !isInsideTmpDir(nsEntry.jarFile)) {
932
+ p.evidence = {
933
+ identity: {
934
+ field: "purl",
935
+ confidence: 0.8,
936
+ methods: [
937
+ {
938
+ technique: "binary-analysis",
939
+ confidence: 0.8,
940
+ value: nsEntry.jarFile,
941
+ },
942
+ ],
943
+ },
944
+ };
945
+ }
946
+ if (nsEntry.hashes && exact && !p?.hashes?.length) {
947
+ p.hashes = nsEntry.hashes;
650
948
  }
651
- // Querying maven requires a valid group name
652
- if (!group || group === "") {
653
- cdepList.push(p);
654
- continue;
949
+ if (nsEntry.namespaces?.length) {
950
+ if (!p.properties) {
951
+ p.properties = [];
952
+ }
953
+ p.properties.push({
954
+ name: "internal:Namespaces",
955
+ value: nsEntry.namespaces.join("\n"),
956
+ });
655
957
  }
656
- const pomMetadata = {
657
- urlPrefix: urlPrefix,
658
- group: group,
659
- name: p.name,
660
- version: p.version,
661
- };
662
- try {
663
- if (DEBUG_MODE) {
664
- console.log(
665
- `Querying ${pomMetadata.urlPrefix} for '${group}/${p.name}@${p.version}' ${composePomXmlUrl(
666
- pomMetadata,
667
- )}`,
668
- );
958
+ // The POM jar analysis already parsed.
959
+ const nsPom = nsEntry.pom;
960
+ if (nsPom) {
961
+ if (!p.publisher && nsPom.organization?.name?._) {
962
+ p.publisher = nsPom.organization.name._;
669
963
  }
670
- const bodyJson = await fetchPomXmlAsJson(pomMetadata);
671
- if (bodyJson) {
672
- p.publisher = bodyJson?.organization?.name
673
- ? bodyJson?.organization.name._
674
- : "";
675
- p.description = bodyJson?.description
676
- ? bodyJson.description._.replace(/[ \t]+/g, " ")
677
- .replace(/^[ \t]+|[ \t]+$/gm, "")
678
- .replace(/\n\s*\n/g, "\n")
679
- .trim()
680
- : "";
681
- if (bodyJson?.scm?.url) {
682
- p.repository = { url: bodyJson.scm.url._ };
964
+ if (!p.description && nsPom.description) {
965
+ p.description = tidyPomDescription(nsPom.description);
966
+ }
967
+ if (!p.repository && nsPom.scm) {
968
+ p.repository = { url: nsPom.scm };
969
+ }
970
+ if (!hasMavenLicense(p)) {
971
+ const license = parseLicenseEntryOrArrayFromPomXml(nsPom.licenses);
972
+ if (license) {
973
+ p.license = license;
683
974
  }
684
- p.license =
685
- parseLicenseEntryOrArrayFromPomXml(bodyJson?.licenses?.license) ||
686
- (await extractLicenseCommentFromPomXml(pomMetadata)) ||
687
- (await getRepoLicense(p.repository?.url, undefined));
688
975
  }
689
- } catch (err) {
690
- if (DEBUG_MODE) {
691
- console.log(
692
- `An error occurred when trying to fetch metadata ${pomMetadata}`,
693
- err,
694
- );
976
+ }
977
+ // The jar's manifest names its licences even when no POM was parsed. A
978
+ // licence the package already has always wins.
979
+ if (!hasMavenLicense(p)) {
980
+ applyManifestLicenses(p, nsEntry.manifestLicenses);
981
+ }
982
+ }
983
+ // Scala components are named without their Scala binary suffix, while the
984
+ // published artifact carries it. The published name is looked up first and
985
+ // the component name is the local fallback; a disk read costs nothing.
986
+ const publishedName = publishedArtifactId(p);
987
+ const primaryArtifact = findLocalMvnArtifact(
988
+ p.group,
989
+ publishedName,
990
+ p.version,
991
+ );
992
+ const localArtifact =
993
+ primaryArtifact ||
994
+ (publishedName !== p.name
995
+ ? findLocalMvnArtifact(p.group, p.name, p.version)
996
+ : null);
997
+ const pomChainName = primaryArtifact ? publishedName : p.name;
998
+ if (localArtifact?.jarPath && (!p.hashes || p.hashes.length === 0)) {
999
+ try {
1000
+ const hashValues = await multiChecksumFile(
1001
+ ["md5", "sha1", "sha256", "sha512"],
1002
+ localArtifact.jarPath,
1003
+ );
1004
+ p.hashes = [
1005
+ { alg: "MD5", content: hashValues["md5"] },
1006
+ { alg: "SHA-1", content: hashValues["sha1"] },
1007
+ { alg: "SHA-256", content: hashValues["sha256"] },
1008
+ { alg: "SHA-512", content: hashValues["sha512"] },
1009
+ ];
1010
+ } catch (_err) {
1011
+ if (localArtifact.sha1) {
1012
+ p.hashes = [{ alg: "SHA-1", content: localArtifact.sha1 }];
695
1013
  }
696
- } finally {
697
- cdepList.push(p);
698
1014
  }
699
1015
  }
700
- return cdepList;
1016
+ if (localArtifact?.pomPath) {
1017
+ const chain = await loadPomChain(
1018
+ { group: p.group, name: pomChainName, version: p.version },
1019
+ { remote: false },
1020
+ );
1021
+ applyPomMetadata(p, chain?.merged, chain?.ownXml);
1022
+ }
701
1023
  }
702
1024
 
703
1025
  /**
704
- * The repository URL a prefetched POM will need a GitHub licence lookup for.
1026
+ * Method to retrieve metadata for maven packages, from the local caches first
1027
+ * and from Maven Central only for what remains.
705
1028
  *
706
- * Mirrors the order `enrichMvnPackages` resolves a licence in: a `<licenses>`
707
- * element wins, then a licence comment, and only a POM offering neither falls
708
- * through to `getRepoLicense`. A POM whose `scm` element is inherited from a
709
- * parent is not covered, since the parent document is not prefetched.
1029
+ * Every package is first enriched from data on disk. Only when license
1030
+ * fetching is enabled (FETCH_LICENSE) or `force` is set are the packages
1031
+ * still without a licence looked up remotely, and then only those a public
1032
+ * repository can hold.
1033
+ *
1034
+ * @param {Array} pkgList Package list
1035
+ * @param {Object} jarNSMapping Jar Namespace mapping object
1036
+ * @param {Boolean} force Force fetching of license
1037
+ * @param {{skipPurls?: Set<string>}} [context] Purls or bom-refs of the
1038
+ * project's own modules, which are never looked up remotely.
710
1039
  *
711
- * @param {{ok?: boolean, body?: String}} entry A prefetched POM batch entry.
712
- * @returns {String|undefined} The scm URL to prefetch a licence for, if any.
1040
+ * @returns {Array} Updated package list
713
1041
  */
714
- function pomScmUrlNeedingLicenseLookup(entry) {
715
- if (!entry?.ok || !entry.body) {
716
- return undefined;
1042
+ export async function getMvnMetadata(
1043
+ pkgList,
1044
+ jarNSMapping = {},
1045
+ force = false,
1046
+ context = {},
1047
+ ) {
1048
+ if (!pkgList?.length) {
1049
+ return pkgList;
717
1050
  }
718
- let pomJson;
719
- try {
720
- pomJson = xml2js(entry.body, {
721
- compact: true,
722
- spaces: 4,
723
- textKey: "_",
724
- attributesKey: "$",
725
- commentKey: "value",
726
- }).project;
727
- } catch (_err) {
728
- return undefined;
1051
+ const repos = {
1052
+ MAVEN_CENTRAL_URL: withTrailingSlash(
1053
+ readEnvironmentVariable("MAVEN_CENTRAL_URL") ||
1054
+ "https://repo1.maven.org/maven2/",
1055
+ ),
1056
+ ANDROID_MAVEN_URL: withTrailingSlash(
1057
+ readEnvironmentVariable("ANDROID_MAVEN_URL") ||
1058
+ "https://maven.google.com/",
1059
+ ),
1060
+ };
1061
+ const jarNSEntryFor = scalaJarEntryLookup(jarNSMapping);
1062
+ for (const p of pkgList) {
1063
+ await enrichMvnPackageLocally(p, jarNSEntryFor);
729
1064
  }
730
- if (parseLicenseEntryOrArrayFromPomXml(pomJson?.licenses?.license)) {
731
- return undefined;
1065
+ if (!shouldFetchLicense() && !force) {
1066
+ return pkgList;
732
1067
  }
733
- const commentMatch = /<!--([\s\S]*?)-->[\s\n]*<project/m.exec(entry.body);
734
- if (commentMatch?.[1] && findLicenseId(commentMatch[1].trim())) {
735
- return undefined;
1068
+ const remoteItems = [];
1069
+ for (const p of pkgList) {
1070
+ if (hasMavenLicense(p)) {
1071
+ continue;
1072
+ }
1073
+ const urlPrefix = mavenRepositoryFor(p, repos, context);
1074
+ if (urlPrefix) {
1075
+ // The remote round asks only for the published name: the component
1076
+ // name has no POM where the artifact is suffixed.
1077
+ remoteItems.push({
1078
+ p,
1079
+ coordinates: {
1080
+ urlPrefix,
1081
+ group: p.group,
1082
+ name: publishedArtifactId(p),
1083
+ version: p.version,
1084
+ },
1085
+ });
1086
+ }
1087
+ }
1088
+ if (!remoteItems.length) {
1089
+ return pkgList;
1090
+ }
1091
+ if (DEBUG_MODE) {
1092
+ console.log(
1093
+ `About to query maven for ${remoteItems.length} of ${pkgList.length} packages`,
1094
+ );
1095
+ }
1096
+ // The direct POMs not on disk are fetched in one batched round. Parent URLs
1097
+ // depend on the direct POM's content, so parents are read in the loop below.
1098
+ const batchUrls = [];
1099
+ const seenPomUrls = new Set();
1100
+ for (const { coordinates } of remoteItems) {
1101
+ const pomUrl = composePomXmlUrl(coordinates);
1102
+ if (
1103
+ seenPomUrls.has(pomUrl) ||
1104
+ readLocalPom(coordinates.group, coordinates.name, coordinates.version)
1105
+ ) {
1106
+ continue;
1107
+ }
1108
+ seenPomUrls.add(pomUrl);
1109
+ batchUrls.push({ url: pomUrl, responseType: "text" });
1110
+ }
1111
+ const ownPomUrls = [];
1112
+ if (batchUrls.length) {
1113
+ for (const [key, value] of await prefetchJson(batchUrls)) {
1114
+ prefetchedPoms.set(key, value);
1115
+ ownPomUrls.push(key);
1116
+ }
1117
+ }
1118
+ try {
1119
+ const needRepoLicense = [];
1120
+ for (const item of remoteItems) {
1121
+ try {
1122
+ const chain = await loadPomChain(item.coordinates, {
1123
+ remote: true,
1124
+ remoteParents: 1,
1125
+ });
1126
+ applyPomMetadata(item.p, chain?.merged, chain?.ownXml);
1127
+ } catch (err) {
1128
+ if (DEBUG_MODE) {
1129
+ console.log(
1130
+ `An error occurred when trying to fetch metadata for ${item.p.group}/${item.p.name}@${item.p.version}`,
1131
+ err,
1132
+ );
1133
+ }
1134
+ }
1135
+ if (!hasMavenLicense(item.p) && item.p.repository?.url) {
1136
+ needRepoLicense.push(item.p);
1137
+ }
1138
+ }
1139
+ // The repository licence is the last resort, asked for only once the POM
1140
+ // and all of its parents have been read, and batched.
1141
+ if (needRepoLicense.length) {
1142
+ await prefetchRepoLicenses(needRepoLicense.map((p) => p.repository.url));
1143
+ for (const p of needRepoLicense) {
1144
+ const license = await getRepoLicense(p.repository.url, undefined);
1145
+ if (license) {
1146
+ p.license = license;
1147
+ }
1148
+ }
1149
+ }
1150
+ } finally {
1151
+ // Each body is read once, by the loop above. Holding them past that only
1152
+ // grows the process, which matters in server mode where one process serves
1153
+ // many scans. Only the documents this call fetched are dropped, so a scan
1154
+ // running alongside this one keeps its own.
1155
+ for (const pomUrl of ownPomUrls) {
1156
+ prefetchedPoms.delete(pomUrl);
1157
+ }
736
1158
  }
737
- return pomJson?.scm?.url?._;
1159
+ return pkgList;
738
1160
  }
739
1161
 
740
1162
  /**
@@ -753,7 +1175,10 @@ export function composePomXmlUrl({ urlPrefix, group, name, version }) {
753
1175
  }
754
1176
 
755
1177
  /**
756
- * Method to fetch pom.xml data and parse it to JSON
1178
+ * Method to fetch pom.xml data and parse it to JSON, merged with its parents.
1179
+ *
1180
+ * Each level is read from the local caches first. When a parent cannot be
1181
+ * found, the child and any nearer parents are still returned.
757
1182
  *
758
1183
  * @param {String} urlPrefix
759
1184
  * @param {String} group
@@ -763,76 +1188,101 @@ export function composePomXmlUrl({ urlPrefix, group, name, version }) {
763
1188
  * @return {Object|undefined}
764
1189
  */
765
1190
  export async function fetchPomXmlAsJson({ urlPrefix, group, name, version }) {
766
- const pomXml = await fetchPomXml({ urlPrefix, group, name, version });
767
- if (!pomXml) {
768
- return undefined;
769
- }
770
- const options = {
771
- compact: true,
772
- spaces: 4,
773
- textKey: "_",
774
- attributesKey: "$",
775
- commentKey: "value",
776
- };
777
- const pomJson = xml2js(pomXml, options).project;
778
- if (pomJson?.parent) {
779
- const parentXml = await fetchPomXml({
780
- urlPrefix,
781
- group: pomJson.parent.groupId?._,
782
- name: pomJson.parent.artifactId?._,
783
- version: pomJson.parent.version?._,
784
- });
785
- if (!parentXml) {
786
- return undefined;
1191
+ const chain = await loadPomChain(
1192
+ {
1193
+ urlPrefix: urlPrefix ? withTrailingSlash(urlPrefix) : undefined,
1194
+ group,
1195
+ name,
1196
+ version,
1197
+ },
1198
+ { remote: Boolean(urlPrefix), remoteParents: 1 },
1199
+ );
1200
+ return chain?.merged;
1201
+ }
1202
+
1203
+ /**
1204
+ * Fetch one document through the batch pool, so that it shares the pool's rate
1205
+ * gate, failure memo and disk cache, and make a gated request of its own only
1206
+ * when the pool could not reach the host.
1207
+ *
1208
+ * @param {string} url Document URL.
1209
+ * @param {"json"|"text"} [responseType] How to decode the body.
1210
+ * @returns {Promise<{body: *}>} The response.
1211
+ * @throws {Error} When the server answered with an error status.
1212
+ */
1213
+ async function pooledDocument(url, responseType = "json") {
1214
+ if (prefetchEnabled()) {
1215
+ const res = prefetchedResponse(
1216
+ await prefetchJson([{ url, responseType }]),
1217
+ url,
1218
+ );
1219
+ if (res) {
1220
+ return res;
787
1221
  }
788
- const parentJson = xml2js(parentXml, options).project;
789
- return { ...parentJson, ...pomJson };
790
1222
  }
791
- return pomJson;
1223
+ return await gatedGet(url, { responseType });
792
1224
  }
793
1225
 
794
1226
  /**
795
1227
  * Prefetched POM documents, populated by `getMvnMetadata` before its loop and
796
- * read by `fetchPomXml`. Module state rather than a parameter because
797
- * `fetchPomXml` is called from `fetchPomXmlAsJson` (direct + parent) and
798
- * `extractLicenseCommentFromPomXml`, and threading a map through all of them
799
- * would be a wide change for no gain.
1228
+ * read by `fetchRemotePomXml`. Module state rather than a parameter because
1229
+ * the POM readers are reached from several exported entry points.
800
1230
  *
801
1231
  * Only the *direct* POMs — the ones whose URLs are known before the loop — are
802
- * prefetched. The parent POM walk inside `fetchPomXmlAsJson` depends on
803
- * parsing the direct POM's content, so those URLs are not known up front and
804
- * remain serial.
1232
+ * prefetched. Parent URLs depend on the direct POM's content, so they are not
1233
+ * known up front and are read serially.
805
1234
  */
806
1235
  const prefetchedPoms = new Map();
807
1236
 
808
1237
  /**
809
- * Method to fetch pom.xml data
810
- *
811
- * @param {String} urlPrefix
812
- * @param {String} group
813
- * @param {String} name
814
- * @param {String} version
1238
+ * Fetch a POM from a remote repository, using a prefetched copy when there is
1239
+ * one. A POM that answered 404 or 410 is not asked for again this run, so
1240
+ * packages sharing a missing parent ask once.
815
1241
  *
816
- * @return {Promise<String>}
1242
+ * @param {{urlPrefix: string, group: string, name: string, version: string}} coordinates
1243
+ * @returns {Promise<string|undefined>} POM text.
817
1244
  */
818
- export async function fetchPomXml({ urlPrefix, group, name, version }) {
1245
+ async function fetchRemotePomXml({ urlPrefix, group, name, version }) {
1246
+ if (!urlPrefix || !group || !name || !version) {
1247
+ return undefined;
1248
+ }
819
1249
  const fullUrl = composePomXmlUrl({ urlPrefix, group, name, version });
820
- // A direct POM may have been prefetched by getMvnMetadata's batch round.
821
- // Parent POMs (fetched from inside fetchPomXmlAsJson) are not prefetched
822
- // because their URL depends on the direct POM's content, so they fall
823
- // through to the serial request. That fallback still reaches Maven Central
824
- // and still counts against the same budget as the batch round, so it queues
825
- // behind the same per-host gate rather than a second, ungoverned one.
1250
+ // Parent POMs, whose URLs are known only once the child is read, are
1251
+ // fetched one at a time but still through the pool: they count against
1252
+ // Maven Central's budget, and a release POM never changes, so the pool's
1253
+ // disk cache keeps it for every later run.
826
1254
  try {
827
1255
  const res =
828
1256
  prefetchedResponse(prefetchedPoms, fullUrl) ||
829
- (await withHostRateLimit(fullUrl, () => cdxgenAgent.get(fullUrl)));
1257
+ (await pooledDocument(fullUrl, "text"));
830
1258
  return res.body;
831
1259
  } catch (_err) {
832
1260
  return undefined;
833
1261
  }
834
1262
  }
835
1263
 
1264
+ /**
1265
+ * Method to fetch pom.xml data, from the local caches when present.
1266
+ *
1267
+ * @param {String} urlPrefix
1268
+ * @param {String} group
1269
+ * @param {String} name
1270
+ * @param {String} version
1271
+ *
1272
+ * @return {Promise<String>}
1273
+ */
1274
+ export async function fetchPomXml({ urlPrefix, group, name, version }) {
1275
+ return (
1276
+ readLocalPom(group, name, version) ||
1277
+ (await fetchRemotePomXml({
1278
+ urlPrefix: urlPrefix ? withTrailingSlash(urlPrefix) : undefined,
1279
+ group,
1280
+ name,
1281
+ version,
1282
+ }))
1283
+ );
1284
+ }
1285
+
836
1286
  /**
837
1287
  * Method extract single or multiple license entries that might appear in pom.xml
838
1288
  *
@@ -866,12 +1316,9 @@ export async function extractLicenseCommentFromPomXml({
866
1316
  name,
867
1317
  version,
868
1318
  }) {
869
- const pom_xml = await fetchPomXml({ urlPrefix, group, name, version });
870
- const licenseRegex = /<!--([\s\S]*?)-->[\s\n]*<project/m;
871
- const match = licenseRegex.exec(pom_xml);
872
- if (match?.[1]) {
873
- return findLicenseId(match[1].trim());
874
- }
1319
+ return licenseFromPomComment(
1320
+ await fetchPomXml({ urlPrefix, group, name, version }),
1321
+ );
875
1322
  }
876
1323
 
877
1324
  /**
@@ -906,6 +1353,63 @@ export function guessPypiMatchingVersion(versionsList, versionSpecifiers) {
906
1353
  return maxSatisfying(versionsList, clean(versionSpecifiers, { loose: true }));
907
1354
  }
908
1355
 
1356
+ /**
1357
+ * Whether PyPI, or the index PYPI_URL points at, can hold the package.
1358
+ *
1359
+ * The Python parsers record where each package came from. A virtual workspace
1360
+ * root, an editable install, and a git, URL or path source are not index
1361
+ * releases; Poetry records a private index the same way, as the package's
1362
+ * source URL. A package from another index is asked only when PYPI_URL points
1363
+ * at that index's server. Asking PyPI about any of them leaks the name and
1364
+ * attaches a look-alike's licence.
1365
+ *
1366
+ * @param {Object} p Package object with the properties the Python parsers record.
1367
+ * @param {string} pypiUrl The JSON API base cdxgen asks.
1368
+ * @returns {boolean}
1369
+ */
1370
+ function canPypiServe(p, pypiUrl) {
1371
+ const props = {};
1372
+ for (const prop of p.properties || []) {
1373
+ props[prop.name] = prop.value;
1374
+ }
1375
+ if (props["internal:virtual_path"]) {
1376
+ return false;
1377
+ }
1378
+ const sourceType = props["cdx:pypi:manifestSourceType"];
1379
+ if (
1380
+ sourceType &&
1381
+ !(
1382
+ sourceType === "url" &&
1383
+ isDefaultPypiRegistry(props["cdx:pypi:manifestSource"])
1384
+ )
1385
+ ) {
1386
+ return false;
1387
+ }
1388
+ if ((p.externalReferences || []).some((ref) => ref.type === "vcs")) {
1389
+ return false;
1390
+ }
1391
+ // The registry property is recorded only for a non-default index.
1392
+ const registry = props["cdx:pypi:registry"];
1393
+ if (registry && !isDefaultPypiRegistry(registry)) {
1394
+ const host = parseUrl(registry)?.host;
1395
+ return !!host && host === parseUrl(pypiUrl)?.host;
1396
+ }
1397
+ return true;
1398
+ }
1399
+
1400
+ /**
1401
+ * Whether a package already carries what a PyPI lookup would be asked for: a
1402
+ * licence and a version from the lockfile or an installed distribution. When
1403
+ * registry provenance is wanted it is still asked, since nothing local holds
1404
+ * that.
1405
+ *
1406
+ * @param {Object} p Package object.
1407
+ * @returns {boolean}
1408
+ */
1409
+ function pypiAnsweredLocally(p) {
1410
+ return !!(p.license && p.version) && !shouldFetchRegistryProvenance();
1411
+ }
1412
+
909
1413
  /**
910
1414
  * The PyPI path for a package, or `undefined` when the package will not be
911
1415
  * queried at all.
@@ -916,14 +1420,19 @@ export function guessPypiMatchingVersion(versionsList, versionSpecifiers) {
916
1420
  * JS path never makes, which is both wasted work and a divergence.
917
1421
  *
918
1422
  * @param {Object} p Package object.
1423
+ * @param {string} pypiUrl The JSON API base cdxgen asks.
919
1424
  * @returns {string|undefined} Path to append to PYPI_URL.
920
1425
  */
921
- function pyUrlAddition(p) {
1426
+ function pyUrlAddition(p, pypiUrl) {
922
1427
  if (!p?.name) {
923
1428
  return undefined;
924
1429
  }
1430
+ // A package PyPI cannot hold is not queried.
1431
+ if (!canPypiServe(p, pypiUrl)) {
1432
+ return undefined;
1433
+ }
925
1434
  // A URL as a name, or a package that already has both fields, is not queried.
926
- if (p.name.includes("https") || (p.license && p.version)) {
1435
+ if (p.name.includes("https") || pypiAnsweredLocally(p)) {
927
1436
  return undefined;
928
1437
  }
929
1438
  // Extras (`requests[security]`) are not part of the PyPI path.
@@ -933,6 +1442,35 @@ function pyUrlAddition(p) {
933
1442
  : `${name}/json`;
934
1443
  }
935
1444
 
1445
+ /**
1446
+ * Whether a failed PyPI request means the project or version does not exist,
1447
+ * as opposed to a rate limit, a timeout or a server error, which say nothing
1448
+ * about the name.
1449
+ *
1450
+ * @param {Error} err Error from the agent or the batch prefetch.
1451
+ * @returns {boolean} True for 404 and 410.
1452
+ */
1453
+ function isPypiNotFound(err) {
1454
+ const status = err?.statusCode ?? err?.response?.statusCode;
1455
+ return status === 404 || status === 410;
1456
+ }
1457
+
1458
+ /**
1459
+ * Fetch a PyPI JSON document, from the batch prefetch when it holds one.
1460
+ * getPyMetadata runs more than once over the same packages, and a URL that
1461
+ * answered 404 or 410 fails at once on every later pass of the run.
1462
+ *
1463
+ * @param {string} url PyPI JSON API URL.
1464
+ * @param {Map} [prefetched] Results of the batch prefetch.
1465
+ * @returns {Promise<Object>} Response with `body`.
1466
+ */
1467
+ async function getPypiDocument(url, prefetched) {
1468
+ return (
1469
+ prefetchedResponse(prefetched, url) ||
1470
+ (await gatedGet(url, { responseType: "json" }))
1471
+ );
1472
+ }
1473
+
936
1474
  /**
937
1475
  * Method to retrieve metadata for python packages by querying pypi
938
1476
  *
@@ -952,7 +1490,7 @@ export async function getPyMetadata(pkgList, fetchDepsInfo) {
952
1490
  const prefetched = await prefetchJson(
953
1491
  prefetchEnabled()
954
1492
  ? pkgList
955
- .map((p) => pyUrlAddition(p))
1493
+ .map((p) => pyUrlAddition(p, PYPI_URL))
956
1494
  .filter(Boolean)
957
1495
  .map((addition) => ({ url: PYPI_URL + addition }))
958
1496
  : [],
@@ -966,8 +1504,13 @@ export async function getPyMetadata(pkgList, fetchDepsInfo) {
966
1504
  // map, so they are available without a registry round trip and are attached
967
1505
  // before the skip below rather than after it.
968
1506
  applyPypiModuleNames(p);
1507
+ // A package PyPI cannot hold keeps the answers the lockfile gave it.
1508
+ if (!canPypiServe(p, PYPI_URL)) {
1509
+ cdepList.push(p);
1510
+ continue;
1511
+ }
969
1512
  // If the package name has a url or already includes license and version skip it
970
- if (p.name.includes("https") || (p.license && p.version)) {
1513
+ if (p.name.includes("https") || pypiAnsweredLocally(p)) {
971
1514
  cdepList.push(p);
972
1515
  continue;
973
1516
  }
@@ -984,30 +1527,36 @@ export async function getPyMetadata(pkgList, fetchDepsInfo) {
984
1527
  url_addition = `${p.name}/json`;
985
1528
  }
986
1529
  try {
987
- res =
988
- prefetchedResponse(prefetched, PYPI_URL + url_addition) ||
989
- (await cdxgenAgent.get(`${PYPI_URL + url_addition}`, {
990
- responseType: "json",
991
- }));
992
- } catch (_err) {
993
- // retry by prefixing django- to the package name
994
- res = await cdxgenAgent.get(`${PYPI_URL}django-${url_addition}`, {
995
- responseType: "json",
996
- });
1530
+ res = await getPypiDocument(PYPI_URL + url_addition, prefetched);
1531
+ } catch (err) {
1532
+ // Only a missing project means the name may be the Django plugin's.
1533
+ // A rate limit, a timeout or a server error says nothing about the
1534
+ // name, and renaming the package then would give it another
1535
+ // project's identity.
1536
+ if (!isPypiNotFound(err)) {
1537
+ throw err;
1538
+ }
1539
+ res = await getPypiDocument(`${PYPI_URL}django-${url_addition}`);
997
1540
  p.name = `django-${p.name}`;
998
1541
  }
999
1542
  const body = res.body;
1543
+ // The registry fills only what the lockfile and the installed
1544
+ // distribution left open, so a local answer survives the round.
1545
+ let registryAuthor;
1000
1546
  if (body.info.author && body.info.author.trim() !== "") {
1001
1547
  if (body.info.author_email && body.info.author_email.trim() !== "") {
1002
- p.author = `${body.info.author.trim()} <${body.info.author_email.trim()}>`;
1548
+ registryAuthor = `${body.info.author.trim()} <${body.info.author_email.trim()}>`;
1003
1549
  } else {
1004
- p.author = body.info.author.trim();
1550
+ registryAuthor = body.info.author.trim();
1005
1551
  }
1006
1552
  } else if (
1007
1553
  body.info.author_email &&
1008
1554
  body.info.author_email.trim() !== ""
1009
1555
  ) {
1010
- p.author = body.info.author_email.trim();
1556
+ registryAuthor = body.info.author_email.trim();
1557
+ }
1558
+ if (registryAuthor && !p.author) {
1559
+ p.author = registryAuthor;
1011
1560
  }
1012
1561
  if (
1013
1562
  p.name !== body.info?.name &&
@@ -1015,36 +1564,40 @@ export async function getPyMetadata(pkgList, fetchDepsInfo) {
1015
1564
  ) {
1016
1565
  p.name = body.info.name;
1017
1566
  }
1018
- p.description = body.info.summary;
1019
- p.license = [];
1567
+ if (!p.description) {
1568
+ p.description = body.info.summary;
1569
+ }
1570
+ const registryLicenses = [];
1020
1571
  if (body.info.classifiers) {
1021
1572
  for (const c of body.info.classifiers) {
1022
1573
  if (c.startsWith("License :: ")) {
1023
1574
  const licenseName = c.split("::").slice(-1)[0].trim();
1024
1575
  const licenseId = findLicenseId(licenseName);
1025
- if (licenseId && !p.license.includes(licenseId)) {
1026
- p.license.push(licenseId);
1576
+ if (licenseId && !registryLicenses.includes(licenseId)) {
1577
+ registryLicenses.push(licenseId);
1027
1578
  }
1028
1579
  }
1029
1580
  }
1030
1581
  applyPypiClassifierMetadata(p, body.info.classifiers);
1031
1582
  }
1032
- if (body.info.license) {
1033
- const licenseId = findLicenseId(body.info.license);
1034
- if (licenseId && !p.license.includes(licenseId)) {
1035
- p.license.push(licenseId);
1583
+ for (const declared of [
1584
+ body.info.license,
1585
+ body.info.license_expression,
1586
+ ]) {
1587
+ const licenseId = declared ? findLicenseId(declared) : undefined;
1588
+ if (licenseId && !registryLicenses.includes(licenseId)) {
1589
+ registryLicenses.push(licenseId);
1036
1590
  }
1037
1591
  }
1038
- if (body.info.license_expression) {
1039
- const licenseId = findLicenseId(body.info.license_expression);
1040
- if (licenseId && !p.license.includes(licenseId)) {
1041
- p.license.push(licenseId);
1042
- }
1592
+ if (!(Array.isArray(p.license) ? p.license.length : p.license)) {
1593
+ p.license = registryLicenses;
1043
1594
  }
1044
1595
  if (body.info.home_page) {
1045
1596
  if (body.info.home_page.includes("git")) {
1046
- p.repository = { url: body.info.home_page };
1047
- } else {
1597
+ if (!p.repository) {
1598
+ p.repository = { url: body.info.home_page };
1599
+ }
1600
+ } else if (!p.homepage) {
1048
1601
  p.homepage = { url: body.info.home_page };
1049
1602
  }
1050
1603
  }
@@ -1482,7 +2035,13 @@ let repoLicensePrefetch = new Map();
1482
2035
  * @returns {Promise<void>}
1483
2036
  */
1484
2037
  export async function prefetchRepoLicenses(repoUrls) {
1485
- if (!prefetchEnabled() || !Array.isArray(repoUrls)) {
2038
+ // While the lookups are paused, getRepoLicense asks nothing, so neither
2039
+ // does the batch.
2040
+ if (
2041
+ !prefetchEnabled() ||
2042
+ !Array.isArray(repoUrls) ||
2043
+ repoLicenseLookupsPaused()
2044
+ ) {
1486
2045
  return;
1487
2046
  }
1488
2047
  const requests = [];
@@ -1496,7 +2055,16 @@ export async function prefetchRepoLicenses(repoUrls) {
1496
2055
  continue;
1497
2056
  }
1498
2057
  const licenseUrl = `${apiUrl}/license`;
1499
- if (seen.has(licenseUrl)) {
2058
+ // A licence this run already has an answer for, a licence or a 404, is
2059
+ // read from the earlier round. A lookup that failed, for instance on a
2060
+ // spent quota, is asked again.
2061
+ const known = repoLicensePrefetch.get(licenseUrl);
2062
+ if (
2063
+ seen.has(licenseUrl) ||
2064
+ known?.ok ||
2065
+ known?.status === 404 ||
2066
+ known?.status === 410
2067
+ ) {
1500
2068
  continue;
1501
2069
  }
1502
2070
  seen.add(licenseUrl);
@@ -1543,7 +2111,7 @@ export async function getRepoLicense(repoUrl, repoMetadata) {
1543
2111
  }
1544
2112
  const apiUrl = toGitHubApiUrl(repoUrl, repoMetadata);
1545
2113
  // Perform github lookups
1546
- if (apiUrl && get_repo_license_errors < MAX_GET_REPO_LICENSE_ERRORS) {
2114
+ if (apiUrl && !repoLicenseLookupsPaused()) {
1547
2115
  const licenseUrl = `${apiUrl}/license`;
1548
2116
  const headers = {};
1549
2117
  if (readEnvironmentVariable("GITHUB_TOKEN")) {
@@ -1553,9 +2121,9 @@ export async function getRepoLicense(repoUrl, repoMetadata) {
1553
2121
  try {
1554
2122
  const res =
1555
2123
  prefetchedResponse(repoLicensePrefetch, licenseUrl) ||
1556
- (await cdxgenAgent.get(licenseUrl, {
2124
+ (await gatedGet(licenseUrl, {
1557
2125
  responseType: "json",
1558
- headers: headers,
2126
+ headers,
1559
2127
  }));
1560
2128
  if (res?.body) {
1561
2129
  const license = res.body.license;
@@ -1582,20 +2150,18 @@ export async function getRepoLicense(repoUrl, repoMetadata) {
1582
2150
  }
1583
2151
  }
1584
2152
  } catch (err) {
1585
- if (err?.message) {
1586
- if (
1587
- err.message.includes("rate limit exceeded") &&
1588
- !readEnvironmentVariable("GITHUB_TOKEN")
1589
- ) {
1590
- console.log(
1591
- "Rate limit exceeded for REST API of github.com. " +
1592
- "Please ensure GITHUB_TOKEN is set as environment variable. " +
1593
- "See: https://docs.github.com/en/rest/overview/rate-limits-for-the-rest-api",
1594
- );
1595
- get_repo_license_errors++;
1596
- } else if (!err.message.includes("404")) {
1597
- get_repo_license_errors++;
1598
- }
2153
+ // A repository without a licence file answers 404. A spent quota is a
2154
+ // 429, or a 403 with nothing remaining; the request layer pauses
2155
+ // api.github.com until the quota resets and says so once, with the
2156
+ // GITHUB_TOKEN advice. Any other failure counts towards pausing these
2157
+ // lookups.
2158
+ const status = err?.response?.statusCode ?? err?.statusCode;
2159
+ const quotaSpent =
2160
+ status === 429 ||
2161
+ (status === 403 &&
2162
+ String(err?.response?.headers?.["x-ratelimit-remaining"]) === "0");
2163
+ if (status !== 404 && !quotaSpent) {
2164
+ get_repo_license_errors++;
1599
2165
  }
1600
2166
  }
1601
2167
  }
@@ -1639,12 +2205,21 @@ export async function prefetchGoPkgMetadata(modules) {
1639
2205
  if (!module?.name) {
1640
2206
  continue;
1641
2207
  }
2208
+ const fullName =
2209
+ module.fullName || getGoPkgFullName(module.group, module.name);
2210
+ // A module the environment marks private is never requested.
2211
+ // getGoPkgLicense applies the same test, so the batch cannot ask for a
2212
+ // module the serial path would skip.
2213
+ if (isPrivateGoModule(fullName)) {
2214
+ continue;
2215
+ }
1642
2216
  const pkgUrl = getGoPkgUrl(module);
1643
2217
  // The licence tab and the module page are distinct documents, read by
1644
2218
  // getGoPkgLicense and getGoPkgVCSUrl respectively. Both are requested only
1645
2219
  // when the corresponding flag is on, so the batch mirrors that.
1646
2220
  const wanted = [];
1647
- if (shouldFetchLicense()) {
2221
+ // A licence notice read on this machine answers the licence tab.
2222
+ if (shouldFetchLicense() && !module.licenseKnown) {
1648
2223
  wanted.push(`${pkgUrl}?tab=licenses`);
1649
2224
  }
1650
2225
  // getGoPkgVCSUrl derives github.com and gitlab.com URLs without asking
@@ -1653,7 +2228,7 @@ export async function prefetchGoPkgMetadata(modules) {
1653
2228
  wanted.push(pkgUrl);
1654
2229
  }
1655
2230
  for (const url of wanted) {
1656
- if (seen.has(url) || metadata_cache[url]) {
2231
+ if (seen.has(url) || goPkgAnswers.has(url)) {
1657
2232
  continue;
1658
2233
  }
1659
2234
  seen.add(url);
@@ -1683,15 +2258,21 @@ export function resetGoPkgPrefetch() {
1683
2258
  */
1684
2259
  export async function getGoPkgLicense(repoMetadata) {
1685
2260
  const group = repoMetadata.group;
2261
+ // A module the environment marks private is never sent to the public site.
2262
+ const fullName =
2263
+ repoMetadata.fullName ||
2264
+ getGoPkgFullName(repoMetadata.group, repoMetadata.name);
2265
+ if (isPrivateGoModule(fullName)) {
2266
+ return undefined;
2267
+ }
1686
2268
  const pkgUrl = `${getGoPkgUrl(repoMetadata)}?tab=licenses`;
1687
- // Check the metadata cache first
1688
- if (metadata_cache[pkgUrl]) {
1689
- return metadata_cache[pkgUrl];
2269
+ if (goPkgAnswers.has(pkgUrl)) {
2270
+ return goPkgAnswers.get(pkgUrl);
1690
2271
  }
1691
2272
  try {
1692
2273
  const res =
1693
2274
  prefetchedResponse(goPkgPrefetch, pkgUrl) ||
1694
- (await cdxgenAgent.get(pkgUrl));
2275
+ (await gatedGet(pkgUrl, { responseType: "text" }));
1695
2276
  if (res?.body) {
1696
2277
  const licenses = extractLicenseText(res.body);
1697
2278
  const licenseIds = licenses.split(", ");
@@ -1714,7 +2295,7 @@ export async function getGoPkgLicense(repoMetadata) {
1714
2295
  licList.push(alicense);
1715
2296
  }
1716
2297
  }
1717
- metadata_cache[pkgUrl] = licList;
2298
+ goPkgAnswers.set(pkgUrl, licList);
1718
2299
  return licList;
1719
2300
  }
1720
2301
  } catch (_err) {
@@ -1731,23 +2312,31 @@ export async function getGoPkgLicense(repoMetadata) {
1731
2312
  *
1732
2313
  * @param {String} group Package group
1733
2314
  * @param {String} name Package name
2315
+ * @param {String} [version] Package version, part of the page URL
1734
2316
  */
1735
- export async function getGoPkgVCSUrl(group, name) {
2317
+ export async function getGoPkgVCSUrl(group, name, version) {
1736
2318
  const fullName = getGoPkgFullName(group, name);
1737
2319
  if (isDirectlyResolvableGoVCS({ group, name })) {
1738
2320
  return `https://${fullName}`;
1739
2321
  }
1740
- const pkgUrl = getGoPkgUrl({ fullName });
1741
- if (metadata_cache[pkgUrl]) {
1742
- return metadata_cache[pkgUrl];
2322
+ // The repository URL of a private module follows from a github.com or
2323
+ // gitlab.com path without a request, but pkg.go.dev is never asked.
2324
+ if (isPrivateGoModule(fullName)) {
2325
+ return undefined;
2326
+ }
2327
+ // The versioned page matches the URL the prefetch requested, so a prefetched
2328
+ // document is found in the cache.
2329
+ const pkgUrl = getGoPkgUrl({ fullName, version });
2330
+ if (goPkgAnswers.has(pkgUrl)) {
2331
+ return goPkgAnswers.get(pkgUrl);
1743
2332
  }
1744
2333
  try {
1745
2334
  const res =
1746
2335
  prefetchedResponse(goPkgPrefetch, pkgUrl) ||
1747
- (await cdxgenAgent.get(pkgUrl));
2336
+ (await gatedGet(pkgUrl, { responseType: "text" }));
1748
2337
  if (res?.body) {
1749
2338
  const vcs = extractRepoUrl(res.body);
1750
- metadata_cache[pkgUrl] = vcs;
2339
+ goPkgAnswers.set(pkgUrl, vcs);
1751
2340
  return vcs;
1752
2341
  }
1753
2342
  } catch (_err) {
@@ -1774,17 +2363,154 @@ function isDirectlyResolvableGoVCS(pkgMetadata) {
1774
2363
  }
1775
2364
 
1776
2365
  /**
1777
- * Method to get go pkg url (go.dev site).
2366
+ * Method to get go pkg url (go.dev site).
2367
+ *
2368
+ * The version is part of the URL whenever it is known, so pkg.go.dev answers
2369
+ * for the release the project pinned rather than for the latest one.
2370
+ *
2371
+ * @param {Object} pkgMetadata pkg metadata
2372
+ */
2373
+ function getGoPkgUrl(pkgMetadata) {
2374
+ const pkgUrlPrefix =
2375
+ readEnvironmentVariable("GO_PKG_URL") || "https://pkg.go.dev/";
2376
+ const fullName =
2377
+ pkgMetadata.fullName ||
2378
+ getGoPkgFullName(pkgMetadata.group, pkgMetadata.name);
2379
+ const version = pkgMetadata.version ? `@${pkgMetadata.version}` : "";
2380
+ return pkgUrlPrefix + fullName + version;
2381
+ }
2382
+
2383
+ /**
2384
+ * Match one path element of a Go module pattern against one element of a
2385
+ * module path, the way Go's `path.Match` does: `*` and `?` do not cross a `/`,
2386
+ * and a bracketed character class is allowed.
2387
+ *
2388
+ * @param {string} pattern One pattern element
2389
+ * @param {string} name One module path element
2390
+ * @returns {boolean}
2391
+ */
2392
+ function matchGoPathElement(pattern, name) {
2393
+ let p = 0;
2394
+ let n = 0;
2395
+ let starP = -1;
2396
+ let starN = -1;
2397
+ while (n < name.length) {
2398
+ if (p < pattern.length) {
2399
+ const c = pattern[p];
2400
+ if (c === "*") {
2401
+ starP = p;
2402
+ starN = n;
2403
+ p++;
2404
+ continue;
2405
+ }
2406
+ if (c === "?") {
2407
+ p++;
2408
+ n++;
2409
+ continue;
2410
+ }
2411
+ if (c === "[") {
2412
+ let i = p + 1;
2413
+ let negate = false;
2414
+ if (i < pattern.length && "^".indexOf(pattern[i]) > -1) {
2415
+ negate = pattern[i] === "^";
2416
+ i++;
2417
+ }
2418
+ let matched = false;
2419
+ let first = true;
2420
+ while (i < pattern.length && (first || pattern[i] !== "]")) {
2421
+ first = false;
2422
+ let lo = pattern[i];
2423
+ if (lo === "\\" && i + 1 < pattern.length) {
2424
+ i++;
2425
+ lo = pattern[i];
2426
+ }
2427
+ let hi = lo;
2428
+ if (
2429
+ i + 2 < pattern.length &&
2430
+ pattern[i + 1] === "-" &&
2431
+ pattern[i + 2] !== "]"
2432
+ ) {
2433
+ hi = pattern[i + 2];
2434
+ i += 2;
2435
+ }
2436
+ if (name[n] >= lo && name[n] <= hi) {
2437
+ matched = true;
2438
+ }
2439
+ i++;
2440
+ }
2441
+ if (i < pattern.length && pattern[i] === "]") {
2442
+ if (matched !== negate) {
2443
+ p = i + 1;
2444
+ n++;
2445
+ continue;
2446
+ }
2447
+ } else {
2448
+ // An unterminated class matches nothing rather than silently
2449
+ // swallowing the rest of the pattern.
2450
+ return false;
2451
+ }
2452
+ } else if (c === "\\" && p + 1 < pattern.length) {
2453
+ if (pattern[p + 1] === name[n]) {
2454
+ p += 2;
2455
+ n++;
2456
+ continue;
2457
+ }
2458
+ } else if (c === name[n]) {
2459
+ p++;
2460
+ n++;
2461
+ continue;
2462
+ }
2463
+ }
2464
+ if (starP !== -1) {
2465
+ p = starP + 1;
2466
+ starN++;
2467
+ n = starN;
2468
+ continue;
2469
+ }
2470
+ return false;
2471
+ }
2472
+ while (p < pattern.length && pattern[p] === "*") {
2473
+ p++;
2474
+ }
2475
+ return p === pattern.length;
2476
+ }
2477
+
2478
+ /**
2479
+ * Whether a module is private and must not be sent to pkg.go.dev.
1778
2480
  *
1779
- * @param {Object} pkgMetadata pkg metadata
2481
+ * `GOPRIVATE`, and `GONOPROXY`/`GONOSUMDB` when they are set, hold
2482
+ * comma-separated globs. Like Go's `module.MatchPrefixPatterns`, a glob with N
2483
+ * path elements is matched against the first N elements of the module path,
2484
+ * so `example.com/org` matches `example.com/org/tool`. Sending a private
2485
+ * module to a public site leaks its path, and the site cannot hold it anyway.
2486
+ *
2487
+ * @param {string} modulePath Full module path
2488
+ * @returns {boolean}
1780
2489
  */
1781
- function getGoPkgUrl(pkgMetadata) {
1782
- const pkgUrlPrefix =
1783
- readEnvironmentVariable("GO_PKG_URL") || "https://pkg.go.dev/";
1784
- const fullName =
1785
- pkgMetadata.fullName ||
1786
- getGoPkgFullName(pkgMetadata.group, pkgMetadata.name);
1787
- return pkgUrlPrefix + fullName;
2490
+ function isPrivateGoModule(modulePath) {
2491
+ if (!modulePath) {
2492
+ return false;
2493
+ }
2494
+ const targetParts = modulePath.split("/");
2495
+ for (const name of ["GOPRIVATE", "GONOPROXY", "GONOSUMDB"]) {
2496
+ for (const rawGlob of (readEnvironmentVariable(name) || "").split(",")) {
2497
+ const trimmed = rawGlob.trim();
2498
+ const glob = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
2499
+ if (!glob) {
2500
+ continue;
2501
+ }
2502
+ const globParts = glob.split("/");
2503
+ if (globParts.length > targetParts.length) {
2504
+ continue;
2505
+ }
2506
+ if (
2507
+ globParts.every((part, i) => matchGoPathElement(part, targetParts[i]))
2508
+ ) {
2509
+ return true;
2510
+ }
2511
+ }
2512
+ }
2513
+ return false;
1788
2514
  }
1789
2515
 
1790
2516
  /**
@@ -1797,6 +2523,35 @@ function getGoPkgFullName(group, name) {
1797
2523
  return group && group !== "." && group !== name ? `${group}/${name}` : name;
1798
2524
  }
1799
2525
 
2526
+ /**
2527
+ * Whether crates.io can hold the crate.
2528
+ *
2529
+ * The Cargo.lock parser records a `cdx:cargo:sourceKind` for every crate whose
2530
+ * source is not plain crates.io: the workspace and path crates, the project
2531
+ * itself, git crates, and crates an alternate registry serves. Asking crates.io
2532
+ * about those attaches a same-named crate's metadata to them, so only a crate
2533
+ * crates.io actually serves is looked up.
2534
+ *
2535
+ * @param {Object} p Package object with the parser's source properties.
2536
+ * @returns {boolean}
2537
+ */
2538
+ function canCratesIoServe(p) {
2539
+ let sourceKind;
2540
+ let alternateRegistry = false;
2541
+ for (const prop of p.properties || []) {
2542
+ if (prop.name === "cdx:cargo:sourceKind") {
2543
+ sourceKind = prop.value;
2544
+ } else if (prop.name === "cdx:cargo:alternateRegistry") {
2545
+ alternateRegistry = prop.value === "true";
2546
+ }
2547
+ }
2548
+ if (!sourceKind) {
2549
+ // The public registry is the norm and goes unrecorded.
2550
+ return true;
2551
+ }
2552
+ return sourceKind === "registry" && !alternateRegistry;
2553
+ }
2554
+
1800
2555
  /**
1801
2556
  * Method to retrieve metadata for rust packages by querying crates
1802
2557
  *
@@ -1807,7 +2562,13 @@ function getGoPkgFullName(group, name) {
1807
2562
  *
1808
2563
  * What it does not hold is publisher identity, so a caller that needs the
1809
2564
  * publisher-drift and release-cadence signals — the predictive audit does —
1810
- * passes `preferLocalCache: false` and takes the slower registry path.
2565
+ * passes `preferLocalCache: false` and takes the slower registry path. That is
2566
+ * also the only caller that needs the crate's owners, so the `/owners` request
2567
+ * is made for it alone.
2568
+ *
2569
+ * Crates a public registry cannot hold are skipped: the project itself, path
2570
+ * and git crates, and crates from an alternate registry. See
2571
+ * {@link canCratesIoServe}.
1811
2572
  *
1812
2573
  * @param {Array} pkgList Package list
1813
2574
  * @param {Object} [options] Options
@@ -1824,12 +2585,26 @@ export async function getCratesMetadata(pkgList, options = {}) {
1824
2585
  // reads; only the crates it cannot account for reach the network.
1825
2586
  const servedLocally = new Set();
1826
2587
  const preferLocalCache = options.preferLocalCache !== false;
2588
+ // The owners listing carries no licence, so only a caller that wants the
2589
+ // publisher identity behind it pays for the second request.
2590
+ const wantsOwners = !preferLocalCache;
1827
2591
  if (preferLocalCache && localCargoMetadataEnabled()) {
1828
2592
  for (const p of pkgList) {
1829
2593
  if (!p?.name || !p?.version || p.version === "workspace") {
1830
2594
  continue;
1831
2595
  }
1832
- if (applyCargoCacheMetadata(p)) {
2596
+ // A crate crates.io does not hold must not take a same-named crate's
2597
+ // place in the local registry cache either.
2598
+ if (!canCratesIoServe(p)) {
2599
+ continue;
2600
+ }
2601
+ // A crate a caller already filled from its own local source, such as a
2602
+ // .crate archive the scan itself read, has nothing left to ask for.
2603
+ if (p.license && p.description) {
2604
+ servedLocally.add(p);
2605
+ continue;
2606
+ }
2607
+ if (await applyCargoCacheMetadata(p)) {
1833
2608
  servedLocally.add(p);
1834
2609
  }
1835
2610
  }
@@ -1844,17 +2619,21 @@ export async function getCratesMetadata(pkgList, options = {}) {
1844
2619
  p?.name &&
1845
2620
  p?.version &&
1846
2621
  p.version !== "workspace" &&
2622
+ canCratesIoServe(p) &&
1847
2623
  !servedLocally.has(p),
1848
2624
  );
1849
- // Two URLs per crate — the crate document and its owners — fetched for the
1850
- // whole list at once. The `workspace` guard below is mirrored here so the
1851
- // batch does not request anything the loop would have skipped.
2625
+ // Two URLs per crate — the crate document and, for the caller that wants
2626
+ // publisher identity, its owners — fetched for the whole list at once. The
2627
+ // `workspace` and source guards below are mirrored here so the batch does
2628
+ // not request anything the loop would have skipped.
1852
2629
  const prefetched = await prefetchJson(
1853
2630
  prefetchEnabled()
1854
- ? needsRegistry.flatMap((p) => [
1855
- { url: CRATES_URL + p.name },
1856
- { url: `${CRATES_URL + p.name}/owners` },
1857
- ])
2631
+ ? needsRegistry.flatMap((p) => {
2632
+ const crateUrl = CRATES_URL + p.name;
2633
+ return wantsOwners
2634
+ ? [{ url: crateUrl }, { url: `${crateUrl}/owners` }]
2635
+ : [{ url: crateUrl }];
2636
+ })
1858
2637
  : [],
1859
2638
  );
1860
2639
  for (const p of pkgList) {
@@ -1863,6 +2642,10 @@ export async function getCratesMetadata(pkgList, options = {}) {
1863
2642
  cdepList.push(p);
1864
2643
  continue;
1865
2644
  }
2645
+ if (!canCratesIoServe(p)) {
2646
+ cdepList.push(p);
2647
+ continue;
2648
+ }
1866
2649
  if (servedLocally.has(p)) {
1867
2650
  cdepList.push(p);
1868
2651
  continue;
@@ -1877,32 +2660,32 @@ export async function getCratesMetadata(pkgList, options = {}) {
1877
2660
  // this process issued it.
1878
2661
  const res =
1879
2662
  prefetchedResponse(prefetched, crateUrl) ||
1880
- (await withHostRateLimit(crateUrl, () =>
1881
- cdxgenAgent.get(crateUrl, {
1882
- responseType: "json",
1883
- }),
1884
- ));
2663
+ (await gatedGet(crateUrl, { responseType: "json" }));
1885
2664
  let ownersRes;
1886
- try {
1887
- const ownersUrl = `${crateUrl}/owners`;
1888
- ownersRes =
1889
- prefetchedResponse(prefetched, ownersUrl) ||
1890
- (await withHostRateLimit(ownersUrl, () =>
1891
- cdxgenAgent.get(ownersUrl, {
1892
- responseType: "json",
1893
- }),
1894
- ));
1895
- } catch (_err) {
1896
- ownersRes = undefined;
1897
- }
1898
- let versionToUse = res?.body?.versions[0];
1899
- if (p.version) {
1900
- for (const aversion of res.body.versions) {
1901
- if (aversion.num === p.version) {
1902
- versionToUse = aversion;
1903
- break;
1904
- }
2665
+ if (wantsOwners) {
2666
+ try {
2667
+ const ownersUrl = `${crateUrl}/owners`;
2668
+ ownersRes =
2669
+ prefetchedResponse(prefetched, ownersUrl) ||
2670
+ (await gatedGet(ownersUrl, { responseType: "json" }));
2671
+ } catch (_err) {
2672
+ ownersRes = undefined;
2673
+ }
2674
+ }
2675
+ // A crate whose version crates.io does not list was not published
2676
+ // there: a path or git crate, or one from another registry, that
2677
+ // shares a crates.io name. Nothing in that document describes it.
2678
+ const versionToUse = res?.body?.versions?.find(
2679
+ (aversion) => aversion.num === p.version,
2680
+ );
2681
+ if (!versionToUse) {
2682
+ if (DEBUG_MODE) {
2683
+ console.log(
2684
+ `crates.io has no version ${p.version} of ${p.name}; leaving it unchanged.`,
2685
+ );
1905
2686
  }
2687
+ cdepList.push(p);
2688
+ continue;
1906
2689
  }
1907
2690
  const body = res.body.crate;
1908
2691
  p.description = body.description;
@@ -1915,10 +2698,6 @@ export async function getCratesMetadata(pkgList, options = {}) {
1915
2698
  if (body.homepage && body.homepage !== body.repository) {
1916
2699
  p.homepage = { url: body.homepage };
1917
2700
  }
1918
- // Use the latest version if none specified
1919
- if (!p.version) {
1920
- p.version = body.newest_version;
1921
- }
1922
2701
  if (!p._integrity && versionToUse.checksum) {
1923
2702
  p._integrity = normalizeCargoIntegrity(versionToUse.checksum);
1924
2703
  }
@@ -1978,12 +2757,17 @@ export async function getCratesMetadata(pkgList, options = {}) {
1978
2757
  * @param {Object} p Package component to enrich in place.
1979
2758
  * @returns {boolean} true when crates.io can be skipped for this crate.
1980
2759
  */
1981
- function applyCargoCacheMetadata(p) {
1982
- const local = readCargoCacheMetadata(p.name, p.version);
1983
- if (!local?.license) {
2760
+ async function applyCargoCacheMetadata(p) {
2761
+ const local = await readCargoCacheMetadata(p.name, p.version);
2762
+ if (!local?.license && !local?.licenses) {
1984
2763
  return false;
1985
2764
  }
1986
- p.license = local.license;
2765
+ if (local.license) {
2766
+ p.license = local.license;
2767
+ }
2768
+ if (local.licenses && !p.licenses) {
2769
+ p.licenses = local.licenses;
2770
+ }
1987
2771
  if (local.description) {
1988
2772
  p.description = local.description;
1989
2773
  }
@@ -2041,14 +2825,47 @@ function applyCargoCacheMetadata(p) {
2041
2825
  return true;
2042
2826
  }
2043
2827
 
2828
+ // pub.dev also answers under its old name, which lockfiles written before
2829
+ // Dart 2.19 record for every hosted package.
2830
+ const PUB_DEV_HOSTS = new Set(["pub.dev", "pub.dartlang.org"]);
2831
+
2832
+ /**
2833
+ * The registry a hosted Dart package records, or undefined when that is
2834
+ * pub.dev or the registry cdxgen is configured to ask.
2835
+ *
2836
+ * @param {Object} p Package object with the pubspec.lock properties.
2837
+ * @param {string} pubDevUrl The registry cdxgen asks by default.
2838
+ * @returns {string|undefined}
2839
+ */
2840
+ function pubHostedRegistry(p, pubDevUrl) {
2841
+ const registry = p.properties?.find(
2842
+ (prop) => prop.name === "cdx:pub:registry" && prop.value,
2843
+ )?.value;
2844
+ const host = parseUrl(registry)?.host;
2845
+ if (!host || PUB_DEV_HOSTS.has(host) || host === parseUrl(pubDevUrl)?.host) {
2846
+ return undefined;
2847
+ }
2848
+ return registry;
2849
+ }
2850
+
2044
2851
  /**
2045
2852
  * Method to retrieve metadata for dart packages by querying pub.dev
2046
2853
  *
2854
+ * A hosted package whose lockfile names another registry is looked up there;
2855
+ * the score endpoint that carries the licence tag is pub.dev's, so a private
2856
+ * registry is asked only for the package document. In secure mode cdxgen does
2857
+ * not contact a registry the scanned project names, so such a package is not
2858
+ * looked up at all.
2859
+ *
2047
2860
  * @param {Array} pkgList Package list
2861
+ * @param {Object} [options] Options
2862
+ * @param {boolean} [options.secureMode] Whether secure mode is on. Defaults to
2863
+ * the process-wide setting.
2048
2864
  */
2049
- export async function getDartMetadata(pkgList) {
2865
+ export async function getDartMetadata(pkgList, options = {}) {
2050
2866
  const PUB_DEV_URL =
2051
2867
  readEnvironmentVariable("PUB_DEV_URL") || "https://pub.dev";
2868
+ const secureMode = options.secureMode ?? isSecureMode;
2052
2869
  const PUB_LICENSE_REGEX = /^license:/i;
2053
2870
  const OPTIONS = {
2054
2871
  responseType: "json",
@@ -2056,31 +2873,50 @@ export async function getDartMetadata(pkgList) {
2056
2873
  Accept: PUB_ACCEPT,
2057
2874
  },
2058
2875
  };
2876
+ const apiBase = (url) => (url.endsWith("/") ? url.slice(0, -1) : url);
2059
2877
 
2060
2878
  const cdepList = [];
2879
+ // Where each package is asked, decided once so the batch and the loop agree.
2061
2880
  // pub.dev needs the package document and its score document; both carry the
2062
- // vendor Accept header, which is part of the cache key on the Rust side.
2881
+ // vendor Accept header, which is part of the cache key on the Rust side. A
2882
+ // package hosted elsewhere is asked only for its package document.
2883
+ const lookups = new Map();
2884
+ for (const p of pkgList) {
2885
+ const registry = pubHostedRegistry(p, PUB_DEV_URL);
2886
+ if (registry && secureMode) {
2887
+ continue;
2888
+ }
2889
+ lookups.set(p, {
2890
+ registry,
2891
+ url: `${apiBase(registry || PUB_DEV_URL)}/api/packages/${p.name}/versions/${p.version}`,
2892
+ });
2893
+ }
2063
2894
  const prefetched = await prefetchJson(
2064
2895
  prefetchEnabled()
2065
- ? pkgList.flatMap((p) => {
2066
- const base = `${PUB_DEV_URL}/api/packages/${p.name}/versions/${p.version}`;
2067
- return [
2068
- { url: base, accept: PUB_ACCEPT },
2069
- { url: `${base}/score`, accept: PUB_ACCEPT },
2070
- ];
2071
- })
2896
+ ? [...lookups.values()].flatMap(({ registry, url }) =>
2897
+ registry
2898
+ ? [{ url, accept: PUB_ACCEPT }]
2899
+ : [
2900
+ { url, accept: PUB_ACCEPT },
2901
+ { url: `${url}/score`, accept: PUB_ACCEPT },
2902
+ ],
2903
+ )
2072
2904
  : [],
2073
2905
  );
2074
2906
  for (const p of pkgList) {
2907
+ const lookup = lookups.get(p);
2908
+ if (!lookup) {
2909
+ cdepList.push(p);
2910
+ continue;
2911
+ }
2075
2912
  try {
2913
+ const { registry, url: PUB_PACKAGE_URL } = lookup;
2076
2914
  if (DEBUG_MODE) {
2077
- console.log(`Querying ${PUB_DEV_URL} for ${p.name}`);
2915
+ console.log(`Querying ${registry || PUB_DEV_URL} for ${p.name}`);
2078
2916
  }
2079
- const PUB_PACKAGE_URL = `${PUB_DEV_URL}/api/packages/${p.name}/versions/${p.version}`;
2080
- const PUB_PACKAGE_SCORE_URL = `${PUB_PACKAGE_URL}/score`;
2081
2917
  const res =
2082
2918
  prefetchedResponse(prefetched, PUB_PACKAGE_URL) ||
2083
- (await cdxgenAgent.get(PUB_PACKAGE_URL, OPTIONS));
2919
+ (await gatedGet(PUB_PACKAGE_URL, OPTIONS));
2084
2920
  if (res?.body) {
2085
2921
  const pubspec = res.body.pubspec;
2086
2922
  p.description = pubspec.description;
@@ -2090,18 +2926,21 @@ export async function getDartMetadata(pkgList) {
2090
2926
  if (pubspec.homepage) {
2091
2927
  p.homepage = { url: pubspec.homepage };
2092
2928
  }
2093
- const score =
2094
- prefetchedResponse(prefetched, PUB_PACKAGE_SCORE_URL) ||
2095
- (await cdxgenAgent.get(PUB_PACKAGE_SCORE_URL, OPTIONS));
2096
- if (score?.body) {
2097
- const tags = score.body.tags;
2098
- const license = tags.find((tag) => PUB_LICENSE_REGEX.test(tag));
2099
- if (license) {
2100
- p.license = spdxLicenses.find(
2101
- (spdxLicense) =>
2102
- spdxLicense.toLowerCase() ===
2103
- license.replace(PUB_LICENSE_REGEX, "").toLowerCase(),
2104
- );
2929
+ if (!registry) {
2930
+ const PUB_PACKAGE_SCORE_URL = `${PUB_PACKAGE_URL}/score`;
2931
+ const score =
2932
+ prefetchedResponse(prefetched, PUB_PACKAGE_SCORE_URL) ||
2933
+ (await gatedGet(PUB_PACKAGE_SCORE_URL, OPTIONS));
2934
+ if (score?.body) {
2935
+ const tags = score.body.tags;
2936
+ const license = tags.find((tag) => PUB_LICENSE_REGEX.test(tag));
2937
+ if (license) {
2938
+ p.license = spdxLicenses.find(
2939
+ (spdxLicense) =>
2940
+ spdxLicense.toLowerCase() ===
2941
+ license.replace(PUB_LICENSE_REGEX, "").toLowerCase(),
2942
+ );
2943
+ }
2105
2944
  }
2106
2945
  }
2107
2946
  cdepList.push(p);
@@ -2152,6 +2991,60 @@ export function normalizeCargoIntegrity(integrity) {
2152
2991
  return undefined;
2153
2992
  }
2154
2993
 
2994
+ /**
2995
+ * The POM stored next to a jar, as in the Maven repository layout.
2996
+ *
2997
+ * @param {string} jarPath Path to a jar.
2998
+ * @returns {string|undefined} Path to `<name>.pom` when it exists.
2999
+ */
3000
+ function siblingPomFile(jarPath) {
3001
+ if (!jarPath?.endsWith(".jar")) {
3002
+ return undefined;
3003
+ }
3004
+ const pomPath = `${jarPath.slice(0, -".jar".length)}.pom`;
3005
+ return safeExistsSync(pomPath) ? pomPath : undefined;
3006
+ }
3007
+
3008
+ /**
3009
+ * Read the groupId, artifactId and version a POM declares, inheriting the
3010
+ * groupId and version from its parent element when they are omitted.
3011
+ *
3012
+ * @param {string} pomPath Path to a POM.
3013
+ * @returns {{groupId: string, artifactId: string, version: string}|undefined}
3014
+ */
3015
+ function readPomCoordinates(pomPath) {
3016
+ try {
3017
+ const pomData = parsePomXml(readFileSync(pomPath, "utf-8"));
3018
+ if (pomData?.groupId && pomData.artifactId && pomData.version) {
3019
+ return {
3020
+ groupId: pomData.groupId,
3021
+ artifactId: pomData.artifactId,
3022
+ version: pomData.version,
3023
+ };
3024
+ }
3025
+ } catch (_err) {
3026
+ // An unreadable POM contributes nothing.
3027
+ }
3028
+ return undefined;
3029
+ }
3030
+
3031
+ /**
3032
+ * Whether a directory lies inside cdxgen's temp directory or the OS temp
3033
+ * directory. Cleanup in {@link extractJarArchive} is limited to such paths.
3034
+ *
3035
+ * @param {string} dir Directory to check.
3036
+ * @returns {boolean}
3037
+ */
3038
+ function isInsideTmpDir(dir) {
3039
+ if (!dir) {
3040
+ return false;
3041
+ }
3042
+ return [getTmpDir(), tmpdir()].some((root) => {
3043
+ const rel = relative(resolve(root), resolve(dir));
3044
+ return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
3045
+ });
3046
+ }
3047
+
2155
3048
  /**
2156
3049
  * Method to extract a war or ear file
2157
3050
  *
@@ -2168,23 +3061,12 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2168
3061
  // reported, as file components with zero-confidence identity evidence.
2169
3062
  const unresolvedJars = [];
2170
3063
  const fname = basename(jarFile);
2171
- let pomname;
2172
- // If there is a pom file in the same directory, try to use it
2173
- const manifestname = join(dirname(jarFile), "META-INF", "MANIFEST.MF");
2174
- // Issue 439: Current implementation checks for existance of a .pom file, but .pom file is not used.
2175
- // Instead code expects to find META-INF/MANIFEST.MF in the same folder as a .jar file.
2176
- // For now check for presence of both .pom and MANIFEST.MF files.
2177
- if (jarFile.endsWith(".jar")) {
2178
- pomname = jarFile.replace(".jar", ".pom");
2179
- }
3064
+ // A POM next to the archive, as in the Maven repository layout, names its
3065
+ // coordinates. It is read where it is. The archive itself is always copied
3066
+ // into tempDir and extracted there, so nothing is ever written to or removed
3067
+ // from the directory being scanned.
3068
+ const siblingPom = siblingPomFile(jarFile);
2180
3069
  if (
2181
- pomname &&
2182
- safeExistsSync(pomname) &&
2183
- manifestname &&
2184
- safeExistsSync(manifestname)
2185
- ) {
2186
- tempDir = dirname(jarFile);
2187
- } else if (
2188
3070
  !safeExistsSync(join(tempDir, fname)) &&
2189
3071
  safeExistsSync(jarFile) &&
2190
3072
  lstatSync(jarFile).isFile()
@@ -2192,6 +3074,8 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2192
3074
  // Only copy if the file doesn't exist
2193
3075
  safeCopyFileSync(jarFile, join(tempDir, fname), constants.COPYFILE_FICLONE);
2194
3076
  }
3077
+ const outerCopy = join(tempDir, fname);
3078
+ let outerExtracted = false;
2195
3079
  const env = {
2196
3080
  ...process.env,
2197
3081
  };
@@ -2207,11 +3091,11 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2207
3091
  jarFile.endsWith(".hpi") ||
2208
3092
  jarFile.endsWith(".jar")
2209
3093
  ) {
2210
- if (safeExistsSync(join(tempDir, fname))) {
3094
+ if (safeExistsSync(outerCopy)) {
2211
3095
  try {
2212
- const zip = new StreamZip.async({ file: join(tempDir, fname) });
3096
+ const zip = new StreamZip.async({ file: outerCopy });
2213
3097
  const extracted = await safeExtractArchive(
2214
- join(tempDir, fname),
3098
+ outerCopy,
2215
3099
  tempDir,
2216
3100
  async () => {
2217
3101
  await zip.extract(null, tempDir);
@@ -2221,6 +3105,7 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2221
3105
  if (!extracted) {
2222
3106
  return pkgList;
2223
3107
  }
3108
+ outerExtracted = true;
2224
3109
  } catch (e) {
2225
3110
  console.log("Unable to extract %s. Skipping.", join(tempDir, fname), e);
2226
3111
  return pkgList;
@@ -2251,32 +3136,41 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2251
3136
  }
2252
3137
  continue;
2253
3138
  }
2254
- pomname = jf.replace(".jar", ".pom");
2255
3139
  const jarname = basename(jf);
2256
- // Ignore test jars
3140
+ // Ignore test, sources and javadoc jars: none of them is a runtime
3141
+ // component, and identifying them only costs lookups.
2257
3142
  if (
2258
3143
  jarname.endsWith("-tests.jar") ||
2259
- jarname.endsWith("-test-sources.jar")
3144
+ jarname.endsWith("-test-sources.jar") ||
3145
+ isDocumentationJar(jarname)
2260
3146
  ) {
2261
3147
  if (DEBUG_MODE) {
2262
3148
  console.log(`Skipping tests jar ${jarname}`);
2263
3149
  }
2264
3150
  continue;
2265
3151
  }
2266
- const manifestDir = join(tempDir, "META-INF");
3152
+ const isOuterArchive = jf === outerCopy || jf === jarFile;
3153
+ // The outer archive is already extracted into tempDir. Every nested
3154
+ // archive gets a directory of its own, so the META-INF of the war or of
3155
+ // a previously read jar can never be mistaken for this jar's.
3156
+ const reuseOuter = jf === outerCopy && outerExtracted;
3157
+ const workDir = reuseOuter
3158
+ ? tempDir
3159
+ : safeMkdtempSync(join(tempDir, "nested-"));
3160
+ const manifestDir = join(workDir, "META-INF");
2267
3161
  const manifestFile = join(manifestDir, "MANIFEST.MF");
2268
3162
  const mavenDir = join(manifestDir, "maven");
2269
3163
  let jarResult = {
2270
3164
  status: 1,
2271
3165
  };
2272
- if (safeExistsSync(pomname)) {
3166
+ if (reuseOuter) {
2273
3167
  jarResult = { status: 0 };
2274
- } else {
3168
+ } else if (workDir) {
2275
3169
  // Unzip natively
2276
3170
  try {
2277
3171
  const zip = new StreamZip.async({ file: jf });
2278
- const extracted = await safeExtractArchive(jf, tempDir, async () => {
2279
- await zip.extract(null, tempDir);
3172
+ const extracted = await safeExtractArchive(jf, workDir, async () => {
3173
+ await zip.extract(null, workDir);
2280
3174
  });
2281
3175
  await zip.close();
2282
3176
  jarResult = { status: extracted ? 0 : 1 };
@@ -2291,36 +3185,79 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2291
3185
  // When maven descriptor is available take group, name and version from pom.properties
2292
3186
  // META-INF/maven/${groupId}/${artifactId}/pom.properties
2293
3187
  // see https://maven.apache.org/shared/maven-archiver/index.html
2294
- const pomProperties = getPomPropertiesFromMavenDir(mavenDir);
3188
+ const pomProperties = getPomPropertiesFromMavenDir(mavenDir, jarname);
2295
3189
  let group = pomProperties["groupId"];
2296
3190
  let name = pomProperties["artifactId"];
2297
3191
  let version = pomProperties["version"];
2298
3192
  let confidence = 0.5;
2299
3193
  let technique = "manifest-analysis";
3194
+ let classifier;
3195
+ // A POM stored next to the archive names the same coordinates.
3196
+ const identityPom = isOuterArchive ? siblingPom : siblingPomFile(jf);
3197
+ if ((!group || !name || !version) && identityPom) {
3198
+ const pomData = readPomCoordinates(identityPom);
3199
+ if (pomData) {
3200
+ group = pomData.groupId;
3201
+ name = pomData.artifactId;
3202
+ version = pomData.version;
3203
+ }
3204
+ }
3205
+ // A jar inside a local Maven repository or Gradle cache is named by
3206
+ // where it sits.
3207
+ if ((!group || !name || !version) && isOuterArchive) {
3208
+ const coordinates = inferMavenCoordinatesFromPath(jarFile);
3209
+ if (coordinates?.extension === "jar") {
3210
+ group = coordinates.group;
3211
+ name = coordinates.name;
3212
+ version = coordinates.version;
3213
+ classifier = coordinates.classifier;
3214
+ technique = "filename";
3215
+ }
3216
+ }
3217
+ // A jar that was copied out of a local cache is known by its SHA-1,
3218
+ // which answers what the Maven Central search would be asked.
3219
+ let sha;
3220
+ if (!group || !name || !version) {
3221
+ sha = await checksumFile("sha1", jf);
3222
+ const indexed = findMavenCoordinatesBySha1(sha);
3223
+ if (indexed) {
3224
+ group = indexed.group;
3225
+ name = indexed.name;
3226
+ version = indexed.version;
3227
+ classifier = indexed.classifier;
3228
+ technique = "hash-comparison";
3229
+ }
3230
+ }
2300
3231
  if (
2301
3232
  (!group || !name || !version) &&
2302
3233
  SEARCH_MAVEN_ORG &&
2303
- search_maven_org_errors < MAX_SEARCH_MAVEN_ORG_ERRORS
3234
+ !isHostCircuitOpen(MAVEN_SEARCH_HOST)
2304
3235
  ) {
2305
3236
  try {
2306
- const sha = await checksumFile("sha1", jf);
2307
- const searchurl = `https://central.sonatype.com/solrsearch/select?q=1:%22${sha}%22&rows=20&wt=json`;
3237
+ sha = sha || (await checksumFile("sha1", jf));
3238
+ const searchurl = `https://${MAVEN_SEARCH_HOST}/solrsearch/select?q=1:%22${sha}%22&rows=20&wt=json`;
2308
3239
  // One Solr query per unidentified jar, against Central's origin
2309
3240
  // rather than its CDN. A directory of jars issues these back to
2310
3241
  // back, so they go through the same per-host gate the batch pool
2311
- // uses (500 ms, serial) instead of a second, ungoverned path.
2312
- const res = await withHostRateLimit(searchurl, () =>
2313
- cdxgenAgent.get(searchurl, {
2314
- responseType: "json",
2315
- timeout: {
2316
- lookup: 1000,
2317
- connect: 5000,
2318
- secureConnect: 5000,
2319
- socket: 1000,
2320
- send: 10000,
2321
- response: 1000,
2322
- },
2323
- }),
3242
+ // uses (500 ms, serial) instead of a second, ungoverned path. A
3243
+ // repeated hash is answered from the response cache without
3244
+ // waiting for that gate.
3245
+ const res = await withHostRateLimit(
3246
+ searchurl,
3247
+ (gate) =>
3248
+ cdxgenAgent.get(searchurl, {
3249
+ ...gate,
3250
+ responseType: "json",
3251
+ timeout: {
3252
+ lookup: 1000,
3253
+ connect: 5000,
3254
+ secureConnect: 5000,
3255
+ socket: 1000,
3256
+ send: 10000,
3257
+ response: 1000,
3258
+ },
3259
+ }),
3260
+ { deferGate: true },
2324
3261
  );
2325
3262
  const data = res?.body ? res.body["response"] : undefined;
2326
3263
  if (data && data["numFound"] === 1) {
@@ -2331,16 +3268,21 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2331
3268
  technique = "hash-comparison";
2332
3269
  }
2333
3270
  } catch (err) {
3271
+ // Any failure other than "not found" pauses the search for a
3272
+ // while; a 429 has already done so. Only a rate limit or an
3273
+ // unanswered request is the host's doing and worth a warning; a
3274
+ // dry run or an allowlist block pauses it quietly.
2334
3275
  if (err?.message && !err.message.includes("404")) {
2335
- if (
2336
- err.message.includes("Timeout") ||
2337
- err.message.includes("429")
2338
- ) {
2339
- console.log(
2340
- "Maven search appears to be unavailable. Search will be skipped for all remaining packages.",
2341
- );
2342
- }
2343
- search_maven_org_errors++;
3276
+ const rateLimited = err.message.includes("429");
3277
+ const unanswered =
3278
+ err.name === "RequestError" &&
3279
+ !err.options?.context?.activityBlocked;
3280
+ openHostCircuit(MAVEN_SEARCH_HOST, {
3281
+ cause: rateLimited
3282
+ ? "answered HTTP 429 (rate limited)"
3283
+ : "did not answer",
3284
+ quiet: !rateLimited && !unanswered,
3285
+ });
2344
3286
  }
2345
3287
  }
2346
3288
  }
@@ -2443,12 +3385,16 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2443
3385
  value: jf,
2444
3386
  },
2445
3387
  ];
3388
+ const qualifiers = { type: "jar" };
3389
+ if (classifier) {
3390
+ qualifiers.classifier = classifier;
3391
+ }
2446
3392
  const purl = build({
2447
3393
  type: "maven",
2448
3394
  namespace: group || null,
2449
3395
  name: name,
2450
3396
  version: version || null,
2451
- qualifiers: { type: "jar" } || null,
3397
+ qualifiers,
2452
3398
  });
2453
3399
  let namespaceValues;
2454
3400
  let namespaceList;
@@ -2500,8 +3446,8 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2500
3446
  }
2501
3447
  }
2502
3448
  const apkg = {
2503
- group: group ? encodeForPurl(group) : "",
2504
- name: name ? encodeForPurl(name) : "",
3449
+ group: group || "",
3450
+ name: name || "",
2505
3451
  version,
2506
3452
  purl,
2507
3453
  evidence: {
@@ -2519,6 +3465,39 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2519
3465
  },
2520
3466
  properties,
2521
3467
  };
3468
+ // The jar carries its own Maven descriptor, which holds the
3469
+ // description and licence no remote lookup is needed for, and a
3470
+ // manifest licence for the jars whose POM declares none.
3471
+ const descriptor = readEmbeddedMavenDescriptor(mavenDir, jarname);
3472
+ if (descriptor?.pomXml) {
3473
+ try {
3474
+ applyPomMetadata(
3475
+ apkg,
3476
+ xml2js(descriptor.pomXml, POM_XML_OPTIONS).project,
3477
+ descriptor.pomXml,
3478
+ );
3479
+ } catch (_err) {
3480
+ // An unparseable embedded POM contributes nothing.
3481
+ }
3482
+ }
3483
+ if (!hasMavenLicense(apkg)) {
3484
+ let manifestText;
3485
+ try {
3486
+ manifestText =
3487
+ safeExistsSync(manifestFile) &&
3488
+ statSync(manifestFile).size <= 256 * 1024
3489
+ ? readFileSync(manifestFile, { encoding: "utf-8" })
3490
+ : undefined;
3491
+ } catch (_err) {
3492
+ manifestText = undefined;
3493
+ }
3494
+ applyManifestLicenses(
3495
+ apkg,
3496
+ parseManifestLicenseList(
3497
+ parseJarManifest(manifestText)["Bundle-License"],
3498
+ ),
3499
+ );
3500
+ }
2522
3501
  pkgList.push(apkg);
2523
3502
  } else {
2524
3503
  unresolvedJars.push(jf);
@@ -2528,12 +3507,17 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2528
3507
  }
2529
3508
  }
2530
3509
  try {
2531
- if (safeExistsSync(join(tempDir, "META-INF"))) {
2532
- // Clean up META-INF
2533
- safeRmSync(join(tempDir, "META-INF"), {
2534
- recursive: true,
2535
- force: true,
2536
- });
3510
+ // Only directories this function created are removed. A caller's
3511
+ // tempDir is never the scanned directory, but the check keeps it that
3512
+ // way should a caller ever pass one.
3513
+ if (workDir && workDir !== tempDir && isInsideTmpDir(workDir)) {
3514
+ safeRmSync(workDir, { recursive: true, force: true });
3515
+ } else if (
3516
+ workDir === tempDir &&
3517
+ isInsideTmpDir(tempDir) &&
3518
+ safeExistsSync(manifestDir)
3519
+ ) {
3520
+ safeRmSync(manifestDir, { recursive: true, force: true });
2537
3521
  }
2538
3522
  } catch (_err) {
2539
3523
  // ignore cleanup errors
@@ -2629,18 +3613,51 @@ async function unresolvedJarComponents(unresolvedJars, jarFile, tempDir) {
2629
3613
  */
2630
3614
  const METADATA_VERSION_PROP = "cdx:nuget:metadata_version";
2631
3615
 
3616
+ /**
3617
+ * Fetch one JSON document through the batch pool, so it shares the rate gate
3618
+ * and the disk cache with every other registry request. A direct request is
3619
+ * made only when batched fetching is off, which is the cassette replay the
3620
+ * golden corpus runs under.
3621
+ *
3622
+ * @param {string} url Document URL
3623
+ * @returns {Promise<{body: Object}|undefined>} The response, or undefined when
3624
+ * the batch answered definitely that the document is not available
3625
+ */
3626
+ async function batchedJsonDocument(url) {
3627
+ if (prefetchEnabled()) {
3628
+ const fetched = await prefetchJson([{ url, responseType: "json" }]);
3629
+ const entry = fetched.get(url);
3630
+ return entry?.ok ? { body: entry.body } : undefined;
3631
+ }
3632
+ return await gatedGet(url, { responseType: "json" });
3633
+ }
3634
+
2632
3635
  async function getNugetUrl() {
2633
3636
  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;
3637
+ const fallbackUrl = "https://api.nuget.org/v3/registration3/";
3638
+ let res;
3639
+ try {
3640
+ res = await batchedJsonDocument(req);
3641
+ } catch (err) {
3642
+ // Package lookups already degrade to unenriched components on error. An
3643
+ // unreachable service index used to reject here instead and abort the
3644
+ // whole BOM, so fall back to the well-known registration base.
3645
+ thoughtLog(
3646
+ `Unable to read the NuGet service index (${err.code || err.message}). Using ${fallbackUrl}.`,
3647
+ );
3648
+ return fallbackUrl;
3649
+ }
3650
+ if (!res) {
3651
+ thoughtLog(`Unable to read the NuGet service index. Using ${fallbackUrl}.`);
3652
+ return fallbackUrl;
3653
+ }
3654
+ const urls = res?.body?.resources || [];
2638
3655
  for (const resource of urls) {
2639
3656
  if (resource["@type"] === "RegistrationsBaseUrl/3.6.0") {
2640
3657
  return resource["@id"];
2641
3658
  }
2642
3659
  }
2643
- return "https://api.nuget.org/v3/registration3/";
3660
+ return fallbackUrl;
2644
3661
  }
2645
3662
 
2646
3663
  /**
@@ -2697,10 +3714,15 @@ async function queryNuget(p, NUGET_URL) {
2697
3714
  const body = [];
2698
3715
  const newBody = [];
2699
3716
  const indexUrl = `${NUGET_URL + np.name.toLowerCase()}/index.json`;
2700
- let res =
2701
- prefetchedResponse(prefetchedNugetIndex, indexUrl) ||
2702
- (await cdxgenAgent.get(indexUrl, { responseType: "json" }));
2703
- const items = res.body.items;
3717
+ let res;
3718
+ try {
3719
+ res =
3720
+ prefetchedResponse(prefetchedNugetIndex, indexUrl) ||
3721
+ (await batchedJsonDocument(indexUrl));
3722
+ } catch {
3723
+ res = undefined;
3724
+ }
3725
+ const items = res?.body?.items;
2704
3726
  if (!items?.[0]) {
2705
3727
  return [np, newBody, body];
2706
3728
  }
@@ -2734,8 +3756,13 @@ async function queryNuget(p, NUGET_URL) {
2734
3756
  // at a page that must be fetched.
2735
3757
  let leaves = item.items;
2736
3758
  if (!leaves) {
2737
- res = await cdxgenAgent.get(item["@id"], { responseType: "json" });
2738
- leaves = res.body.items;
3759
+ // The page URL is only known once the index answered, but it still
3760
+ // goes through the batch pool, its rate gate and its disk cache.
3761
+ const page = await batchedJsonDocument(item["@id"]);
3762
+ if (!page) {
3763
+ return [np, newBody, body];
3764
+ }
3765
+ leaves = page.body.items;
2739
3766
  }
2740
3767
  for (const i of leaves.reverse()) {
2741
3768
  if (
@@ -2751,6 +3778,200 @@ async function queryNuget(p, NUGET_URL) {
2751
3778
  return [np, newBody];
2752
3779
  }
2753
3780
 
3781
+ /**
3782
+ * The global NuGet packages folder of this machine.
3783
+ *
3784
+ * @returns {string} `$NUGET_PACKAGES`, or the default `~/.nuget/packages`.
3785
+ */
3786
+ function nugetPackagesRoot() {
3787
+ return resolve(
3788
+ readEnvironmentVariable("NUGET_PACKAGES") ||
3789
+ join(homedir(), ".nuget", "packages"),
3790
+ );
3791
+ }
3792
+
3793
+ /**
3794
+ * Read the nuspec of a package version installed in the global packages
3795
+ * folder, without asking the NuGet service.
3796
+ *
3797
+ * The folder and the nuspec inside it are named by the lower-cased package id,
3798
+ * under a folder named by the lower-cased version.
3799
+ *
3800
+ * @param {Object} p Component with `name` and an exact `version`
3801
+ * @returns {{license: string|undefined, description: string|undefined,
3802
+ * author: string|undefined, repository: {url: string}|undefined,
3803
+ * source: string}|undefined} The nuspec's fields, or undefined when the
3804
+ * package is not installed here or states no exact version
3805
+ */
3806
+ function readLocalNugetNuspec(p) {
3807
+ if (
3808
+ !p?.name ||
3809
+ !p.version ||
3810
+ ["0.0.0", "latest"].includes(p.version) ||
3811
+ p.version === "workspace"
3812
+ ) {
3813
+ return undefined;
3814
+ }
3815
+ // The folder holds the lower-cased id and version. A name or version that is
3816
+ // not a single path segment cannot be a package there.
3817
+ const id = p.name.toLowerCase();
3818
+ const version = p.version.trim().toLowerCase();
3819
+ if (
3820
+ [id, version].some(
3821
+ (segment) =>
3822
+ segment.includes("/") ||
3823
+ segment.includes("\\") ||
3824
+ segment === "." ||
3825
+ segment === "..",
3826
+ )
3827
+ ) {
3828
+ return undefined;
3829
+ }
3830
+ const nuspecFile = join(nugetPackagesRoot(), id, version, `${id}.nuspec`);
3831
+ if (!safeExistsSync(nuspecFile)) {
3832
+ return undefined;
3833
+ }
3834
+ let nuspec;
3835
+ try {
3836
+ nuspec = parseNuspecData(nuspecFile, readFileSync(nuspecFile, "utf-8"));
3837
+ } catch {
3838
+ return undefined;
3839
+ }
3840
+ const pkg = nuspec?.pkgList?.[0];
3841
+ if (!pkg?.name) {
3842
+ return undefined;
3843
+ }
3844
+ return {
3845
+ license: pkg.license,
3846
+ description: pkg.description,
3847
+ author: pkg.author,
3848
+ repository: pkg.repository,
3849
+ source: nuspecFile,
3850
+ };
3851
+ }
3852
+
3853
+ /**
3854
+ * Apply the installed nuspec of a package to its component, and say whether it
3855
+ * answered everything the registry round would have been asked for.
3856
+ *
3857
+ * @param {Object} p Component, enriched in place
3858
+ * @returns {boolean} true when the registry can be skipped for this package
3859
+ */
3860
+ function applyLocalNugetNuspec(p) {
3861
+ const local = readLocalNugetNuspec(p);
3862
+ if (!local) {
3863
+ return false;
3864
+ }
3865
+ if (!p.license && local.license) {
3866
+ p.license = local.license;
3867
+ }
3868
+ if (!p.description && local.description) {
3869
+ p.description = local.description;
3870
+ }
3871
+ if (!p.author && local.author) {
3872
+ p.author = local.author;
3873
+ }
3874
+ if (!p.repository && local.repository) {
3875
+ p.repository = local.repository;
3876
+ }
3877
+ if (!p.license || !p.description) {
3878
+ return false;
3879
+ }
3880
+ p.properties = p.properties || [];
3881
+ if (!p.properties.some((prop) => prop.name === "cdx:nuget:metadataSource")) {
3882
+ p.properties.push({
3883
+ name: "cdx:nuget:metadataSource",
3884
+ value: "local-nuspec",
3885
+ });
3886
+ }
3887
+ return true;
3888
+ }
3889
+
3890
+ /**
3891
+ * Whether a licence is still the URL a package named rather than an id.
3892
+ *
3893
+ * @param {*} license Licence value.
3894
+ * @returns {boolean}
3895
+ */
3896
+ function isUnresolvedLicenseUrl(license) {
3897
+ return typeof license === "string" && license.includes("://");
3898
+ }
3899
+
3900
+ /**
3901
+ * Fill a component from the NuGet catalog entry of its release, keeping every
3902
+ * value the project already gave it.
3903
+ *
3904
+ * When the entry names no licence id, only a GitHub licence URL or project URL,
3905
+ * the repository's licence is the remaining source. Those lookups are returned
3906
+ * rather than made here, so the caller can batch them for every package.
3907
+ *
3908
+ * @param {Object} p Component, enriched in place
3909
+ * @param {{leaf: Object, metadataVersion?: string}} entry The registration leaf
3910
+ * the service returned, and the release it describes when the component
3911
+ * states no version
3912
+ * @returns {string[]} GitHub URLs to ask for the licence, in order of preference
3913
+ */
3914
+ function applyNugetCatalogEntry(p, { leaf, metadataVersion }) {
3915
+ const repoLicenseUrls = [];
3916
+ const catalogEntry = leaf.catalogEntry || {};
3917
+ // The component states no version, so the description and licence below
3918
+ // were read from whichever release the lookup fell back to. That release is
3919
+ // recorded rather than adopted as the version: a version cdxgen chose is not
3920
+ // a version the project declared.
3921
+ if (!p.version && metadataVersion) {
3922
+ p.properties = p.properties || [];
3923
+ if (!p.properties.some((prop) => prop.name === METADATA_VERSION_PROP)) {
3924
+ p.properties.push({
3925
+ name: METADATA_VERSION_PROP,
3926
+ value: metadataVersion,
3927
+ });
3928
+ }
3929
+ }
3930
+ if (catalogEntry.description && !p.description) {
3931
+ p.description = catalogEntry.description;
3932
+ }
3933
+ if (catalogEntry.authors && !p.author) {
3934
+ p.author = catalogEntry.authors.trim();
3935
+ }
3936
+ if (
3937
+ !p.license &&
3938
+ catalogEntry.licenseExpression &&
3939
+ catalogEntry.licenseExpression !== ""
3940
+ ) {
3941
+ p.license = findLicenseId(catalogEntry.licenseExpression);
3942
+ } else if (!p.license && catalogEntry.licenseUrl) {
3943
+ p.license = findLicenseId(catalogEntry.licenseUrl);
3944
+ if (isUnresolvedLicenseUrl(p.license) && isGitHubUrl(p.license)) {
3945
+ repoLicenseUrls.push(p.license);
3946
+ }
3947
+ }
3948
+ // Capture the tags
3949
+ if (catalogEntry.tags?.length && Array.isArray(catalogEntry.tags)) {
3950
+ p.tags = catalogEntry.tags.map((t) => t.toLowerCase().replaceAll(" ", "-"));
3951
+ }
3952
+ if (catalogEntry.projectUrl && !p.repository) {
3953
+ p.repository = { url: catalogEntry.projectUrl };
3954
+ // A versionless component gets the package's landing page. Pasting an
3955
+ // undefined version into the path produced a 404 link.
3956
+ p.homepage = {
3957
+ url: p.version
3958
+ ? `https://www.nuget.org/packages/${p.name}/${p.version}/`
3959
+ : `https://www.nuget.org/packages/${p.name}/`,
3960
+ };
3961
+ // Only a licence that is missing or still a URL is looked for in the
3962
+ // repository. An id the package declared is its own answer, and asking
3963
+ // GitHub about every package would spend its small anonymous quota.
3964
+ if (
3965
+ (!p.license || isUnresolvedLicenseUrl(p.license)) &&
3966
+ typeof p.repository.url === "string" &&
3967
+ isGitHubUrl(p.repository.url)
3968
+ ) {
3969
+ repoLicenseUrls.push(p.repository.url);
3970
+ }
3971
+ }
3972
+ return repoLicenseUrls;
3973
+ }
3974
+
2754
3975
  /**
2755
3976
  * Method to retrieve metadata for nuget packages
2756
3977
  *
@@ -2765,14 +3986,21 @@ export async function getNugetMetadata(pkgList, dependencies = undefined) {
2765
3986
  // Batch the per-package registration index requests. queryNuget's first
2766
3987
  // request for each package is the index; the follow-up to a specific
2767
3988
  // registration page depends on the index response and stays serial. Only
2768
- // packages that would actually be queried (not in metadata_cache, no prior
2769
- // error) are prefetched, matching the loop's skip conditions.
3989
+ // packages that would actually be queried (no remembered answer or error) are
3990
+ // prefetched, matching the loop's skip conditions.
2770
3991
  const batchUrls = [];
2771
3992
  const seenNugetUrls = new Set();
3993
+ // Packages the installed nuspec answered, so neither the batch nor the loop
3994
+ // asks the service about them.
3995
+ const servedLocally = new Set();
2772
3996
  for (const p of pkgList) {
2773
3997
  // A cached entry — a body or a recorded error — means the loop will not
2774
3998
  // reach the network for this package, so the batch must not either.
2775
- if (metadata_cache[`${p.name}|${p.version}`]) {
3999
+ if (nugetEntries.has(`${p.name}|${p.version}`)) {
4000
+ continue;
4001
+ }
4002
+ if (applyLocalNugetNuspec(p)) {
4003
+ servedLocally.add(p);
2776
4004
  continue;
2777
4005
  }
2778
4006
  const indexUrl = `${NUGET_URL + p.name.toLowerCase()}/index.json`;
@@ -2789,110 +4017,58 @@ export async function getNugetMetadata(pkgList, dependencies = undefined) {
2789
4017
  ownIndexUrls.push(key);
2790
4018
  }
2791
4019
  }
4020
+ // Packages whose licence can only come from their GitHub repository, with
4021
+ // the URLs to try, so those lookups are batched once the loop is done.
4022
+ const repoLicenseLookups = [];
2792
4023
  try {
2793
4024
  for (const p of pkgList) {
2794
- let cacheKey;
4025
+ // If there is a version, we can safely use the cache to retrieve the license
4026
+ // See: https://github.com/cdxgen/cdxgen/issues/352
4027
+ const cacheKey = `${p.name}|${p.version}`;
2795
4028
  try {
2796
- // If there is a version, we can safely use the cache to retrieve the license
2797
- // See: https://github.com/cdxgen/cdxgen/issues/352
2798
- cacheKey = `${p.name}|${p.version}`;
2799
- let body = metadata_cache[cacheKey];
2800
-
2801
- if (body?.error) {
4029
+ let entry = nugetEntries.get(cacheKey);
4030
+ if (entry?.error) {
2802
4031
  cdepList.push(p);
2803
4032
  continue;
2804
4033
  }
2805
- if (!body) {
2806
- let newBody = {};
2807
- let np = {};
2808
- [np, newBody] = await queryNuget(p, NUGET_URL);
2809
- if (p.version !== np.version) {
2810
- const oldRef = p["bom-ref"];
2811
- // The purl is rebuilt alongside the bom-ref. Leaving it behind let a
2812
- // component state one version in `version` and another - or none - in
2813
- // its purl, which is what a scanner range matches on. See #4359.
2814
- p.version = np.version;
2815
- applyPurl(p, nugetPurl(np.name, np.version));
2816
- if (oldRef && p["bom-ref"] !== oldRef) {
2817
- depRepList[oldRef] = p["bom-ref"];
2818
- }
4034
+ if (!entry) {
4035
+ if (servedLocally.has(p)) {
4036
+ cdepList.push(p);
4037
+ continue;
2819
4038
  }
2820
- if (newBody && newBody.length > 0) {
2821
- body = newBody[0];
4039
+ const [np, leaves] = await queryNuget(p, NUGET_URL);
4040
+ entry = {
4041
+ leaf: leaves?.[0],
4042
+ version: np.version,
4043
+ metadataVersion: np.metadataVersion,
4044
+ };
4045
+ if (entry.leaf) {
4046
+ nugetEntries.set(cacheKey, entry);
2822
4047
  }
2823
- if (body) {
2824
- metadata_cache[cacheKey] = body;
2825
- // The component states no version, so the description and licence
2826
- // below were read from whichever release the lookup fell back to.
2827
- // That release is recorded rather than adopted as the version: a
2828
- // version cdxgen chose is not a version the project declared.
2829
- if (!p.version && np.metadataVersion) {
2830
- p.properties = p.properties || [];
2831
- if (
2832
- !p.properties.some(
2833
- (prop) => prop.name === METADATA_VERSION_PROP,
2834
- )
2835
- ) {
2836
- p.properties.push({
2837
- name: METADATA_VERSION_PROP,
2838
- value: np.metadataVersion,
2839
- });
2840
- }
2841
- }
2842
- p.description = body.catalogEntry.description;
2843
- if (body.catalogEntry.authors) {
2844
- p.author = body.catalogEntry.authors.trim();
2845
- }
2846
- if (
2847
- body.catalogEntry.licenseExpression &&
2848
- body.catalogEntry.licenseExpression !== ""
2849
- ) {
2850
- p.license = findLicenseId(body.catalogEntry.licenseExpression);
2851
- } else if (body.catalogEntry.licenseUrl) {
2852
- p.license = findLicenseId(body.catalogEntry.licenseUrl);
2853
- if (typeof p.license === "string" && isGitHubUrl(p.license)) {
2854
- p.license =
2855
- (await getRepoLicense(p.license, undefined)) || p.license;
2856
- }
2857
- }
2858
- // Capture the tags
2859
- if (
2860
- body.catalogEntry?.tags?.length &&
2861
- Array.isArray(body.catalogEntry.tags)
2862
- ) {
2863
- p.tags = body.catalogEntry.tags.map((t) =>
2864
- t.toLowerCase().replaceAll(" ", "-"),
2865
- );
2866
- }
2867
- if (body.catalogEntry.projectUrl) {
2868
- p.repository = { url: body.catalogEntry.projectUrl };
2869
- // A versionless component gets the package's landing page. Pasting
2870
- // an undefined version into the path produced a 404 link.
2871
- p.homepage = {
2872
- url: p.version
2873
- ? `https://www.nuget.org/packages/${p.name}/${p.version}/`
2874
- : `https://www.nuget.org/packages/${p.name}/`,
2875
- };
2876
- if (
2877
- (!p.license || typeof p.license === "string") &&
2878
- typeof p.repository.url === "string" &&
2879
- isGitHubUrl(p.repository.url)
2880
- ) {
2881
- // license couldn't be properly identified and is still a url,
2882
- // therefore trying to resolve license via repository
2883
- p.license =
2884
- (await getRepoLicense(p.repository.url, undefined)) ||
2885
- p.license;
2886
- }
2887
- }
2888
- cdepList.push(p);
4048
+ }
4049
+ if (p.version !== entry.version) {
4050
+ const oldRef = p["bom-ref"];
4051
+ // The purl is rebuilt alongside the bom-ref. Leaving it behind let a
4052
+ // component state one version in `version` and another - or none - in
4053
+ // its purl, which is what a scanner range matches on. See #4359.
4054
+ p.version = entry.version;
4055
+ applyPurl(p, nugetPurl(p.name, entry.version));
4056
+ if (oldRef && p["bom-ref"] !== oldRef) {
4057
+ depRepList[oldRef] = p["bom-ref"];
2889
4058
  }
2890
4059
  }
2891
- } catch (err) {
2892
- if (cacheKey) {
2893
- metadata_cache[cacheKey] = { error: err.code };
4060
+ // A package the service does not know, such as a private one, stays in
4061
+ // the BOM as the project described it.
4062
+ if (entry.leaf) {
4063
+ const repoLicenseUrls = applyNugetCatalogEntry(p, entry);
4064
+ if (repoLicenseUrls.length) {
4065
+ repoLicenseLookups.push({ p, repoLicenseUrls });
4066
+ }
2894
4067
  }
2895
4068
  cdepList.push(p);
4069
+ } catch (err) {
4070
+ nugetEntries.set(cacheKey, { error: err.code });
4071
+ cdepList.push(p);
2896
4072
  }
2897
4073
  }
2898
4074
  } finally {
@@ -2904,6 +4080,20 @@ export async function getNugetMetadata(pkgList, dependencies = undefined) {
2904
4080
  prefetchedNugetIndex.delete(indexUrl);
2905
4081
  }
2906
4082
  }
4083
+ // The first URL of each package is batched. A second is asked only when the
4084
+ // first gave no licence, as it was before.
4085
+ await prefetchRepoLicenses(
4086
+ repoLicenseLookups.map(({ repoLicenseUrls }) => repoLicenseUrls[0]),
4087
+ );
4088
+ for (const { p, repoLicenseUrls } of repoLicenseLookups) {
4089
+ for (const repoUrl of repoLicenseUrls) {
4090
+ const license = await getRepoLicense(repoUrl, undefined);
4091
+ if (license) {
4092
+ p.license = license;
4093
+ break;
4094
+ }
4095
+ }
4096
+ }
2907
4097
  const newDependencies = [].concat(dependencies);
2908
4098
  if (depRepList && newDependencies.length) {
2909
4099
  const changed = Object.keys(depRepList);