@cdxgen/cdxgen 13.5.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 (182) hide show
  1. package/README.md +6 -6
  2. package/bin/cdxgen.js +4 -0
  3. package/bin/evinse.js +3 -0
  4. package/bin/validate.js +6 -0
  5. package/data/license-aliases.json +24 -2
  6. package/data/remediations.json +124 -0
  7. package/data/rules/ai-bom.yaml +1 -1
  8. package/data/rules/brotli-decoder.yaml +107 -0
  9. package/data/rules/vscode-extensions.yaml +75 -0
  10. package/data/spdx-license-list.json +121 -7
  11. package/data/spdx-licenses.json +69 -0
  12. package/data/spdx.schema.json +845 -2
  13. package/lib/cli/bomAssembly.js +92 -3
  14. package/lib/cli/index.js +108 -1
  15. package/lib/cli/jsBom.js +153 -64
  16. package/lib/cli/jvmBom.js +237 -46
  17. package/lib/cli/managedBom.js +142 -16
  18. package/lib/cli/nativeBom.js +42 -5
  19. package/lib/core/boundedFile.js +344 -0
  20. package/lib/core/brotliProbe.js +182 -0
  21. package/lib/core/env.js +8 -0
  22. package/lib/core/fs.js +12 -0
  23. package/lib/core/httpClient.js +98 -1
  24. package/lib/core/lockPaths.js +38 -0
  25. package/lib/core/paths.js +24 -1
  26. package/lib/core/processTree.js +5 -0
  27. package/lib/core/toolRegistry.js +170 -0
  28. package/lib/ecosystems/cppEvidence.js +263 -106
  29. package/lib/ecosystems/cppIncludeResolver.js +16 -5
  30. package/lib/ecosystems/ecosystems.js +25 -10
  31. package/lib/ecosystems/gemHostProbe.js +301 -0
  32. package/lib/ecosystems/gradleutils.js +828 -224
  33. package/lib/ecosystems/parsers-bazel.js +107 -6
  34. package/lib/ecosystems/parsers-js.js +505 -22
  35. package/lib/ecosystems/parsers-misc.js +209 -20
  36. package/lib/ecosystems/parsers-python.js +89 -2
  37. package/lib/ecosystems/pythonutils.js +128 -2
  38. package/lib/ecosystems/rubyutils.js +538 -31
  39. package/lib/ecosystems/scalacliutils.js +2 -0
  40. package/lib/ecosystems/scalasem.js +796 -151
  41. package/lib/ecosystems/vendoredCode.js +237 -3
  42. package/lib/ecosystems/venvProbe.js +360 -0
  43. package/lib/evinser/evinser.js +346 -43
  44. package/lib/helpers/agenticToolutils.js +413 -93
  45. package/lib/helpers/vsixutils.js +1574 -315
  46. package/lib/inventory/atomUtils.js +77 -0
  47. package/lib/inventory/brotliDecoder.js +142 -0
  48. package/lib/inventory/buildToolRateLimit.js +1 -1
  49. package/lib/inventory/cbomutils.js +56 -7
  50. package/lib/inventory/deps.js +35 -0
  51. package/lib/inventory/dosai.js +139 -5
  52. package/lib/inventory/dotnetRuntime.js +217 -0
  53. package/lib/inventory/envcontext.js +307 -13
  54. package/lib/inventory/evidenceUtils.js +11 -0
  55. package/lib/inventory/fetchBatch.js +1 -1
  56. package/lib/inventory/formulationParsers.js +31 -3
  57. package/lib/inventory/gtfobins.js +13 -0
  58. package/lib/inventory/hostRuntimeProbe.js +686 -0
  59. package/lib/inventory/jvmLocalRepos.js +9 -2
  60. package/lib/inventory/jvmToolEnv.js +29 -0
  61. package/lib/inventory/licenseEnhancer.js +12 -0
  62. package/lib/inventory/mavenCentralMirror.js +534 -0
  63. package/lib/inventory/nodeBuildInfo.js +218 -0
  64. package/lib/inventory/nodeHostProbe.js +202 -0
  65. package/lib/inventory/osPackageResolver.js +182 -0
  66. package/lib/inventory/phpBuildInfo.js +10 -59
  67. package/lib/inventory/phpHostProbe.js +32 -488
  68. package/lib/inventory/rubyBuildInfo.js +972 -0
  69. package/lib/inventory/rubyHostProbe.js +218 -0
  70. package/lib/inventory/scalaCoords.js +4 -3
  71. package/lib/inventory/scalaDedupe.js +440 -0
  72. package/lib/inventory/source.js +49 -2
  73. package/lib/managers/binary.js +337 -3
  74. package/lib/managers/docker.js +79 -0
  75. package/lib/parsers/largeJson.js +28 -11
  76. package/lib/parsers/vscodeExtensionId.js +183 -0
  77. package/lib/server/server.js +12 -1
  78. package/lib/stages/postgen/ruleEngine.js +13 -5
  79. package/lib/stages/pregen/envAudit.js +1 -0
  80. package/lib/stages/pregen/pregen.js +114 -57
  81. package/lib/validator/bomValidator.js +6 -1
  82. package/lib/validator/generated/validate-1.6.mjs +1 -1
  83. package/lib/validator/generated/validate-1.7.mjs +1 -1
  84. package/lib/validator/generated/validate-2.0.mjs +1 -1
  85. package/lib/validator/index.js +1 -1
  86. package/package.json +3 -2
  87. package/types/lib/cli/bomAssembly.d.ts +13 -1
  88. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  89. package/types/lib/cli/index.d.ts.map +1 -1
  90. package/types/lib/cli/jsBom.d.ts +1 -17
  91. package/types/lib/cli/jsBom.d.ts.map +1 -1
  92. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  93. package/types/lib/cli/managedBom.d.ts.map +1 -1
  94. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  95. package/types/lib/core/boundedFile.d.ts +70 -0
  96. package/types/lib/core/boundedFile.d.ts.map +1 -0
  97. package/types/lib/core/brotliProbe.d.ts +37 -0
  98. package/types/lib/core/brotliProbe.d.ts.map +1 -0
  99. package/types/lib/core/env.d.ts +4 -0
  100. package/types/lib/core/env.d.ts.map +1 -1
  101. package/types/lib/core/fs.d.ts.map +1 -1
  102. package/types/lib/core/httpClient.d.ts +24 -0
  103. package/types/lib/core/httpClient.d.ts.map +1 -1
  104. package/types/lib/core/lockPaths.d.ts +19 -0
  105. package/types/lib/core/lockPaths.d.ts.map +1 -0
  106. package/types/lib/core/processTree.d.ts +2 -0
  107. package/types/lib/core/processTree.d.ts.map +1 -1
  108. package/types/lib/core/toolRegistry.d.ts +36 -0
  109. package/types/lib/core/toolRegistry.d.ts.map +1 -0
  110. package/types/lib/ecosystems/cppEvidence.d.ts +40 -13
  111. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  112. package/types/lib/ecosystems/cppIncludeResolver.d.ts +2 -1
  113. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -1
  114. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  115. package/types/lib/ecosystems/gemHostProbe.d.ts +28 -0
  116. package/types/lib/ecosystems/gemHostProbe.d.ts.map +1 -0
  117. package/types/lib/ecosystems/gradleutils.d.ts +47 -13
  118. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  119. package/types/lib/ecosystems/parsers-bazel.d.ts.map +1 -1
  120. package/types/lib/ecosystems/parsers-js.d.ts +17 -20
  121. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  122. package/types/lib/ecosystems/parsers-misc.d.ts +19 -0
  123. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  124. package/types/lib/ecosystems/parsers-python.d.ts +10 -0
  125. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  126. package/types/lib/ecosystems/pythonutils.d.ts +20 -2
  127. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  128. package/types/lib/ecosystems/rubyutils.d.ts +144 -12
  129. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  130. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -1
  131. package/types/lib/ecosystems/vendoredCode.d.ts +26 -0
  132. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -1
  133. package/types/lib/ecosystems/venvProbe.d.ts +40 -0
  134. package/types/lib/ecosystems/venvProbe.d.ts.map +1 -0
  135. package/types/lib/evinser/evinser.d.ts +63 -8
  136. package/types/lib/evinser/evinser.d.ts.map +1 -1
  137. package/types/lib/inventory/atomUtils.d.ts +19 -0
  138. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  139. package/types/lib/inventory/brotliDecoder.d.ts +58 -0
  140. package/types/lib/inventory/brotliDecoder.d.ts.map +1 -0
  141. package/types/lib/inventory/cbomutils.d.ts +17 -0
  142. package/types/lib/inventory/cbomutils.d.ts.map +1 -1
  143. package/types/lib/inventory/deps.d.ts.map +1 -1
  144. package/types/lib/inventory/dosai.d.ts +17 -1
  145. package/types/lib/inventory/dosai.d.ts.map +1 -1
  146. package/types/lib/inventory/dotnetRuntime.d.ts +44 -0
  147. package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -0
  148. package/types/lib/inventory/envcontext.d.ts +52 -6
  149. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  150. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  151. package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
  152. package/types/lib/inventory/gtfobins.d.ts.map +1 -1
  153. package/types/lib/inventory/hostRuntimeProbe.d.ts +44 -0
  154. package/types/lib/inventory/hostRuntimeProbe.d.ts.map +1 -0
  155. package/types/lib/inventory/jvmToolEnv.d.ts +12 -0
  156. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
  157. package/types/lib/inventory/licenseEnhancer.d.ts.map +1 -1
  158. package/types/lib/inventory/mavenCentralMirror.d.ts +170 -0
  159. package/types/lib/inventory/mavenCentralMirror.d.ts.map +1 -0
  160. package/types/lib/inventory/nodeBuildInfo.d.ts +95 -0
  161. package/types/lib/inventory/nodeBuildInfo.d.ts.map +1 -0
  162. package/types/lib/inventory/nodeHostProbe.d.ts +15 -0
  163. package/types/lib/inventory/nodeHostProbe.d.ts.map +1 -0
  164. package/types/lib/inventory/osPackageResolver.d.ts +38 -0
  165. package/types/lib/inventory/osPackageResolver.d.ts.map +1 -1
  166. package/types/lib/inventory/phpBuildInfo.d.ts.map +1 -1
  167. package/types/lib/inventory/phpHostProbe.d.ts.map +1 -1
  168. package/types/lib/inventory/rubyBuildInfo.d.ts +271 -0
  169. package/types/lib/inventory/rubyBuildInfo.d.ts.map +1 -0
  170. package/types/lib/inventory/rubyHostProbe.d.ts +47 -0
  171. package/types/lib/inventory/rubyHostProbe.d.ts.map +1 -0
  172. package/types/lib/inventory/source.d.ts +10 -0
  173. package/types/lib/inventory/source.d.ts.map +1 -1
  174. package/types/lib/managers/binary.d.ts.map +1 -1
  175. package/types/lib/managers/docker.d.ts +12 -0
  176. package/types/lib/managers/docker.d.ts.map +1 -1
  177. package/types/lib/parsers/largeJson.d.ts +4 -3
  178. package/types/lib/parsers/largeJson.d.ts.map +1 -1
  179. package/types/lib/server/server.d.ts.map +1 -1
  180. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  181. package/types/lib/stages/pregen/pregen.d.ts +12 -8
  182. package/types/lib/stages/pregen/pregen.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,12 +16,12 @@ 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";
@@ -147,22 +155,25 @@ function scalasemTimeoutMs(options = {}) {
147
155
  const TIMEOUT_GRACE_MS = 30_000;
148
156
 
149
157
  // The build files that identify a Scala project, for a source argument that
150
- // 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/.
151
160
  const SCALA_BUILD_MARKERS = [
152
161
  "build.sbt",
153
162
  "build.sc",
154
163
  "build.mill",
155
164
  "pom.xml",
156
165
  "project.scala",
166
+ join("project", "build.properties"),
157
167
  ];
158
168
 
159
169
  /**
160
170
  * Where the scalasem report is persisted. An absolute --semantics-slices-file
161
171
  * keeps its directory; anything else lands in the evinse output directory, the
162
- * 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.
163
174
  *
164
175
  * @param {Object} options CLI options carrying `output` and `semanticsSlicesFile`.
165
- * @returns {string} Absolute report path.
176
+ * @returns {string|undefined} Absolute report path.
166
177
  */
167
178
  export function scalasemOutputFile(options = {}) {
168
179
  const requested = options.semanticsSlicesFile || "semantics.slices.json";
@@ -171,21 +182,18 @@ export function scalasemOutputFile(options = {}) {
171
182
  if (isAbsolute(requested)) {
172
183
  return requested;
173
184
  }
174
- let outputDir;
175
- if (options.output) {
176
- const resolvedOutput = resolve(options.output);
177
- let isDir = false;
178
- try {
179
- isDir =
180
- safeExistsSync(resolvedOutput) &&
181
- lstatSync(resolvedOutput).isDirectory();
182
- } catch (_err) {
183
- isDir = false;
184
- }
185
- outputDir = isDir ? resolvedOutput : dirname(resolvedOutput);
186
- } else {
187
- 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;
188
195
  }
196
+ const outputDir = isDir ? resolvedOutput : dirname(resolvedOutput);
189
197
  return join(outputDir, basename(requested));
190
198
  }
191
199
 
@@ -224,6 +232,18 @@ export function isV1SemanticsSlice(parsed) {
224
232
  return Object.keys(parsed).some((key) => key.endsWith(".scala"));
225
233
  }
226
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
+
227
247
  /**
228
248
  * Whether a version 1 slice describes this project: it names no project, so
229
249
  * every source file it lists must exist under the project directory.
@@ -234,15 +254,104 @@ function isV1SliceOfProject(slice, projectDir) {
234
254
  files.length > 0 &&
235
255
  files.every((file) => {
236
256
  const path = resolve(projectDir, file);
237
- return path.startsWith(`${projectDir}${sep}`) && safeExistsSync(path);
257
+ return isInside(projectDir, path) && safeExistsSync(path);
238
258
  })
239
259
  );
240
260
  }
241
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
+
242
350
  /**
243
351
  * Run scalasem over a Scala project and return its report, reusing a
244
- * persisted version 2 report that belongs to this project and is newer than
245
- * 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.
246
355
  *
247
356
  * Failures are visible rather than silent: the reason is printed once, the
248
357
  * run's diagnostics reach the caller as `cdx:scalasem:diagnostic` properties,
@@ -280,10 +389,18 @@ export function analyzeScalaProject(src, options = {}) {
280
389
  for (const candidate of candidates) {
281
390
  const existing = readScalasemReport(candidate);
282
391
  if (existing?._meta?.schemaVersion === "scalasem/2") {
283
- if (
284
- resolve(existing._meta.projectPath || "") === analysisDir &&
285
- isNewerThan(candidate, options.input)
286
- ) {
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) {
287
404
  if (DEBUG_MODE) {
288
405
  console.log(`Reusing the scalasem report "${candidate}".`);
289
406
  }
@@ -340,13 +457,38 @@ function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
340
457
  // earlier run (or another project's run) had left there, and a report
341
458
  // written within the millisecond the run started passed for fresh.
342
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
+ ) {
343
484
  const runReportFile = join(runDir, "scalasem.slices.json");
344
485
  const args = [scalasem, analysisDir, runReportFile];
486
+ const settings = scalasemRunSettings(options);
345
487
  // The build runs the project's sbt definitions
346
- if (!isProjectToolingAllowed(options)) {
488
+ if (!settings.build) {
347
489
  args.push("--no-build");
348
490
  }
349
- if (options.scalasemIncludeTests && !options.requiredOnly) {
491
+ if (settings.includeTests) {
350
492
  args.push("--include-tests");
351
493
  }
352
494
  if (DEBUG_MODE) {
@@ -357,17 +499,24 @@ function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
357
499
  // scalasem enforces the limit itself, so the builds it started stop with
358
500
  // it; cdxgen's own timeout is the backstop. Naming cdxgen as the supervisor
359
501
  // stops scalasem when cdxgen is killed.
360
- const result = safeSpawnSync(process.execPath, args, {
361
- cwd: analysisDir,
362
- shell: false,
363
- killSignal: "SIGKILL",
364
- timeout: timeoutMs + Math.min(TIMEOUT_GRACE_MS, timeoutMs),
365
- env: {
366
- ...process.env,
367
- ATOM_PARENT_PID: String(process.pid),
368
- SCALASEM_TIMEOUT: String(timeoutMs),
369
- },
370
- });
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
+ }
371
520
  const elapsedMs = Date.now() - startedAt;
372
521
  const wroteReport = safeExistsSync(runReportFile);
373
522
  const written = wroteReport ? readScalasemReport(runReportFile) : undefined;
@@ -431,7 +580,9 @@ function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
431
580
  ),
432
581
  ];
433
582
  }
434
- const summary = summarizeDiagnostics(report._meta.diagnostics);
583
+ const summary = summarizeDiagnostics(
584
+ degradingDiagnostics(report._meta.diagnostics),
585
+ );
435
586
  if (summary) {
436
587
  console.warn(
437
588
  `scalasem: the report is degraded: ${summary}. The cdx:scalasem:diagnostic:* metadata properties of the BOM say where.`,
@@ -443,19 +594,26 @@ function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
443
594
  }
444
595
  // Only a usable report replaces the one at the requested path, and a
445
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.
446
599
  let reportFile;
447
- if (usable && v1File !== requestedFile) {
600
+ if (usable && requestedFile && v1File !== requestedFile) {
448
601
  try {
449
602
  safeMkdirSync(dirname(requestedFile), { recursive: true });
450
- safeCopyFileSync(runReportFile, requestedFile);
451
- 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;
452
611
  } catch (err) {
453
612
  console.warn(
454
613
  `scalasem: the report could not be kept at ${requestedFile} (${err.message}); this run still uses it.`,
455
614
  );
456
615
  }
457
616
  }
458
- safeRmSync(runDir, { recursive: true, force: true });
459
617
  return {
460
618
  report,
461
619
  reportFile,
@@ -464,6 +622,21 @@ function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
464
622
  };
465
623
  }
466
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
+
467
640
  function summarizeDiagnostics(diagnostics = []) {
468
641
  const byCode = new Map();
469
642
  for (const diagnostic of diagnostics || []) {
@@ -490,8 +663,9 @@ function summarizeDiagnostics(diagnostics = []) {
490
663
  */
491
664
  export function scalasemMetadataProperties(
492
665
  report,
493
- diagnostics = report?._meta?.diagnostics || [],
666
+ allDiagnostics = report?._meta?.diagnostics || [],
494
667
  ) {
668
+ const diagnostics = degradingDiagnostics(allDiagnostics);
495
669
  const properties = [];
496
670
  const meta = report?._meta || {};
497
671
  appendUniqueProperty(
@@ -553,20 +727,23 @@ export function sortedCsv(values) {
553
727
 
554
728
  /**
555
729
  * The platform a purl's artifact name targets: `_sjs1` is Scala.js,
556
- * `_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).
557
733
  *
558
734
  * @param {string} purl Package URL.
559
735
  * @returns {"jvm"|"js"|"native"} Platform key.
560
736
  */
561
737
  export function scalaPurlPlatform(purl) {
562
- const name = purlPurlName(purl);
563
- if (!name) {
564
- return "jvm";
565
- }
566
- 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")) {
567
744
  return "js";
568
745
  }
569
- if (/_(native\d+(?:\.\d+)*)$/.test(name)) {
746
+ if (platformSuffix.startsWith("_native")) {
570
747
  return "native";
571
748
  }
572
749
  return "jvm";
@@ -584,6 +761,20 @@ function purlPurlName(purl) {
584
761
  }
585
762
  }
586
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
+
587
778
  /**
588
779
  * The class and package index that joins scalasem symbols to component purls.
589
780
  *
@@ -633,11 +824,22 @@ export class ScalaJoinIndex {
633
824
  if (!symbol || symbol.includes(" ") || symbol.includes("/")) {
634
825
  continue;
635
826
  }
636
- indexed.push(symbol);
637
- 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);
638
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
+ }
639
841
  this.addSymbol(this.classPurls, topLevel, purl, source);
640
- if (dollar > 0) {
842
+ if (dollar > 0 && !synthetic) {
641
843
  const nested = symbol.replaceAll("$", ".").replace(/\.+$/, "");
642
844
  if (nested !== topLevel) {
643
845
  this.addSymbol(this.classPurls, nested, purl, source);
@@ -734,15 +936,25 @@ export class ScalaJoinIndex {
734
936
  * sits in. A package the project also fills is ambiguous and joins nothing,
735
937
  * and `javax.` names join only through an exact class, since the JDK ships
736
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.
737
945
  */
738
946
  packageOwners(symbol) {
739
947
  if (symbol.startsWith(JDK_EXTENSION_PREFIX)) {
740
948
  return undefined;
741
949
  }
742
950
  const parts = symbol.replace(/\$+$/, "").split(".");
743
- for (let i = parts.length; i > 0; i--) {
744
- const name = parts.slice(0, i).join(".");
745
- 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;
746
958
  if (owners?.size) {
747
959
  return this.projectPackages.has(name) ? undefined : owners;
748
960
  }
@@ -875,41 +1087,83 @@ function componentBinaryVersions(component, coordinate) {
875
1087
  );
876
1088
  }
877
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
+
878
1109
  /**
879
1110
  * Index the BOM components by their normalized Scala coordinates, both with
880
1111
  * and without the version, so a classpath jar, a namespace property and a map
881
1112
  * entry of the same library meet on one component. Each entry keeps the Scala
882
- * binary version the component was built for, so the `_2.13` and `_3`
883
- * 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.
884
1120
  */
885
1121
  export function componentCoordinateIndex(components = []) {
886
- const byVersion = new Map();
887
- const withoutVersion = new Map();
1122
+ const groupsByKey = new Map();
888
1123
  for (const component of components) {
889
1124
  const coordinate = scalaCoordinateOfPurl(component?.purl);
890
1125
  if (!coordinate) {
891
1126
  continue;
892
1127
  }
893
- const [type, group, artifactBase, platformSuffix] =
894
- coordinate.key.split(":");
895
- 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 = {
896
1140
  component,
897
- binaryVersions: componentBinaryVersions(component, coordinate),
1141
+ binaryVersions: new Set(binaryVersions),
898
1142
  };
899
- if (!byVersion.has(coordinate.key)) {
900
- 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);
901
1149
  }
902
- byVersion.get(coordinate.key).push(entry);
903
- 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({
904
1158
  type,
905
1159
  group,
906
1160
  artifactBase,
907
1161
  platformSuffix,
908
1162
  });
909
- if (!withoutVersion.has(key)) {
910
- withoutVersion.set(key, []);
911
- }
912
- withoutVersion.get(key).push(entry);
1163
+ withoutVersion.set(versionless, [
1164
+ ...(withoutVersion.get(versionless) || []),
1165
+ ...groups,
1166
+ ]);
913
1167
  }
914
1168
  return { byVersion, withoutVersion };
915
1169
  }
@@ -944,11 +1198,126 @@ function pickByBinaryVersion(entries, binaryVersion) {
944
1198
  * @returns {{component: Object, exact: boolean}|undefined} The component, and
945
1199
  * whether its version is the one on the classpath.
946
1200
  */
947
- export function matchClasspathEntry(
948
- entry,
949
- { byVersion, withoutVersion },
950
- platform,
951
- ) {
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) {
952
1321
  const parsed = parseScalaArtifact(entry?.artifact);
953
1322
  const parts = {
954
1323
  group: entry?.group,
@@ -963,7 +1332,7 @@ export function matchClasspathEntry(
963
1332
  return { component: exact[0].component, exact: true };
964
1333
  }
965
1334
  if (exact.length > 1) {
966
- return undefined;
1335
+ return { ambiguous: true };
967
1336
  }
968
1337
  const candidates = pickByBinaryVersion(
969
1338
  withoutVersion.get(scalaCoordinateKey(parts)),
@@ -973,9 +1342,10 @@ export function matchClasspathEntry(
973
1342
  (candidate) => scalaPurlPlatform(candidate.component.purl) === platform,
974
1343
  );
975
1344
  const pool = matching.length ? matching : candidates;
976
- return pool.length === 1
977
- ? { component: pool[0].component, exact: false }
978
- : undefined;
1345
+ if (pool.length === 1) {
1346
+ return { component: pool[0].component, exact: false };
1347
+ }
1348
+ return pool.length > 1 ? { ambiguous: true } : undefined;
979
1349
  }
980
1350
 
981
1351
  /**
@@ -1043,8 +1413,14 @@ export async function buildScalaJoinIndex(report, components, jarNSMapping) {
1043
1413
  }
1044
1414
  }
1045
1415
  for (const mapPurl of Object.keys(jarNSMapping || {})) {
1046
- const entry = jarNSMapping[mapPurl] || {};
1047
- 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) {
1048
1424
  continue;
1049
1425
  }
1050
1426
  const segments = /^pkg:[^/]+\/(?:([^/@?]+)\/)?([^@?]+)@([^?#]*)/.exec(
@@ -1063,7 +1439,7 @@ export async function buildScalaJoinIndex(report, components, jarNSMapping) {
1063
1439
  scalaPurlPlatform(mapPurl),
1064
1440
  );
1065
1441
  if (matched) {
1066
- index.addNamespaces(matched.component.purl, entry.namespaces, 3);
1442
+ index.addNamespaces(matched.component.purl, namespaces, 3);
1067
1443
  }
1068
1444
  }
1069
1445
  return index;
@@ -1310,10 +1686,11 @@ export function scalasemCryptoOidKey(name, keySize, mode, curve, padding) {
1310
1686
  return undefined;
1311
1687
  }
1312
1688
  if (SCALASEM_AES_NAMES.has(name)) {
1689
+ const upperMode = mode?.toUpperCase();
1313
1690
  const modeKey =
1314
- SCALASEM_AES_MODE_KEYS[mode] ||
1691
+ SCALASEM_AES_MODE_KEYS[upperMode] ||
1315
1692
  SCALASEM_AES_MODE_KEYS[name.split("-")[1]];
1316
- const wrap = name === "AES-KW" || mode === "KW";
1693
+ const wrap = name === "AES-KW" || upperMode === "KW";
1317
1694
  if (keySize && (modeKey !== undefined || wrap)) {
1318
1695
  const sizedKey = wrap ? `aes${keySize}-wrap` : `aes${keySize}-${modeKey}`;
1319
1696
  const hit = SCALASEM_OID_KEYS.get(sizedKey.toLowerCase());
@@ -1456,6 +1833,7 @@ export async function collectScalasemEvidence(
1456
1833
  componentPropertiesMap,
1457
1834
  metadataProperties,
1458
1835
  options,
1836
+ allowedPurlsByModule,
1459
1837
  );
1460
1838
  addJsModuleEvidence(
1461
1839
  report,
@@ -1788,7 +2166,17 @@ function stackTargetPurls(sink, fileEntry, joinIndex, platform, allowed) {
1788
2166
  return [...purls];
1789
2167
  }
1790
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.
1791
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
+ }
1792
2180
  const text = String(className || "").replace(/\$+$/, "");
1793
2181
  const dot = text.lastIndexOf(".");
1794
2182
  return dot > 0 ? text.slice(0, dot) : "";
@@ -1848,9 +2236,22 @@ function providerAliases(purl, components = []) {
1848
2236
  /**
1849
2237
  * The component purl of the library a crypto finding names as its provider.
1850
2238
  * Maven coordinates match on group and base artifact; the short aliases cover
1851
- * 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.
1852
2249
  */
1853
- export function scalasemProviderPurl(provider, components = []) {
2250
+ export function scalasemProviderPurl(
2251
+ provider,
2252
+ components = [],
2253
+ classpathPurls = undefined,
2254
+ ) {
1854
2255
  if (!provider) {
1855
2256
  return undefined;
1856
2257
  }
@@ -1865,6 +2266,7 @@ export function scalasemProviderPurl(provider, components = []) {
1865
2266
  return undefined;
1866
2267
  }
1867
2268
  const base = parseScalaArtifact(artifactPrefix).artifactBase;
2269
+ const matches = [];
1868
2270
  for (const component of components) {
1869
2271
  if (!component?.purl) {
1870
2272
  continue;
@@ -1877,10 +2279,23 @@ export function scalasemProviderPurl(provider, components = []) {
1877
2279
  }
1878
2280
  const name = parseScalaArtifact(decodeSafe(segments[2])).artifactBase;
1879
2281
  if (name === base || name.startsWith(`${base}-`)) {
1880
- return component.purl;
2282
+ matches.push({ purl: component.purl, name });
1881
2283
  }
1882
2284
  }
1883
- 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;
1884
2299
  }
1885
2300
 
1886
2301
  // Primitives a mode of operation applies to. RSA spells `RSA/ECB/...` in JCA,
@@ -1965,6 +2380,7 @@ function addCryptoEvidence(
1965
2380
  componentPropertiesMap,
1966
2381
  metadataProperties,
1967
2382
  options = {},
2383
+ allowedPurlsByModule = new Map(),
1968
2384
  ) {
1969
2385
  const includeTests = options.scalasemIncludeTests === true;
1970
2386
  const nativeLibraryBySymbol = new Map();
@@ -1979,11 +2395,17 @@ function addCryptoEvidence(
1979
2395
  if (scope === "test" && !includeTests) {
1980
2396
  continue;
1981
2397
  }
1982
- const location = `${finding.file || ""}#${finding.line || 0}`;
2398
+ const location = finding.line
2399
+ ? `${finding.file || ""}#${finding.line}`
2400
+ : finding.file || "";
1983
2401
  const nativeLibrary = finding.kind?.startsWith("native")
1984
2402
  ? nativeLibraryBySymbol.get(finding.name)
1985
2403
  : undefined;
1986
- const providerPurl = scalasemProviderPurl(finding.provider, components);
2404
+ const providerPurl = scalasemProviderPurl(
2405
+ finding.provider,
2406
+ components,
2407
+ allowedPurlsFor(report[finding.file], allowedPurlsByModule),
2408
+ );
1987
2409
  const asset =
1988
2410
  finding.resolution === "unresolved"
1989
2411
  ? undefined
@@ -2054,12 +2476,18 @@ function addCryptoEvidence(
2054
2476
  }
2055
2477
  }
2056
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.
2057
2481
  function parseLocationString(location) {
2058
2482
  const separator = location.lastIndexOf("#");
2059
- 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);
2060
2488
  return {
2061
2489
  location: location.slice(0, separator),
2062
- ...(Number.isFinite(line) && line > 0 ? { line } : {}),
2490
+ ...(line > 0 ? { line } : {}),
2063
2491
  };
2064
2492
  }
2065
2493
 
@@ -2187,6 +2615,191 @@ function cryptoAssetProperties(finding, nativeLibrary) {
2187
2615
  return properties;
2188
2616
  }
2189
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
+
2190
2803
  /**
2191
2804
  * A JDBC address without what may carry credentials: userinfo, the Oracle
2192
2805
  * `user/password@` prefix, URL parameters and the `;key=value` properties
@@ -2197,49 +2810,58 @@ function cryptoAssetProperties(finding, nativeLibrary) {
2197
2810
  * @returns {string} The address part.
2198
2811
  */
2199
2812
  export function sanitizeJdbcUrl(value) {
2200
- let text = String(value || "").trim();
2201
- if (!/^jdbc:/i.test(text)) {
2202
- return text;
2203
- }
2204
- text = `jdbc:${text.slice(5)}`;
2205
- for (const separator of ["?", "#", ";"]) {
2206
- const index = text.indexOf(separator);
2207
- if (index >= 0) {
2208
- text = text.slice(0, index);
2209
- }
2210
- }
2211
- const slashes = text.indexOf("//");
2212
- if (slashes >= 0) {
2213
- const authorityEnd = text.indexOf("/", slashes + 2);
2214
- const authority = text.slice(
2215
- slashes + 2,
2216
- authorityEnd >= 0 ? authorityEnd : undefined,
2217
- );
2218
- const at = authority.lastIndexOf("@");
2219
- if (at >= 0) {
2220
- text =
2221
- text.slice(0, slashes + 2) +
2222
- authority.slice(at + 1) +
2223
- (authorityEnd >= 0 ? text.slice(authorityEnd) : "");
2224
- }
2225
- }
2226
- // Oracle thin: jdbc:oracle:thin:user/password@host:port:sid
2227
- const thin = /^(jdbc:oracle:thin:)[^@]*@/i.exec(text);
2228
- if (thin) {
2229
- 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;
2230
2822
  }
2231
- return text;
2823
+ return host.split(":")[0] || undefined;
2232
2824
  }
2233
2825
 
2234
2826
  function jdbcServiceName(value) {
2235
- const address = sanitizeJdbcUrl(value);
2236
- const scheme = address.slice(5).split(":", 1)[0].toLowerCase() || "jdbc";
2237
- let host = /\/\/([^/:]+)/.exec(address)?.[1];
2238
- if (!host) {
2239
- // Oracle thin and similar: the host follows the @.
2240
- 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.
2241
2863
  }
2242
- return [scheme, host].filter(Boolean).join("-");
2864
+ return bareHost(withoutUserinfo);
2243
2865
  }
2244
2866
 
2245
2867
  /**
@@ -2259,13 +2881,11 @@ export function scalasemServiceName(service) {
2259
2881
  return jdbcServiceName(value);
2260
2882
  }
2261
2883
  if (service?.host) {
2262
- return service.host.split(":")[0];
2263
- }
2264
- try {
2265
- return new URL(value).hostname || undefined;
2266
- } catch (_err) {
2267
- return undefined;
2884
+ const host = bareHost(service.host);
2885
+ return host ? hostName(host) : undefined;
2268
2886
  }
2887
+ const host = urlHost(value);
2888
+ return host ? hostName(host) : undefined;
2269
2889
  }
2270
2890
 
2271
2891
  /**
@@ -2287,15 +2907,12 @@ export function scalasemServiceEndpoint(service) {
2287
2907
  return sanitizeJdbcUrl(value);
2288
2908
  }
2289
2909
  if (service?.host) {
2290
- return service.host;
2291
- }
2292
- try {
2293
- const parsed = new URL(value);
2294
- return `${parsed.protocol}//${parsed.host}`;
2295
- } catch (_err) {
2296
- const hostMatch = /:\/\/([^/?#@]+@)?([^/?#]+)/.exec(value);
2297
- return hostMatch ? `${value.split("://")[0]}://${hostMatch[2]}` : undefined;
2910
+ return bareHost(service.host);
2298
2911
  }
2912
+ const host = urlHost(value);
2913
+ return host
2914
+ ? `${value.slice(0, value.indexOf("://")).toLowerCase()}://${host}`
2915
+ : undefined;
2299
2916
  }
2300
2917
 
2301
2918
  function serviceLocation(service) {
@@ -2459,7 +3076,10 @@ export async function collectScalaJsNpmComponents(
2459
3076
  ) {
2460
3077
  const isScalaJs = pkgList.some((pkg) => {
2461
3078
  const name = pkg?.name || purlPurlName(pkg?.purl) || "";
2462
- return /_(sjs\d+(?:\.\d+)?)$/.test(name) || name === "scalajs-library";
3079
+ return (
3080
+ scalaNamePlatform(name) === "js" ||
3081
+ parseScalaArtifact(name).artifactBase === "scalajs-library"
3082
+ );
2463
3083
  });
2464
3084
  const empty = { components: [], roots: [], dependencies: [] };
2465
3085
  if (!isScalaJs) {
@@ -2548,6 +3168,24 @@ function npmNameFromPurl(purl) {
2548
3168
  }
2549
3169
  }
2550
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
+
2551
3189
  /**
2552
3190
  * Attribute the report's JavaScript module imports to the npm components the
2553
3191
  * build bundles. A module no component owns is reported on the project
@@ -2564,10 +3202,17 @@ function addJsModuleEvidence(
2564
3202
  if (!jsModules.length) {
2565
3203
  return;
2566
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.
2567
3208
  const npmByName = new Map();
2568
3209
  for (const component of components) {
2569
3210
  const name = npmNameFromPurl(component?.purl);
2570
- if (name) {
3211
+ if (!name) {
3212
+ continue;
3213
+ }
3214
+ const known = npmByName.get(name);
3215
+ if (!known || isPreferredNpmCopy(component, known)) {
2571
3216
  npmByName.set(name, component);
2572
3217
  }
2573
3218
  }