Skip to content

Commit

Permalink
fix(sbom): deduplicate sbom dependencies (npm#7992)
Browse files Browse the repository at this point in the history
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: npm#6967

Signed-off-by: Brian DeHamer <bdehamer@github.com>
  • Loading branch information
bdehamer authored Dec 20, 2024
1 parent f7da341 commit ab9ddc0
Show file tree
Hide file tree
Showing 8 changed files with 554 additions and 166 deletions.
29 changes: 12 additions & 17 deletions lib/utils/sbom-cyclonedx.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const CYCLONEDX_SCHEMA = 'http://cyclonedx.org/schema/bom-1.5.schema.json'
const CYCLONEDX_FORMAT = 'CycloneDX'
const CYCLONEDX_SCHEMA_VERSION = '1.5'

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

const deps = []
const seen = new Set()
for (let node of nodes) {
if (node.isLink) {
node = node.target
// Create list of child nodes w/ unique IDs
const childNodeMap = new Map()
for (const item of childNodes) {
const id = toCyclonedxID(item)
if (!childNodeMap.has(id)) {
childNodeMap.set(id, item)
}

if (seen.has(node)) {
continue
}
seen.add(node)
deps.push(toCyclonedxDependency(node, nodes))
}
const uniqueChildNodes = Array.from(childNodeMap.values())

const deps = [rootNode, ...uniqueChildNodes]
.map(node => toCyclonedxDependency(node, nodes))

const bom = {
$schema: CYCLONEDX_SCHEMA,
Expand All @@ -65,7 +63,7 @@ const cyclonedxOutput = ({ npm, nodes, packageType, packageLockOnly }) => {
],
component: toCyclonedxItem(rootNode, { packageType }),
},
components: childNodes.map(toCyclonedxItem),
components: uniqueChildNodes.map(toCyclonedxItem),
dependencies: deps,
}

Expand Down Expand Up @@ -109,10 +107,7 @@ const toCyclonedxItem = (node, { packageType }) => {
: (node.package?.author || undefined),
description: node.package?.description || undefined,
purl: purl,
properties: [{
name: PROP_PATH,
value: node.location,
}],
properties: [],
externalReferences: [],
}

Expand Down
12 changes: 11 additions & 1 deletion lib/utils/sbom-spdx.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ const spdxOutput = ({ npm, nodes, packageType }) => {
const uuid = crypto.randomUUID()
const ns = `http://spdx.org/spdxdocs/${npa(rootID).escapedName}-${rootNode.version}-${uuid}`

// Create list of child nodes w/ unique IDs
const childNodeMap = new Map()
for (const item of childNodes) {
const id = toSpdxID(item)
if (!childNodeMap.has(id)) {
childNodeMap.set(id, item)
}
}
const uniqueChildNodes = Array.from(childNodeMap.values())

const relationships = []
const seen = new Set()
for (let node of nodes) {
Expand Down Expand Up @@ -65,7 +75,7 @@ const spdxOutput = ({ npm, nodes, packageType }) => {
],
},
documentDescribes: [toSpdxID(rootNode)],
packages: [toSpdxItem(rootNode, { packageType }), ...childNodes.map(toSpdxItem)],
packages: [toSpdxItem(rootNode, { packageType }), ...uniqueChildNodes.map(toSpdxItem)],
relationships: [
{
spdxElementId: SPDX_IDENTIFER,
Expand Down
274 changes: 250 additions & 24 deletions tap-snapshots/test/lib/commands/sbom.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,7 @@ exports[`test/lib/commands/sbom.js TAP sbom basic sbom - cyclonedx > must match
"version": "1.0.0",
"scope": "required",
"purl": "pkg:npm/test-npm-sbom@1.0.0",
"properties": [
{
"name": "cdx:npm:package:path",
"value": ""
}
],
"properties": [],
"externalReferences": []
}
},
Expand All @@ -276,12 +271,7 @@ exports[`test/lib/commands/sbom.js TAP sbom basic sbom - cyclonedx > must match
"version": "1.0.0",
"scope": "required",
"purl": "pkg:npm/chai@1.0.0",
"properties": [
{
"name": "cdx:npm:package:path",
"value": "node_modules/chai"
}
],
"properties": [],
"externalReferences": []
},
{
Expand All @@ -291,12 +281,7 @@ exports[`test/lib/commands/sbom.js TAP sbom basic sbom - cyclonedx > must match
"version": "1.0.0",
"scope": "required",
"purl": "pkg:npm/foo@1.0.0",
"properties": [
{
"name": "cdx:npm:package:path",
"value": "node_modules/foo"
}
],
"properties": [],
"externalReferences": []
},
{
Expand All @@ -306,12 +291,7 @@ exports[`test/lib/commands/sbom.js TAP sbom basic sbom - cyclonedx > must match
"version": "1.0.0",
"scope": "required",
"purl": "pkg:npm/dog@1.0.0",
"properties": [
{
"name": "cdx:npm:package:path",
"value": "node_modules/foo/node_modules/dog"
}
],
"properties": [],
"externalReferences": []
}
],
Expand Down Expand Up @@ -453,6 +433,252 @@ exports[`test/lib/commands/sbom.js TAP sbom basic sbom - spdx > must match snaps
}
`

exports[`test/lib/commands/sbom.js TAP sbom duplicate deps - cyclonedx > must match snapshot 1`] = `
{
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000000",
"version": 1,
"metadata": {
"timestamp": "2020-01-01T00:00:00.000Z",
"lifecycles": [
{
"phase": "build"
}
],
"tools": [
{
"vendor": "npm",
"name": "cli",
"version": "10.0.0"
}
],
"component": {
"bom-ref": "test-npm-sbom@1.0.0",
"type": "library",
"name": "prefix",
"version": "1.0.0",
"scope": "required",
"purl": "pkg:npm/test-npm-sbom@1.0.0",
"properties": [],
"externalReferences": []
}
},
"components": [
{
"bom-ref": "bar@1.0.0",
"type": "library",
"name": "bar",
"version": "1.0.0",
"scope": "required",
"purl": "pkg:npm/bar@1.0.0",
"properties": [],
"externalReferences": []
},
{
"bom-ref": "chai@1.0.0",
"type": "library",
"name": "chai",
"version": "1.0.0",
"scope": "required",
"purl": "pkg:npm/chai@1.0.0",
"properties": [],
"externalReferences": []
},
{
"bom-ref": "chai@2.0.0",
"type": "library",
"name": "chai",
"version": "2.0.0",
"scope": "required",
"purl": "pkg:npm/chai@2.0.0",
"properties": [],
"externalReferences": []
},
{
"bom-ref": "foo@1.0.0",
"type": "library",
"name": "foo",
"version": "1.0.0",
"scope": "required",
"purl": "pkg:npm/foo@1.0.0",
"properties": [],
"externalReferences": []
}
],
"dependencies": [
{
"ref": "test-npm-sbom@1.0.0",
"dependsOn": [
"foo@1.0.0",
"bar@1.0.0",
"chai@2.0.0"
]
},
{
"ref": "bar@1.0.0",
"dependsOn": [
"chai@1.0.0"
]
},
{
"ref": "chai@1.0.0",
"dependsOn": []
},
{
"ref": "chai@2.0.0",
"dependsOn": []
},
{
"ref": "foo@1.0.0",
"dependsOn": [
"chai@1.0.0"
]
}
]
}
`

exports[`test/lib/commands/sbom.js TAP sbom duplicate deps - spdx > must match snapshot 1`] = `
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "test-npm-sbom@1.0.0",
"documentNamespace": "http://spdx.org/spdxdocs/test-npm-sbom-1.0.0-00000000-0000-0000-0000-000000000000",
"creationInfo": {
"created": "2020-01-01T00:00:00.000Z",
"creators": [
"Tool: npm/cli-10.0.0"
]
},
"documentDescribes": [
"SPDXRef-Package-test-npm-sbom-1.0.0"
],
"packages": [
{
"name": "test-npm-sbom",
"SPDXID": "SPDXRef-Package-test-npm-sbom-1.0.0",
"versionInfo": "1.0.0",
"packageFileName": "",
"primaryPackagePurpose": "LIBRARY",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"homepage": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:npm/test-npm-sbom@1.0.0"
}
]
},
{
"name": "bar",
"SPDXID": "SPDXRef-Package-bar-1.0.0",
"versionInfo": "1.0.0",
"packageFileName": "node_modules/bar",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"homepage": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:npm/bar@1.0.0"
}
]
},
{
"name": "chai",
"SPDXID": "SPDXRef-Package-chai-1.0.0",
"versionInfo": "1.0.0",
"packageFileName": "node_modules/bar/node_modules/chai",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"homepage": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:npm/chai@1.0.0"
}
]
},
{
"name": "chai",
"SPDXID": "SPDXRef-Package-chai-2.0.0",
"versionInfo": "2.0.0",
"packageFileName": "node_modules/chai",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"homepage": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:npm/chai@2.0.0"
}
]
},
{
"name": "foo",
"SPDXID": "SPDXRef-Package-foo-1.0.0",
"versionInfo": "1.0.0",
"packageFileName": "node_modules/foo",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"homepage": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:npm/foo@1.0.0"
}
]
}
],
"relationships": [
{
"spdxElementId": "SPDXRef-DOCUMENT",
"relatedSpdxElement": "SPDXRef-Package-test-npm-sbom-1.0.0",
"relationshipType": "DESCRIBES"
},
{
"spdxElementId": "SPDXRef-Package-foo-1.0.0",
"relatedSpdxElement": "SPDXRef-Package-test-npm-sbom-1.0.0",
"relationshipType": "DEPENDENCY_OF"
},
{
"spdxElementId": "SPDXRef-Package-bar-1.0.0",
"relatedSpdxElement": "SPDXRef-Package-test-npm-sbom-1.0.0",
"relationshipType": "DEPENDENCY_OF"
},
{
"spdxElementId": "SPDXRef-Package-chai-2.0.0",
"relatedSpdxElement": "SPDXRef-Package-test-npm-sbom-1.0.0",
"relationshipType": "DEPENDENCY_OF"
},
{
"spdxElementId": "SPDXRef-Package-chai-1.0.0",
"relatedSpdxElement": "SPDXRef-Package-bar-1.0.0",
"relationshipType": "DEPENDENCY_OF"
},
{
"spdxElementId": "SPDXRef-Package-chai-1.0.0",
"relatedSpdxElement": "SPDXRef-Package-foo-1.0.0",
"relationshipType": "DEPENDENCY_OF"
}
]
}
`

exports[`test/lib/commands/sbom.js TAP sbom extraneous dep > must match snapshot 1`] = `
{
"spdxVersion": "SPDX-2.3",
Expand Down
Loading

0 comments on commit ab9ddc0

Please sign in to comment.