Skip to content

Commit fef6c57

Browse files
committed
fix(sbom) deduplicate dependencies
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. Signed-off-by: Brian DeHamer <bdehamer@github.com>
1 parent f7da341 commit fef6c57

File tree

8 files changed

+555
-162
lines changed

8 files changed

+555
-162
lines changed

lib/utils/sbom-cyclonedx.js

+13-13
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,17 +30,21 @@ const cyclonedxOutput = ({ npm, nodes, packageType, packageLockOnly }) => {
3130
const childNodes = nodes.filter(node => !node.isRoot && !node.isLink)
3231
const uuid = crypto.randomUUID()
3332

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)
39+
}
40+
}
41+
const uniqueChildNodes = Array.from(childNodeMap.values())
42+
3443
const deps = []
35-
const seen = new Set()
36-
for (let node of nodes) {
44+
for (let node of [rootNode, ...uniqueChildNodes]) {
3745
if (node.isLink) {
3846
node = node.target
3947
}
40-
41-
if (seen.has(node)) {
42-
continue
43-
}
44-
seen.add(node)
4548
deps.push(toCyclonedxDependency(node, nodes))
4649
}
4750

@@ -65,7 +68,7 @@ const cyclonedxOutput = ({ npm, nodes, packageType, packageLockOnly }) => {
6568
],
6669
component: toCyclonedxItem(rootNode, { packageType }),
6770
},
68-
components: childNodes.map(toCyclonedxItem),
71+
components: uniqueChildNodes.map(toCyclonedxItem),
6972
dependencies: deps,
7073
}
7174

@@ -109,10 +112,7 @@ const toCyclonedxItem = (node, { packageType }) => {
109112
: (node.package?.author || undefined),
110113
description: node.package?.description || undefined,
111114
purl: purl,
112-
properties: [{
113-
name: PROP_PATH,
114-
value: node.location,
115-
}],
115+
properties: [],
116116
externalReferences: [],
117117
}
118118

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/test-npm-sbom@1.0.0",
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/chai@1.0.0",
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/foo@1.0.0",
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/dog@1.0.0",
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": "test-npm-sbom@1.0.0",
459+
"type": "library",
460+
"name": "prefix",
461+
"version": "1.0.0",
462+
"scope": "required",
463+
"purl": "pkg:npm/test-npm-sbom@1.0.0",
464+
"properties": [],
465+
"externalReferences": []
466+
}
467+
},
468+
"components": [
469+
{
470+
"bom-ref": "bar@1.0.0",
471+
"type": "library",
472+
"name": "bar",
473+
"version": "1.0.0",
474+
"scope": "required",
475+
"purl": "pkg:npm/bar@1.0.0",
476+
"properties": [],
477+
"externalReferences": []
478+
},
479+
{
480+
"bom-ref": "chai@1.0.0",
481+
"type": "library",
482+
"name": "chai",
483+
"version": "1.0.0",
484+
"scope": "required",
485+
"purl": "pkg:npm/chai@1.0.0",
486+
"properties": [],
487+
"externalReferences": []
488+
},
489+
{
490+
"bom-ref": "chai@2.0.0",
491+
"type": "library",
492+
"name": "chai",
493+
"version": "2.0.0",
494+
"scope": "required",
495+
"purl": "pkg:npm/chai@2.0.0",
496+
"properties": [],
497+
"externalReferences": []
498+
},
499+
{
500+
"bom-ref": "foo@1.0.0",
501+
"type": "library",
502+
"name": "foo",
503+
"version": "1.0.0",
504+
"scope": "required",
505+
"purl": "pkg:npm/foo@1.0.0",
506+
"properties": [],
507+
"externalReferences": []
508+
}
509+
],
510+
"dependencies": [
511+
{
512+
"ref": "test-npm-sbom@1.0.0",
513+
"dependsOn": [
514+
"foo@1.0.0",
515+
"bar@1.0.0",
516+
"chai@2.0.0"
517+
]
518+
},
519+
{
520+
"ref": "bar@1.0.0",
521+
"dependsOn": [
522+
"chai@1.0.0"
523+
]
524+
},
525+
{
526+
"ref": "chai@1.0.0",
527+
"dependsOn": []
528+
},
529+
{
530+
"ref": "chai@2.0.0",
531+
"dependsOn": []
532+
},
533+
{
534+
"ref": "foo@1.0.0",
535+
"dependsOn": [
536+
"chai@1.0.0"
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": "test-npm-sbom@1.0.0",
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/test-npm-sbom@1.0.0"
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/bar@1.0.0"
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/chai@1.0.0"
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/chai@2.0.0"
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/foo@1.0.0"
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)