Skip to content

Commit ab9ddc0

Browse files
authored
fix(sbom): deduplicate sbom dependencies (#7992)
Certain project dependency trees may result in an SBOM with duplicate entries. This fix ensures that each unique dependency (identified by the combination of package name and version) only appears in the SBOM once. Applies to both SPDX and CycloneDX SBOM formats. Specific to the CycloneDX format, this change also removes the `cdx:npm:package:path` property from the `component` entries in the generated SBOM. Since the same package may be present at multiple paths within the project and we're now de-duplicating those packages, it no longer makes sense to include this in the SBOM. This does not impact the SPDX format as there is no equivalent property. Fixes: #6967 Signed-off-by: Brian DeHamer <[email protected]>
1 parent f7da341 commit ab9ddc0

File tree

8 files changed

+554
-166
lines changed

8 files changed

+554
-166
lines changed

lib/utils/sbom-cyclonedx.js

+12-17
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ const CYCLONEDX_SCHEMA = 'http://cyclonedx.org/schema/bom-1.5.schema.json'
88
const CYCLONEDX_FORMAT = 'CycloneDX'
99
const CYCLONEDX_SCHEMA_VERSION = '1.5'
1010

11-
const PROP_PATH = 'cdx:npm:package:path'
1211
const PROP_BUNDLED = 'cdx:npm:package:bundled'
1312
const PROP_DEVELOPMENT = 'cdx:npm:package:development'
1413
const PROP_EXTRANEOUS = 'cdx:npm:package:extraneous'
@@ -31,19 +30,18 @@ const cyclonedxOutput = ({ npm, nodes, packageType, packageLockOnly }) => {
3130
const childNodes = nodes.filter(node => !node.isRoot && !node.isLink)
3231
const uuid = crypto.randomUUID()
3332

34-
const deps = []
35-
const seen = new Set()
36-
for (let node of nodes) {
37-
if (node.isLink) {
38-
node = node.target
33+
// Create list of child nodes w/ unique IDs
34+
const childNodeMap = new Map()
35+
for (const item of childNodes) {
36+
const id = toCyclonedxID(item)
37+
if (!childNodeMap.has(id)) {
38+
childNodeMap.set(id, item)
3939
}
40-
41-
if (seen.has(node)) {
42-
continue
43-
}
44-
seen.add(node)
45-
deps.push(toCyclonedxDependency(node, nodes))
4640
}
41+
const uniqueChildNodes = Array.from(childNodeMap.values())
42+
43+
const deps = [rootNode, ...uniqueChildNodes]
44+
.map(node => toCyclonedxDependency(node, nodes))
4745

4846
const bom = {
4947
$schema: CYCLONEDX_SCHEMA,
@@ -65,7 +63,7 @@ const cyclonedxOutput = ({ npm, nodes, packageType, packageLockOnly }) => {
6563
],
6664
component: toCyclonedxItem(rootNode, { packageType }),
6765
},
68-
components: childNodes.map(toCyclonedxItem),
66+
components: uniqueChildNodes.map(toCyclonedxItem),
6967
dependencies: deps,
7068
}
7169

@@ -109,10 +107,7 @@ const toCyclonedxItem = (node, { packageType }) => {
109107
: (node.package?.author || undefined),
110108
description: node.package?.description || undefined,
111109
purl: purl,
112-
properties: [{
113-
name: PROP_PATH,
114-
value: node.location,
115-
}],
110+
properties: [],
116111
externalReferences: [],
117112
}
118113

lib/utils/sbom-spdx.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ const spdxOutput = ({ npm, nodes, packageType }) => {
2626
const uuid = crypto.randomUUID()
2727
const ns = `http://spdx.org/spdxdocs/${npa(rootID).escapedName}-${rootNode.version}-${uuid}`
2828

29+
// Create list of child nodes w/ unique IDs
30+
const childNodeMap = new Map()
31+
for (const item of childNodes) {
32+
const id = toSpdxID(item)
33+
if (!childNodeMap.has(id)) {
34+
childNodeMap.set(id, item)
35+
}
36+
}
37+
const uniqueChildNodes = Array.from(childNodeMap.values())
38+
2939
const relationships = []
3040
const seen = new Set()
3141
for (let node of nodes) {
@@ -65,7 +75,7 @@ const spdxOutput = ({ npm, nodes, packageType }) => {
6575
],
6676
},
6777
documentDescribes: [toSpdxID(rootNode)],
68-
packages: [toSpdxItem(rootNode, { packageType }), ...childNodes.map(toSpdxItem)],
78+
packages: [toSpdxItem(rootNode, { packageType }), ...uniqueChildNodes.map(toSpdxItem)],
6979
relationships: [
7080
{
7181
spdxElementId: SPDX_IDENTIFER,

tap-snapshots/test/lib/commands/sbom.js.test.cjs

+250-24
Original file line numberDiff line numberDiff line change
@@ -259,12 +259,7 @@ exports[`test/lib/commands/sbom.js TAP sbom basic sbom - cyclonedx > must match
259259
"version": "1.0.0",
260260
"scope": "required",
261261
"purl": "pkg:npm/[email protected]",
262-
"properties": [
263-
{
264-
"name": "cdx:npm:package:path",
265-
"value": ""
266-
}
267-
],
262+
"properties": [],
268263
"externalReferences": []
269264
}
270265
},
@@ -276,12 +271,7 @@ exports[`test/lib/commands/sbom.js TAP sbom basic sbom - cyclonedx > must match
276271
"version": "1.0.0",
277272
"scope": "required",
278273
"purl": "pkg:npm/[email protected]",
279-
"properties": [
280-
{
281-
"name": "cdx:npm:package:path",
282-
"value": "node_modules/chai"
283-
}
284-
],
274+
"properties": [],
285275
"externalReferences": []
286276
},
287277
{
@@ -291,12 +281,7 @@ exports[`test/lib/commands/sbom.js TAP sbom basic sbom - cyclonedx > must match
291281
"version": "1.0.0",
292282
"scope": "required",
293283
"purl": "pkg:npm/[email protected]",
294-
"properties": [
295-
{
296-
"name": "cdx:npm:package:path",
297-
"value": "node_modules/foo"
298-
}
299-
],
284+
"properties": [],
300285
"externalReferences": []
301286
},
302287
{
@@ -306,12 +291,7 @@ exports[`test/lib/commands/sbom.js TAP sbom basic sbom - cyclonedx > must match
306291
"version": "1.0.0",
307292
"scope": "required",
308293
"purl": "pkg:npm/[email protected]",
309-
"properties": [
310-
{
311-
"name": "cdx:npm:package:path",
312-
"value": "node_modules/foo/node_modules/dog"
313-
}
314-
],
294+
"properties": [],
315295
"externalReferences": []
316296
}
317297
],
@@ -453,6 +433,252 @@ exports[`test/lib/commands/sbom.js TAP sbom basic sbom - spdx > must match snaps
453433
}
454434
`
455435

436+
exports[`test/lib/commands/sbom.js TAP sbom duplicate deps - cyclonedx > must match snapshot 1`] = `
437+
{
438+
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
439+
"bomFormat": "CycloneDX",
440+
"specVersion": "1.5",
441+
"serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000000",
442+
"version": 1,
443+
"metadata": {
444+
"timestamp": "2020-01-01T00:00:00.000Z",
445+
"lifecycles": [
446+
{
447+
"phase": "build"
448+
}
449+
],
450+
"tools": [
451+
{
452+
"vendor": "npm",
453+
"name": "cli",
454+
"version": "10.0.0"
455+
}
456+
],
457+
"component": {
458+
"bom-ref": "[email protected]",
459+
"type": "library",
460+
"name": "prefix",
461+
"version": "1.0.0",
462+
"scope": "required",
463+
"purl": "pkg:npm/[email protected]",
464+
"properties": [],
465+
"externalReferences": []
466+
}
467+
},
468+
"components": [
469+
{
470+
"bom-ref": "[email protected]",
471+
"type": "library",
472+
"name": "bar",
473+
"version": "1.0.0",
474+
"scope": "required",
475+
"purl": "pkg:npm/[email protected]",
476+
"properties": [],
477+
"externalReferences": []
478+
},
479+
{
480+
"bom-ref": "[email protected]",
481+
"type": "library",
482+
"name": "chai",
483+
"version": "1.0.0",
484+
"scope": "required",
485+
"purl": "pkg:npm/[email protected]",
486+
"properties": [],
487+
"externalReferences": []
488+
},
489+
{
490+
"bom-ref": "[email protected]",
491+
"type": "library",
492+
"name": "chai",
493+
"version": "2.0.0",
494+
"scope": "required",
495+
"purl": "pkg:npm/[email protected]",
496+
"properties": [],
497+
"externalReferences": []
498+
},
499+
{
500+
"bom-ref": "[email protected]",
501+
"type": "library",
502+
"name": "foo",
503+
"version": "1.0.0",
504+
"scope": "required",
505+
"purl": "pkg:npm/[email protected]",
506+
"properties": [],
507+
"externalReferences": []
508+
}
509+
],
510+
"dependencies": [
511+
{
512+
513+
"dependsOn": [
514+
515+
516+
517+
]
518+
},
519+
{
520+
521+
"dependsOn": [
522+
523+
]
524+
},
525+
{
526+
527+
"dependsOn": []
528+
},
529+
{
530+
531+
"dependsOn": []
532+
},
533+
{
534+
535+
"dependsOn": [
536+
537+
]
538+
}
539+
]
540+
}
541+
`
542+
543+
exports[`test/lib/commands/sbom.js TAP sbom duplicate deps - spdx > must match snapshot 1`] = `
544+
{
545+
"spdxVersion": "SPDX-2.3",
546+
"dataLicense": "CC0-1.0",
547+
"SPDXID": "SPDXRef-DOCUMENT",
548+
"name": "[email protected]",
549+
"documentNamespace": "http://spdx.org/spdxdocs/test-npm-sbom-1.0.0-00000000-0000-0000-0000-000000000000",
550+
"creationInfo": {
551+
"created": "2020-01-01T00:00:00.000Z",
552+
"creators": [
553+
"Tool: npm/cli-10.0.0"
554+
]
555+
},
556+
"documentDescribes": [
557+
"SPDXRef-Package-test-npm-sbom-1.0.0"
558+
],
559+
"packages": [
560+
{
561+
"name": "test-npm-sbom",
562+
"SPDXID": "SPDXRef-Package-test-npm-sbom-1.0.0",
563+
"versionInfo": "1.0.0",
564+
"packageFileName": "",
565+
"primaryPackagePurpose": "LIBRARY",
566+
"downloadLocation": "NOASSERTION",
567+
"filesAnalyzed": false,
568+
"homepage": "NOASSERTION",
569+
"licenseDeclared": "NOASSERTION",
570+
"externalRefs": [
571+
{
572+
"referenceCategory": "PACKAGE-MANAGER",
573+
"referenceType": "purl",
574+
"referenceLocator": "pkg:npm/[email protected]"
575+
}
576+
]
577+
},
578+
{
579+
"name": "bar",
580+
"SPDXID": "SPDXRef-Package-bar-1.0.0",
581+
"versionInfo": "1.0.0",
582+
"packageFileName": "node_modules/bar",
583+
"downloadLocation": "NOASSERTION",
584+
"filesAnalyzed": false,
585+
"homepage": "NOASSERTION",
586+
"licenseDeclared": "NOASSERTION",
587+
"externalRefs": [
588+
{
589+
"referenceCategory": "PACKAGE-MANAGER",
590+
"referenceType": "purl",
591+
"referenceLocator": "pkg:npm/[email protected]"
592+
}
593+
]
594+
},
595+
{
596+
"name": "chai",
597+
"SPDXID": "SPDXRef-Package-chai-1.0.0",
598+
"versionInfo": "1.0.0",
599+
"packageFileName": "node_modules/bar/node_modules/chai",
600+
"downloadLocation": "NOASSERTION",
601+
"filesAnalyzed": false,
602+
"homepage": "NOASSERTION",
603+
"licenseDeclared": "NOASSERTION",
604+
"externalRefs": [
605+
{
606+
"referenceCategory": "PACKAGE-MANAGER",
607+
"referenceType": "purl",
608+
"referenceLocator": "pkg:npm/[email protected]"
609+
}
610+
]
611+
},
612+
{
613+
"name": "chai",
614+
"SPDXID": "SPDXRef-Package-chai-2.0.0",
615+
"versionInfo": "2.0.0",
616+
"packageFileName": "node_modules/chai",
617+
"downloadLocation": "NOASSERTION",
618+
"filesAnalyzed": false,
619+
"homepage": "NOASSERTION",
620+
"licenseDeclared": "NOASSERTION",
621+
"externalRefs": [
622+
{
623+
"referenceCategory": "PACKAGE-MANAGER",
624+
"referenceType": "purl",
625+
"referenceLocator": "pkg:npm/[email protected]"
626+
}
627+
]
628+
},
629+
{
630+
"name": "foo",
631+
"SPDXID": "SPDXRef-Package-foo-1.0.0",
632+
"versionInfo": "1.0.0",
633+
"packageFileName": "node_modules/foo",
634+
"downloadLocation": "NOASSERTION",
635+
"filesAnalyzed": false,
636+
"homepage": "NOASSERTION",
637+
"licenseDeclared": "NOASSERTION",
638+
"externalRefs": [
639+
{
640+
"referenceCategory": "PACKAGE-MANAGER",
641+
"referenceType": "purl",
642+
"referenceLocator": "pkg:npm/[email protected]"
643+
}
644+
]
645+
}
646+
],
647+
"relationships": [
648+
{
649+
"spdxElementId": "SPDXRef-DOCUMENT",
650+
"relatedSpdxElement": "SPDXRef-Package-test-npm-sbom-1.0.0",
651+
"relationshipType": "DESCRIBES"
652+
},
653+
{
654+
"spdxElementId": "SPDXRef-Package-foo-1.0.0",
655+
"relatedSpdxElement": "SPDXRef-Package-test-npm-sbom-1.0.0",
656+
"relationshipType": "DEPENDENCY_OF"
657+
},
658+
{
659+
"spdxElementId": "SPDXRef-Package-bar-1.0.0",
660+
"relatedSpdxElement": "SPDXRef-Package-test-npm-sbom-1.0.0",
661+
"relationshipType": "DEPENDENCY_OF"
662+
},
663+
{
664+
"spdxElementId": "SPDXRef-Package-chai-2.0.0",
665+
"relatedSpdxElement": "SPDXRef-Package-test-npm-sbom-1.0.0",
666+
"relationshipType": "DEPENDENCY_OF"
667+
},
668+
{
669+
"spdxElementId": "SPDXRef-Package-chai-1.0.0",
670+
"relatedSpdxElement": "SPDXRef-Package-bar-1.0.0",
671+
"relationshipType": "DEPENDENCY_OF"
672+
},
673+
{
674+
"spdxElementId": "SPDXRef-Package-chai-1.0.0",
675+
"relatedSpdxElement": "SPDXRef-Package-foo-1.0.0",
676+
"relationshipType": "DEPENDENCY_OF"
677+
}
678+
]
679+
}
680+
`
681+
456682
exports[`test/lib/commands/sbom.js TAP sbom extraneous dep > must match snapshot 1`] = `
457683
{
458684
"spdxVersion": "SPDX-2.3",

0 commit comments

Comments
 (0)