@cdxgen/cdxgen 13.3.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 (255) hide show
  1. package/README.md +9 -6
  2. package/bin/cdxgen.js +90 -26
  3. package/bin/evinse.js +18 -1
  4. package/bin/repl.js +86 -0
  5. package/bin/sign.js +45 -15
  6. package/bin/tracebom.js +5 -1
  7. package/bin/validate.js +84 -18
  8. package/bin/verify.js +155 -86
  9. package/data/helpers/init.gradle +46 -21
  10. package/data/remediations.json +61 -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/index.js +113 -54
  15. package/lib/cli/jsBom.js +50 -29
  16. package/lib/cli/jvmBom.js +665 -138
  17. package/lib/cli/managedBom.js +320 -200
  18. package/lib/cli/nativeBom.js +139 -30
  19. package/lib/core/activity.js +12 -0
  20. package/lib/core/env.js +17 -9
  21. package/lib/core/fs.js +3 -2
  22. package/lib/core/httpClient.js +159 -24
  23. package/lib/core/pluginRun.js +184 -0
  24. package/lib/core/processTree.js +100 -0
  25. package/lib/core/runState.js +39 -0
  26. package/lib/core/toml.js +72 -0
  27. package/lib/ecosystems/asarutils.js +4 -2
  28. package/lib/ecosystems/bunutils.js +5 -7
  29. package/lib/ecosystems/cargoCache.js +231 -8
  30. package/lib/ecosystems/cmakeResolver.js +10 -21
  31. package/lib/ecosystems/cppBuildContext.js +631 -0
  32. package/lib/ecosystems/cppEvidence.js +337 -22
  33. package/lib/ecosystems/cppIncludeResolver.js +294 -0
  34. package/lib/ecosystems/denoutils.js +93 -66
  35. package/lib/ecosystems/ecosystems.js +1967 -788
  36. package/lib/ecosystems/golem.js +28 -12
  37. package/lib/ecosystems/gradleutils.js +314 -30
  38. package/lib/ecosystems/jsEvidence.js +85 -24
  39. package/lib/ecosystems/kosi.js +21 -4
  40. package/lib/ecosystems/npmutils.js +64 -0
  41. package/lib/ecosystems/parsers-dotnet.js +567 -151
  42. package/lib/ecosystems/parsers-elm.js +46 -28
  43. package/lib/ecosystems/parsers-gleam.js +3 -4
  44. package/lib/ecosystems/parsers-go.js +324 -61
  45. package/lib/ecosystems/parsers-js.js +92 -26
  46. package/lib/ecosystems/parsers-julia.js +2 -3
  47. package/lib/ecosystems/parsers-jvm.js +204 -12
  48. package/lib/ecosystems/parsers-misc.js +182 -55
  49. package/lib/ecosystems/parsers-python.js +141 -10
  50. package/lib/ecosystems/parsers-rust.js +4 -4
  51. package/lib/ecosystems/parsers-terraform.js +1573 -19
  52. package/lib/ecosystems/pythonutils.js +102 -6
  53. package/lib/ecosystems/remote/huggingface.js +3 -0
  54. package/lib/ecosystems/rubyutils.js +67 -17
  55. package/lib/ecosystems/rusi.js +22 -9
  56. package/lib/ecosystems/sbtutils.js +243 -187
  57. package/lib/ecosystems/scalacliutils.js +342 -0
  58. package/lib/ecosystems/scalasem.js +2618 -0
  59. package/lib/ecosystems/terraformRegistry.js +288 -0
  60. package/lib/ecosystems/utils.js +2 -0
  61. package/lib/ecosystems/vendoredCode.js +156 -0
  62. package/lib/evinser/evinser.js +578 -69
  63. package/lib/helpers/agenticToolutils.js +2 -2
  64. package/lib/helpers/bomSigner.js +576 -128
  65. package/lib/inventory/aiCollector.js +4 -1
  66. package/lib/inventory/analyzer.js +110 -127
  67. package/lib/inventory/atomUtils.js +171 -48
  68. package/lib/inventory/auditCategories.js +7 -0
  69. package/lib/inventory/buildToolRateLimit.js +144 -0
  70. package/lib/inventory/cacheCommands.js +2 -1
  71. package/lib/inventory/cmakeBuildDirs.js +254 -0
  72. package/lib/inventory/deps.js +501 -124
  73. package/lib/inventory/dosai.js +248 -44
  74. package/lib/inventory/dosaiParsers.js +310 -15
  75. package/lib/inventory/dynamic.js +52 -6
  76. package/lib/inventory/evidenceUtils.js +154 -109
  77. package/lib/inventory/fetchBatch.js +574 -38
  78. package/lib/inventory/fetchDiskCache.js +597 -0
  79. package/lib/inventory/forgeEnricher.js +14 -5
  80. package/lib/inventory/jvmLocalRepos.js +917 -0
  81. package/lib/inventory/mcpConfigParser.js +2 -3
  82. package/lib/inventory/parentSelection.js +146 -0
  83. package/lib/inventory/rustFormulationParser.js +3 -4
  84. package/lib/inventory/scalaCoords.js +153 -0
  85. package/lib/inventory/source.js +8 -8
  86. package/lib/inventory/spdx.js +101 -0
  87. package/lib/inventory/traceRunner.js +88 -7
  88. package/lib/inventory/tracedCrypto.js +294 -0
  89. package/lib/managers/docker.js +33 -32
  90. package/lib/managers/oci.js +24 -2
  91. package/lib/parsers/cmakeCache.js +69 -0
  92. package/lib/parsers/cmakeLists.js +16 -2
  93. package/lib/parsers/cmakePresets.js +400 -0
  94. package/lib/parsers/compileCommands.js +557 -0
  95. package/lib/parsers/strictJson.js +322 -0
  96. package/lib/parsers/terraformConfig.js +1073 -0
  97. package/lib/parsers/terraformSource.js +652 -0
  98. package/lib/server/server.js +23 -0
  99. package/lib/stages/postgen/introspection/markers.js +1 -1
  100. package/lib/stages/postgen/postgen.js +15 -1
  101. package/lib/stages/postgen/specVersionCompat.js +32 -0
  102. package/lib/validator/index.js +25 -10
  103. package/lib/validator/reporters/json.js +1 -0
  104. package/package.json +21 -21
  105. package/types/lib/audit/index.d.ts.map +1 -1
  106. package/types/lib/cli/index.d.ts.map +1 -1
  107. package/types/lib/cli/jsBom.d.ts.map +1 -1
  108. package/types/lib/cli/jvmBom.d.ts +24 -1
  109. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  110. package/types/lib/cli/managedBom.d.ts +7 -4
  111. package/types/lib/cli/managedBom.d.ts.map +1 -1
  112. package/types/lib/cli/nativeBom.d.ts +3 -1
  113. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  114. package/types/lib/core/activity.d.ts.map +1 -1
  115. package/types/lib/core/env.d.ts +9 -0
  116. package/types/lib/core/env.d.ts.map +1 -1
  117. package/types/lib/core/fs.d.ts.map +1 -1
  118. package/types/lib/core/httpClient.d.ts +13 -8
  119. package/types/lib/core/httpClient.d.ts.map +1 -1
  120. package/types/lib/core/pluginRun.d.ts +97 -0
  121. package/types/lib/core/pluginRun.d.ts.map +1 -0
  122. package/types/lib/core/processTree.d.ts +36 -0
  123. package/types/lib/core/processTree.d.ts.map +1 -0
  124. package/types/lib/core/runState.d.ts +29 -0
  125. package/types/lib/core/runState.d.ts.map +1 -0
  126. package/types/lib/core/toml.d.ts +20 -0
  127. package/types/lib/core/toml.d.ts.map +1 -0
  128. package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
  129. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  130. package/types/lib/ecosystems/cargoCache.d.ts +12 -0
  131. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
  132. package/types/lib/ecosystems/cmakeResolver.d.ts +5 -3
  133. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  134. package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
  135. package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
  136. package/types/lib/ecosystems/cppEvidence.d.ts +18 -2
  137. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  138. package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
  139. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
  140. package/types/lib/ecosystems/denoutils.d.ts +9 -3
  141. package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
  142. package/types/lib/ecosystems/ecosystems.d.ts +62 -13
  143. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  144. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  145. package/types/lib/ecosystems/gradleutils.d.ts +50 -0
  146. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  147. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  148. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  149. package/types/lib/ecosystems/npmutils.d.ts +15 -0
  150. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  151. package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
  152. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  153. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
  154. package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
  155. package/types/lib/ecosystems/parsers-go.d.ts +44 -2
  156. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  157. package/types/lib/ecosystems/parsers-js.d.ts +6 -1
  158. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  159. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
  160. package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
  161. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  162. package/types/lib/ecosystems/parsers-misc.d.ts +3 -1
  163. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  164. package/types/lib/ecosystems/parsers-python.d.ts +53 -3
  165. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  166. package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
  167. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
  168. package/types/lib/ecosystems/pythonutils.d.ts +27 -2
  169. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  170. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  171. package/types/lib/ecosystems/rubyutils.d.ts +21 -1
  172. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  173. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  174. package/types/lib/ecosystems/sbtutils.d.ts +88 -2
  175. package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
  176. package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
  177. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
  178. package/types/lib/ecosystems/scalasem.d.ts +311 -0
  179. package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
  180. package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
  181. package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
  182. package/types/lib/ecosystems/utils.d.ts +2 -2
  183. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  184. package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
  185. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
  186. package/types/lib/evinser/evinser.d.ts +75 -8
  187. package/types/lib/evinser/evinser.d.ts.map +1 -1
  188. package/types/lib/helpers/bomSigner.d.ts +80 -4
  189. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  190. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  191. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  192. package/types/lib/inventory/atomUtils.d.ts +82 -10
  193. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  194. package/types/lib/inventory/auditCategories.d.ts +3 -0
  195. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  196. package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
  197. package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
  198. package/types/lib/inventory/cacheCommands.d.ts +2 -1
  199. package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
  200. package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
  201. package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
  202. package/types/lib/inventory/deps.d.ts +59 -1
  203. package/types/lib/inventory/deps.d.ts.map +1 -1
  204. package/types/lib/inventory/dosai.d.ts +57 -8
  205. package/types/lib/inventory/dosai.d.ts.map +1 -1
  206. package/types/lib/inventory/dosaiParsers.d.ts +53 -7
  207. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  208. package/types/lib/inventory/dynamic.d.ts +10 -0
  209. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  210. package/types/lib/inventory/evidenceUtils.d.ts +19 -4
  211. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  212. package/types/lib/inventory/fetchBatch.d.ts +83 -14
  213. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  214. package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
  215. package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
  216. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  217. package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
  218. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
  219. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  220. package/types/lib/inventory/parentSelection.d.ts +34 -0
  221. package/types/lib/inventory/parentSelection.d.ts.map +1 -0
  222. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  223. package/types/lib/inventory/scalaCoords.d.ts +80 -0
  224. package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
  225. package/types/lib/inventory/source.d.ts.map +1 -1
  226. package/types/lib/inventory/spdx.d.ts +9 -0
  227. package/types/lib/inventory/spdx.d.ts.map +1 -1
  228. package/types/lib/inventory/traceRunner.d.ts +12 -0
  229. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  230. package/types/lib/inventory/tracedCrypto.d.ts +42 -0
  231. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
  232. package/types/lib/managers/docker.d.ts.map +1 -1
  233. package/types/lib/managers/oci.d.ts +3 -1
  234. package/types/lib/managers/oci.d.ts.map +1 -1
  235. package/types/lib/parsers/cmakeCache.d.ts +16 -0
  236. package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
  237. package/types/lib/parsers/cmakeLists.d.ts +2 -1
  238. package/types/lib/parsers/cmakeLists.d.ts.map +1 -1
  239. package/types/lib/parsers/cmakePresets.d.ts +78 -0
  240. package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
  241. package/types/lib/parsers/compileCommands.d.ts +115 -0
  242. package/types/lib/parsers/compileCommands.d.ts.map +1 -0
  243. package/types/lib/parsers/strictJson.d.ts +46 -0
  244. package/types/lib/parsers/strictJson.d.ts.map +1 -0
  245. package/types/lib/parsers/terraformConfig.d.ts +70 -0
  246. package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
  247. package/types/lib/parsers/terraformSource.d.ts +66 -0
  248. package/types/lib/parsers/terraformSource.d.ts.map +1 -0
  249. package/types/lib/server/server.d.ts.map +1 -1
  250. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  251. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  252. package/types/lib/validator/index.d.ts +8 -0
  253. package/types/lib/validator/index.d.ts.map +1 -1
  254. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  255. 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,48 +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
82
  genericPurl,
74
83
  nugetPurl,
75
84
  pypiPurl,
76
85
  } from "../inventory/purl.js";
86
+ import {
87
+ publishedArtifactId,
88
+ scalaCoordinateOfPurl,
89
+ } from "../inventory/scalaCoords.js";
77
90
  import {
78
91
  findLicenseId,
79
92
  guessLicenseId,
@@ -85,6 +98,9 @@ import {
85
98
  localCargoMetadataEnabled,
86
99
  readCargoCacheMetadata,
87
100
  } from "./cargoCache.js";
101
+ import { normalizeNpmRegistryUrl, resolveNpmLicense } from "./npmutils.js";
102
+ import { parseNuspecData } from "./parsers-dotnet.js";
103
+ import { isDefaultPypiRegistry } from "./pylockutils.js";
88
104
  import {
89
105
  applyPypiClassifierMetadata,
90
106
  applyPypiModuleNames,
@@ -95,26 +111,63 @@ import {
95
111
  collectPypiRegistryProvenanceProperties,
96
112
  } from "./registryProvenance.js";
97
113
 
98
- // Metadata cache
99
- 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
+ });
100
139
 
101
140
  // Speed up lookup namespaces for a given jar
102
141
 
103
- // circuit breaker for search maven.org
104
- let search_maven_org_errors = 0;
105
- 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";
106
144
 
107
- // 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.
108
147
  let get_repo_license_errors = 0;
148
+ let get_repo_license_paused_until = 0;
109
149
  const MAX_GET_REPO_LICENSE_ERRORS = 5;
150
+ const GET_REPO_LICENSE_PAUSE_MS = 15 * 60 * 1000;
110
151
 
111
152
  /**
112
- * Internal helper to reset metadata_cache. Used by parseGoModData (still in
113
- * utils.js until batch 6) because ESM forbids reassigning an imported binding.
114
- * NOT re-exported through the utils.js barrel.
153
+ * Whether repository licence lookups are currently paused.
154
+ *
155
+ * @returns {boolean}
115
156
  */
116
- export function _clearMetadataCache() {
117
- 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;
118
171
  }
119
172
 
120
173
  /**
@@ -182,75 +235,286 @@ function npmRegistryKey(p) {
182
235
  return p.name;
183
236
  }
184
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
+
185
403
  /**
186
404
  * Method to retrieve metadata for npm packages by querying npmjs
187
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
+ *
188
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.
189
422
  */
190
- export async function getNpmMetadata(pkgList, registryUrl) {
423
+ export async function getNpmMetadata(pkgList, registryUrl, options = {}) {
191
424
  const NPM_URL =
192
425
  registryUrl ||
193
426
  readEnvironmentVariable("NPM_URL") ||
194
427
  "https://registry.npmjs.org/";
428
+ const secureMode = options.secureMode ?? isSecureMode;
195
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
+ }
196
451
  // Every URL this loop needs is known up front, so they are fetched
197
452
  // concurrently before the loop rather than one at a time inside it. The
198
453
  // derivation below is unchanged and still reads `body`; only where `body`
199
- // 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.
200
456
  const prefetched = await prefetchJson(
201
457
  prefetchEnabled()
202
- ? pkgList.map((p) => ({ url: NPM_URL + npmRegistryKey(p) }))
458
+ ? [...lookupUrls.values()]
459
+ .filter((url) => !npmDocuments.has(url))
460
+ .map((url) => ({ url }))
203
461
  : [],
204
462
  );
205
463
  // The licence fallback calls getRepoLicense with a URL that only exists once
206
464
  // the registry document has arrived, so it needs a second batched round. The
207
465
  // documents are already in hand here, which is why this can be done without a
208
466
  // second pass over the network for the registry itself.
209
- if (prefetched.size) {
467
+ if (prefetched.size || npmDocuments.size) {
210
468
  const repoUrls = [];
211
469
  for (const p of pkgList) {
212
- const entry = prefetched.get(NPM_URL + npmRegistryKey(p));
213
- 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) {
214
474
  continue;
215
475
  }
216
- const body = entry.body;
217
- const versionLicense = body?.versions?.[p.version]?.license;
218
- if (!versionLicense && !body?.license && body?.repository?.url) {
476
+ if (
477
+ !p.license &&
478
+ !npmRegistryLicense(body, p.version) &&
479
+ body?.repository?.url
480
+ ) {
219
481
  repoUrls.push(body.repository.url);
220
482
  }
221
483
  }
222
484
  await prefetchRepoLicenses(repoUrls);
223
485
  }
224
486
  for (const p of pkgList) {
487
+ const url = lookupUrls.get(p);
488
+ if (!url) {
489
+ cdepList.push(p);
490
+ continue;
491
+ }
225
492
  try {
226
- const key = npmRegistryKey(p);
227
493
  // Namespace the cache by registry so packages resolved from a
228
494
  // non-default registry (e.g. jsr's npm mirror at npm.jsr.io) do not
229
495
  // collide with identically-named packages on the default registry.
230
- const cacheKey = `${NPM_URL}${key}`;
231
- let body = {};
232
- if (metadata_cache[cacheKey]) {
233
- body = metadata_cache[cacheKey];
234
- } else {
235
- const url = NPM_URL + key;
496
+ let body = npmDocuments.get(url);
497
+ if (!body) {
236
498
  const res =
237
499
  prefetchedResponse(prefetched, url) ||
238
- (await cdxgenAgent.get(url, {
239
- responseType: "json",
240
- }));
500
+ (await gatedGet(url, { responseType: "json" }));
241
501
  body = res.body;
242
- metadata_cache[cacheKey] = body;
502
+ npmDocuments.set(url, body);
243
503
  }
504
+ // The registry fills only what the project, the lockfile or an
505
+ // installed manifest left open, so a local answer survives.
244
506
  p.description =
245
- body.versions?.[p.version]?.description || body.description;
507
+ p.description ||
508
+ body.versions?.[p.version]?.description ||
509
+ body.description;
246
510
  p.license =
247
- body.versions?.[p.version]?.license ||
248
- body.license ||
511
+ p.license ||
512
+ npmRegistryLicense(body, p.version) ||
249
513
  (await getRepoLicense(body.repository?.url, undefined));
250
- if (body.repository?.url) {
514
+ if (body.repository?.url && !p.repository) {
251
515
  p.repository = { url: body.repository.url };
252
516
  }
253
- if (body.homepage) {
517
+ if (body.homepage && !p.homepage) {
254
518
  p.homepage = { url: body.homepage };
255
519
  }
256
520
  // Capture the resolved tarball as a distribution external reference when
@@ -295,445 +559,604 @@ export async function getNpmMetadata(pkgList, registryUrl) {
295
559
  * @returns {Object|null} Object containing jarPath, sha1, and pomPath, or null
296
560
  */
297
561
  export function findLocalMvnArtifact(group, name, version) {
298
- if (!group || !name || !version) {
562
+ const found = findLocalMavenArtifact(group, name, version);
563
+ if (!found) {
299
564
  return null;
300
565
  }
566
+ return {
567
+ jarPath: found.jarPath || null,
568
+ sha1: found.sha1 || null,
569
+ pomPath: found.pomPath || null,
570
+ };
571
+ }
301
572
 
302
- // 1. Gradle cache
303
- const gradleUserHome = readEnvironmentVariable("GRADLE_USER_HOME");
304
- const gradleCacheDir =
305
- readEnvironmentVariable("GRADLE_CACHE_DIR") ||
306
- (gradleUserHome
307
- ? join(gradleUserHome, "caches", "modules-2", "files-2.1")
308
- : 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
+ };
309
581
 
310
- if (existsSync(gradleCacheDir)) {
311
- const gradlePath = join(gradleCacheDir, group, name, version);
312
- if (existsSync(gradlePath)) {
313
- try {
314
- const subdirs = readdirSync(gradlePath);
315
- let jarPath = null;
316
- let sha1 = null;
317
- let pomPath = null;
318
- for (const subdir of subdirs) {
319
- const subdirPath = join(gradlePath, subdir);
320
- if (statSync(subdirPath).isDirectory()) {
321
- const files = readdirSync(subdirPath);
322
- for (const file of files) {
323
- if (
324
- file.endsWith(".jar") &&
325
- !file.endsWith("-sources.jar") &&
326
- !file.endsWith("-javadoc.jar")
327
- ) {
328
- jarPath = join(subdirPath, file);
329
- sha1 = subdir;
330
- } else if (file.endsWith(".pom")) {
331
- pomPath = join(subdirPath, file);
332
- }
333
- }
334
- }
335
- }
336
- if (jarPath || pomPath) {
337
- return { jarPath, sha1, pomPath };
338
- }
339
- } catch (_err) {
340
- // ignore
341
- }
342
- }
343
- }
582
+ // A POM chain is walked at most this deep. Real hierarchies stop well short.
583
+ const MAX_POM_PARENT_DEPTH = 8;
344
584
 
345
- // 2. Maven local repo
346
- const mavenLocal = join(homedir(), ".m2", "repository");
347
- const groupPath = group.replace(/\./g, "/");
348
- const mavenPath = join(mavenLocal, groupPath, name, version);
349
- if (existsSync(mavenPath)) {
350
- const jarPath = join(mavenPath, `${name}-${version}.jar`);
351
- const pomPath = join(mavenPath, `${name}-${version}.pom`);
352
- let hasFiles = false;
353
- const result = {};
354
- if (existsSync(jarPath)) {
355
- result.jarPath = jarPath;
356
- hasFiles = true;
357
- }
358
- if (existsSync(pomPath)) {
359
- result.pomPath = pomPath;
360
- hasFiles = true;
361
- }
362
- if (hasFiles) {
363
- return result;
364
- }
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
+ );
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;
365
655
  }
656
+ return repos.MAVEN_CENTRAL_URL;
657
+ }
366
658
 
367
- // 3. Coursier cache (SBT)
368
- let cacheRoot = readEnvironmentVariable("COURSIER_CACHE");
369
- if (!cacheRoot) {
370
- if (process.platform === "darwin") {
371
- cacheRoot = join(homedir(), "Library", "Caches", "Coursier", "v1");
372
- } else if (process.platform === "win32") {
373
- const localAppData = readEnvironmentVariable("LOCALAPPDATA");
374
- if (localAppData) {
375
- cacheRoot = join(localAppData, "Coursier", "Cache", "v1");
376
- } else {
377
- cacheRoot = join(
378
- homedir(),
379
- "AppData",
380
- "Local",
381
- "Coursier",
382
- "Cache",
383
- "v1",
384
- );
385
- }
386
- } else {
387
- cacheRoot = join(homedir(), ".cache", "coursier", "v1");
388
- }
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);
389
670
  }
671
+ return Boolean(p.license) || Boolean(p.licenses?.length);
672
+ }
390
673
 
391
- if (existsSync(cacheRoot)) {
392
- const pattern = `**/${groupPath}/${name}/${version}/*`;
393
- try {
394
- const matches = globSync(pattern, { cwd: cacheRoot });
395
- if (matches && matches.length > 0) {
396
- let jarPath = null;
397
- let pomPath = null;
398
- for (const m of matches) {
399
- const match = m.replace(/\\/g, "/");
400
- if (
401
- match.endsWith(".jar") &&
402
- !match.endsWith("-sources.jar") &&
403
- !match.endsWith("-javadoc.jar")
404
- ) {
405
- const localPath = join(cacheRoot, match);
406
- if (existsSync(localPath)) {
407
- jarPath = localPath;
408
- }
409
- } else if (match.endsWith(".pom")) {
410
- const localPath = join(cacheRoot, match);
411
- if (existsSync(localPath)) {
412
- pomPath = localPath;
413
- }
414
- }
415
- }
416
- if (jarPath || pomPath) {
417
- return { jarPath, pomPath };
418
- }
419
- }
420
- } catch (_err) {
421
- // ignore
422
- }
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;
423
686
  }
687
+ }
424
688
 
425
- 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();
426
701
  }
427
702
 
428
703
  /**
429
- * 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>`.
430
706
  *
431
- * @param {Array} pkgList Package list
432
- * @param {Object} jarNSMapping Jar Namespace mapping object
433
- * @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.
434
718
  *
435
- * @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}
436
723
  */
437
- export async function getMvnMetadata(
438
- pkgList,
439
- jarNSMapping = {},
440
- force = false,
441
- ) {
442
- const MAVEN_CENTRAL_URL =
443
- readEnvironmentVariable("MAVEN_CENTRAL_URL") ||
444
- "https://repo1.maven.org/maven2/";
445
- const ANDROID_MAVEN_URL =
446
- readEnvironmentVariable("ANDROID_MAVEN_URL") || "https://maven.google.com/";
447
- const cdepList = [];
448
- if (!pkgList?.length) {
449
- return pkgList;
724
+ function applyPomMetadata(p, pomJson, ownPomXml) {
725
+ if (!pomJson) {
726
+ return;
450
727
  }
451
- if (DEBUG_MODE && shouldFetchLicense()) {
452
- console.log(`About to query maven for ${pkgList.length} packages`);
728
+ if (!p.publisher && pomJson.organization?.name?._) {
729
+ p.publisher = pomJson.organization.name._;
453
730
  }
454
- // Collect the direct POM URLs the loop is going to need and fetch them in
455
- // one batched round. Only the direct POMs are prefetched: parent POMs (walked
456
- // inside fetchPomXmlAsJson) depend on the direct POM's content and stay
457
- // serial. The conditions below mirror the loop's skip checks so the batch
458
- // does not request anything the loop would skip.
459
- const batchUrls = [];
460
- const seenPomUrls = new Set();
461
- for (const p of pkgList) {
462
- const group = p.group || "";
463
- if (
464
- !p.version ||
465
- (group && p.name && p.version && !shouldFetchLicense() && !force) ||
466
- (group && p.name && p.version && p.license)
467
- ) {
468
- continue;
469
- }
470
- if (!group || group === "") {
471
- continue;
472
- }
473
- const urlPrefix =
474
- group.indexOf("android") !== -1 ? ANDROID_MAVEN_URL : MAVEN_CENTRAL_URL;
475
- const pomUrl = composePomXmlUrl({
476
- urlPrefix,
477
- group,
478
- name: p.name,
479
- version: p.version,
480
- });
481
- if (!seenPomUrls.has(pomUrl)) {
482
- seenPomUrls.add(pomUrl);
483
- batchUrls.push({ url: pomUrl, responseType: "text" });
484
- }
731
+ if (!p.description && pomJson.description?._) {
732
+ p.description = tidyPomDescription(pomJson.description._);
485
733
  }
486
- const ownPomUrls = [];
487
- if (batchUrls.length) {
488
- const fetched = await prefetchJson(batchUrls);
489
- const repoUrls = [];
490
- for (const [key, value] of fetched) {
491
- prefetchedPoms.set(key, value);
492
- ownPomUrls.push(key);
493
- const scmUrl = pomScmUrlNeedingLicenseLookup(value);
494
- if (scmUrl) {
495
- repoUrls.push(scmUrl);
496
- }
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;
497
743
  }
498
- // The POMs are in hand, so the licence fallback's repository URLs are known
499
- // without a second network round for the POMs themselves — the same second
500
- // round getNpmMetadata does. Only a POM that declares no licence and no
501
- // licence comment reaches getRepoLicense, and only its own scm element is
502
- // read here, so this never fetches a licence the serial path would not.
503
- 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;
504
759
  }
505
760
  try {
506
- return await enrichMvnPackages(pkgList, cdepList, jarNSMapping, force, {
507
- MAVEN_CENTRAL_URL,
508
- ANDROID_MAVEN_URL,
509
- });
510
- } finally {
511
- // Each body is read once, by the loop below. Holding them past that only
512
- // grows the process, which matters in server mode where one process serves
513
- // many scans. Only the documents this call fetched are dropped, so a scan
514
- // running alongside this one keeps its own.
515
- for (const pomUrl of ownPomUrls) {
516
- prefetchedPoms.delete(pomUrl);
517
- }
761
+ return readFileSync(pomPath, "utf-8");
762
+ } catch (_err) {
763
+ return undefined;
518
764
  }
519
765
  }
520
766
 
521
767
  /**
522
- * Enrich Maven packages from their POMs, reading any document already fetched
523
- * by {@link getMvnMetadata}'s batch round.
768
+ * Load a POM and its parents and merge them child over parent.
524
769
  *
525
- * @param {Array} pkgList Packages to enrich.
526
- * @param {Array} cdepList Accumulator for the enriched packages.
527
- * @param {Object} jarNSMapping Namespace and hash data from jar analysis.
528
- * @param {Boolean} force Whether to query even when metadata is present.
529
- * @param {{MAVEN_CENTRAL_URL: string, ANDROID_MAVEN_URL: string}} repos Repository
530
- * base URLs resolved by the caller from the environment.
531
- * @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>}
532
780
  */
533
- async function enrichMvnPackages(
534
- pkgList,
535
- cdepList,
536
- jarNSMapping,
537
- force,
538
- { MAVEN_CENTRAL_URL, ANDROID_MAVEN_URL },
781
+ async function loadPomChain(
782
+ coordinates,
783
+ { remote = false, remoteParents = 1 } = {},
539
784
  ) {
540
- for (const p of pkgList) {
541
- // Reuse any namespace and hashes data from jarNSMapping
542
- if (jarNSMapping && p.purl && jarNSMapping[p.purl]) {
543
- if (jarNSMapping[p.purl].jarFile) {
544
- p.evidence = {
545
- identity: {
546
- field: "purl",
547
- confidence: 0.8,
548
- methods: [
549
- {
550
- technique: "binary-analysis",
551
- confidence: 0.8,
552
- value: jarNSMapping[p.purl].jarFile,
553
- },
554
- ],
555
- },
556
- };
557
- }
558
- if (jarNSMapping[p.purl].hashes && !p?.hashes?.length) {
559
- p.hashes = jarNSMapping[p.purl].hashes;
560
- }
561
- if (
562
- jarNSMapping[p.purl].namespaces &&
563
- jarNSMapping[p.purl].namespaces.length
564
- ) {
565
- if (!p.properties) {
566
- p.properties = [];
567
- }
568
- p.properties.push({
569
- name: "internal:Namespaces",
570
- value: jarNSMapping[p.purl].namespaces.join("\n"),
571
- });
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++;
572
798
  }
799
+ xml = await fetchRemotePomXml({
800
+ urlPrefix: coordinates.urlPrefix,
801
+ group: current.group,
802
+ name: current.name,
803
+ version: current.version,
804
+ });
573
805
  }
574
- const localArtifact = findLocalMvnArtifact(p.group, p.name, p.version);
575
- if (localArtifact) {
576
- if (localArtifact.jarPath && (!p.hashes || p.hashes.length === 0)) {
577
- try {
578
- const hashValues = await multiChecksumFile(
579
- ["md5", "sha1", "sha256", "sha512"],
580
- localArtifact.jarPath,
581
- );
582
- p.hashes = [
583
- { alg: "MD5", content: hashValues["md5"] },
584
- { alg: "SHA-1", content: hashValues["sha1"] },
585
- { alg: "SHA-256", content: hashValues["sha256"] },
586
- { alg: "SHA-512", content: hashValues["sha512"] },
587
- ];
588
- } catch (_err) {
589
- if (localArtifact.sha1) {
590
- p.hashes = [{ alg: "SHA-1", content: localArtifact.sha1 }];
591
- }
592
- }
593
- }
594
- if (localArtifact.pomPath) {
595
- try {
596
- const xmlData = readFileSync(localArtifact.pomPath, "utf-8");
597
- const pomData = parsePomXml(xmlData);
598
- if (pomData) {
599
- if (!p.publisher && pomData.organization?.name?._) {
600
- p.publisher = pomData.organization.name._;
601
- }
602
- if (!p.description && pomData.description) {
603
- p.description = pomData.description
604
- .replace(/[ \t]+/g, " ")
605
- .replace(/^[ \t]+|[ \t]+$/gm, "")
606
- .replace(/\n\s*\n/g, "\n")
607
- .trim();
608
- }
609
- if (!p.repository && pomData.scm) {
610
- p.repository = { url: pomData.scm };
611
- }
612
- if (!p.license) {
613
- const parsedLicense = parseLicenseEntryOrArrayFromPomXml(
614
- pomData.licenses,
615
- );
616
- if (parsedLicense) {
617
- p.license = parsedLicense;
618
- } else {
619
- const licenseRegex = /<!--([\s\S]*?)-->[\s\n]*<project/m;
620
- const match = licenseRegex.exec(xmlData);
621
- if (match?.[1]) {
622
- const commentLicense = findLicenseId(match[1].trim());
623
- if (commentLicense) {
624
- p.license = commentLicense;
625
- }
626
- }
627
- }
628
- }
629
- }
630
- } catch (_err) {
631
- // ignore
632
- }
633
- }
806
+ if (!xml) {
807
+ break;
634
808
  }
635
- const group = p.group || "";
636
- // 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);
637
820
  if (
638
- !p.version ||
639
- (group && p.name && p.version && !shouldFetchLicense() && !force) ||
640
- (group && p.name && p.version && p.license)
821
+ parseLicenseEntryOrArrayFromPomXml(merged.licenses?.license) &&
822
+ merged.organization?.name?._ &&
823
+ merged.scm?.url?._
641
824
  ) {
642
- 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) {
643
877
  continue;
644
878
  }
645
- let urlPrefix = MAVEN_CENTRAL_URL;
646
- // Ideally we should try one resolver after the other. But it increases the time taken
647
- if (group.indexOf("android") !== -1) {
648
- 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;
649
948
  }
650
- // Querying maven requires a valid group name
651
- if (!group || group === "") {
652
- cdepList.push(p);
653
- 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
+ });
654
957
  }
655
- const pomMetadata = {
656
- urlPrefix: urlPrefix,
657
- group: group,
658
- name: p.name,
659
- version: p.version,
660
- };
661
- try {
662
- if (DEBUG_MODE) {
663
- console.log(
664
- `Querying ${pomMetadata.urlPrefix} for '${group}/${p.name}@${p.version}' ${composePomXmlUrl(
665
- pomMetadata,
666
- )}`,
667
- );
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._;
668
963
  }
669
- const bodyJson = await fetchPomXmlAsJson(pomMetadata);
670
- if (bodyJson) {
671
- p.publisher = bodyJson?.organization?.name
672
- ? bodyJson?.organization.name._
673
- : "";
674
- p.description = bodyJson?.description
675
- ? bodyJson.description._.replace(/[ \t]+/g, " ")
676
- .replace(/^[ \t]+|[ \t]+$/gm, "")
677
- .replace(/\n\s*\n/g, "\n")
678
- .trim()
679
- : "";
680
- if (bodyJson?.scm?.url) {
681
- 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;
682
974
  }
683
- p.license =
684
- parseLicenseEntryOrArrayFromPomXml(bodyJson?.licenses?.license) ||
685
- (await extractLicenseCommentFromPomXml(pomMetadata)) ||
686
- (await getRepoLicense(p.repository?.url, undefined));
687
975
  }
688
- } catch (err) {
689
- if (DEBUG_MODE) {
690
- console.log(
691
- `An error occurred when trying to fetch metadata ${pomMetadata}`,
692
- err,
693
- );
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 }];
694
1013
  }
695
- } finally {
696
- cdepList.push(p);
697
1014
  }
698
1015
  }
699
- 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
+ }
700
1023
  }
701
1024
 
702
1025
  /**
703
- * 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.
1028
+ *
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.
704
1033
  *
705
- * Mirrors the order `enrichMvnPackages` resolves a licence in: a `<licenses>`
706
- * element wins, then a licence comment, and only a POM offering neither falls
707
- * through to `getRepoLicense`. A POM whose `scm` element is inherited from a
708
- * parent is not covered, since the parent document is not prefetched.
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.
709
1039
  *
710
- * @param {{ok?: boolean, body?: String}} entry A prefetched POM batch entry.
711
- * @returns {String|undefined} The scm URL to prefetch a licence for, if any.
1040
+ * @returns {Array} Updated package list
712
1041
  */
713
- function pomScmUrlNeedingLicenseLookup(entry) {
714
- if (!entry?.ok || !entry.body) {
715
- return undefined;
1042
+ export async function getMvnMetadata(
1043
+ pkgList,
1044
+ jarNSMapping = {},
1045
+ force = false,
1046
+ context = {},
1047
+ ) {
1048
+ if (!pkgList?.length) {
1049
+ return pkgList;
716
1050
  }
717
- let pomJson;
718
- try {
719
- pomJson = xml2js(entry.body, {
720
- compact: true,
721
- spaces: 4,
722
- textKey: "_",
723
- attributesKey: "$",
724
- commentKey: "value",
725
- }).project;
726
- } catch (_err) {
727
- 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);
728
1064
  }
729
- if (parseLicenseEntryOrArrayFromPomXml(pomJson?.licenses?.license)) {
730
- return undefined;
1065
+ if (!shouldFetchLicense() && !force) {
1066
+ return pkgList;
731
1067
  }
732
- const commentMatch = /<!--([\s\S]*?)-->[\s\n]*<project/m.exec(entry.body);
733
- if (commentMatch?.[1] && findLicenseId(commentMatch[1].trim())) {
734
- 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
+ }
735
1158
  }
736
- return pomJson?.scm?.url?._;
1159
+ return pkgList;
737
1160
  }
738
1161
 
739
1162
  /**
@@ -752,7 +1175,10 @@ export function composePomXmlUrl({ urlPrefix, group, name, version }) {
752
1175
  }
753
1176
 
754
1177
  /**
755
- * 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.
756
1182
  *
757
1183
  * @param {String} urlPrefix
758
1184
  * @param {String} group
@@ -762,76 +1188,101 @@ export function composePomXmlUrl({ urlPrefix, group, name, version }) {
762
1188
  * @return {Object|undefined}
763
1189
  */
764
1190
  export async function fetchPomXmlAsJson({ urlPrefix, group, name, version }) {
765
- const pomXml = await fetchPomXml({ urlPrefix, group, name, version });
766
- if (!pomXml) {
767
- return undefined;
768
- }
769
- const options = {
770
- compact: true,
771
- spaces: 4,
772
- textKey: "_",
773
- attributesKey: "$",
774
- commentKey: "value",
775
- };
776
- const pomJson = xml2js(pomXml, options).project;
777
- if (pomJson?.parent) {
778
- const parentXml = await fetchPomXml({
779
- urlPrefix,
780
- group: pomJson.parent.groupId?._,
781
- name: pomJson.parent.artifactId?._,
782
- version: pomJson.parent.version?._,
783
- });
784
- if (!parentXml) {
785
- 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;
786
1221
  }
787
- const parentJson = xml2js(parentXml, options).project;
788
- return { ...parentJson, ...pomJson };
789
1222
  }
790
- return pomJson;
1223
+ return await gatedGet(url, { responseType });
791
1224
  }
792
1225
 
793
1226
  /**
794
1227
  * Prefetched POM documents, populated by `getMvnMetadata` before its loop and
795
- * read by `fetchPomXml`. Module state rather than a parameter because
796
- * `fetchPomXml` is called from `fetchPomXmlAsJson` (direct + parent) and
797
- * `extractLicenseCommentFromPomXml`, and threading a map through all of them
798
- * 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.
799
1230
  *
800
1231
  * Only the *direct* POMs — the ones whose URLs are known before the loop — are
801
- * prefetched. The parent POM walk inside `fetchPomXmlAsJson` depends on
802
- * parsing the direct POM's content, so those URLs are not known up front and
803
- * 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.
804
1234
  */
805
1235
  const prefetchedPoms = new Map();
806
1236
 
807
1237
  /**
808
- * Method to fetch pom.xml data
809
- *
810
- * @param {String} urlPrefix
811
- * @param {String} group
812
- * @param {String} name
813
- * @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.
814
1241
  *
815
- * @return {Promise<String>}
1242
+ * @param {{urlPrefix: string, group: string, name: string, version: string}} coordinates
1243
+ * @returns {Promise<string|undefined>} POM text.
816
1244
  */
817
- 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
+ }
818
1249
  const fullUrl = composePomXmlUrl({ urlPrefix, group, name, version });
819
- // A direct POM may have been prefetched by getMvnMetadata's batch round.
820
- // Parent POMs (fetched from inside fetchPomXmlAsJson) are not prefetched
821
- // because their URL depends on the direct POM's content, so they fall
822
- // through to the serial request. That fallback still reaches Maven Central
823
- // and still counts against the same budget as the batch round, so it queues
824
- // 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.
825
1254
  try {
826
1255
  const res =
827
1256
  prefetchedResponse(prefetchedPoms, fullUrl) ||
828
- (await withHostRateLimit(fullUrl, () => cdxgenAgent.get(fullUrl)));
1257
+ (await pooledDocument(fullUrl, "text"));
829
1258
  return res.body;
830
1259
  } catch (_err) {
831
1260
  return undefined;
832
1261
  }
833
1262
  }
834
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
+
835
1286
  /**
836
1287
  * Method extract single or multiple license entries that might appear in pom.xml
837
1288
  *
@@ -865,12 +1316,9 @@ export async function extractLicenseCommentFromPomXml({
865
1316
  name,
866
1317
  version,
867
1318
  }) {
868
- const pom_xml = await fetchPomXml({ urlPrefix, group, name, version });
869
- const licenseRegex = /<!--([\s\S]*?)-->[\s\n]*<project/m;
870
- const match = licenseRegex.exec(pom_xml);
871
- if (match?.[1]) {
872
- return findLicenseId(match[1].trim());
873
- }
1319
+ return licenseFromPomComment(
1320
+ await fetchPomXml({ urlPrefix, group, name, version }),
1321
+ );
874
1322
  }
875
1323
 
876
1324
  /**
@@ -905,6 +1353,63 @@ export function guessPypiMatchingVersion(versionsList, versionSpecifiers) {
905
1353
  return maxSatisfying(versionsList, clean(versionSpecifiers, { loose: true }));
906
1354
  }
907
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
+
908
1413
  /**
909
1414
  * The PyPI path for a package, or `undefined` when the package will not be
910
1415
  * queried at all.
@@ -915,14 +1420,19 @@ export function guessPypiMatchingVersion(versionsList, versionSpecifiers) {
915
1420
  * JS path never makes, which is both wasted work and a divergence.
916
1421
  *
917
1422
  * @param {Object} p Package object.
1423
+ * @param {string} pypiUrl The JSON API base cdxgen asks.
918
1424
  * @returns {string|undefined} Path to append to PYPI_URL.
919
1425
  */
920
- function pyUrlAddition(p) {
1426
+ function pyUrlAddition(p, pypiUrl) {
921
1427
  if (!p?.name) {
922
1428
  return undefined;
923
1429
  }
1430
+ // A package PyPI cannot hold is not queried.
1431
+ if (!canPypiServe(p, pypiUrl)) {
1432
+ return undefined;
1433
+ }
924
1434
  // A URL as a name, or a package that already has both fields, is not queried.
925
- if (p.name.includes("https") || (p.license && p.version)) {
1435
+ if (p.name.includes("https") || pypiAnsweredLocally(p)) {
926
1436
  return undefined;
927
1437
  }
928
1438
  // Extras (`requests[security]`) are not part of the PyPI path.
@@ -932,6 +1442,35 @@ function pyUrlAddition(p) {
932
1442
  : `${name}/json`;
933
1443
  }
934
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
+
935
1474
  /**
936
1475
  * Method to retrieve metadata for python packages by querying pypi
937
1476
  *
@@ -951,7 +1490,7 @@ export async function getPyMetadata(pkgList, fetchDepsInfo) {
951
1490
  const prefetched = await prefetchJson(
952
1491
  prefetchEnabled()
953
1492
  ? pkgList
954
- .map((p) => pyUrlAddition(p))
1493
+ .map((p) => pyUrlAddition(p, PYPI_URL))
955
1494
  .filter(Boolean)
956
1495
  .map((addition) => ({ url: PYPI_URL + addition }))
957
1496
  : [],
@@ -965,8 +1504,13 @@ export async function getPyMetadata(pkgList, fetchDepsInfo) {
965
1504
  // map, so they are available without a registry round trip and are attached
966
1505
  // before the skip below rather than after it.
967
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
+ }
968
1512
  // If the package name has a url or already includes license and version skip it
969
- if (p.name.includes("https") || (p.license && p.version)) {
1513
+ if (p.name.includes("https") || pypiAnsweredLocally(p)) {
970
1514
  cdepList.push(p);
971
1515
  continue;
972
1516
  }
@@ -983,30 +1527,36 @@ export async function getPyMetadata(pkgList, fetchDepsInfo) {
983
1527
  url_addition = `${p.name}/json`;
984
1528
  }
985
1529
  try {
986
- res =
987
- prefetchedResponse(prefetched, PYPI_URL + url_addition) ||
988
- (await cdxgenAgent.get(`${PYPI_URL + url_addition}`, {
989
- responseType: "json",
990
- }));
991
- } catch (_err) {
992
- // retry by prefixing django- to the package name
993
- res = await cdxgenAgent.get(`${PYPI_URL}django-${url_addition}`, {
994
- responseType: "json",
995
- });
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}`);
996
1540
  p.name = `django-${p.name}`;
997
1541
  }
998
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;
999
1546
  if (body.info.author && body.info.author.trim() !== "") {
1000
1547
  if (body.info.author_email && body.info.author_email.trim() !== "") {
1001
- p.author = `${body.info.author.trim()} <${body.info.author_email.trim()}>`;
1548
+ registryAuthor = `${body.info.author.trim()} <${body.info.author_email.trim()}>`;
1002
1549
  } else {
1003
- p.author = body.info.author.trim();
1550
+ registryAuthor = body.info.author.trim();
1004
1551
  }
1005
1552
  } else if (
1006
1553
  body.info.author_email &&
1007
1554
  body.info.author_email.trim() !== ""
1008
1555
  ) {
1009
- p.author = body.info.author_email.trim();
1556
+ registryAuthor = body.info.author_email.trim();
1557
+ }
1558
+ if (registryAuthor && !p.author) {
1559
+ p.author = registryAuthor;
1010
1560
  }
1011
1561
  if (
1012
1562
  p.name !== body.info?.name &&
@@ -1014,36 +1564,40 @@ export async function getPyMetadata(pkgList, fetchDepsInfo) {
1014
1564
  ) {
1015
1565
  p.name = body.info.name;
1016
1566
  }
1017
- p.description = body.info.summary;
1018
- p.license = [];
1567
+ if (!p.description) {
1568
+ p.description = body.info.summary;
1569
+ }
1570
+ const registryLicenses = [];
1019
1571
  if (body.info.classifiers) {
1020
1572
  for (const c of body.info.classifiers) {
1021
1573
  if (c.startsWith("License :: ")) {
1022
1574
  const licenseName = c.split("::").slice(-1)[0].trim();
1023
1575
  const licenseId = findLicenseId(licenseName);
1024
- if (licenseId && !p.license.includes(licenseId)) {
1025
- p.license.push(licenseId);
1576
+ if (licenseId && !registryLicenses.includes(licenseId)) {
1577
+ registryLicenses.push(licenseId);
1026
1578
  }
1027
1579
  }
1028
1580
  }
1029
1581
  applyPypiClassifierMetadata(p, body.info.classifiers);
1030
1582
  }
1031
- if (body.info.license) {
1032
- const licenseId = findLicenseId(body.info.license);
1033
- if (licenseId && !p.license.includes(licenseId)) {
1034
- 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);
1035
1590
  }
1036
1591
  }
1037
- if (body.info.license_expression) {
1038
- const licenseId = findLicenseId(body.info.license_expression);
1039
- if (licenseId && !p.license.includes(licenseId)) {
1040
- p.license.push(licenseId);
1041
- }
1592
+ if (!(Array.isArray(p.license) ? p.license.length : p.license)) {
1593
+ p.license = registryLicenses;
1042
1594
  }
1043
1595
  if (body.info.home_page) {
1044
1596
  if (body.info.home_page.includes("git")) {
1045
- p.repository = { url: body.info.home_page };
1046
- } else {
1597
+ if (!p.repository) {
1598
+ p.repository = { url: body.info.home_page };
1599
+ }
1600
+ } else if (!p.homepage) {
1047
1601
  p.homepage = { url: body.info.home_page };
1048
1602
  }
1049
1603
  }
@@ -1481,7 +2035,13 @@ let repoLicensePrefetch = new Map();
1481
2035
  * @returns {Promise<void>}
1482
2036
  */
1483
2037
  export async function prefetchRepoLicenses(repoUrls) {
1484
- 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
+ ) {
1485
2045
  return;
1486
2046
  }
1487
2047
  const requests = [];
@@ -1495,7 +2055,16 @@ export async function prefetchRepoLicenses(repoUrls) {
1495
2055
  continue;
1496
2056
  }
1497
2057
  const licenseUrl = `${apiUrl}/license`;
1498
- 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
+ ) {
1499
2068
  continue;
1500
2069
  }
1501
2070
  seen.add(licenseUrl);
@@ -1542,7 +2111,7 @@ export async function getRepoLicense(repoUrl, repoMetadata) {
1542
2111
  }
1543
2112
  const apiUrl = toGitHubApiUrl(repoUrl, repoMetadata);
1544
2113
  // Perform github lookups
1545
- if (apiUrl && get_repo_license_errors < MAX_GET_REPO_LICENSE_ERRORS) {
2114
+ if (apiUrl && !repoLicenseLookupsPaused()) {
1546
2115
  const licenseUrl = `${apiUrl}/license`;
1547
2116
  const headers = {};
1548
2117
  if (readEnvironmentVariable("GITHUB_TOKEN")) {
@@ -1552,9 +2121,9 @@ export async function getRepoLicense(repoUrl, repoMetadata) {
1552
2121
  try {
1553
2122
  const res =
1554
2123
  prefetchedResponse(repoLicensePrefetch, licenseUrl) ||
1555
- (await cdxgenAgent.get(licenseUrl, {
2124
+ (await gatedGet(licenseUrl, {
1556
2125
  responseType: "json",
1557
- headers: headers,
2126
+ headers,
1558
2127
  }));
1559
2128
  if (res?.body) {
1560
2129
  const license = res.body.license;
@@ -1581,20 +2150,18 @@ export async function getRepoLicense(repoUrl, repoMetadata) {
1581
2150
  }
1582
2151
  }
1583
2152
  } catch (err) {
1584
- if (err?.message) {
1585
- if (
1586
- err.message.includes("rate limit exceeded") &&
1587
- !readEnvironmentVariable("GITHUB_TOKEN")
1588
- ) {
1589
- console.log(
1590
- "Rate limit exceeded for REST API of github.com. " +
1591
- "Please ensure GITHUB_TOKEN is set as environment variable. " +
1592
- "See: https://docs.github.com/en/rest/overview/rate-limits-for-the-rest-api",
1593
- );
1594
- get_repo_license_errors++;
1595
- } else if (!err.message.includes("404")) {
1596
- get_repo_license_errors++;
1597
- }
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++;
1598
2165
  }
1599
2166
  }
1600
2167
  }
@@ -1638,12 +2205,21 @@ export async function prefetchGoPkgMetadata(modules) {
1638
2205
  if (!module?.name) {
1639
2206
  continue;
1640
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
+ }
1641
2216
  const pkgUrl = getGoPkgUrl(module);
1642
2217
  // The licence tab and the module page are distinct documents, read by
1643
2218
  // getGoPkgLicense and getGoPkgVCSUrl respectively. Both are requested only
1644
2219
  // when the corresponding flag is on, so the batch mirrors that.
1645
2220
  const wanted = [];
1646
- if (shouldFetchLicense()) {
2221
+ // A licence notice read on this machine answers the licence tab.
2222
+ if (shouldFetchLicense() && !module.licenseKnown) {
1647
2223
  wanted.push(`${pkgUrl}?tab=licenses`);
1648
2224
  }
1649
2225
  // getGoPkgVCSUrl derives github.com and gitlab.com URLs without asking
@@ -1652,7 +2228,7 @@ export async function prefetchGoPkgMetadata(modules) {
1652
2228
  wanted.push(pkgUrl);
1653
2229
  }
1654
2230
  for (const url of wanted) {
1655
- if (seen.has(url) || metadata_cache[url]) {
2231
+ if (seen.has(url) || goPkgAnswers.has(url)) {
1656
2232
  continue;
1657
2233
  }
1658
2234
  seen.add(url);
@@ -1682,15 +2258,21 @@ export function resetGoPkgPrefetch() {
1682
2258
  */
1683
2259
  export async function getGoPkgLicense(repoMetadata) {
1684
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
+ }
1685
2268
  const pkgUrl = `${getGoPkgUrl(repoMetadata)}?tab=licenses`;
1686
- // Check the metadata cache first
1687
- if (metadata_cache[pkgUrl]) {
1688
- return metadata_cache[pkgUrl];
2269
+ if (goPkgAnswers.has(pkgUrl)) {
2270
+ return goPkgAnswers.get(pkgUrl);
1689
2271
  }
1690
2272
  try {
1691
2273
  const res =
1692
2274
  prefetchedResponse(goPkgPrefetch, pkgUrl) ||
1693
- (await cdxgenAgent.get(pkgUrl));
2275
+ (await gatedGet(pkgUrl, { responseType: "text" }));
1694
2276
  if (res?.body) {
1695
2277
  const licenses = extractLicenseText(res.body);
1696
2278
  const licenseIds = licenses.split(", ");
@@ -1713,7 +2295,7 @@ export async function getGoPkgLicense(repoMetadata) {
1713
2295
  licList.push(alicense);
1714
2296
  }
1715
2297
  }
1716
- metadata_cache[pkgUrl] = licList;
2298
+ goPkgAnswers.set(pkgUrl, licList);
1717
2299
  return licList;
1718
2300
  }
1719
2301
  } catch (_err) {
@@ -1730,23 +2312,31 @@ export async function getGoPkgLicense(repoMetadata) {
1730
2312
  *
1731
2313
  * @param {String} group Package group
1732
2314
  * @param {String} name Package name
2315
+ * @param {String} [version] Package version, part of the page URL
1733
2316
  */
1734
- export async function getGoPkgVCSUrl(group, name) {
2317
+ export async function getGoPkgVCSUrl(group, name, version) {
1735
2318
  const fullName = getGoPkgFullName(group, name);
1736
2319
  if (isDirectlyResolvableGoVCS({ group, name })) {
1737
2320
  return `https://${fullName}`;
1738
2321
  }
1739
- const pkgUrl = getGoPkgUrl({ fullName });
1740
- if (metadata_cache[pkgUrl]) {
1741
- 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);
1742
2332
  }
1743
2333
  try {
1744
2334
  const res =
1745
2335
  prefetchedResponse(goPkgPrefetch, pkgUrl) ||
1746
- (await cdxgenAgent.get(pkgUrl));
2336
+ (await gatedGet(pkgUrl, { responseType: "text" }));
1747
2337
  if (res?.body) {
1748
2338
  const vcs = extractRepoUrl(res.body);
1749
- metadata_cache[pkgUrl] = vcs;
2339
+ goPkgAnswers.set(pkgUrl, vcs);
1750
2340
  return vcs;
1751
2341
  }
1752
2342
  } catch (_err) {
@@ -1773,17 +2363,154 @@ function isDirectlyResolvableGoVCS(pkgMetadata) {
1773
2363
  }
1774
2364
 
1775
2365
  /**
1776
- * 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.
1777
2480
  *
1778
- * @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}
1779
2489
  */
1780
- function getGoPkgUrl(pkgMetadata) {
1781
- const pkgUrlPrefix =
1782
- readEnvironmentVariable("GO_PKG_URL") || "https://pkg.go.dev/";
1783
- const fullName =
1784
- pkgMetadata.fullName ||
1785
- getGoPkgFullName(pkgMetadata.group, pkgMetadata.name);
1786
- 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;
1787
2514
  }
1788
2515
 
1789
2516
  /**
@@ -1796,6 +2523,35 @@ function getGoPkgFullName(group, name) {
1796
2523
  return group && group !== "." && group !== name ? `${group}/${name}` : name;
1797
2524
  }
1798
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
+
1799
2555
  /**
1800
2556
  * Method to retrieve metadata for rust packages by querying crates
1801
2557
  *
@@ -1806,7 +2562,13 @@ function getGoPkgFullName(group, name) {
1806
2562
  *
1807
2563
  * What it does not hold is publisher identity, so a caller that needs the
1808
2564
  * publisher-drift and release-cadence signals — the predictive audit does —
1809
- * 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}.
1810
2572
  *
1811
2573
  * @param {Array} pkgList Package list
1812
2574
  * @param {Object} [options] Options
@@ -1823,12 +2585,26 @@ export async function getCratesMetadata(pkgList, options = {}) {
1823
2585
  // reads; only the crates it cannot account for reach the network.
1824
2586
  const servedLocally = new Set();
1825
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;
1826
2591
  if (preferLocalCache && localCargoMetadataEnabled()) {
1827
2592
  for (const p of pkgList) {
1828
2593
  if (!p?.name || !p?.version || p.version === "workspace") {
1829
2594
  continue;
1830
2595
  }
1831
- 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)) {
1832
2608
  servedLocally.add(p);
1833
2609
  }
1834
2610
  }
@@ -1843,17 +2619,21 @@ export async function getCratesMetadata(pkgList, options = {}) {
1843
2619
  p?.name &&
1844
2620
  p?.version &&
1845
2621
  p.version !== "workspace" &&
2622
+ canCratesIoServe(p) &&
1846
2623
  !servedLocally.has(p),
1847
2624
  );
1848
- // Two URLs per crate — the crate document and its owners — fetched for the
1849
- // whole list at once. The `workspace` guard below is mirrored here so the
1850
- // 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.
1851
2629
  const prefetched = await prefetchJson(
1852
2630
  prefetchEnabled()
1853
- ? needsRegistry.flatMap((p) => [
1854
- { url: CRATES_URL + p.name },
1855
- { url: `${CRATES_URL + p.name}/owners` },
1856
- ])
2631
+ ? needsRegistry.flatMap((p) => {
2632
+ const crateUrl = CRATES_URL + p.name;
2633
+ return wantsOwners
2634
+ ? [{ url: crateUrl }, { url: `${crateUrl}/owners` }]
2635
+ : [{ url: crateUrl }];
2636
+ })
1857
2637
  : [],
1858
2638
  );
1859
2639
  for (const p of pkgList) {
@@ -1862,6 +2642,10 @@ export async function getCratesMetadata(pkgList, options = {}) {
1862
2642
  cdepList.push(p);
1863
2643
  continue;
1864
2644
  }
2645
+ if (!canCratesIoServe(p)) {
2646
+ cdepList.push(p);
2647
+ continue;
2648
+ }
1865
2649
  if (servedLocally.has(p)) {
1866
2650
  cdepList.push(p);
1867
2651
  continue;
@@ -1876,32 +2660,32 @@ export async function getCratesMetadata(pkgList, options = {}) {
1876
2660
  // this process issued it.
1877
2661
  const res =
1878
2662
  prefetchedResponse(prefetched, crateUrl) ||
1879
- (await withHostRateLimit(crateUrl, () =>
1880
- cdxgenAgent.get(crateUrl, {
1881
- responseType: "json",
1882
- }),
1883
- ));
2663
+ (await gatedGet(crateUrl, { responseType: "json" }));
1884
2664
  let ownersRes;
1885
- try {
1886
- const ownersUrl = `${crateUrl}/owners`;
1887
- ownersRes =
1888
- prefetchedResponse(prefetched, ownersUrl) ||
1889
- (await withHostRateLimit(ownersUrl, () =>
1890
- cdxgenAgent.get(ownersUrl, {
1891
- responseType: "json",
1892
- }),
1893
- ));
1894
- } catch (_err) {
1895
- ownersRes = undefined;
1896
- }
1897
- let versionToUse = res?.body?.versions[0];
1898
- if (p.version) {
1899
- for (const aversion of res.body.versions) {
1900
- if (aversion.num === p.version) {
1901
- versionToUse = aversion;
1902
- break;
1903
- }
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
+ );
1904
2686
  }
2687
+ cdepList.push(p);
2688
+ continue;
1905
2689
  }
1906
2690
  const body = res.body.crate;
1907
2691
  p.description = body.description;
@@ -1914,10 +2698,6 @@ export async function getCratesMetadata(pkgList, options = {}) {
1914
2698
  if (body.homepage && body.homepage !== body.repository) {
1915
2699
  p.homepage = { url: body.homepage };
1916
2700
  }
1917
- // Use the latest version if none specified
1918
- if (!p.version) {
1919
- p.version = body.newest_version;
1920
- }
1921
2701
  if (!p._integrity && versionToUse.checksum) {
1922
2702
  p._integrity = normalizeCargoIntegrity(versionToUse.checksum);
1923
2703
  }
@@ -1977,12 +2757,17 @@ export async function getCratesMetadata(pkgList, options = {}) {
1977
2757
  * @param {Object} p Package component to enrich in place.
1978
2758
  * @returns {boolean} true when crates.io can be skipped for this crate.
1979
2759
  */
1980
- function applyCargoCacheMetadata(p) {
1981
- const local = readCargoCacheMetadata(p.name, p.version);
1982
- if (!local?.license) {
2760
+ async function applyCargoCacheMetadata(p) {
2761
+ const local = await readCargoCacheMetadata(p.name, p.version);
2762
+ if (!local?.license && !local?.licenses) {
1983
2763
  return false;
1984
2764
  }
1985
- 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
+ }
1986
2771
  if (local.description) {
1987
2772
  p.description = local.description;
1988
2773
  }
@@ -2040,14 +2825,47 @@ function applyCargoCacheMetadata(p) {
2040
2825
  return true;
2041
2826
  }
2042
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
+
2043
2851
  /**
2044
2852
  * Method to retrieve metadata for dart packages by querying pub.dev
2045
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
+ *
2046
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.
2047
2864
  */
2048
- export async function getDartMetadata(pkgList) {
2865
+ export async function getDartMetadata(pkgList, options = {}) {
2049
2866
  const PUB_DEV_URL =
2050
2867
  readEnvironmentVariable("PUB_DEV_URL") || "https://pub.dev";
2868
+ const secureMode = options.secureMode ?? isSecureMode;
2051
2869
  const PUB_LICENSE_REGEX = /^license:/i;
2052
2870
  const OPTIONS = {
2053
2871
  responseType: "json",
@@ -2055,31 +2873,50 @@ export async function getDartMetadata(pkgList) {
2055
2873
  Accept: PUB_ACCEPT,
2056
2874
  },
2057
2875
  };
2876
+ const apiBase = (url) => (url.endsWith("/") ? url.slice(0, -1) : url);
2058
2877
 
2059
2878
  const cdepList = [];
2879
+ // Where each package is asked, decided once so the batch and the loop agree.
2060
2880
  // pub.dev needs the package document and its score document; both carry the
2061
- // 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
+ }
2062
2894
  const prefetched = await prefetchJson(
2063
2895
  prefetchEnabled()
2064
- ? pkgList.flatMap((p) => {
2065
- const base = `${PUB_DEV_URL}/api/packages/${p.name}/versions/${p.version}`;
2066
- return [
2067
- { url: base, accept: PUB_ACCEPT },
2068
- { url: `${base}/score`, accept: PUB_ACCEPT },
2069
- ];
2070
- })
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
+ )
2071
2904
  : [],
2072
2905
  );
2073
2906
  for (const p of pkgList) {
2907
+ const lookup = lookups.get(p);
2908
+ if (!lookup) {
2909
+ cdepList.push(p);
2910
+ continue;
2911
+ }
2074
2912
  try {
2913
+ const { registry, url: PUB_PACKAGE_URL } = lookup;
2075
2914
  if (DEBUG_MODE) {
2076
- console.log(`Querying ${PUB_DEV_URL} for ${p.name}`);
2915
+ console.log(`Querying ${registry || PUB_DEV_URL} for ${p.name}`);
2077
2916
  }
2078
- const PUB_PACKAGE_URL = `${PUB_DEV_URL}/api/packages/${p.name}/versions/${p.version}`;
2079
- const PUB_PACKAGE_SCORE_URL = `${PUB_PACKAGE_URL}/score`;
2080
2917
  const res =
2081
2918
  prefetchedResponse(prefetched, PUB_PACKAGE_URL) ||
2082
- (await cdxgenAgent.get(PUB_PACKAGE_URL, OPTIONS));
2919
+ (await gatedGet(PUB_PACKAGE_URL, OPTIONS));
2083
2920
  if (res?.body) {
2084
2921
  const pubspec = res.body.pubspec;
2085
2922
  p.description = pubspec.description;
@@ -2089,18 +2926,21 @@ export async function getDartMetadata(pkgList) {
2089
2926
  if (pubspec.homepage) {
2090
2927
  p.homepage = { url: pubspec.homepage };
2091
2928
  }
2092
- const score =
2093
- prefetchedResponse(prefetched, PUB_PACKAGE_SCORE_URL) ||
2094
- (await cdxgenAgent.get(PUB_PACKAGE_SCORE_URL, OPTIONS));
2095
- if (score?.body) {
2096
- const tags = score.body.tags;
2097
- const license = tags.find((tag) => PUB_LICENSE_REGEX.test(tag));
2098
- if (license) {
2099
- p.license = spdxLicenses.find(
2100
- (spdxLicense) =>
2101
- spdxLicense.toLowerCase() ===
2102
- license.replace(PUB_LICENSE_REGEX, "").toLowerCase(),
2103
- );
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
+ }
2104
2944
  }
2105
2945
  }
2106
2946
  cdepList.push(p);
@@ -2151,6 +2991,60 @@ export function normalizeCargoIntegrity(integrity) {
2151
2991
  return undefined;
2152
2992
  }
2153
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
+
2154
3048
  /**
2155
3049
  * Method to extract a war or ear file
2156
3050
  *
@@ -2167,23 +3061,12 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2167
3061
  // reported, as file components with zero-confidence identity evidence.
2168
3062
  const unresolvedJars = [];
2169
3063
  const fname = basename(jarFile);
2170
- let pomname;
2171
- // If there is a pom file in the same directory, try to use it
2172
- const manifestname = join(dirname(jarFile), "META-INF", "MANIFEST.MF");
2173
- // Issue 439: Current implementation checks for existance of a .pom file, but .pom file is not used.
2174
- // Instead code expects to find META-INF/MANIFEST.MF in the same folder as a .jar file.
2175
- // For now check for presence of both .pom and MANIFEST.MF files.
2176
- if (jarFile.endsWith(".jar")) {
2177
- pomname = jarFile.replace(".jar", ".pom");
2178
- }
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);
2179
3069
  if (
2180
- pomname &&
2181
- safeExistsSync(pomname) &&
2182
- manifestname &&
2183
- safeExistsSync(manifestname)
2184
- ) {
2185
- tempDir = dirname(jarFile);
2186
- } else if (
2187
3070
  !safeExistsSync(join(tempDir, fname)) &&
2188
3071
  safeExistsSync(jarFile) &&
2189
3072
  lstatSync(jarFile).isFile()
@@ -2191,6 +3074,8 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2191
3074
  // Only copy if the file doesn't exist
2192
3075
  safeCopyFileSync(jarFile, join(tempDir, fname), constants.COPYFILE_FICLONE);
2193
3076
  }
3077
+ const outerCopy = join(tempDir, fname);
3078
+ let outerExtracted = false;
2194
3079
  const env = {
2195
3080
  ...process.env,
2196
3081
  };
@@ -2206,11 +3091,11 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2206
3091
  jarFile.endsWith(".hpi") ||
2207
3092
  jarFile.endsWith(".jar")
2208
3093
  ) {
2209
- if (safeExistsSync(join(tempDir, fname))) {
3094
+ if (safeExistsSync(outerCopy)) {
2210
3095
  try {
2211
- const zip = new StreamZip.async({ file: join(tempDir, fname) });
3096
+ const zip = new StreamZip.async({ file: outerCopy });
2212
3097
  const extracted = await safeExtractArchive(
2213
- join(tempDir, fname),
3098
+ outerCopy,
2214
3099
  tempDir,
2215
3100
  async () => {
2216
3101
  await zip.extract(null, tempDir);
@@ -2220,6 +3105,7 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2220
3105
  if (!extracted) {
2221
3106
  return pkgList;
2222
3107
  }
3108
+ outerExtracted = true;
2223
3109
  } catch (e) {
2224
3110
  console.log("Unable to extract %s. Skipping.", join(tempDir, fname), e);
2225
3111
  return pkgList;
@@ -2250,32 +3136,41 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2250
3136
  }
2251
3137
  continue;
2252
3138
  }
2253
- pomname = jf.replace(".jar", ".pom");
2254
3139
  const jarname = basename(jf);
2255
- // Ignore test jars
3140
+ // Ignore test, sources and javadoc jars: none of them is a runtime
3141
+ // component, and identifying them only costs lookups.
2256
3142
  if (
2257
3143
  jarname.endsWith("-tests.jar") ||
2258
- jarname.endsWith("-test-sources.jar")
3144
+ jarname.endsWith("-test-sources.jar") ||
3145
+ isDocumentationJar(jarname)
2259
3146
  ) {
2260
3147
  if (DEBUG_MODE) {
2261
3148
  console.log(`Skipping tests jar ${jarname}`);
2262
3149
  }
2263
3150
  continue;
2264
3151
  }
2265
- 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");
2266
3161
  const manifestFile = join(manifestDir, "MANIFEST.MF");
2267
3162
  const mavenDir = join(manifestDir, "maven");
2268
3163
  let jarResult = {
2269
3164
  status: 1,
2270
3165
  };
2271
- if (safeExistsSync(pomname)) {
3166
+ if (reuseOuter) {
2272
3167
  jarResult = { status: 0 };
2273
- } else {
3168
+ } else if (workDir) {
2274
3169
  // Unzip natively
2275
3170
  try {
2276
3171
  const zip = new StreamZip.async({ file: jf });
2277
- const extracted = await safeExtractArchive(jf, tempDir, async () => {
2278
- await zip.extract(null, tempDir);
3172
+ const extracted = await safeExtractArchive(jf, workDir, async () => {
3173
+ await zip.extract(null, workDir);
2279
3174
  });
2280
3175
  await zip.close();
2281
3176
  jarResult = { status: extracted ? 0 : 1 };
@@ -2290,36 +3185,79 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2290
3185
  // When maven descriptor is available take group, name and version from pom.properties
2291
3186
  // META-INF/maven/${groupId}/${artifactId}/pom.properties
2292
3187
  // see https://maven.apache.org/shared/maven-archiver/index.html
2293
- const pomProperties = getPomPropertiesFromMavenDir(mavenDir);
3188
+ const pomProperties = getPomPropertiesFromMavenDir(mavenDir, jarname);
2294
3189
  let group = pomProperties["groupId"];
2295
3190
  let name = pomProperties["artifactId"];
2296
3191
  let version = pomProperties["version"];
2297
3192
  let confidence = 0.5;
2298
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
+ }
2299
3231
  if (
2300
3232
  (!group || !name || !version) &&
2301
3233
  SEARCH_MAVEN_ORG &&
2302
- search_maven_org_errors < MAX_SEARCH_MAVEN_ORG_ERRORS
3234
+ !isHostCircuitOpen(MAVEN_SEARCH_HOST)
2303
3235
  ) {
2304
3236
  try {
2305
- const sha = await checksumFile("sha1", jf);
2306
- 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`;
2307
3239
  // One Solr query per unidentified jar, against Central's origin
2308
3240
  // rather than its CDN. A directory of jars issues these back to
2309
3241
  // back, so they go through the same per-host gate the batch pool
2310
- // uses (500 ms, serial) instead of a second, ungoverned path.
2311
- const res = await withHostRateLimit(searchurl, () =>
2312
- cdxgenAgent.get(searchurl, {
2313
- responseType: "json",
2314
- timeout: {
2315
- lookup: 1000,
2316
- connect: 5000,
2317
- secureConnect: 5000,
2318
- socket: 1000,
2319
- send: 10000,
2320
- response: 1000,
2321
- },
2322
- }),
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 },
2323
3261
  );
2324
3262
  const data = res?.body ? res.body["response"] : undefined;
2325
3263
  if (data && data["numFound"] === 1) {
@@ -2330,16 +3268,21 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2330
3268
  technique = "hash-comparison";
2331
3269
  }
2332
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.
2333
3275
  if (err?.message && !err.message.includes("404")) {
2334
- if (
2335
- err.message.includes("Timeout") ||
2336
- err.message.includes("429")
2337
- ) {
2338
- console.log(
2339
- "Maven search appears to be unavailable. Search will be skipped for all remaining packages.",
2340
- );
2341
- }
2342
- 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
+ });
2343
3286
  }
2344
3287
  }
2345
3288
  }
@@ -2442,12 +3385,16 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2442
3385
  value: jf,
2443
3386
  },
2444
3387
  ];
3388
+ const qualifiers = { type: "jar" };
3389
+ if (classifier) {
3390
+ qualifiers.classifier = classifier;
3391
+ }
2445
3392
  const purl = build({
2446
3393
  type: "maven",
2447
3394
  namespace: group || null,
2448
3395
  name: name,
2449
3396
  version: version || null,
2450
- qualifiers: { type: "jar" } || null,
3397
+ qualifiers,
2451
3398
  });
2452
3399
  let namespaceValues;
2453
3400
  let namespaceList;
@@ -2518,6 +3465,39 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2518
3465
  },
2519
3466
  properties,
2520
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
+ }
2521
3501
  pkgList.push(apkg);
2522
3502
  } else {
2523
3503
  unresolvedJars.push(jf);
@@ -2527,12 +3507,17 @@ export async function extractJarArchive(jarFile, tempDir, jarNSMapping = {}) {
2527
3507
  }
2528
3508
  }
2529
3509
  try {
2530
- if (safeExistsSync(join(tempDir, "META-INF"))) {
2531
- // Clean up META-INF
2532
- safeRmSync(join(tempDir, "META-INF"), {
2533
- recursive: true,
2534
- force: true,
2535
- });
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 });
2536
3521
  }
2537
3522
  } catch (_err) {
2538
3523
  // ignore cleanup errors
@@ -2628,14 +3613,31 @@ async function unresolvedJarComponents(unresolvedJars, jarFile, tempDir) {
2628
3613
  */
2629
3614
  const METADATA_VERSION_PROP = "cdx:nuget:metadata_version";
2630
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
+
2631
3635
  async function getNugetUrl() {
2632
3636
  const req = "https://api.nuget.org/v3/index.json";
2633
3637
  const fallbackUrl = "https://api.nuget.org/v3/registration3/";
2634
3638
  let res;
2635
3639
  try {
2636
- res = await cdxgenAgent.get(req, {
2637
- responseType: "json",
2638
- });
3640
+ res = await batchedJsonDocument(req);
2639
3641
  } catch (err) {
2640
3642
  // Package lookups already degrade to unenriched components on error. An
2641
3643
  // unreachable service index used to reject here instead and abort the
@@ -2645,6 +3647,10 @@ async function getNugetUrl() {
2645
3647
  );
2646
3648
  return fallbackUrl;
2647
3649
  }
3650
+ if (!res) {
3651
+ thoughtLog(`Unable to read the NuGet service index. Using ${fallbackUrl}.`);
3652
+ return fallbackUrl;
3653
+ }
2648
3654
  const urls = res?.body?.resources || [];
2649
3655
  for (const resource of urls) {
2650
3656
  if (resource["@type"] === "RegistrationsBaseUrl/3.6.0") {
@@ -2708,10 +3714,15 @@ async function queryNuget(p, NUGET_URL) {
2708
3714
  const body = [];
2709
3715
  const newBody = [];
2710
3716
  const indexUrl = `${NUGET_URL + np.name.toLowerCase()}/index.json`;
2711
- let res =
2712
- prefetchedResponse(prefetchedNugetIndex, indexUrl) ||
2713
- (await cdxgenAgent.get(indexUrl, { responseType: "json" }));
2714
- 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;
2715
3726
  if (!items?.[0]) {
2716
3727
  return [np, newBody, body];
2717
3728
  }
@@ -2745,8 +3756,13 @@ async function queryNuget(p, NUGET_URL) {
2745
3756
  // at a page that must be fetched.
2746
3757
  let leaves = item.items;
2747
3758
  if (!leaves) {
2748
- res = await cdxgenAgent.get(item["@id"], { responseType: "json" });
2749
- 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;
2750
3766
  }
2751
3767
  for (const i of leaves.reverse()) {
2752
3768
  if (
@@ -2762,6 +3778,200 @@ async function queryNuget(p, NUGET_URL) {
2762
3778
  return [np, newBody];
2763
3779
  }
2764
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
+
2765
3975
  /**
2766
3976
  * Method to retrieve metadata for nuget packages
2767
3977
  *
@@ -2776,14 +3986,21 @@ export async function getNugetMetadata(pkgList, dependencies = undefined) {
2776
3986
  // Batch the per-package registration index requests. queryNuget's first
2777
3987
  // request for each package is the index; the follow-up to a specific
2778
3988
  // registration page depends on the index response and stays serial. Only
2779
- // packages that would actually be queried (not in metadata_cache, no prior
2780
- // 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.
2781
3991
  const batchUrls = [];
2782
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();
2783
3996
  for (const p of pkgList) {
2784
3997
  // A cached entry — a body or a recorded error — means the loop will not
2785
3998
  // reach the network for this package, so the batch must not either.
2786
- 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);
2787
4004
  continue;
2788
4005
  }
2789
4006
  const indexUrl = `${NUGET_URL + p.name.toLowerCase()}/index.json`;
@@ -2800,110 +4017,58 @@ export async function getNugetMetadata(pkgList, dependencies = undefined) {
2800
4017
  ownIndexUrls.push(key);
2801
4018
  }
2802
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 = [];
2803
4023
  try {
2804
4024
  for (const p of pkgList) {
2805
- 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}`;
2806
4028
  try {
2807
- // If there is a version, we can safely use the cache to retrieve the license
2808
- // See: https://github.com/cdxgen/cdxgen/issues/352
2809
- cacheKey = `${p.name}|${p.version}`;
2810
- let body = metadata_cache[cacheKey];
2811
-
2812
- if (body?.error) {
4029
+ let entry = nugetEntries.get(cacheKey);
4030
+ if (entry?.error) {
2813
4031
  cdepList.push(p);
2814
4032
  continue;
2815
4033
  }
2816
- if (!body) {
2817
- let newBody = {};
2818
- let np = {};
2819
- [np, newBody] = await queryNuget(p, NUGET_URL);
2820
- if (p.version !== np.version) {
2821
- const oldRef = p["bom-ref"];
2822
- // The purl is rebuilt alongside the bom-ref. Leaving it behind let a
2823
- // component state one version in `version` and another - or none - in
2824
- // its purl, which is what a scanner range matches on. See #4359.
2825
- p.version = np.version;
2826
- applyPurl(p, nugetPurl(np.name, np.version));
2827
- if (oldRef && p["bom-ref"] !== oldRef) {
2828
- depRepList[oldRef] = p["bom-ref"];
2829
- }
4034
+ if (!entry) {
4035
+ if (servedLocally.has(p)) {
4036
+ cdepList.push(p);
4037
+ continue;
2830
4038
  }
2831
- if (newBody && newBody.length > 0) {
2832
- 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);
2833
4047
  }
2834
- if (body) {
2835
- metadata_cache[cacheKey] = body;
2836
- // The component states no version, so the description and licence
2837
- // below were read from whichever release the lookup fell back to.
2838
- // That release is recorded rather than adopted as the version: a
2839
- // version cdxgen chose is not a version the project declared.
2840
- if (!p.version && np.metadataVersion) {
2841
- p.properties = p.properties || [];
2842
- if (
2843
- !p.properties.some(
2844
- (prop) => prop.name === METADATA_VERSION_PROP,
2845
- )
2846
- ) {
2847
- p.properties.push({
2848
- name: METADATA_VERSION_PROP,
2849
- value: np.metadataVersion,
2850
- });
2851
- }
2852
- }
2853
- p.description = body.catalogEntry.description;
2854
- if (body.catalogEntry.authors) {
2855
- p.author = body.catalogEntry.authors.trim();
2856
- }
2857
- if (
2858
- body.catalogEntry.licenseExpression &&
2859
- body.catalogEntry.licenseExpression !== ""
2860
- ) {
2861
- p.license = findLicenseId(body.catalogEntry.licenseExpression);
2862
- } else if (body.catalogEntry.licenseUrl) {
2863
- p.license = findLicenseId(body.catalogEntry.licenseUrl);
2864
- if (typeof p.license === "string" && isGitHubUrl(p.license)) {
2865
- p.license =
2866
- (await getRepoLicense(p.license, undefined)) || p.license;
2867
- }
2868
- }
2869
- // Capture the tags
2870
- if (
2871
- body.catalogEntry?.tags?.length &&
2872
- Array.isArray(body.catalogEntry.tags)
2873
- ) {
2874
- p.tags = body.catalogEntry.tags.map((t) =>
2875
- t.toLowerCase().replaceAll(" ", "-"),
2876
- );
2877
- }
2878
- if (body.catalogEntry.projectUrl) {
2879
- p.repository = { url: body.catalogEntry.projectUrl };
2880
- // A versionless component gets the package's landing page. Pasting
2881
- // an undefined version into the path produced a 404 link.
2882
- p.homepage = {
2883
- url: p.version
2884
- ? `https://www.nuget.org/packages/${p.name}/${p.version}/`
2885
- : `https://www.nuget.org/packages/${p.name}/`,
2886
- };
2887
- if (
2888
- (!p.license || typeof p.license === "string") &&
2889
- typeof p.repository.url === "string" &&
2890
- isGitHubUrl(p.repository.url)
2891
- ) {
2892
- // license couldn't be properly identified and is still a url,
2893
- // therefore trying to resolve license via repository
2894
- p.license =
2895
- (await getRepoLicense(p.repository.url, undefined)) ||
2896
- p.license;
2897
- }
2898
- }
2899
- 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"];
2900
4058
  }
2901
4059
  }
2902
- } catch (err) {
2903
- if (cacheKey) {
2904
- 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
+ }
2905
4067
  }
2906
4068
  cdepList.push(p);
4069
+ } catch (err) {
4070
+ nugetEntries.set(cacheKey, { error: err.code });
4071
+ cdepList.push(p);
2907
4072
  }
2908
4073
  }
2909
4074
  } finally {
@@ -2915,6 +4080,20 @@ export async function getNugetMetadata(pkgList, dependencies = undefined) {
2915
4080
  prefetchedNugetIndex.delete(indexUrl);
2916
4081
  }
2917
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
+ }
2918
4097
  const newDependencies = [].concat(dependencies);
2919
4098
  if (depRepList && newDependencies.length) {
2920
4099
  const changed = Object.keys(depRepList);