Skip to content

Commit

Permalink
fix(sbom) deduplicate dependencies
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.

Signed-off-by: Brian DeHamer <bdehamer@github.com>
  • Loading branch information
bdehamer committed Dec 19, 2024
1 parent f7da341 commit 54caf08
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 54caf08

Please sign in to comment.