@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.
- package/README.md +28 -12
- package/bin/cdxgen.js +227 -119
- package/bin/evinse.js +5 -0
- package/bin/sign.js +88 -14
- package/bin/tracebom.js +64 -4
- package/bin/validate.js +29 -7
- package/bin/verify.js +79 -39
- package/data/remediations.json +24 -0
- package/lib/cli/bomAssembly.js +105 -15
- package/lib/cli/cliOptions.js +93 -1
- package/lib/cli/index.js +43 -4
- package/lib/cli/jsBom.js +49 -24
- package/lib/cli/jvmBom.js +46 -12
- package/lib/cli/managedBom.js +112 -41
- package/lib/cli/nativeBom.js +125 -14
- package/lib/core/env.js +5 -6
- package/lib/core/fs.js +174 -6
- package/lib/core/metadataProperties.js +97 -0
- package/lib/core/projectConfig.js +31 -4
- package/lib/ecosystems/bunutils.js +269 -101
- package/lib/ecosystems/caxa.js +434 -2
- package/lib/ecosystems/cmakeResolver.js +66 -0
- package/lib/ecosystems/cppEvidence.js +64 -31
- package/lib/ecosystems/ecosystems.js +19 -8
- package/lib/ecosystems/gradleutils.js +42 -7
- package/lib/ecosystems/jsEvidence.js +19 -20
- package/lib/ecosystems/kosi.js +10 -3
- package/lib/ecosystems/npmutils.js +110 -9
- package/lib/ecosystems/parsers-dotnet.js +51 -10
- package/lib/ecosystems/parsers-go.js +47 -9
- package/lib/ecosystems/parsers-js.js +171 -50
- package/lib/ecosystems/parsers-misc.js +747 -161
- package/lib/ecosystems/parsers-python.js +26 -14
- package/lib/ecosystems/parsers-rust.js +24 -18
- package/lib/ecosystems/rubyutils.js +1426 -166
- package/lib/ecosystems/rusi.js +32 -1
- package/lib/evinser/evinser.js +436 -128
- package/lib/evinser/swiftsem.js +1914 -302
- package/lib/helpers/bomSigner.js +652 -221
- package/lib/inventory/atomUtils.js +568 -31
- package/lib/inventory/cdxrs.js +103 -12
- package/lib/inventory/depsUtils.js +183 -11
- package/lib/inventory/display.js +4 -5
- package/lib/inventory/dosai.js +550 -13
- package/lib/inventory/dynamic.js +39 -1
- package/lib/inventory/evidenceUtils.js +14 -6
- package/lib/inventory/fetchBatch.js +116 -17
- package/lib/inventory/protobom.js +8 -1
- package/lib/inventory/purl.js +152 -3
- package/lib/inventory/sbomDocument.js +2 -70
- package/lib/inventory/source.js +37 -19
- package/lib/inventory/traceRunner.js +210 -21
- package/lib/managers/binary.js +115 -24
- package/lib/managers/docker.js +440 -20
- package/lib/managers/oci.js +39 -1
- package/lib/parsers/cmakeLists.js +452 -0
- package/lib/parsers/largeJson.js +454 -0
- package/lib/parsers/npmrc.js +19 -1
- package/lib/stages/postgen/postgen.js +68 -15
- package/lib/validator/bomValidator.js +6 -16
- package/lib/validator/index.js +48 -10
- package/lib/validator/reporters/console.js +3 -0
- package/lib/validator/reporters/json.js +1 -0
- package/lib/validator/reporters/sarif.js +7 -0
- package/package.json +20 -20
- package/types/lib/cli/bomAssembly.d.ts +2 -1
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/cliOptions.d.ts +33 -1
- package/types/lib/cli/cliOptions.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/env.d.ts.map +1 -1
- package/types/lib/core/fs.d.ts +55 -0
- package/types/lib/core/fs.d.ts.map +1 -1
- package/types/lib/core/metadataProperties.d.ts +39 -0
- package/types/lib/core/metadataProperties.d.ts.map +1 -0
- package/types/lib/core/projectConfig.d.ts +6 -2
- package/types/lib/core/projectConfig.d.ts.map +1 -1
- package/types/lib/ecosystems/bunutils.d.ts +12 -5
- package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
- package/types/lib/ecosystems/caxa.d.ts +14 -0
- package/types/lib/ecosystems/caxa.d.ts.map +1 -1
- package/types/lib/ecosystems/cmakeResolver.d.ts +23 -0
- package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/cppEvidence.d.ts +1 -1
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
- package/types/lib/ecosystems/golem.d.ts.map +1 -1
- package/types/lib/ecosystems/gradleutils.d.ts +20 -4
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
- package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/kosi.d.ts +13 -0
- package/types/lib/ecosystems/kosi.d.ts.map +1 -1
- package/types/lib/ecosystems/npmutils.d.ts +42 -0
- package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-go.d.ts +10 -0
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +10 -4
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-misc.d.ts +67 -3
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
- package/types/lib/ecosystems/pythonutils.d.ts +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +143 -2
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rusi.d.ts +13 -0
- package/types/lib/ecosystems/rusi.d.ts.map +1 -1
- package/types/lib/evinser/evinser.d.ts +59 -9
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/evinser/swiftsem.d.ts +280 -15
- package/types/lib/evinser/swiftsem.d.ts.map +1 -1
- package/types/lib/helpers/bomSigner.d.ts +106 -13
- package/types/lib/helpers/bomSigner.d.ts.map +1 -1
- package/types/lib/inventory/atomUtils.d.ts +145 -3
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/cdxrs.d.ts +8 -3
- package/types/lib/inventory/cdxrs.d.ts.map +1 -1
- package/types/lib/inventory/depsUtils.d.ts +15 -0
- package/types/lib/inventory/depsUtils.d.ts.map +1 -1
- package/types/lib/inventory/display.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +64 -1
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dynamic.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
- package/types/lib/inventory/plugins.d.ts.map +1 -1
- package/types/lib/inventory/protobom.d.ts.map +1 -1
- package/types/lib/inventory/purl.d.ts +71 -2
- package/types/lib/inventory/purl.d.ts.map +1 -1
- package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/inventory/traceRunner.d.ts +47 -1
- package/types/lib/inventory/traceRunner.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts +30 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts +29 -0
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts +14 -0
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/parsers/cmakeLists.d.ts +76 -0
- package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
- package/types/lib/parsers/largeJson.d.ts +52 -0
- package/types/lib/parsers/largeJson.d.ts.map +1 -0
- package/types/lib/parsers/npmrc.d.ts +5 -0
- package/types/lib/parsers/npmrc.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +10 -1
- package/types/lib/validator/index.d.ts.map +1 -1
- package/types/lib/validator/reporters/console.d.ts.map +1 -1
- package/types/lib/validator/reporters/json.d.ts.map +1 -1
- 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 {
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/lib/inventory/purl.js
CHANGED
|
@@ -53,15 +53,30 @@ export function purlTypeIsRegisteredString(purlString) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Encode a string for
|
|
57
|
-
*
|
|
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)
|
|
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 =
|
|
182
|
+
const parts = splitPurlLoose(repaired);
|
|
251
183
|
if (!parts) {
|
|
252
184
|
return;
|
|
253
185
|
}
|
package/lib/inventory/source.js
CHANGED
|
@@ -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
|
-
|
|
968
|
-
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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;
|