@cdxgen/cdxgen 13.4.0 → 13.6.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 (305) hide show
  1. package/README.md +9 -9
  2. package/bin/cdxgen-secure.js +15 -0
  3. package/bin/cdxgen.js +29 -5
  4. package/bin/evinse.js +8 -0
  5. package/bin/hbom.js +15 -7
  6. package/bin/repl.js +29 -0
  7. package/bin/validate.js +6 -0
  8. package/data/cbomosdb-queries.json +13 -1
  9. package/data/component-tags.json +8 -0
  10. package/data/license-aliases.json +24 -2
  11. package/data/queries-darwin.json +25 -1
  12. package/data/queries-win.json +19 -1
  13. package/data/queries.json +41 -0
  14. package/data/remediations.json +407 -1
  15. package/data/rules/ai-bom.yaml +1 -1
  16. package/data/rules/brotli-decoder.yaml +107 -0
  17. package/data/rules/cbom-security.yaml +235 -4
  18. package/data/rules/go-binaries.yaml +46 -0
  19. package/data/rules/golem-go.yaml +80 -0
  20. package/data/rules/hbom-compliance.yaml +40 -22
  21. package/data/rules/hbom-performance.yaml +7 -1
  22. package/data/rules/host-topology.yaml +1 -1
  23. package/data/rules/obom-runtime.yaml +31 -2
  24. package/data/rules/php-runtime.yaml +143 -0
  25. package/data/rules/vscode-extensions.yaml +75 -0
  26. package/data/spdx-license-list.json +121 -7
  27. package/data/spdx-licenses.json +69 -0
  28. package/data/spdx.schema.json +845 -2
  29. package/lib/audit/index.js +14 -0
  30. package/lib/cli/bomAssembly.js +120 -6
  31. package/lib/cli/cliOptions.js +110 -3
  32. package/lib/cli/index.js +182 -6
  33. package/lib/cli/jsBom.js +153 -64
  34. package/lib/cli/jvmBom.js +363 -66
  35. package/lib/cli/managedBom.js +853 -87
  36. package/lib/cli/nativeBom.js +267 -33
  37. package/lib/core/boundedFile.js +344 -0
  38. package/lib/core/brotliProbe.js +182 -0
  39. package/lib/core/env.js +38 -8
  40. package/lib/core/fs.js +40 -19
  41. package/lib/core/httpClient.js +172 -46
  42. package/lib/core/lockPaths.js +38 -0
  43. package/lib/core/paths.js +24 -1
  44. package/lib/core/processTree.js +5 -0
  45. package/lib/core/projectConfig.js +1 -0
  46. package/lib/core/projectTooling.js +97 -0
  47. package/lib/core/propertySanitizer.js +72 -0
  48. package/lib/core/toolRegistry.js +170 -0
  49. package/lib/ecosystems/cppEvidence.js +263 -106
  50. package/lib/ecosystems/cppIncludeResolver.js +16 -5
  51. package/lib/ecosystems/ecosystems.js +25 -10
  52. package/lib/ecosystems/gemHostProbe.js +301 -0
  53. package/lib/ecosystems/golem.js +550 -6
  54. package/lib/ecosystems/gradleutils.js +858 -244
  55. package/lib/ecosystems/parsers-bazel.js +107 -6
  56. package/lib/ecosystems/parsers-dotnet.js +117 -18
  57. package/lib/ecosystems/parsers-go.js +1090 -367
  58. package/lib/ecosystems/parsers-js.js +505 -22
  59. package/lib/ecosystems/parsers-jvm.js +259 -17
  60. package/lib/ecosystems/parsers-misc.js +438 -64
  61. package/lib/ecosystems/parsers-python.js +100 -12
  62. package/lib/ecosystems/pythonutils.js +128 -2
  63. package/lib/ecosystems/rubyutils.js +544 -34
  64. package/lib/ecosystems/rusi.js +10 -0
  65. package/lib/ecosystems/scalacliutils.js +4 -1
  66. package/lib/ecosystems/scalasem.js +798 -151
  67. package/lib/ecosystems/vendoredCode.js +237 -3
  68. package/lib/ecosystems/venvProbe.js +360 -0
  69. package/lib/evinser/evinser.js +472 -61
  70. package/lib/evinser/swiftsem.js +68 -5
  71. package/lib/helpers/agenticToolutils.js +413 -93
  72. package/lib/helpers/vsixutils.js +1574 -315
  73. package/lib/inventory/agentSkillSpec.js +17 -0
  74. package/lib/inventory/apkInstalledDb.js +243 -0
  75. package/lib/inventory/atomUtils.js +110 -0
  76. package/lib/inventory/auditCategories.js +3 -3
  77. package/lib/inventory/brotliDecoder.js +142 -0
  78. package/lib/inventory/buildToolRateLimit.js +1 -1
  79. package/lib/inventory/cacheDir.js +52 -9
  80. package/lib/inventory/cbomutils.js +56 -7
  81. package/lib/inventory/certificates.js +2371 -0
  82. package/lib/inventory/communityAiConfigParser.js +4 -9
  83. package/lib/inventory/deps.js +41 -4
  84. package/lib/inventory/display.js +200 -3
  85. package/lib/inventory/dosai.js +639 -63
  86. package/lib/inventory/dotnetRuntime.js +217 -0
  87. package/lib/inventory/dynamic.js +45 -3
  88. package/lib/inventory/envcontext.js +475 -18
  89. package/lib/inventory/evidenceUtils.js +447 -23
  90. package/lib/inventory/fetchBatch.js +1 -1
  91. package/lib/inventory/formulationParsers.js +32 -3
  92. package/lib/inventory/goBuildInfo.js +140 -0
  93. package/lib/inventory/gtfobins.js +13 -0
  94. package/lib/inventory/hbom.js +67 -7
  95. package/lib/inventory/hbomAnalysis.js +16 -0
  96. package/lib/inventory/hostRuntimeProbe.js +686 -0
  97. package/lib/inventory/hostTopology.js +21 -0
  98. package/lib/inventory/jvmLocalRepos.js +9 -2
  99. package/lib/inventory/jvmToolEnv.js +29 -0
  100. package/lib/inventory/licenseEnhancer.js +12 -0
  101. package/lib/inventory/mavenCentralMirror.js +534 -0
  102. package/lib/inventory/nodeBuildInfo.js +218 -0
  103. package/lib/inventory/nodeHostProbe.js +202 -0
  104. package/lib/inventory/osPackagePurl.js +244 -0
  105. package/lib/inventory/osPackageResolver.js +271 -87
  106. package/lib/inventory/osinfo.js +79 -31
  107. package/lib/inventory/osqueryTransform.js +114 -5
  108. package/lib/inventory/phpBuildInfo.js +438 -0
  109. package/lib/inventory/phpHostProbe.js +185 -0
  110. package/lib/inventory/rootfsCerts.js +426 -0
  111. package/lib/inventory/rubyBuildInfo.js +972 -0
  112. package/lib/inventory/rubyHostProbe.js +218 -0
  113. package/lib/inventory/scalaCoords.js +4 -3
  114. package/lib/inventory/scalaDedupe.js +440 -0
  115. package/lib/inventory/source.js +256 -5
  116. package/lib/inventory/spdx.js +144 -0
  117. package/lib/inventory/traceRunner.js +5 -1
  118. package/lib/inventory/tracedCrypto.js +129 -0
  119. package/lib/managers/binary.js +866 -186
  120. package/lib/managers/docker.js +245 -12
  121. package/lib/parsers/email.js +277 -0
  122. package/lib/parsers/largeJson.js +62 -13
  123. package/lib/parsers/vscodeExtensionId.js +183 -0
  124. package/lib/server/server.js +198 -2
  125. package/lib/stages/postgen/annotator.js +17 -1
  126. package/lib/stages/postgen/introspection/reflect.js +4 -1
  127. package/lib/stages/postgen/postgen.js +5 -5
  128. package/lib/stages/postgen/ruleEngine.js +71 -6
  129. package/lib/stages/postgen/specVersionCompat.js +30 -0
  130. package/lib/stages/pregen/envAudit.js +1 -0
  131. package/lib/stages/pregen/pregen.js +231 -62
  132. package/lib/validator/bomValidator.js +32 -6
  133. package/lib/validator/generated/runtime.mjs +13 -0
  134. package/lib/validator/generated/validate-1.6.mjs +27 -8
  135. package/lib/validator/generated/validate-1.7.mjs +32 -13
  136. package/lib/validator/generated/validate-2.0.mjs +31 -12
  137. package/lib/validator/index.js +1 -1
  138. package/package.json +19 -18
  139. package/types/bin/cdxgen-secure.d.ts +3 -0
  140. package/types/bin/cdxgen-secure.d.ts.map +1 -0
  141. package/types/bin/repl.d.ts.map +1 -1
  142. package/types/lib/audit/index.d.ts.map +1 -1
  143. package/types/lib/cli/bomAssembly.d.ts +22 -3
  144. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  145. package/types/lib/cli/cliOptions.d.ts +43 -0
  146. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  147. package/types/lib/cli/index.d.ts.map +1 -1
  148. package/types/lib/cli/jsBom.d.ts +1 -17
  149. package/types/lib/cli/jsBom.d.ts.map +1 -1
  150. package/types/lib/cli/jvmBom.d.ts +20 -0
  151. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  152. package/types/lib/cli/managedBom.d.ts.map +1 -1
  153. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  154. package/types/lib/core/boundedFile.d.ts +70 -0
  155. package/types/lib/core/boundedFile.d.ts.map +1 -0
  156. package/types/lib/core/brotliProbe.d.ts +37 -0
  157. package/types/lib/core/brotliProbe.d.ts.map +1 -0
  158. package/types/lib/core/env.d.ts +22 -2
  159. package/types/lib/core/env.d.ts.map +1 -1
  160. package/types/lib/core/fs.d.ts.map +1 -1
  161. package/types/lib/core/httpClient.d.ts +24 -0
  162. package/types/lib/core/httpClient.d.ts.map +1 -1
  163. package/types/lib/core/lockPaths.d.ts +19 -0
  164. package/types/lib/core/lockPaths.d.ts.map +1 -0
  165. package/types/lib/core/processTree.d.ts +2 -0
  166. package/types/lib/core/processTree.d.ts.map +1 -1
  167. package/types/lib/core/projectConfig.d.ts.map +1 -1
  168. package/types/lib/core/projectTooling.d.ts +41 -0
  169. package/types/lib/core/projectTooling.d.ts.map +1 -0
  170. package/types/lib/core/propertySanitizer.d.ts +26 -0
  171. package/types/lib/core/propertySanitizer.d.ts.map +1 -1
  172. package/types/lib/core/toolRegistry.d.ts +36 -0
  173. package/types/lib/core/toolRegistry.d.ts.map +1 -0
  174. package/types/lib/ecosystems/cppEvidence.d.ts +40 -13
  175. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  176. package/types/lib/ecosystems/cppIncludeResolver.d.ts +2 -1
  177. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -1
  178. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  179. package/types/lib/ecosystems/gemHostProbe.d.ts +28 -0
  180. package/types/lib/ecosystems/gemHostProbe.d.ts.map +1 -0
  181. package/types/lib/ecosystems/golem.d.ts +81 -1
  182. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  183. package/types/lib/ecosystems/gradleutils.d.ts +57 -21
  184. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  185. package/types/lib/ecosystems/parsers-bazel.d.ts.map +1 -1
  186. package/types/lib/ecosystems/parsers-dotnet.d.ts +40 -4
  187. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  188. package/types/lib/ecosystems/parsers-go.d.ts +85 -13
  189. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  190. package/types/lib/ecosystems/parsers-js.d.ts +17 -20
  191. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  192. package/types/lib/ecosystems/parsers-jvm.d.ts +28 -2
  193. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  194. package/types/lib/ecosystems/parsers-misc.d.ts +37 -58
  195. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  196. package/types/lib/ecosystems/parsers-python.d.ts +14 -3
  197. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  198. package/types/lib/ecosystems/pythonutils.d.ts +19 -1
  199. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  200. package/types/lib/ecosystems/rubyutils.d.ts +144 -12
  201. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  202. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  203. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -1
  204. package/types/lib/ecosystems/scalasem.d.ts.map +1 -1
  205. package/types/lib/ecosystems/vendoredCode.d.ts +26 -0
  206. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -1
  207. package/types/lib/ecosystems/venvProbe.d.ts +40 -0
  208. package/types/lib/ecosystems/venvProbe.d.ts.map +1 -0
  209. package/types/lib/evinser/evinser.d.ts +88 -8
  210. package/types/lib/evinser/evinser.d.ts.map +1 -1
  211. package/types/lib/evinser/swiftsem.d.ts +10 -0
  212. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  213. package/types/lib/inventory/agentSkillSpec.d.ts +13 -0
  214. package/types/lib/inventory/agentSkillSpec.d.ts.map +1 -0
  215. package/types/lib/inventory/apkInstalledDb.d.ts +42 -0
  216. package/types/lib/inventory/apkInstalledDb.d.ts.map +1 -0
  217. package/types/lib/inventory/atomUtils.d.ts +33 -0
  218. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  219. package/types/lib/inventory/brotliDecoder.d.ts +58 -0
  220. package/types/lib/inventory/brotliDecoder.d.ts.map +1 -0
  221. package/types/lib/inventory/cacheDir.d.ts +12 -4
  222. package/types/lib/inventory/cacheDir.d.ts.map +1 -1
  223. package/types/lib/inventory/cbomutils.d.ts +17 -0
  224. package/types/lib/inventory/cbomutils.d.ts.map +1 -1
  225. package/types/lib/inventory/certificates.d.ts +205 -0
  226. package/types/lib/inventory/certificates.d.ts.map +1 -0
  227. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  228. package/types/lib/inventory/deps.d.ts +2 -1
  229. package/types/lib/inventory/deps.d.ts.map +1 -1
  230. package/types/lib/inventory/display.d.ts +18 -0
  231. package/types/lib/inventory/display.d.ts.map +1 -1
  232. package/types/lib/inventory/dosai.d.ts +162 -7
  233. package/types/lib/inventory/dosai.d.ts.map +1 -1
  234. package/types/lib/inventory/dotnetRuntime.d.ts +44 -0
  235. package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -0
  236. package/types/lib/inventory/dynamic.d.ts +6 -1
  237. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  238. package/types/lib/inventory/envcontext.d.ts +92 -8
  239. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  240. package/types/lib/inventory/evidenceUtils.d.ts +29 -10
  241. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  242. package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
  243. package/types/lib/inventory/goBuildInfo.d.ts +10 -0
  244. package/types/lib/inventory/goBuildInfo.d.ts.map +1 -0
  245. package/types/lib/inventory/gtfobins.d.ts.map +1 -1
  246. package/types/lib/inventory/hbom.d.ts.map +1 -1
  247. package/types/lib/inventory/hbomAnalysis.d.ts +10 -0
  248. package/types/lib/inventory/hbomAnalysis.d.ts.map +1 -1
  249. package/types/lib/inventory/hostRuntimeProbe.d.ts +44 -0
  250. package/types/lib/inventory/hostRuntimeProbe.d.ts.map +1 -0
  251. package/types/lib/inventory/hostTopology.d.ts.map +1 -1
  252. package/types/lib/inventory/jvmToolEnv.d.ts +12 -0
  253. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
  254. package/types/lib/inventory/licenseEnhancer.d.ts.map +1 -1
  255. package/types/lib/inventory/mavenCentralMirror.d.ts +170 -0
  256. package/types/lib/inventory/mavenCentralMirror.d.ts.map +1 -0
  257. package/types/lib/inventory/nodeBuildInfo.d.ts +95 -0
  258. package/types/lib/inventory/nodeBuildInfo.d.ts.map +1 -0
  259. package/types/lib/inventory/nodeHostProbe.d.ts +15 -0
  260. package/types/lib/inventory/nodeHostProbe.d.ts.map +1 -0
  261. package/types/lib/inventory/osPackagePurl.d.ts +99 -0
  262. package/types/lib/inventory/osPackagePurl.d.ts.map +1 -0
  263. package/types/lib/inventory/osPackageResolver.d.ts +44 -2
  264. package/types/lib/inventory/osPackageResolver.d.ts.map +1 -1
  265. package/types/lib/inventory/osinfo.d.ts +31 -6
  266. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  267. package/types/lib/inventory/osqueryTransform.d.ts +51 -0
  268. package/types/lib/inventory/osqueryTransform.d.ts.map +1 -1
  269. package/types/lib/inventory/phpBuildInfo.d.ts +142 -0
  270. package/types/lib/inventory/phpBuildInfo.d.ts.map +1 -0
  271. package/types/lib/inventory/phpHostProbe.d.ts +35 -0
  272. package/types/lib/inventory/phpHostProbe.d.ts.map +1 -0
  273. package/types/lib/inventory/rootfsCerts.d.ts +27 -0
  274. package/types/lib/inventory/rootfsCerts.d.ts.map +1 -0
  275. package/types/lib/inventory/rubyBuildInfo.d.ts +271 -0
  276. package/types/lib/inventory/rubyBuildInfo.d.ts.map +1 -0
  277. package/types/lib/inventory/rubyHostProbe.d.ts +47 -0
  278. package/types/lib/inventory/rubyHostProbe.d.ts.map +1 -0
  279. package/types/lib/inventory/source.d.ts +38 -0
  280. package/types/lib/inventory/source.d.ts.map +1 -1
  281. package/types/lib/inventory/spdx.d.ts +15 -0
  282. package/types/lib/inventory/spdx.d.ts.map +1 -1
  283. package/types/lib/inventory/traceRunner.d.ts +4 -1
  284. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  285. package/types/lib/inventory/tracedCrypto.d.ts +22 -0
  286. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -1
  287. package/types/lib/managers/binary.d.ts +10 -0
  288. package/types/lib/managers/binary.d.ts.map +1 -1
  289. package/types/lib/managers/docker.d.ts +24 -0
  290. package/types/lib/managers/docker.d.ts.map +1 -1
  291. package/types/lib/parsers/email.d.ts +19 -0
  292. package/types/lib/parsers/email.d.ts.map +1 -0
  293. package/types/lib/parsers/largeJson.d.ts +17 -3
  294. package/types/lib/parsers/largeJson.d.ts.map +1 -1
  295. package/types/lib/server/server.d.ts +8 -0
  296. package/types/lib/server/server.d.ts.map +1 -1
  297. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  298. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -1
  299. package/types/lib/stages/postgen/ruleEngine.d.ts +7 -4
  300. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  301. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  302. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  303. package/types/lib/stages/pregen/pregen.d.ts +26 -9
  304. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  305. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -1,5 +1,13 @@
1
- import { lstatSync, readFileSync, statSync } from "node:fs";
2
- import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
1
+ import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import {
3
+ basename,
4
+ dirname,
5
+ isAbsolute,
6
+ join,
7
+ relative,
8
+ resolve,
9
+ sep,
10
+ } from "node:path";
3
11
  import process from "node:process";
4
12
 
5
13
  import { DEBUG_MODE, readEnvironmentVariable } from "../core/activity.js";
@@ -8,16 +16,17 @@ import { PROJECT_TYPE_ALIASES } from "../core/env.js";
8
16
  import {
9
17
  getAllFiles,
10
18
  getTmpDir,
11
- safeCopyFileSync,
12
19
  safeExistsSync,
13
20
  safeMkdirSync,
14
21
  safeMkdtempSync,
15
22
  safeRmSync,
16
23
  safeSpawnSync,
24
+ safeWriteSync,
17
25
  } from "../core/fs.js";
18
26
  import { dirNameStr } from "../core/paths.js";
19
27
  import { resolvePluginSourceDir } from "../core/pluginRun.js";
20
28
  import { formatDuration } from "../core/processTree.js";
29
+ import { isProjectToolingAllowed } from "../core/projectTooling.js";
21
30
  import { parsetoolsFile } from "../inventory/atomUtils.js";
22
31
  import { applyAlgorithmProperties } from "../inventory/cryptoAlgorithmFamily.js";
23
32
  import { getJarClasses } from "../inventory/deps.js";
@@ -146,22 +155,25 @@ function scalasemTimeoutMs(options = {}) {
146
155
  const TIMEOUT_GRACE_MS = 30_000;
147
156
 
148
157
  // The build files that identify a Scala project, for a source argument that
149
- // names the working directory relative to itself.
158
+ // names the working directory relative to itself, and that a persisted report
159
+ // must be newer than. sbt 0.13 builds may define everything under project/.
150
160
  const SCALA_BUILD_MARKERS = [
151
161
  "build.sbt",
152
162
  "build.sc",
153
163
  "build.mill",
154
164
  "pom.xml",
155
165
  "project.scala",
166
+ join("project", "build.properties"),
156
167
  ];
157
168
 
158
169
  /**
159
170
  * Where the scalasem report is persisted. An absolute --semantics-slices-file
160
171
  * keeps its directory; anything else lands in the evinse output directory, the
161
- * same rule the atom slice writer follows.
172
+ * same rule the atom slice writer follows. Without either there is nowhere to
173
+ * keep it, and the report lives only as long as the run.
162
174
  *
163
175
  * @param {Object} options CLI options carrying `output` and `semanticsSlicesFile`.
164
- * @returns {string} Absolute report path.
176
+ * @returns {string|undefined} Absolute report path.
165
177
  */
166
178
  export function scalasemOutputFile(options = {}) {
167
179
  const requested = options.semanticsSlicesFile || "semantics.slices.json";
@@ -170,21 +182,18 @@ export function scalasemOutputFile(options = {}) {
170
182
  if (isAbsolute(requested)) {
171
183
  return requested;
172
184
  }
173
- let outputDir;
174
- if (options.output) {
175
- const resolvedOutput = resolve(options.output);
176
- let isDir = false;
177
- try {
178
- isDir =
179
- safeExistsSync(resolvedOutput) &&
180
- lstatSync(resolvedOutput).isDirectory();
181
- } catch (_err) {
182
- isDir = false;
183
- }
184
- outputDir = isDir ? resolvedOutput : dirname(resolvedOutput);
185
- } else {
186
- outputDir = safeMkdtempSync(join(getTmpDir(), "scalasem-"));
185
+ if (!options.output) {
186
+ return undefined;
187
+ }
188
+ const resolvedOutput = resolve(options.output);
189
+ let isDir = false;
190
+ try {
191
+ isDir =
192
+ safeExistsSync(resolvedOutput) && lstatSync(resolvedOutput).isDirectory();
193
+ } catch (_err) {
194
+ isDir = false;
187
195
  }
196
+ const outputDir = isDir ? resolvedOutput : dirname(resolvedOutput);
188
197
  return join(outputDir, basename(requested));
189
198
  }
190
199
 
@@ -223,6 +232,18 @@ export function isV1SemanticsSlice(parsed) {
223
232
  return Object.keys(parsed).some((key) => key.endsWith(".scala"));
224
233
  }
225
234
 
235
+ // Whether a path lies below a directory. A prefix test fails for a root
236
+ // directory (`/`, `C:\\`), whose path already ends in the separator.
237
+ function isInside(directory, path) {
238
+ const rel = relative(directory, path);
239
+ return (
240
+ Boolean(rel) &&
241
+ rel !== ".." &&
242
+ !rel.startsWith(`..${sep}`) &&
243
+ !isAbsolute(rel)
244
+ );
245
+ }
246
+
226
247
  /**
227
248
  * Whether a version 1 slice describes this project: it names no project, so
228
249
  * every source file it lists must exist under the project directory.
@@ -233,15 +254,104 @@ function isV1SliceOfProject(slice, projectDir) {
233
254
  files.length > 0 &&
234
255
  files.every((file) => {
235
256
  const path = resolve(projectDir, file);
236
- return path.startsWith(`${projectDir}${sep}`) && safeExistsSync(path);
257
+ return isInside(projectDir, path) && safeExistsSync(path);
237
258
  })
238
259
  );
239
260
  }
240
261
 
262
+ // The run settings a report depends on. cdxgen records them in the report it
263
+ // keeps, so a later run reuses that report only when it would ask for the
264
+ // same: tests included or not, the build run or not.
265
+ const RUN_SETTINGS_KEY = "cdxgenRunSettings";
266
+
267
+ function scalasemRunSettings(options) {
268
+ return {
269
+ build: isProjectToolingAllowed(options),
270
+ includeTests: Boolean(
271
+ options.scalasemIncludeTests && !options.requiredOnly,
272
+ ),
273
+ };
274
+ }
275
+
276
+ function canonicalPath(path) {
277
+ try {
278
+ return realpathSync(path);
279
+ } catch (_err) {
280
+ return resolve(path);
281
+ }
282
+ }
283
+
284
+ // Whether two paths name one directory, through symbolic links such as
285
+ // /var and /private/var, and in any case on Windows.
286
+ function isSamePath(left, right) {
287
+ const a = canonicalPath(left);
288
+ const b = canonicalPath(right);
289
+ return process.platform === "win32"
290
+ ? a.toLowerCase() === b.toLowerCase()
291
+ : a === b;
292
+ }
293
+
294
+ function modifiedAt(file) {
295
+ try {
296
+ return statSync(file).mtimeMs;
297
+ } catch (_err) {
298
+ return undefined;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Why a persisted report cannot stand in for a new run: it describes another
304
+ * project, it predates the input BOM, cdxgen kept it from a run with other
305
+ * settings, or a build file or a source it describes changed or went away
306
+ * since it was written.
307
+ *
308
+ * @returns {string|undefined} The reason, or undefined when it can be reused.
309
+ */
310
+ function reportReuseProblem(report, reportFile, analysisDir, options) {
311
+ const projectPath = report._meta?.projectPath;
312
+ if (!projectPath || !isSamePath(projectPath, analysisDir)) {
313
+ return "it describes another project";
314
+ }
315
+ if (!isNewerThan(reportFile, options.input)) {
316
+ return "it is older than the input BOM";
317
+ }
318
+ const recorded = report._meta[RUN_SETTINGS_KEY];
319
+ const wanted = scalasemRunSettings(options);
320
+ if (
321
+ recorded &&
322
+ (recorded.build !== wanted.build ||
323
+ recorded.includeTests !== wanted.includeTests)
324
+ ) {
325
+ return "it was made with other settings";
326
+ }
327
+ const writtenAt = modifiedAt(reportFile);
328
+ if (writtenAt === undefined) {
329
+ return "its modification time is unknown";
330
+ }
331
+ for (const marker of SCALA_BUILD_MARKERS) {
332
+ const changedAt = modifiedAt(join(analysisDir, marker));
333
+ if (changedAt !== undefined && changedAt > writtenAt) {
334
+ return `${marker} changed since`;
335
+ }
336
+ }
337
+ for (const key of Object.keys(report)) {
338
+ const file = isReportFileKey(key) ? resolve(analysisDir, key) : undefined;
339
+ if (!file || !isInside(analysisDir, file)) {
340
+ continue;
341
+ }
342
+ const changedAt = modifiedAt(file);
343
+ if (changedAt === undefined || changedAt > writtenAt) {
344
+ return `${key} changed since`;
345
+ }
346
+ }
347
+ return undefined;
348
+ }
349
+
241
350
  /**
242
351
  * Run scalasem over a Scala project and return its report, reusing a
243
- * persisted version 2 report that belongs to this project and is newer than
244
- * the input BOM.
352
+ * persisted version 2 report that belongs to this project, is newer than the
353
+ * input BOM and than the build files and sources it describes, and, when
354
+ * cdxgen kept it, was made with the settings this run asks for.
245
355
  *
246
356
  * Failures are visible rather than silent: the reason is printed once, the
247
357
  * run's diagnostics reach the caller as `cdx:scalasem:diagnostic` properties,
@@ -279,10 +389,18 @@ export function analyzeScalaProject(src, options = {}) {
279
389
  for (const candidate of candidates) {
280
390
  const existing = readScalasemReport(candidate);
281
391
  if (existing?._meta?.schemaVersion === "scalasem/2") {
282
- if (
283
- resolve(existing._meta.projectPath || "") === analysisDir &&
284
- isNewerThan(candidate, options.input)
285
- ) {
392
+ const problem = reportReuseProblem(
393
+ existing,
394
+ candidate,
395
+ analysisDir,
396
+ options,
397
+ );
398
+ if (problem && DEBUG_MODE) {
399
+ console.log(
400
+ `Not reusing the scalasem report "${candidate}": ${problem}.`,
401
+ );
402
+ }
403
+ if (!problem) {
286
404
  if (DEBUG_MODE) {
287
405
  console.log(`Reusing the scalasem report "${candidate}".`);
288
406
  }
@@ -339,12 +457,38 @@ function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
339
457
  // earlier run (or another project's run) had left there, and a report
340
458
  // written within the millisecond the run started passed for fresh.
341
459
  const runDir = safeMkdtempSync(join(getTmpDir(), "scalasem-"));
460
+ try {
461
+ return runScalasemIn(
462
+ runDir,
463
+ scalasem,
464
+ analysisDir,
465
+ options,
466
+ requestedFile,
467
+ v1Slice,
468
+ v1File,
469
+ );
470
+ } finally {
471
+ safeRmSync(runDir, { recursive: true, force: true });
472
+ }
473
+ }
474
+
475
+ function runScalasemIn(
476
+ runDir,
477
+ scalasem,
478
+ analysisDir,
479
+ options,
480
+ requestedFile,
481
+ v1Slice,
482
+ v1File,
483
+ ) {
342
484
  const runReportFile = join(runDir, "scalasem.slices.json");
343
485
  const args = [scalasem, analysisDir, runReportFile];
344
- if (options.installDeps === false) {
486
+ const settings = scalasemRunSettings(options);
487
+ // The build runs the project's sbt definitions
488
+ if (!settings.build) {
345
489
  args.push("--no-build");
346
490
  }
347
- if (options.scalasemIncludeTests && !options.requiredOnly) {
491
+ if (settings.includeTests) {
348
492
  args.push("--include-tests");
349
493
  }
350
494
  if (DEBUG_MODE) {
@@ -355,17 +499,24 @@ function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
355
499
  // scalasem enforces the limit itself, so the builds it started stop with
356
500
  // it; cdxgen's own timeout is the backstop. Naming cdxgen as the supervisor
357
501
  // stops scalasem when cdxgen is killed.
358
- const result = safeSpawnSync(process.execPath, args, {
359
- cwd: analysisDir,
360
- shell: false,
361
- killSignal: "SIGKILL",
362
- timeout: timeoutMs + Math.min(TIMEOUT_GRACE_MS, timeoutMs),
363
- env: {
364
- ...process.env,
365
- ATOM_PARENT_PID: String(process.pid),
366
- SCALASEM_TIMEOUT: String(timeoutMs),
367
- },
368
- });
502
+ // A spawn that throws, such as a command the allow list refuses, degrades
503
+ // the evidence like any other run that could not start.
504
+ let result;
505
+ try {
506
+ result = safeSpawnSync(process.execPath, args, {
507
+ cwd: analysisDir,
508
+ shell: false,
509
+ killSignal: "SIGKILL",
510
+ timeout: timeoutMs + Math.min(TIMEOUT_GRACE_MS, timeoutMs),
511
+ env: {
512
+ ...process.env,
513
+ ATOM_PARENT_PID: String(process.pid),
514
+ SCALASEM_TIMEOUT: String(timeoutMs),
515
+ },
516
+ });
517
+ } catch (err) {
518
+ result = { error: err };
519
+ }
369
520
  const elapsedMs = Date.now() - startedAt;
370
521
  const wroteReport = safeExistsSync(runReportFile);
371
522
  const written = wroteReport ? readScalasemReport(runReportFile) : undefined;
@@ -429,7 +580,9 @@ function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
429
580
  ),
430
581
  ];
431
582
  }
432
- const summary = summarizeDiagnostics(report._meta.diagnostics);
583
+ const summary = summarizeDiagnostics(
584
+ degradingDiagnostics(report._meta.diagnostics),
585
+ );
433
586
  if (summary) {
434
587
  console.warn(
435
588
  `scalasem: the report is degraded: ${summary}. The cdx:scalasem:diagnostic:* metadata properties of the BOM say where.`,
@@ -441,19 +594,26 @@ function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
441
594
  }
442
595
  // Only a usable report replaces the one at the requested path, and a
443
596
  // version 1 slice there stays as the user left it.
597
+ // The kept copy records the settings of this run, so a later run reuses it
598
+ // only when it asks for the same.
444
599
  let reportFile;
445
- if (usable && v1File !== requestedFile) {
600
+ if (usable && requestedFile && v1File !== requestedFile) {
446
601
  try {
447
602
  safeMkdirSync(dirname(requestedFile), { recursive: true });
448
- safeCopyFileSync(runReportFile, requestedFile);
449
- reportFile = requestedFile;
603
+ safeWriteSync(
604
+ requestedFile,
605
+ JSON.stringify({
606
+ ...report,
607
+ _meta: { ...report._meta, [RUN_SETTINGS_KEY]: settings },
608
+ }),
609
+ );
610
+ reportFile = safeExistsSync(requestedFile) ? requestedFile : undefined;
450
611
  } catch (err) {
451
612
  console.warn(
452
613
  `scalasem: the report could not be kept at ${requestedFile} (${err.message}); this run still uses it.`,
453
614
  );
454
615
  }
455
616
  }
456
- safeRmSync(runDir, { recursive: true, force: true });
457
617
  return {
458
618
  report,
459
619
  reportFile,
@@ -462,6 +622,21 @@ function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
462
622
  };
463
623
  }
464
624
 
625
+ // Diagnostics scalasem counts on a healthy run: symbols its semantic pass
626
+ // could not resolve, and stale build outputs it correctly skipped. They say
627
+ // the analysis is incomplete, not that it is degraded, so they stay out of
628
+ // the BOM and out of the degraded flag.
629
+ const INFORMATIONAL_DIAGNOSTIC_CODES = new Set([
630
+ "unresolved-symbols",
631
+ "stale-output",
632
+ ]);
633
+
634
+ function degradingDiagnostics(diagnostics = []) {
635
+ return (diagnostics || []).filter(
636
+ (diagnostic) => !INFORMATIONAL_DIAGNOSTIC_CODES.has(diagnostic?.code),
637
+ );
638
+ }
639
+
465
640
  function summarizeDiagnostics(diagnostics = []) {
466
641
  const byCode = new Map();
467
642
  for (const diagnostic of diagnostics || []) {
@@ -488,8 +663,9 @@ function summarizeDiagnostics(diagnostics = []) {
488
663
  */
489
664
  export function scalasemMetadataProperties(
490
665
  report,
491
- diagnostics = report?._meta?.diagnostics || [],
666
+ allDiagnostics = report?._meta?.diagnostics || [],
492
667
  ) {
668
+ const diagnostics = degradingDiagnostics(allDiagnostics);
493
669
  const properties = [];
494
670
  const meta = report?._meta || {};
495
671
  appendUniqueProperty(
@@ -551,20 +727,23 @@ export function sortedCsv(values) {
551
727
 
552
728
  /**
553
729
  * The platform a purl's artifact name targets: `_sjs1` is Scala.js,
554
- * `_native0.x` is Scala Native, and neither is the JVM.
730
+ * `_native0.x` is Scala Native, and neither is the JVM. The platform suffix
731
+ * counts whether the Scala binary suffix follows it (`upickle_sjs1_3`, the
732
+ * published name a jar scan keeps) or not (`upickle_sjs1`, the sbt name).
555
733
  *
556
734
  * @param {string} purl Package URL.
557
735
  * @returns {"jvm"|"js"|"native"} Platform key.
558
736
  */
559
737
  export function scalaPurlPlatform(purl) {
560
- const name = purlPurlName(purl);
561
- if (!name) {
562
- return "jvm";
563
- }
564
- if (/_(sjs\d+(?:\.\d+)?)$/.test(name)) {
738
+ return scalaNamePlatform(purlPurlName(purl));
739
+ }
740
+
741
+ function scalaNamePlatform(name) {
742
+ const { platformSuffix } = parseScalaArtifact(name);
743
+ if (platformSuffix.startsWith("_sjs")) {
565
744
  return "js";
566
745
  }
567
- if (/_(native\d+(?:\.\d+)*)$/.test(name)) {
746
+ if (platformSuffix.startsWith("_native")) {
568
747
  return "native";
569
748
  }
570
749
  return "jvm";
@@ -582,6 +761,20 @@ function purlPurlName(purl) {
582
761
  }
583
762
  }
584
763
 
764
+ // A compiler-generated class: a `$`-separated part that is empty past the
765
+ // top-level name (`$$`) or numeric, or one of the anonymous and lambda markers.
766
+ function isSyntheticClassName(symbol, dollar) {
767
+ return symbol
768
+ .slice(dollar + 1)
769
+ .split("$")
770
+ .some(
771
+ (part, index, parts) =>
772
+ (part === "" && index < parts.length - 1) ||
773
+ /^\d+$/.test(part) ||
774
+ ["anon", "anonfun", "Lambda", "lambda"].includes(part),
775
+ );
776
+ }
777
+
585
778
  /**
586
779
  * The class and package index that joins scalasem symbols to component purls.
587
780
  *
@@ -631,11 +824,22 @@ export class ScalaJoinIndex {
631
824
  if (!symbol || symbol.includes(" ") || symbol.includes("/")) {
632
825
  continue;
633
826
  }
634
- indexed.push(symbol);
635
- const dollar = symbol.indexOf("$");
827
+ // A `$` inside the simple name separates nested classes; one that starts
828
+ // it belongs to an encoded operator name (`$colon$colon` for `::`).
829
+ const simpleName = symbol.lastIndexOf(".") + 1;
830
+ const dollar = symbol.startsWith("$", simpleName)
831
+ ? -1
832
+ : symbol.indexOf("$", simpleName);
636
833
  const topLevel = dollar > 0 ? symbol.slice(0, dollar) : symbol;
834
+ // Anonymous classes and lambdas (`Foo$$anon$1`, `Foo$$anonfun$bar$1`,
835
+ // `Foo$$Lambda$12`) are no names a source can reference; their outer
836
+ // class speaks for them.
837
+ const synthetic = dollar > 0 && isSyntheticClassName(symbol, dollar);
838
+ if (!synthetic) {
839
+ indexed.push(symbol);
840
+ }
637
841
  this.addSymbol(this.classPurls, topLevel, purl, source);
638
- if (dollar > 0) {
842
+ if (dollar > 0 && !synthetic) {
639
843
  const nested = symbol.replaceAll("$", ".").replace(/\.+$/, "");
640
844
  if (nested !== topLevel) {
641
845
  this.addSymbol(this.classPurls, nested, purl, source);
@@ -732,15 +936,25 @@ export class ScalaJoinIndex {
732
936
  * sits in. A package the project also fills is ambiguous and joins nothing,
733
937
  * and `javax.` names join only through an exact class, since the JDK ships
734
938
  * most of them.
939
+ *
940
+ * The package is the part of the symbol before its first type name, which
941
+ * starts with a capital letter (`io.circe` of `io.circe.Json.fromString`). A
942
+ * symbol without one names a package (`io.circe`) or a member of one, such
943
+ * as a package object member (`io.circe.syntax.asJson`). Parent packages
944
+ * never answer: a missing `io.circe.generic` is not circe-core.
735
945
  */
736
946
  packageOwners(symbol) {
737
947
  if (symbol.startsWith(JDK_EXTENSION_PREFIX)) {
738
948
  return undefined;
739
949
  }
740
950
  const parts = symbol.replace(/\$+$/, "").split(".");
741
- for (let i = parts.length; i > 0; i--) {
742
- const name = parts.slice(0, i).join(".");
743
- const owners = this.packagePurls.get(name);
951
+ const firstType = parts.findIndex((part) => /^[A-Z]/.test(part));
952
+ const packages =
953
+ firstType >= 0
954
+ ? [parts.slice(0, firstType).join(".")]
955
+ : [parts.join("."), parts.slice(0, -1).join(".")];
956
+ for (const name of packages) {
957
+ const owners = name ? this.packagePurls.get(name) : undefined;
744
958
  if (owners?.size) {
745
959
  return this.projectPackages.has(name) ? undefined : owners;
746
960
  }
@@ -873,41 +1087,83 @@ function componentBinaryVersions(component, coordinate) {
873
1087
  );
874
1088
  }
875
1089
 
1090
+ // Which of two components standing for the same library is the one evidence
1091
+ // attaches to: the plain purl over one with qualifiers, then the shorter, then
1092
+ // the lexicographically first, so the choice never depends on BOM order.
1093
+ function preferredComponent(current, candidate) {
1094
+ const rank = (component) => [
1095
+ component.purl.includes("?") ? 1 : 0,
1096
+ component.purl.length,
1097
+ ];
1098
+ const [currentQualified, currentLength] = rank(current);
1099
+ const [candidateQualified, candidateLength] = rank(candidate);
1100
+ if (currentQualified !== candidateQualified) {
1101
+ return candidateQualified < currentQualified ? candidate : current;
1102
+ }
1103
+ if (currentLength !== candidateLength) {
1104
+ return candidateLength < currentLength ? candidate : current;
1105
+ }
1106
+ return candidate.purl < current.purl ? candidate : current;
1107
+ }
1108
+
876
1109
  /**
877
1110
  * Index the BOM components by their normalized Scala coordinates, both with
878
1111
  * and without the version, so a classpath jar, a namespace property and a map
879
1112
  * entry of the same library meet on one component. Each entry keeps the Scala
880
- * binary version the component was built for, so the `_2.13` and `_3`
881
- * artifacts of one library in a Maven build stay apart.
1113
+ * binary versions the component was built for, so the `_2.13` and `_3`
1114
+ * artifacts of one library in a Maven build stay apart. Components that share
1115
+ * a normalized coordinate and were built for an overlapping set of Scala
1116
+ * binary versions are one library listed more than once (a repository_url
1117
+ * qualifier, a `_3` suffix beside the sbt name built for 2.13 and 3, a stale
1118
+ * duplicate), so they share one entry, which the preferred component of the
1119
+ * group stands for.
882
1120
  */
883
1121
  export function componentCoordinateIndex(components = []) {
884
- const byVersion = new Map();
885
- const withoutVersion = new Map();
1122
+ const groupsByKey = new Map();
886
1123
  for (const component of components) {
887
1124
  const coordinate = scalaCoordinateOfPurl(component?.purl);
888
1125
  if (!coordinate) {
889
1126
  continue;
890
1127
  }
891
- const [type, group, artifactBase, platformSuffix] =
892
- coordinate.key.split(":");
893
- const entry = {
1128
+ const binaryVersions = componentBinaryVersions(component, coordinate);
1129
+ const groups = groupsByKey.get(coordinate.key) || [];
1130
+ groupsByKey.set(coordinate.key, groups);
1131
+ // Every group this component overlaps becomes one with it.
1132
+ const overlapping = groups.filter((group) =>
1133
+ binaryVersions.size
1134
+ ? [...binaryVersions].some((version) =>
1135
+ group.binaryVersions.has(version),
1136
+ )
1137
+ : !group.binaryVersions.size,
1138
+ );
1139
+ const merged = {
894
1140
  component,
895
- binaryVersions: componentBinaryVersions(component, coordinate),
1141
+ binaryVersions: new Set(binaryVersions),
896
1142
  };
897
- if (!byVersion.has(coordinate.key)) {
898
- byVersion.set(coordinate.key, []);
1143
+ for (const group of overlapping) {
1144
+ merged.component = preferredComponent(merged.component, group.component);
1145
+ for (const version of group.binaryVersions) {
1146
+ merged.binaryVersions.add(version);
1147
+ }
1148
+ groups.splice(groups.indexOf(group), 1);
899
1149
  }
900
- byVersion.get(coordinate.key).push(entry);
901
- const key = scalaCoordinateKey({
1150
+ groups.push(merged);
1151
+ }
1152
+ const byVersion = new Map();
1153
+ const withoutVersion = new Map();
1154
+ for (const [key, groups] of groupsByKey) {
1155
+ const [type, group, artifactBase, platformSuffix] = key.split(":");
1156
+ byVersion.set(key, groups);
1157
+ const versionless = scalaCoordinateKey({
902
1158
  type,
903
1159
  group,
904
1160
  artifactBase,
905
1161
  platformSuffix,
906
1162
  });
907
- if (!withoutVersion.has(key)) {
908
- withoutVersion.set(key, []);
909
- }
910
- withoutVersion.get(key).push(entry);
1163
+ withoutVersion.set(versionless, [
1164
+ ...(withoutVersion.get(versionless) || []),
1165
+ ...groups,
1166
+ ]);
911
1167
  }
912
1168
  return { byVersion, withoutVersion };
913
1169
  }
@@ -942,11 +1198,126 @@ function pickByBinaryVersion(entries, binaryVersion) {
942
1198
  * @returns {{component: Object, exact: boolean}|undefined} The component, and
943
1199
  * whether its version is the one on the classpath.
944
1200
  */
945
- export function matchClasspathEntry(
946
- entry,
947
- { byVersion, withoutVersion },
948
- platform,
949
- ) {
1201
+ export function matchClasspathEntry(entry, coordinateIndex, platform) {
1202
+ const direct =
1203
+ entry?.group && entry?.artifact
1204
+ ? matchCoordinates(entry, coordinateIndex, platform)
1205
+ : undefined;
1206
+ if (direct?.component || direct?.ambiguous) {
1207
+ return direct.component ? direct : undefined;
1208
+ }
1209
+ // The report reads coordinates from a Maven repository layout and can take
1210
+ // directories above the group for part of it (`maven.io.circe` for jars
1211
+ // under `<repository>/maven/io/circe/`), and reads none from the Gradle and
1212
+ // Ivy cache layouts. The coordinates the jar's own path spells are tried
1213
+ // then, and must still meet a component on artifact and version.
1214
+ for (const coordinates of jarPathCoordinates(entry)) {
1215
+ const byPath = matchCoordinates(
1216
+ { ...entry, ...coordinates },
1217
+ coordinateIndex,
1218
+ platform,
1219
+ );
1220
+ if (byPath?.exact) {
1221
+ return byPath;
1222
+ }
1223
+ }
1224
+ return undefined;
1225
+ }
1226
+
1227
+ /**
1228
+ * The coordinates a classpath jar's path spells, for the layouts the report
1229
+ * does not read itself, most specific first:
1230
+ *
1231
+ * - a Maven layout `<group dirs>/<artifact>/<version>/<file>` whose group is
1232
+ * a dotted tail of the group the report recorded, so a group is only ever
1233
+ * shortened by the directories the report took for it, never guessed
1234
+ * - the Gradle cache `files-2.1/<group>/<artifact>/<version>/<sha1>/<file>`
1235
+ * - an Ivy cache `<org>/<artifact>/jars/<artifact>-<version>.jar`, as sbt 0.13
1236
+ * and sbt 1.0 to 1.2 keep it, and an Ivy local repository
1237
+ * `<org>/<artifact>/<version>/jars/<artifact>.jar`
1238
+ *
1239
+ * @param {Object} entry Classpath entry with `path` and, when the report read
1240
+ * them, `group`, `artifact` and `version`.
1241
+ * @returns {{group: string, artifact: string, version: string}[]}
1242
+ */
1243
+ export function jarPathCoordinates(entry) {
1244
+ if (!entry?.path) {
1245
+ return [];
1246
+ }
1247
+ const segments = String(entry.path).split(/[\\/]/).filter(Boolean);
1248
+ const total = segments.length;
1249
+ const file = segments[total - 1] || "";
1250
+ if (total < 4 || !file.toLowerCase().endsWith(".jar")) {
1251
+ return [];
1252
+ }
1253
+ const found = [];
1254
+ if (entry.group && entry.artifact && entry.version) {
1255
+ const artifactIndex = total - 3;
1256
+ if (
1257
+ segments[artifactIndex] === entry.artifact &&
1258
+ segments[artifactIndex + 1] === entry.version
1259
+ ) {
1260
+ const groupParts = entry.group.split(".");
1261
+ for (let start = 1; start < groupParts.length; start++) {
1262
+ const tail = groupParts.slice(start);
1263
+ const directories = segments.slice(
1264
+ artifactIndex - tail.length,
1265
+ artifactIndex,
1266
+ );
1267
+ if (
1268
+ artifactIndex - tail.length >= 0 &&
1269
+ directories.join("/") === tail.join("/")
1270
+ ) {
1271
+ found.push({
1272
+ group: tail.join("."),
1273
+ artifact: entry.artifact,
1274
+ version: entry.version,
1275
+ });
1276
+ }
1277
+ }
1278
+ }
1279
+ }
1280
+ const gradle = segments.lastIndexOf("files-2.1");
1281
+ if (gradle >= 0 && gradle + 5 === total - 1) {
1282
+ found.push({
1283
+ group: segments[gradle + 1],
1284
+ artifact: segments[gradle + 2],
1285
+ version: segments[gradle + 3],
1286
+ });
1287
+ }
1288
+ const stem = file.slice(0, -".jar".length);
1289
+ if (["jars", "bundles"].includes(segments[total - 2])) {
1290
+ // Ivy local: <org>/<artifact>/<version>/jars/<artifact>.jar
1291
+ if (total >= 5 && stem === segments[total - 4]) {
1292
+ found.push({
1293
+ group: segments[total - 5],
1294
+ artifact: segments[total - 4],
1295
+ version: segments[total - 3],
1296
+ });
1297
+ } else if (stem.startsWith(`${segments[total - 3]}-`)) {
1298
+ // Ivy cache: <org>/<artifact>/jars/<artifact>-<version>.jar
1299
+ found.push({
1300
+ group: segments[total - 4],
1301
+ artifact: segments[total - 3],
1302
+ version: stem.slice(segments[total - 3].length + 1),
1303
+ });
1304
+ }
1305
+ }
1306
+ return found.filter(
1307
+ (coordinates) =>
1308
+ coordinates.group &&
1309
+ coordinates.artifact &&
1310
+ coordinates.version &&
1311
+ (coordinates.group !== entry.group ||
1312
+ coordinates.artifact !== entry.artifact ||
1313
+ coordinates.version !== entry.version),
1314
+ );
1315
+ }
1316
+
1317
+ // The component a set of coordinates names: `{component, exact}` for a single
1318
+ // owner, `{ambiguous: true}` when several components answer, undefined when
1319
+ // none does.
1320
+ function matchCoordinates(entry, { byVersion, withoutVersion }, platform) {
950
1321
  const parsed = parseScalaArtifact(entry?.artifact);
951
1322
  const parts = {
952
1323
  group: entry?.group,
@@ -961,7 +1332,7 @@ export function matchClasspathEntry(
961
1332
  return { component: exact[0].component, exact: true };
962
1333
  }
963
1334
  if (exact.length > 1) {
964
- return undefined;
1335
+ return { ambiguous: true };
965
1336
  }
966
1337
  const candidates = pickByBinaryVersion(
967
1338
  withoutVersion.get(scalaCoordinateKey(parts)),
@@ -971,9 +1342,10 @@ export function matchClasspathEntry(
971
1342
  (candidate) => scalaPurlPlatform(candidate.component.purl) === platform,
972
1343
  );
973
1344
  const pool = matching.length ? matching : candidates;
974
- return pool.length === 1
975
- ? { component: pool[0].component, exact: false }
976
- : undefined;
1345
+ if (pool.length === 1) {
1346
+ return { component: pool[0].component, exact: false };
1347
+ }
1348
+ return pool.length > 1 ? { ambiguous: true } : undefined;
977
1349
  }
978
1350
 
979
1351
  /**
@@ -1041,8 +1413,14 @@ export async function buildScalaJoinIndex(report, components, jarNSMapping) {
1041
1413
  }
1042
1414
  }
1043
1415
  for (const mapPurl of Object.keys(jarNSMapping || {})) {
1044
- const entry = jarNSMapping[mapPurl] || {};
1045
- if (!Array.isArray(entry.namespaces) || !entry.namespaces.length) {
1416
+ // Maps written before every producer used `{ namespaces }` hold the
1417
+ // newline-joined namespaces of a jar-scanned component as a string.
1418
+ const value = jarNSMapping[mapPurl];
1419
+ const namespaces =
1420
+ typeof value === "string"
1421
+ ? value.split("\n").filter(Boolean)
1422
+ : value?.namespaces;
1423
+ if (!Array.isArray(namespaces) || !namespaces.length) {
1046
1424
  continue;
1047
1425
  }
1048
1426
  const segments = /^pkg:[^/]+\/(?:([^/@?]+)\/)?([^@?]+)@([^?#]*)/.exec(
@@ -1061,7 +1439,7 @@ export async function buildScalaJoinIndex(report, components, jarNSMapping) {
1061
1439
  scalaPurlPlatform(mapPurl),
1062
1440
  );
1063
1441
  if (matched) {
1064
- index.addNamespaces(matched.component.purl, entry.namespaces, 3);
1442
+ index.addNamespaces(matched.component.purl, namespaces, 3);
1065
1443
  }
1066
1444
  }
1067
1445
  return index;
@@ -1308,10 +1686,11 @@ export function scalasemCryptoOidKey(name, keySize, mode, curve, padding) {
1308
1686
  return undefined;
1309
1687
  }
1310
1688
  if (SCALASEM_AES_NAMES.has(name)) {
1689
+ const upperMode = mode?.toUpperCase();
1311
1690
  const modeKey =
1312
- SCALASEM_AES_MODE_KEYS[mode] ||
1691
+ SCALASEM_AES_MODE_KEYS[upperMode] ||
1313
1692
  SCALASEM_AES_MODE_KEYS[name.split("-")[1]];
1314
- const wrap = name === "AES-KW" || mode === "KW";
1693
+ const wrap = name === "AES-KW" || upperMode === "KW";
1315
1694
  if (keySize && (modeKey !== undefined || wrap)) {
1316
1695
  const sizedKey = wrap ? `aes${keySize}-wrap` : `aes${keySize}-${modeKey}`;
1317
1696
  const hit = SCALASEM_OID_KEYS.get(sizedKey.toLowerCase());
@@ -1454,6 +1833,7 @@ export async function collectScalasemEvidence(
1454
1833
  componentPropertiesMap,
1455
1834
  metadataProperties,
1456
1835
  options,
1836
+ allowedPurlsByModule,
1457
1837
  );
1458
1838
  addJsModuleEvidence(
1459
1839
  report,
@@ -1786,7 +2166,17 @@ function stackTargetPurls(sink, fileEntry, joinIndex, platform, allowed) {
1786
2166
  return [...purls];
1787
2167
  }
1788
2168
 
2169
+ // The package of a class the report names. A class nested in an object reads
2170
+ // `Outer$.Inner`, so the package ends before the first part that is a type:
2171
+ // one that carries a `$` or starts with a capital letter.
1789
2172
  function packageOf(className) {
2173
+ const parts = String(className || "").split(".");
2174
+ const firstType = parts.findIndex(
2175
+ (part) => part.includes("$") || /^[A-Z]/.test(part),
2176
+ );
2177
+ if (firstType > 0) {
2178
+ return parts.slice(0, firstType).join(".");
2179
+ }
1790
2180
  const text = String(className || "").replace(/\$+$/, "");
1791
2181
  const dot = text.lastIndexOf(".");
1792
2182
  return dot > 0 ? text.slice(0, dot) : "";
@@ -1846,9 +2236,22 @@ function providerAliases(purl, components = []) {
1846
2236
  /**
1847
2237
  * The component purl of the library a crypto finding names as its provider.
1848
2238
  * Maven coordinates match on group and base artifact; the short aliases cover
1849
- * the providers the rules emit for libraries without a coordinate.
2239
+ * the providers the rules emit for libraries without a coordinate. Of several
2240
+ * matching components, one on the referencing module's classpath wins, then
2241
+ * the artifact whose name extends the provider's least (`bcprov-jdk18on`
2242
+ * over `bcprov-ext-jdk18on`), then the first purl, so the choice never
2243
+ * depends on the order of the BOM.
2244
+ *
2245
+ * @param {string} provider Provider the finding names.
2246
+ * @param {Object[]} components BOM components.
2247
+ * @param {Set<string>} [classpathPurls] Purls on the module's classpath.
2248
+ * @returns {string|undefined} Component purl.
1850
2249
  */
1851
- export function scalasemProviderPurl(provider, components = []) {
2250
+ export function scalasemProviderPurl(
2251
+ provider,
2252
+ components = [],
2253
+ classpathPurls = undefined,
2254
+ ) {
1852
2255
  if (!provider) {
1853
2256
  return undefined;
1854
2257
  }
@@ -1863,6 +2266,7 @@ export function scalasemProviderPurl(provider, components = []) {
1863
2266
  return undefined;
1864
2267
  }
1865
2268
  const base = parseScalaArtifact(artifactPrefix).artifactBase;
2269
+ const matches = [];
1866
2270
  for (const component of components) {
1867
2271
  if (!component?.purl) {
1868
2272
  continue;
@@ -1875,10 +2279,23 @@ export function scalasemProviderPurl(provider, components = []) {
1875
2279
  }
1876
2280
  const name = parseScalaArtifact(decodeSafe(segments[2])).artifactBase;
1877
2281
  if (name === base || name.startsWith(`${base}-`)) {
1878
- return component.purl;
2282
+ matches.push({ purl: component.purl, name });
1879
2283
  }
1880
2284
  }
1881
- return undefined;
2285
+ const rank = (match) => [
2286
+ classpathPurls?.has(match.purl) ? 0 : 1,
2287
+ match.name.length,
2288
+ ];
2289
+ matches.sort((left, right) => {
2290
+ const [leftClasspath, leftLength] = rank(left);
2291
+ const [rightClasspath, rightLength] = rank(right);
2292
+ return (
2293
+ leftClasspath - rightClasspath ||
2294
+ leftLength - rightLength ||
2295
+ (left.purl < right.purl ? -1 : left.purl > right.purl ? 1 : 0)
2296
+ );
2297
+ });
2298
+ return matches[0]?.purl;
1882
2299
  }
1883
2300
 
1884
2301
  // Primitives a mode of operation applies to. RSA spells `RSA/ECB/...` in JCA,
@@ -1963,6 +2380,7 @@ function addCryptoEvidence(
1963
2380
  componentPropertiesMap,
1964
2381
  metadataProperties,
1965
2382
  options = {},
2383
+ allowedPurlsByModule = new Map(),
1966
2384
  ) {
1967
2385
  const includeTests = options.scalasemIncludeTests === true;
1968
2386
  const nativeLibraryBySymbol = new Map();
@@ -1977,11 +2395,17 @@ function addCryptoEvidence(
1977
2395
  if (scope === "test" && !includeTests) {
1978
2396
  continue;
1979
2397
  }
1980
- const location = `${finding.file || ""}#${finding.line || 0}`;
2398
+ const location = finding.line
2399
+ ? `${finding.file || ""}#${finding.line}`
2400
+ : finding.file || "";
1981
2401
  const nativeLibrary = finding.kind?.startsWith("native")
1982
2402
  ? nativeLibraryBySymbol.get(finding.name)
1983
2403
  : undefined;
1984
- const providerPurl = scalasemProviderPurl(finding.provider, components);
2404
+ const providerPurl = scalasemProviderPurl(
2405
+ finding.provider,
2406
+ components,
2407
+ allowedPurlsFor(report[finding.file], allowedPurlsByModule),
2408
+ );
1985
2409
  const asset =
1986
2410
  finding.resolution === "unresolved"
1987
2411
  ? undefined
@@ -2052,12 +2476,18 @@ function addCryptoEvidence(
2052
2476
  }
2053
2477
  }
2054
2478
 
2479
+ // `file#line` as an occurrence. A location without a line, or whose `#` is
2480
+ // part of the file name, is the file alone.
2055
2481
  function parseLocationString(location) {
2056
2482
  const separator = location.lastIndexOf("#");
2057
- const line = Number.parseInt(location.slice(separator + 1), 10);
2483
+ const suffix = separator >= 0 ? location.slice(separator + 1) : "";
2484
+ if (!/^\d+$/.test(suffix)) {
2485
+ return { location };
2486
+ }
2487
+ const line = Number.parseInt(suffix, 10);
2058
2488
  return {
2059
2489
  location: location.slice(0, separator),
2060
- ...(Number.isFinite(line) && line > 0 ? { line } : {}),
2490
+ ...(line > 0 ? { line } : {}),
2061
2491
  };
2062
2492
  }
2063
2493
 
@@ -2185,6 +2615,191 @@ function cryptoAssetProperties(finding, nativeLibrary) {
2185
2615
  return properties;
2186
2616
  }
2187
2617
 
2618
+ // The characters that end a host list: a path, URL parameters, and the
2619
+ // `;key=value` properties some JDBC drivers take.
2620
+ const ADDRESS_END = ["/", "?", "#", ";"];
2621
+
2622
+ const HOST_PATTERN = /^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._%-]+)(?::\d+)?$/;
2623
+
2624
+ function indexOfAny(text, characters, from = 0) {
2625
+ let found = -1;
2626
+ for (const character of characters) {
2627
+ const index = text.indexOf(character, from);
2628
+ if (index >= 0 && (found < 0 || index < found)) {
2629
+ found = index;
2630
+ }
2631
+ }
2632
+ return found;
2633
+ }
2634
+
2635
+ function cutAt(text, characters) {
2636
+ const index = indexOfAny(text, characters);
2637
+ return index >= 0 ? text.slice(0, index) : text;
2638
+ }
2639
+
2640
+ // `host`, `host:port`, `[ipv6]:port`, or a comma separated list of those.
2641
+ function isHostList(text) {
2642
+ return (
2643
+ Boolean(text) && text.split(",").every((host) => HOST_PATTERN.test(host))
2644
+ );
2645
+ }
2646
+
2647
+ /**
2648
+ * Where the userinfo in front of a host ends, or -1 when there is none. A
2649
+ * password may hold any of `/ ? # ; @ =`, so an `@` ends the userinfo unless a
2650
+ * well-formed host list is followed by parameters that hold it
2651
+ * (`host:1433;user=a@b`). Of several `@`, the last one before the first
2652
+ * parameter ends it, and the last one of all when that leaves none: a host the
2653
+ * split gets wrong costs a service name, a split that keeps a password leaks it.
2654
+ *
2655
+ * @param {string} rest The text after `//`, or after a JDBC subprotocol.
2656
+ * @returns {number} Index of the `@` that ends the userinfo.
2657
+ */
2658
+ function userinfoEnd(rest) {
2659
+ const firstAt = rest.indexOf("@");
2660
+ if (firstAt < 0) {
2661
+ return -1;
2662
+ }
2663
+ const end = indexOfAny(rest, ADDRESS_END);
2664
+ if (
2665
+ end >= 0 &&
2666
+ end < firstAt &&
2667
+ isHostList(rest.slice(0, end)) &&
2668
+ rest.slice(end + 1, firstAt).includes("=")
2669
+ ) {
2670
+ return -1;
2671
+ }
2672
+ const firstEquals = rest.indexOf("=");
2673
+ const beforeParameters =
2674
+ firstEquals >= 0 ? rest.lastIndexOf("@", firstEquals) : -1;
2675
+ return beforeParameters >= 0 ? beforeParameters : rest.lastIndexOf("@");
2676
+ }
2677
+
2678
+ /**
2679
+ * The `host[:port]` list of a parenthesized address: MySQL
2680
+ * `address=(host=h)(port=3306)` and `(host=h,port=3306,user=u)`, Oracle
2681
+ * `(DESCRIPTION=(ADDRESS=(HOST=h)(PORT=1521)))`. Only host and port values are
2682
+ * read, so a user or password beside them never leaves.
2683
+ */
2684
+ function parenthesizedHosts(text) {
2685
+ const hosts = [];
2686
+ const ports = [];
2687
+ for (const group of text.split(/[()]/)) {
2688
+ for (const pair of group.split(",")) {
2689
+ const equals = pair.indexOf("=");
2690
+ if (equals <= 0) {
2691
+ continue;
2692
+ }
2693
+ const key = pair.slice(0, equals).trim().toLowerCase();
2694
+ const value = pair.slice(equals + 1).trim();
2695
+ if (key === "host" && HOST_PATTERN.test(value)) {
2696
+ hosts.push(value);
2697
+ } else if (key === "port" && /^\d+$/.test(value)) {
2698
+ ports[hosts.length ? hosts.length - 1 : 0] ??= value;
2699
+ }
2700
+ }
2701
+ }
2702
+ return hosts.map((host, index) =>
2703
+ ports[index] && !host.includes(":") ? `${host}:${ports[index]}` : host,
2704
+ );
2705
+ }
2706
+
2707
+ // The hosts and database path of an authority, after its userinfo is gone.
2708
+ function parseAuthority(text) {
2709
+ const parenthesized = text.startsWith("(") || /^[A-Za-z]+=\(/.test(text);
2710
+ if (parenthesized) {
2711
+ const close = text.lastIndexOf(")");
2712
+ const after = close >= 0 ? text.slice(close + 1) : "";
2713
+ return {
2714
+ hosts: parenthesizedHosts(text.slice(0, close + 1)),
2715
+ path: after.startsWith("/") ? cutAt(after, ["?", "#", ";", ":"]) : "",
2716
+ };
2717
+ }
2718
+ const end = indexOfAny(text, ADDRESS_END);
2719
+ const hostPart = end >= 0 ? text.slice(0, end) : text;
2720
+ const hosts = isHostList(hostPart) ? hostPart.split(",") : [];
2721
+ // DB2 and Informix append `:key=value;` properties to the database name.
2722
+ const path =
2723
+ hosts.length && end >= 0 && text[end] === "/"
2724
+ ? cutAt(text.slice(end), ["?", "#", ";", ":"])
2725
+ : "";
2726
+ return { hosts, path };
2727
+ }
2728
+
2729
+ /**
2730
+ * Split a JDBC URL into its subprotocol, hosts and database path, dropping
2731
+ * anything that may carry credentials: userinfo, the `user/password@` prefix
2732
+ * Oracle takes, URL parameters, and the `;key=value` and `:key=value`
2733
+ * properties some drivers take. Host values that do not look like hosts are
2734
+ * dropped too, rather than passed on.
2735
+ *
2736
+ * @param {string} value JDBC URL.
2737
+ * @returns {{scheme: string, hosts: string[], address: string}|undefined}
2738
+ * The first subprotocol, the hosts, and the address rebuilt from those
2739
+ * parts; undefined for anything but a JDBC URL.
2740
+ */
2741
+ function parseJdbcUrl(value) {
2742
+ const text = String(value || "").trim();
2743
+ if (!/^jdbc:/i.test(text)) {
2744
+ return undefined;
2745
+ }
2746
+ let rest = text.slice(5);
2747
+ const tokens = [];
2748
+ for (
2749
+ let token = /^[A-Za-z][\w+.-]*:/.exec(rest);
2750
+ token && !rest.startsWith("//");
2751
+ token = /^[A-Za-z][\w+.-]*:/.exec(rest)
2752
+ ) {
2753
+ tokens.push(token[0]);
2754
+ rest = rest.slice(token[0].length);
2755
+ }
2756
+ const scheme = (tokens[0] || "jdbc:").slice(0, -1).toLowerCase();
2757
+ if (rest.startsWith("//")) {
2758
+ const authority = rest.slice(2);
2759
+ const { hosts, path } = parseAuthority(
2760
+ authority.slice(userinfoEnd(authority) + 1),
2761
+ );
2762
+ return {
2763
+ scheme,
2764
+ hosts,
2765
+ address: `jdbc:${tokens.join("")}//${hosts.join(",")}${path}`,
2766
+ };
2767
+ }
2768
+ const at = userinfoEnd(rest);
2769
+ if (at < 0) {
2770
+ return {
2771
+ scheme,
2772
+ hosts: [],
2773
+ address: `jdbc:${tokens.join("")}${cutAt(rest, ["?", "#", ";"])}`,
2774
+ };
2775
+ }
2776
+ // `user/password@host:port:sid`: a word before the credentials can read as
2777
+ // a subprotocol (`jdbc:db:user:password@host`), so only the driver keeps
2778
+ // its tokens, and Oracle its driver type.
2779
+ const kept = tokens.slice(0, scheme === "oracle" ? 2 : 1).join("");
2780
+ const target = rest.slice(at + 1);
2781
+ if (target.startsWith("//")) {
2782
+ const { hosts, path } = parseAuthority(target.slice(2));
2783
+ return {
2784
+ scheme,
2785
+ hosts,
2786
+ address: `jdbc:${kept}@//${hosts.join(",")}${path}`,
2787
+ };
2788
+ }
2789
+ if (target.startsWith("(")) {
2790
+ const hosts = parenthesizedHosts(target);
2791
+ return { scheme, hosts, address: `jdbc:${kept}@${hosts.join(",")}` };
2792
+ }
2793
+ const address = cutAt(target, ["?", "#", ";", "/"]);
2794
+ const host = address.split(":")[0];
2795
+ const hosts = HOST_PATTERN.test(host) ? [host] : [];
2796
+ return {
2797
+ scheme,
2798
+ hosts,
2799
+ address: `jdbc:${kept}@${hosts.length ? address : ""}`,
2800
+ };
2801
+ }
2802
+
2188
2803
  /**
2189
2804
  * A JDBC address without what may carry credentials: userinfo, the Oracle
2190
2805
  * `user/password@` prefix, URL parameters and the `;key=value` properties
@@ -2195,49 +2810,58 @@ function cryptoAssetProperties(finding, nativeLibrary) {
2195
2810
  * @returns {string} The address part.
2196
2811
  */
2197
2812
  export function sanitizeJdbcUrl(value) {
2198
- let text = String(value || "").trim();
2199
- if (!/^jdbc:/i.test(text)) {
2200
- return text;
2201
- }
2202
- text = `jdbc:${text.slice(5)}`;
2203
- for (const separator of ["?", "#", ";"]) {
2204
- const index = text.indexOf(separator);
2205
- if (index >= 0) {
2206
- text = text.slice(0, index);
2207
- }
2208
- }
2209
- const slashes = text.indexOf("//");
2210
- if (slashes >= 0) {
2211
- const authorityEnd = text.indexOf("/", slashes + 2);
2212
- const authority = text.slice(
2213
- slashes + 2,
2214
- authorityEnd >= 0 ? authorityEnd : undefined,
2215
- );
2216
- const at = authority.lastIndexOf("@");
2217
- if (at >= 0) {
2218
- text =
2219
- text.slice(0, slashes + 2) +
2220
- authority.slice(at + 1) +
2221
- (authorityEnd >= 0 ? text.slice(authorityEnd) : "");
2222
- }
2223
- }
2224
- // Oracle thin: jdbc:oracle:thin:user/password@host:port:sid
2225
- const thin = /^(jdbc:oracle:thin:)[^@]*@/i.exec(text);
2226
- if (thin) {
2227
- text = `${thin[1]}@${text.slice(thin[0].length)}`;
2813
+ const parsed = parseJdbcUrl(value);
2814
+ return parsed ? parsed.address : String(value || "").trim();
2815
+ }
2816
+
2817
+ // The host name of `host`, `host:port` or `[ipv6]:port`.
2818
+ function hostName(host) {
2819
+ if (host.startsWith("[")) {
2820
+ const close = host.indexOf("]");
2821
+ return close > 0 ? host.slice(1, close) : undefined;
2228
2822
  }
2229
- return text;
2823
+ return host.split(":")[0] || undefined;
2230
2824
  }
2231
2825
 
2232
2826
  function jdbcServiceName(value) {
2233
- const address = sanitizeJdbcUrl(value);
2234
- const scheme = address.slice(5).split(":", 1)[0].toLowerCase() || "jdbc";
2235
- let host = /\/\/([^/:]+)/.exec(address)?.[1];
2236
- if (!host) {
2237
- // Oracle thin and similar: the host follows the @.
2238
- host = /@(?:\/\/)?([^/:]+)/.exec(address)?.[1];
2827
+ const { scheme, hosts } = parseJdbcUrl(value);
2828
+ return [scheme, hosts.length ? hostName(hosts[0]) : undefined]
2829
+ .filter(Boolean)
2830
+ .join("-");
2831
+ }
2832
+
2833
+ // A host value as `host[:port]`, without a scheme, userinfo, path or
2834
+ // parameters, or undefined when what is left is not a host.
2835
+ function bareHost(value) {
2836
+ let text = String(value || "").trim();
2837
+ const scheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.exec(text);
2838
+ if (scheme) {
2839
+ text = text.slice(scheme[0].length);
2840
+ }
2841
+ text = cutAt(text.slice(userinfoEnd(text) + 1), ADDRESS_END);
2842
+ return HOST_PATTERN.test(text) ? text : undefined;
2843
+ }
2844
+
2845
+ // The `host[:port]` a URL names. The userinfo is dropped before the URL is
2846
+ // parsed: a password holding `/` would otherwise end the authority early and
2847
+ // leave the user name to read as the host.
2848
+ function urlHost(value) {
2849
+ const separator = value.indexOf("://");
2850
+ if (separator <= 0) {
2851
+ return undefined;
2852
+ }
2853
+ const rest = value.slice(separator + 3);
2854
+ const withoutUserinfo = rest.slice(userinfoEnd(rest) + 1);
2855
+ try {
2856
+ const host = new URL(`${value.slice(0, separator)}://${withoutUserinfo}`)
2857
+ .host;
2858
+ if (host) {
2859
+ return host;
2860
+ }
2861
+ } catch (_err) {
2862
+ // Not a URL the WHATWG parser takes; the host is read by hand.
2239
2863
  }
2240
- return [scheme, host].filter(Boolean).join("-");
2864
+ return bareHost(withoutUserinfo);
2241
2865
  }
2242
2866
 
2243
2867
  /**
@@ -2257,13 +2881,11 @@ export function scalasemServiceName(service) {
2257
2881
  return jdbcServiceName(value);
2258
2882
  }
2259
2883
  if (service?.host) {
2260
- return service.host.split(":")[0];
2261
- }
2262
- try {
2263
- return new URL(value).hostname || undefined;
2264
- } catch (_err) {
2265
- return undefined;
2884
+ const host = bareHost(service.host);
2885
+ return host ? hostName(host) : undefined;
2266
2886
  }
2887
+ const host = urlHost(value);
2888
+ return host ? hostName(host) : undefined;
2267
2889
  }
2268
2890
 
2269
2891
  /**
@@ -2285,15 +2907,12 @@ export function scalasemServiceEndpoint(service) {
2285
2907
  return sanitizeJdbcUrl(value);
2286
2908
  }
2287
2909
  if (service?.host) {
2288
- return service.host;
2289
- }
2290
- try {
2291
- const parsed = new URL(value);
2292
- return `${parsed.protocol}//${parsed.host}`;
2293
- } catch (_err) {
2294
- const hostMatch = /:\/\/([^/?#@]+@)?([^/?#]+)/.exec(value);
2295
- return hostMatch ? `${value.split("://")[0]}://${hostMatch[2]}` : undefined;
2910
+ return bareHost(service.host);
2296
2911
  }
2912
+ const host = urlHost(value);
2913
+ return host
2914
+ ? `${value.slice(0, value.indexOf("://")).toLowerCase()}://${host}`
2915
+ : undefined;
2297
2916
  }
2298
2917
 
2299
2918
  function serviceLocation(service) {
@@ -2457,7 +3076,10 @@ export async function collectScalaJsNpmComponents(
2457
3076
  ) {
2458
3077
  const isScalaJs = pkgList.some((pkg) => {
2459
3078
  const name = pkg?.name || purlPurlName(pkg?.purl) || "";
2460
- return /_(sjs\d+(?:\.\d+)?)$/.test(name) || name === "scalajs-library";
3079
+ return (
3080
+ scalaNamePlatform(name) === "js" ||
3081
+ parseScalaArtifact(name).artifactBase === "scalajs-library"
3082
+ );
2461
3083
  });
2462
3084
  const empty = { components: [], roots: [], dependencies: [] };
2463
3085
  if (!isScalaJs) {
@@ -2546,6 +3168,24 @@ function npmNameFromPurl(purl) {
2546
3168
  }
2547
3169
  }
2548
3170
 
3171
+ function nodeModulesDepth(component) {
3172
+ const location = (component.properties || []).find(
3173
+ (property) => property.name === "internal:LocalNodeModulesPath",
3174
+ )?.value;
3175
+ return location
3176
+ ? location.split("node_modules/").length - 1
3177
+ : Number.POSITIVE_INFINITY;
3178
+ }
3179
+
3180
+ function isPreferredNpmCopy(candidate, known) {
3181
+ const candidateDepth = nodeModulesDepth(candidate);
3182
+ const knownDepth = nodeModulesDepth(known);
3183
+ if (candidateDepth !== knownDepth) {
3184
+ return candidateDepth < knownDepth;
3185
+ }
3186
+ return candidate.purl < known.purl;
3187
+ }
3188
+
2549
3189
  /**
2550
3190
  * Attribute the report's JavaScript module imports to the npm components the
2551
3191
  * build bundles. A module no component owns is reported on the project
@@ -2562,10 +3202,17 @@ function addJsModuleEvidence(
2562
3202
  if (!jsModules.length) {
2563
3203
  return;
2564
3204
  }
3205
+ // Of several versions of one package, the one a bare import resolves to is
3206
+ // the hoisted copy, the shallowest under node_modules; the first purl
3207
+ // breaks a tie, so the choice never depends on the order of the BOM.
2565
3208
  const npmByName = new Map();
2566
3209
  for (const component of components) {
2567
3210
  const name = npmNameFromPurl(component?.purl);
2568
- if (name) {
3211
+ if (!name) {
3212
+ continue;
3213
+ }
3214
+ const known = npmByName.get(name);
3215
+ if (!known || isPreferredNpmCopy(component, known)) {
2569
3216
  npmByName.set(name, component);
2570
3217
  }
2571
3218
  }