@cdxgen/cdxgen 13.2.0 → 13.3.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 (158) hide show
  1. package/README.md +28 -12
  2. package/bin/cdxgen.js +227 -119
  3. package/bin/evinse.js +5 -0
  4. package/bin/sign.js +88 -14
  5. package/bin/tracebom.js +64 -4
  6. package/bin/validate.js +29 -7
  7. package/bin/verify.js +79 -39
  8. package/data/remediations.json +24 -0
  9. package/lib/cli/bomAssembly.js +105 -15
  10. package/lib/cli/cliOptions.js +93 -1
  11. package/lib/cli/index.js +43 -4
  12. package/lib/cli/jsBom.js +49 -24
  13. package/lib/cli/jvmBom.js +46 -12
  14. package/lib/cli/managedBom.js +112 -41
  15. package/lib/cli/nativeBom.js +125 -14
  16. package/lib/core/env.js +5 -6
  17. package/lib/core/fs.js +174 -6
  18. package/lib/core/metadataProperties.js +97 -0
  19. package/lib/core/projectConfig.js +31 -4
  20. package/lib/ecosystems/bunutils.js +269 -101
  21. package/lib/ecosystems/caxa.js +434 -2
  22. package/lib/ecosystems/cmakeResolver.js +66 -0
  23. package/lib/ecosystems/cppEvidence.js +64 -31
  24. package/lib/ecosystems/ecosystems.js +19 -8
  25. package/lib/ecosystems/gradleutils.js +42 -7
  26. package/lib/ecosystems/jsEvidence.js +19 -20
  27. package/lib/ecosystems/kosi.js +10 -3
  28. package/lib/ecosystems/npmutils.js +110 -9
  29. package/lib/ecosystems/parsers-dotnet.js +51 -10
  30. package/lib/ecosystems/parsers-go.js +47 -9
  31. package/lib/ecosystems/parsers-js.js +171 -50
  32. package/lib/ecosystems/parsers-misc.js +747 -161
  33. package/lib/ecosystems/parsers-python.js +26 -14
  34. package/lib/ecosystems/parsers-rust.js +24 -18
  35. package/lib/ecosystems/rubyutils.js +1426 -166
  36. package/lib/ecosystems/rusi.js +32 -1
  37. package/lib/evinser/evinser.js +436 -128
  38. package/lib/evinser/swiftsem.js +1914 -302
  39. package/lib/helpers/bomSigner.js +652 -221
  40. package/lib/inventory/atomUtils.js +568 -31
  41. package/lib/inventory/cdxrs.js +103 -12
  42. package/lib/inventory/depsUtils.js +183 -11
  43. package/lib/inventory/display.js +4 -5
  44. package/lib/inventory/dosai.js +550 -13
  45. package/lib/inventory/dynamic.js +39 -1
  46. package/lib/inventory/evidenceUtils.js +14 -6
  47. package/lib/inventory/fetchBatch.js +116 -17
  48. package/lib/inventory/protobom.js +8 -1
  49. package/lib/inventory/purl.js +152 -3
  50. package/lib/inventory/sbomDocument.js +2 -70
  51. package/lib/inventory/source.js +37 -19
  52. package/lib/inventory/traceRunner.js +210 -21
  53. package/lib/managers/binary.js +115 -24
  54. package/lib/managers/docker.js +440 -20
  55. package/lib/managers/oci.js +39 -1
  56. package/lib/parsers/cmakeLists.js +452 -0
  57. package/lib/parsers/largeJson.js +454 -0
  58. package/lib/parsers/npmrc.js +19 -1
  59. package/lib/stages/postgen/postgen.js +68 -15
  60. package/lib/validator/bomValidator.js +6 -16
  61. package/lib/validator/index.js +48 -10
  62. package/lib/validator/reporters/console.js +3 -0
  63. package/lib/validator/reporters/json.js +1 -0
  64. package/lib/validator/reporters/sarif.js +7 -0
  65. package/package.json +20 -20
  66. package/types/lib/cli/bomAssembly.d.ts +2 -1
  67. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  68. package/types/lib/cli/cliOptions.d.ts +33 -1
  69. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  70. package/types/lib/cli/index.d.ts.map +1 -1
  71. package/types/lib/cli/jsBom.d.ts.map +1 -1
  72. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  73. package/types/lib/cli/managedBom.d.ts.map +1 -1
  74. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  75. package/types/lib/core/env.d.ts.map +1 -1
  76. package/types/lib/core/fs.d.ts +55 -0
  77. package/types/lib/core/fs.d.ts.map +1 -1
  78. package/types/lib/core/metadataProperties.d.ts +39 -0
  79. package/types/lib/core/metadataProperties.d.ts.map +1 -0
  80. package/types/lib/core/projectConfig.d.ts +6 -2
  81. package/types/lib/core/projectConfig.d.ts.map +1 -1
  82. package/types/lib/ecosystems/bunutils.d.ts +12 -5
  83. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  84. package/types/lib/ecosystems/caxa.d.ts +14 -0
  85. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  86. package/types/lib/ecosystems/cmakeResolver.d.ts +23 -0
  87. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  88. package/types/lib/ecosystems/cppEvidence.d.ts +1 -1
  89. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  90. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  91. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  92. package/types/lib/ecosystems/gradleutils.d.ts +20 -4
  93. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  94. package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
  95. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  96. package/types/lib/ecosystems/kosi.d.ts +13 -0
  97. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  98. package/types/lib/ecosystems/npmutils.d.ts +42 -0
  99. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  100. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  101. package/types/lib/ecosystems/parsers-go.d.ts +10 -0
  102. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  103. package/types/lib/ecosystems/parsers-js.d.ts +10 -4
  104. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  105. package/types/lib/ecosystems/parsers-misc.d.ts +67 -3
  106. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  107. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  108. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  109. package/types/lib/ecosystems/pythonutils.d.ts +1 -1
  110. package/types/lib/ecosystems/rubyutils.d.ts +143 -2
  111. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  112. package/types/lib/ecosystems/rusi.d.ts +13 -0
  113. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  114. package/types/lib/evinser/evinser.d.ts +59 -9
  115. package/types/lib/evinser/evinser.d.ts.map +1 -1
  116. package/types/lib/evinser/swiftsem.d.ts +280 -15
  117. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  118. package/types/lib/helpers/bomSigner.d.ts +106 -13
  119. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  120. package/types/lib/inventory/atomUtils.d.ts +145 -3
  121. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  122. package/types/lib/inventory/cdxrs.d.ts +8 -3
  123. package/types/lib/inventory/cdxrs.d.ts.map +1 -1
  124. package/types/lib/inventory/depsUtils.d.ts +15 -0
  125. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  126. package/types/lib/inventory/display.d.ts.map +1 -1
  127. package/types/lib/inventory/dosai.d.ts +64 -1
  128. package/types/lib/inventory/dosai.d.ts.map +1 -1
  129. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  130. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  131. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  132. package/types/lib/inventory/plugins.d.ts.map +1 -1
  133. package/types/lib/inventory/protobom.d.ts.map +1 -1
  134. package/types/lib/inventory/purl.d.ts +71 -2
  135. package/types/lib/inventory/purl.d.ts.map +1 -1
  136. package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
  137. package/types/lib/inventory/source.d.ts.map +1 -1
  138. package/types/lib/inventory/traceRunner.d.ts +47 -1
  139. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  140. package/types/lib/managers/binary.d.ts +30 -1
  141. package/types/lib/managers/binary.d.ts.map +1 -1
  142. package/types/lib/managers/docker.d.ts +29 -0
  143. package/types/lib/managers/docker.d.ts.map +1 -1
  144. package/types/lib/managers/oci.d.ts +14 -0
  145. package/types/lib/managers/oci.d.ts.map +1 -1
  146. package/types/lib/parsers/cmakeLists.d.ts +76 -0
  147. package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
  148. package/types/lib/parsers/largeJson.d.ts +52 -0
  149. package/types/lib/parsers/largeJson.d.ts.map +1 -0
  150. package/types/lib/parsers/npmrc.d.ts +5 -0
  151. package/types/lib/parsers/npmrc.d.ts.map +1 -1
  152. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  153. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  154. package/types/lib/validator/index.d.ts +10 -1
  155. package/types/lib/validator/index.d.ts.map +1 -1
  156. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  157. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  158. package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
@@ -1,4 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { platform } from "node:os";
3
2
  import { extname } from "node:path";
4
3
 
@@ -6,7 +5,10 @@ import { build } from "@cdxgen/cdx-purl";
6
5
 
7
6
  import { safeExistsSync } from "../core/fs.js";
8
7
  import { thoughtLog } from "../core/logger.js";
9
- import { addDosaiReachabilityProperties } from "./dosai.js";
8
+ import {
9
+ addDosaiReachabilityProperties,
10
+ readDosaiMethodsReport,
11
+ } from "./dosai.js";
10
12
  import {
11
13
  addDosaiSetValue,
12
14
  buildDosaiPurlAliasMap,
@@ -275,10 +277,16 @@ export function addEvidenceForDotnet(pkgList, slicesFile) {
275
277
  });
276
278
  }
277
279
  }
278
- let slicesData;
279
- try {
280
- slicesData = JSON.parse(readFileSync(slicesFile, "utf-8"));
281
- } catch (_err) {
280
+ // Only MethodCalls into a package .dll produce evidence below, so a report
281
+ // too large to parse whole keeps just those.
282
+ const slicesData = readDosaiMethodsReport(slicesFile, {
283
+ keepMethodCall: (methodCall) =>
284
+ Boolean(
285
+ methodCall?.Module?.endsWith(".dll") &&
286
+ pkgFilePurlMap[methodCall.Module],
287
+ ),
288
+ });
289
+ if (!slicesData) {
282
290
  return pkgList;
283
291
  }
284
292
  if (slicesData && Object.keys(slicesData)) {
@@ -300,18 +300,126 @@ export function isSingleGateHost(host) {
300
300
  return isCratesHost(host) || host.toLowerCase() === "package.elm-lang.org";
301
301
  }
302
302
 
303
+ /**
304
+ * Most URLs sent to one `cdxrs fetch` run. The envelope comes back as a single
305
+ * JSON string and V8 caps strings at ~512 MB, while a full npm packument is a
306
+ * few hundred KB on average and tens of MB for the largest packages. One run
307
+ * per workspace therefore outgrew the limit on large pnpm workspaces (issue
308
+ * 4393); runs of this size stay far below it for any realistic mix.
309
+ */
310
+ const RUST_FETCH_CHUNK_SIZE = 250;
311
+
303
312
  /**
304
313
  * Run the batch through the `cdxrs fetch` subprocess and translate its
305
314
  * envelope into the shared result shape.
306
315
  *
316
+ * The URLs go out in chunks of {@link RUST_FETCH_CHUNK_SIZE}, one run at a time
317
+ * so each host still sees a single rate gate. A chunk whose envelope is still
318
+ * too large to deliver is split in half and retried, which is cheap because
319
+ * the first attempt filled the cdxrs disk cache. A chunk that fails for any
320
+ * other reason falls back to the JS pool on its own, so one bad run never
321
+ * costs the rest of the batch its Rust fetch.
322
+ *
307
323
  * @param {Array<{url: string, accept?: string, authRealm?: string}>} unique
308
324
  * Already deduplicated by URL.
309
325
  * @param {Object} options Options with `timeoutMs`.
310
326
  * @returns {Promise<Map<string, BatchEntry>>}
311
327
  */
312
328
  async function rustBatchFetch(unique, options) {
329
+ const pending = [];
330
+ for (let i = 0; i < unique.length; i += RUST_FETCH_CHUNK_SIZE) {
331
+ pending.push(unique.slice(i, i + RUST_FETCH_CHUNK_SIZE));
332
+ }
333
+ const results = new Map();
334
+ const chunkStats = [];
335
+ while (pending.length) {
336
+ const chunk = pending.shift();
337
+ const outcome = await rustFetchChunk(chunk, options);
338
+ if (outcome.reason === "stdout-too-large" && chunk.length > 1) {
339
+ const middle = Math.ceil(chunk.length / 2);
340
+ pending.unshift(chunk.slice(0, middle), chunk.slice(middle));
341
+ continue;
342
+ }
343
+ if (!outcome.results) {
344
+ // Not "binary absent" (that never reaches here), so fall through to the
345
+ // JS pool rather than returning nothing: a transient cdxrs crash must
346
+ // not take this chunk's concurrency with it.
347
+ for (const [url, entry] of await jsBatchFetch(chunk)) {
348
+ results.set(url, entry);
349
+ }
350
+ chunkStats.push(_lastStats);
351
+ continue;
352
+ }
353
+ for (const [url, entry] of outcome.results) {
354
+ results.set(url, entry);
355
+ }
356
+ chunkStats.push(outcome.stats);
357
+ }
358
+ _lastStats = combineBatchStats(chunkStats);
359
+ if (DEBUG_MODE && _lastStats) {
360
+ const s = _lastStats;
361
+ console.log(
362
+ `cdxrs fetch: ${s.requests} url(s), ${s.unique} unique, ${s.ok} ok, ${s.failures} failed, ${s.cacheHits} cached, peak concurrency ${s.peakConcurrency}, ${s.elapsedMs} ms`,
363
+ );
364
+ }
365
+ return results;
366
+ }
367
+
368
+ /**
369
+ * Add up the stats of the runs that served one batch. Numeric fields sum
370
+ * because the runs are sequential, except peak concurrency, which is the
371
+ * widest run. Per-host entries under `hosts` are combined the same way.
372
+ *
373
+ * @param {Array<Object|null|undefined>} parts Stats from each run.
374
+ * @returns {Object|null} Combined stats, or null when no run reported any.
375
+ */
376
+ function combineBatchStats(parts) {
377
+ const reported = parts.filter(Boolean);
378
+ if (!reported.length) {
379
+ return null;
380
+ }
381
+ if (reported.length === 1) {
382
+ return reported[0];
383
+ }
384
+ const combined = addStats({}, reported);
385
+ const hosts = {};
386
+ for (const part of reported) {
387
+ for (const [host, entry] of Object.entries(part.hosts || {})) {
388
+ hosts[host] = addStats(hosts[host] || {}, [entry]);
389
+ }
390
+ }
391
+ if (Object.keys(hosts).length) {
392
+ combined.hosts = hosts;
393
+ }
394
+ return combined;
395
+ }
396
+
397
+ function addStats(target, parts) {
398
+ for (const part of parts) {
399
+ for (const [key, value] of Object.entries(part)) {
400
+ if (typeof value !== "number") {
401
+ continue;
402
+ }
403
+ target[key] =
404
+ key === "peakConcurrency"
405
+ ? Math.max(target[key] || 0, value)
406
+ : (target[key] || 0) + value;
407
+ }
408
+ }
409
+ return target;
410
+ }
411
+
412
+ /**
413
+ * Run one chunk through `cdxrs fetch`.
414
+ *
415
+ * @param {Array<{url: string, accept?: string, authRealm?: string}>} chunk
416
+ * @param {Object} options Options with `timeoutMs`.
417
+ * @returns {Promise<{results?: Map<string, BatchEntry>, stats?: Object, reason?: string}>}
418
+ * `results` when the run succeeded; otherwise the reason it did not.
419
+ */
420
+ async function rustFetchChunk(chunk, options) {
313
421
  const payload = { requests: [] };
314
- for (const request of unique) {
422
+ for (const request of chunk) {
315
423
  payload.requests.push({
316
424
  id: request.url,
317
425
  url: request.url,
@@ -328,20 +436,19 @@ async function rustBatchFetch(unique, options) {
328
436
  if (!ok) {
329
437
  if (DEBUG_MODE && reason !== "disabled" && reason !== "binary-not-found") {
330
438
  console.log(
331
- `cdxrs fetch unavailable (${reason}); falling back to the JS batch pool.`,
439
+ reason === "stdout-too-large" && chunk.length > 1
440
+ ? `cdxrs fetch output for ${chunk.length} url(s) is too large; retrying in two halves.`
441
+ : `cdxrs fetch unavailable (${reason}); falling back to the JS batch pool.`,
332
442
  );
333
443
  }
334
- // The Rust path failed in a way that is not "binary absent". Fall through
335
- // to the JS pool rather than returning an empty map, so a transient cdxrs
336
- // crash does not take the whole batch's concurrency with it.
337
- return await jsBatchFetch(unique);
444
+ return { reason };
338
445
  }
339
446
 
340
447
  let envelope;
341
448
  try {
342
449
  envelope = JSON.parse(stdout);
343
450
  } catch (_err) {
344
- return await jsBatchFetch(unique);
451
+ return { reason: "malformed-envelope" };
345
452
  }
346
453
  if (envelope?.schemaVersion !== BATCH_SCHEMA_VERSION) {
347
454
  // A binary from a different generation of this protocol is not something
@@ -351,10 +458,9 @@ async function rustBatchFetch(unique, options) {
351
458
  `cdxrs fetch envelope version ${envelope?.schemaVersion} != ${BATCH_SCHEMA_VERSION}; falling back to the JS batch pool.`,
352
459
  );
353
460
  }
354
- return await jsBatchFetch(unique);
461
+ return { reason: "version-mismatch" };
355
462
  }
356
463
 
357
- _lastStats = envelope.stats || null;
358
464
  const results = new Map();
359
465
  for (const result of envelope.results || []) {
360
466
  if (!result?.id) {
@@ -372,14 +478,7 @@ async function rustBatchFetch(unique, options) {
372
478
  const definite = status >= 400 && status < 500 && status !== 429;
373
479
  results.set(result.id, { ok: false, status, definite });
374
480
  }
375
-
376
- if (DEBUG_MODE && envelope.stats) {
377
- const s = envelope.stats;
378
- console.log(
379
- `cdxrs fetch: ${s.requests} url(s), ${s.unique} unique, ${s.ok} ok, ${s.failures} failed, ${s.cacheHits} cached, peak concurrency ${s.peakConcurrency}, ${s.elapsedMs} ms`,
380
- );
381
- }
382
- return results;
481
+ return { results, stats: envelope.stats || null };
383
482
  }
384
483
 
385
484
  /**
@@ -143,7 +143,14 @@ export const readBinary = (binFile, asJson, specVersion) => {
143
143
  ? decodeBomBinary(specVersion, binaryData, BINARY_READ_OPTIONS)
144
144
  : parseBomBinary(binaryData, BINARY_READ_OPTIONS);
145
145
  if (asJson) {
146
- return encodeBomJson(bomObject);
146
+ const bomJson = encodeBomJson(bomObject);
147
+ // Protobuf omits empty repeated fields, so a round-trip drops empty
148
+ // dependsOn lists. Restore them so JSON and protobuf inputs yield
149
+ // identical documents.
150
+ for (const adep of bomJson?.dependencies || []) {
151
+ adep.dependsOn ??= [];
152
+ }
153
+ return bomJson;
147
154
  }
148
155
  return bomObject;
149
156
  };
@@ -53,15 +53,30 @@ export function purlTypeIsRegisteredString(purlString) {
53
53
  }
54
54
 
55
55
  /**
56
- * Encode a string for safe inclusion in a PackageURL, percent-encoding special characters
57
- * while preserving already-encoded `%40` sequences and keeping `:` and `/` unencoded.
56
+ * Encode a string for inclusion in a hand-written PackageURL string,
57
+ * percent-encoding every character the purl grammar does not allow literally,
58
+ * while preserving already-encoded `%40` sequences and keeping `:` and `/`
59
+ * unencoded.
60
+ *
61
+ * `encodeURIComponent` alone leaves `!`, `'`, `(`, `)` and `*` literal, and the
62
+ * purl grammar requires them escaped, so they are escaped here as well.
63
+ *
64
+ * Only for comparing against or assembling purl text. Never pass the result to
65
+ * `build()`, `new Purl()` or one of the helpers in this module: they escape
66
+ * their inputs themselves, and escaping twice turns `+` into `%252B`.
58
67
  *
59
68
  * @param {string} s String to encode
60
69
  * @returns {string} Encoded string suitable for use in a PackageURL component
61
70
  */
62
71
  export function encodeForPurl(s) {
63
72
  return s && !s.includes("%40")
64
- ? encodeURIComponent(s).replace(/%3A/g, ":").replace(/%2F/g, "/")
73
+ ? encodeURIComponent(s)
74
+ .replace(
75
+ /[!'()*]/g,
76
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
77
+ )
78
+ .replace(/%3A/g, ":")
79
+ .replace(/%2F/g, "/")
65
80
  : s;
66
81
  }
67
82
 
@@ -328,6 +343,140 @@ export function isValidPurl(candidate) {
328
343
  }
329
344
  }
330
345
 
346
+ /**
347
+ * Split a purl into its components without requiring it to be well-formed.
348
+ *
349
+ * `Purl.parse` rejects a purl whose parts are not already escaped, which is the
350
+ * case this exists to handle: a `bom-ref` (cdxgen writes the decoded form of the
351
+ * purl there), a `go mod graph` token such as `github.com/Azure/go-autorest@v14.2.0+incompatible`,
352
+ * or a purl from a third-party document that left a `+` or a `://` unescaped.
353
+ * The separators (`:`, `/`, `@`, `?`, `#`, `&`, `=`) are structural and are read
354
+ * positionally; everything between them is decoded and handed back raw, ready
355
+ * for `build` to escape correctly.
356
+ *
357
+ * The version is taken after the last `@` of the path, so a scoped npm name
358
+ * (`@scope/name@1.0.0`) and a composer branch version (`dev-feature/x`) both
359
+ * split correctly.
360
+ *
361
+ * @param {string} purl Purl to split
362
+ * @returns {{type: string, namespace: (string|null), name: string, version: (string|null), qualifiers: (Object|null), subpath: (string|null)}|null} Parts, or null when the purl has no type and name
363
+ */
364
+ export function splitPurlLoose(purl) {
365
+ if (typeof purl !== "string" || !purl.startsWith("pkg:")) {
366
+ return null;
367
+ }
368
+ const decode = (value) => {
369
+ try {
370
+ return decodeURIComponent(value);
371
+ } catch {
372
+ return value;
373
+ }
374
+ };
375
+ let rest = purl.slice("pkg:".length).replace(/^\/+/, "");
376
+ let subpath = null;
377
+ const hash = rest.indexOf("#");
378
+ if (hash !== -1) {
379
+ subpath = decode(rest.slice(hash + 1)) || null;
380
+ rest = rest.slice(0, hash);
381
+ }
382
+ let qualifiers = null;
383
+ const query = rest.indexOf("?");
384
+ if (query !== -1) {
385
+ qualifiers = {};
386
+ for (const pair of rest.slice(query + 1).split("&")) {
387
+ const equals = pair.indexOf("=");
388
+ if (equals <= 0) {
389
+ continue;
390
+ }
391
+ qualifiers[pair.slice(0, equals)] = decode(pair.slice(equals + 1));
392
+ }
393
+ if (!Object.keys(qualifiers).length) {
394
+ qualifiers = null;
395
+ }
396
+ rest = rest.slice(0, query);
397
+ }
398
+ const slash = rest.indexOf("/");
399
+ if (slash <= 0) {
400
+ return null;
401
+ }
402
+ const type = rest.slice(0, slash);
403
+ let path = rest.slice(slash + 1);
404
+ let version = null;
405
+ const at = path.lastIndexOf("@");
406
+ if (at > 0 && path[at - 1] !== "/") {
407
+ version = decode(path.slice(at + 1)) || null;
408
+ path = path.slice(0, at);
409
+ }
410
+ const segments = path.split("/").filter(Boolean).map(decode);
411
+ const name = segments.pop();
412
+ if (!name) {
413
+ return null;
414
+ }
415
+ return {
416
+ type,
417
+ namespace: segments.length ? segments.join("/") : null,
418
+ name,
419
+ version,
420
+ qualifiers,
421
+ subpath,
422
+ };
423
+ }
424
+
425
+ /**
426
+ * Parse a purl string that may carry unescaped reserved characters.
427
+ *
428
+ * A well-formed purl is parsed strictly, so its components come back exactly as
429
+ * cdx-purl reads them. Anything else is split structurally by
430
+ * {@link splitPurlLoose}. Use this where the input is known to be a decoded or
431
+ * hand-written purl (a `bom-ref`, a workspace reference, a package manager's
432
+ * `name@version` token), never to accept an invalid purl into a `purl` field:
433
+ * pass the parts to `build()` instead, which escapes them.
434
+ *
435
+ * @param {string} purlString Purl or decoded purl
436
+ * @returns {{type: string, namespace: (string|null), name: string, version: (string|null), qualifiers: (Object|null), subpath: (string|null)}|null} Raw components, or null when nothing purl-shaped remains
437
+ */
438
+ export function parsePurlLoose(purlString) {
439
+ if (typeof purlString !== "string" || !purlString.startsWith("pkg:")) {
440
+ return null;
441
+ }
442
+ try {
443
+ const parsed = Purl.parse(purlString);
444
+ return {
445
+ type: parsed.type,
446
+ namespace: parsed.namespace || null,
447
+ name: parsed.name,
448
+ version: parsed.version || null,
449
+ qualifiers: parsed.qualifiers || null,
450
+ subpath: parsed.subpath || null,
451
+ };
452
+ } catch (err) {
453
+ if (!err?.code?.startsWith("E_")) {
454
+ throw err;
455
+ }
456
+ }
457
+ return splitPurlLoose(purlString);
458
+ }
459
+
460
+ /**
461
+ * Turn a decoded or hand-written purl into a canonical, correctly escaped one.
462
+ *
463
+ * `bom-ref` values are the decoded form of a purl
464
+ * (`pkg:npm/foo@1.0.0+build`), which cdx-purl rightly rejects as a purl. This
465
+ * recovers the purl the ref was made from by splitting it structurally and
466
+ * rebuilding it with `build()`, so every reserved character is escaped exactly
467
+ * once.
468
+ *
469
+ * @param {string} purlString Purl or decoded purl
470
+ * @returns {string|null} Canonical purl string, or `null` when no valid purl can be built
471
+ */
472
+ export function canonicalPurlFromLooseString(purlString) {
473
+ const parts = parsePurlLoose(purlString);
474
+ if (!parts) {
475
+ return null;
476
+ }
477
+ return tryBuildPurl(parts);
478
+ }
479
+
331
480
  /**
332
481
  * Parse a purl string, returning `null` instead of throwing when it is invalid.
333
482
  *
@@ -14,6 +14,7 @@
14
14
  import { build, Purl } from "@cdxgen/cdx-purl";
15
15
 
16
16
  import { isSpdxJsonLd } from "./bomUtils.js";
17
+ import { splitPurlLoose } from "./purl.js";
17
18
  import { toCycloneDxLikeBom } from "./spdxUtils.js";
18
19
 
19
20
  // A document larger than this is not a plausible SBOM for a single
@@ -150,75 +151,6 @@ function encodeVersionBuildMetadata(purl) {
150
151
  return `${head.slice(0, at + 1)}${head.slice(at + 1).replaceAll("+", "%2B")}${tail}`;
151
152
  }
152
153
 
153
- /**
154
- * Split a purl into its components without requiring it to be well-formed.
155
- *
156
- * `Purl.parse` rejects a purl whose parts are not already escaped, which is the
157
- * case this exists to handle. The separators (`:`, `/`, `@`, `?`, `#`, `&`,
158
- * `=`) are structural and are read positionally; everything between them is
159
- * decoded and handed back raw, ready for `build` to escape correctly.
160
- *
161
- * @param {string} purl Purl to split
162
- * @returns {{type: string, namespace: (string|null), name: string, version: (string|null), qualifiers: (Object|null), subpath: (string|null)}|null} Parts, or null when the purl has no type and name
163
- */
164
- function splitPurl(purl) {
165
- const decode = (value) => {
166
- try {
167
- return decodeURIComponent(value);
168
- } catch {
169
- return value;
170
- }
171
- };
172
- let rest = purl.slice("pkg:".length).replace(/^\/+/, "");
173
- let subpath = null;
174
- const hash = rest.indexOf("#");
175
- if (hash !== -1) {
176
- subpath = decode(rest.slice(hash + 1)) || null;
177
- rest = rest.slice(0, hash);
178
- }
179
- let qualifiers = null;
180
- const query = rest.indexOf("?");
181
- if (query !== -1) {
182
- qualifiers = {};
183
- for (const pair of rest.slice(query + 1).split("&")) {
184
- const equals = pair.indexOf("=");
185
- if (equals <= 0) {
186
- continue;
187
- }
188
- qualifiers[pair.slice(0, equals)] = decode(pair.slice(equals + 1));
189
- }
190
- if (!Object.keys(qualifiers).length) {
191
- qualifiers = null;
192
- }
193
- rest = rest.slice(0, query);
194
- }
195
- const slash = rest.indexOf("/");
196
- if (slash <= 0) {
197
- return null;
198
- }
199
- const type = rest.slice(0, slash);
200
- let path = rest.slice(slash + 1);
201
- let version = null;
202
- const at = path.lastIndexOf("@");
203
- if (at > 0) {
204
- version = decode(path.slice(at + 1)) || null;
205
- path = path.slice(0, at);
206
- }
207
- const segments = path.split("/").filter(Boolean).map(decode);
208
- const name = segments.pop();
209
- if (!name) {
210
- return null;
211
- }
212
- return {
213
- type,
214
- namespace: segments.length ? segments.join("/") : null,
215
- name,
216
- version,
217
- qualifiers,
218
- subpath,
219
- };
220
- }
221
-
222
154
  /**
223
155
  * Repair the purl of a component read from a third-party document.
224
156
  *
@@ -247,7 +179,7 @@ export function repairPurl(component) {
247
179
  } catch {
248
180
  // Fall through to the structural rebuild below.
249
181
  }
250
- const parts = splitPurl(repaired);
182
+ const parts = splitPurlLoose(repaired);
251
183
  if (!parts) {
252
184
  return;
253
185
  }
@@ -30,6 +30,7 @@ import {
30
30
  prefetchJson,
31
31
  withHostRateLimit,
32
32
  } from "./fetchBatch.js";
33
+ import { canonicalPurlFromLooseString } from "./purl.js";
33
34
 
34
35
  /** Raised when a caller omits a registry helper that the purl type requires. */
35
36
  class MissingRegistryHelperError extends Error {}
@@ -954,6 +955,36 @@ function packageNameForLookup(purlObj) {
954
955
  return `${namespace}/${purlObj.name}`;
955
956
  }
956
957
 
958
+ /**
959
+ * Read a purl supplied as a scan source.
960
+ *
961
+ * People type purls the way their package manager prints versions and URLs:
962
+ * `pkg:cargo/wasi@0.11.0+wasi-snapshot-preview1` or
963
+ * `pkg:npm/foo?vcs_url=git+https://...`. cdx-purl requires those `+` to be
964
+ * escaped, so a purl that does not parse strictly is split on its separators
965
+ * and rebuilt with every component escaped once. The rebuilt purl is then
966
+ * parsed strictly, so validation always inspects cdx-purl's reading of it.
967
+ *
968
+ * @param {string} purlString Package URL as supplied
969
+ * @returns {Purl|undefined} Parsed purl, or undefined when none can be recovered
970
+ */
971
+ function parsePurlSourceInput(purlString) {
972
+ try {
973
+ return Purl.parse(purlString);
974
+ } catch {
975
+ // Fall through to the structural rebuild.
976
+ }
977
+ const canonical = canonicalPurlFromLooseString(purlString);
978
+ if (!canonical) {
979
+ return undefined;
980
+ }
981
+ try {
982
+ return Purl.parse(canonical);
983
+ } catch {
984
+ return undefined;
985
+ }
986
+ }
987
+
957
988
  /**
958
989
  * Validate package URL source input and return an error object when invalid.
959
990
  *
@@ -964,10 +995,8 @@ export function validatePurlSource(purlString) {
964
995
  if (!maybePurlSource(purlString)) {
965
996
  return null;
966
997
  }
967
- let purlObj;
968
- try {
969
- purlObj = Purl.parse(purlString);
970
- } catch (_err) {
998
+ const purlObj = parsePurlSourceInput(purlString);
999
+ if (!purlObj) {
971
1000
  return {
972
1001
  status: 400,
973
1002
  error: "Invalid purl source",
@@ -1209,24 +1238,13 @@ function parsePurlForSourceResolution(purlString) {
1209
1238
  if (!maybePurlSource(purlString) || validatePurlSource(purlString)) {
1210
1239
  return undefined;
1211
1240
  }
1212
- let purlObj;
1213
- try {
1214
- // Some purls in the wild use unencoded + in qualifier values (e.g.
1215
- // git+https:// in vcs_url). cdx-purl requires percent-encoding; normalize
1216
- // the qualifier section before parsing so real-world purls resolve.
1217
- const qIdx = purlString.indexOf("?");
1218
- const safePurlString =
1219
- qIdx >= 0
1220
- ? purlString.slice(0, qIdx + 1) +
1221
- purlString.slice(qIdx + 1).replaceAll("+", "%2B")
1222
- : purlString;
1223
- purlObj = Purl.parse(safePurlString);
1224
- } catch (err) {
1241
+ // Purls in the wild leave `+` unescaped in versions and in qualifier values
1242
+ // such as `vcs_url=git+https://`; the shared reader rebuilds them.
1243
+ const purlObj = parsePurlSourceInput(purlString);
1244
+ if (!purlObj) {
1225
1245
  if (DEBUG_MODE) {
1226
- const errorMessage = err?.message || String(err);
1227
1246
  thoughtLog("Unable to resolve repository URL for purl.", {
1228
1247
  purlString,
1229
- errorMessage,
1230
1248
  });
1231
1249
  }
1232
1250
  return undefined;