From 54caf08b3e5838d0c6b757fd593669577bcc6a6c Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Mon, 16 Dec 2024 13:56:36 -0800 Subject: [PATCH] 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 --- lib/utils/sbom-cyclonedx.js | 29 +- lib/utils/sbom-spdx.js | 12 +- .../test/lib/commands/sbom.js.test.cjs | 274 ++++++++++++++++-- .../test/lib/utils/sbom-cyclonedx.js.test.cjs | 202 +++++-------- .../test/lib/utils/sbom-spdx.js.test.cjs | 89 ++++++ test/lib/commands/sbom.js | 91 ++++++ test/lib/utils/sbom-cyclonedx.js | 12 + test/lib/utils/sbom-spdx.js | 11 + 8 files changed, 554 insertions(+), 166 deletions(-) diff --git a/lib/utils/sbom-cyclonedx.js b/lib/utils/sbom-cyclonedx.js index f3bab28000953..e09d2486e21c4 100644 --- a/lib/utils/sbom-cyclonedx.js +++ b/lib/utils/sbom-cyclonedx.js @@ -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' @@ -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, @@ -65,7 +63,7 @@ const cyclonedxOutput = ({ npm, nodes, packageType, packageLockOnly }) => { ], component: toCyclonedxItem(rootNode, { packageType }), }, - components: childNodes.map(toCyclonedxItem), + components: uniqueChildNodes.map(toCyclonedxItem), dependencies: deps, } @@ -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: [], } diff --git a/lib/utils/sbom-spdx.js b/lib/utils/sbom-spdx.js index 16aed18656764..7f6ce0580ed41 100644 --- a/lib/utils/sbom-spdx.js +++ b/lib/utils/sbom-spdx.js @@ -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) { @@ -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, diff --git a/tap-snapshots/test/lib/commands/sbom.js.test.cjs b/tap-snapshots/test/lib/commands/sbom.js.test.cjs index 826cf074e6038..5b2e93a3df6d6 100644 --- a/tap-snapshots/test/lib/commands/sbom.js.test.cjs +++ b/tap-snapshots/test/lib/commands/sbom.js.test.cjs @@ -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": [] } }, @@ -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": [] }, { @@ -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": [] }, { @@ -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": [] } ], @@ -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", diff --git a/tap-snapshots/test/lib/utils/sbom-cyclonedx.js.test.cjs b/tap-snapshots/test/lib/utils/sbom-cyclonedx.js.test.cjs index 7a8d79017f36a..2f0af32f7f501 100644 --- a/tap-snapshots/test/lib/utils/sbom-cyclonedx.js.test.cjs +++ b/tap-snapshots/test/lib/utils/sbom-cyclonedx.js.test.cjs @@ -34,12 +34,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP node - with deps > must match snap "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [] } }, @@ -51,12 +46,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP node - with deps > must match snap "version": "0.0.1", "scope": "required", "purl": "pkg:npm/dep1@0.0.1", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "node_modules/dep1" - } - ], + "properties": [], "externalReferences": [] }, { @@ -66,12 +56,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP node - with deps > must match snap "version": "0.0.2", "scope": "required", "purl": "pkg:npm/dep2@0.0.2", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "node_modules/dep2" - } - ], + "properties": [], "externalReferences": [] } ], @@ -97,6 +82,66 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP node - with deps > must match snap } ` +exports[`test/lib/utils/sbom-cyclonedx.js TAP node - with duplicate deps > 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": "root@1.0.0", + "type": "library", + "name": "root", + "version": "1.0.0", + "scope": "required", + "author": "Author", + "purl": "pkg:npm/root@1.0.0", + "properties": [], + "externalReferences": [] + } + }, + "components": [ + { + "bom-ref": "dep1@0.0.1", + "type": "library", + "name": "dep1", + "version": "0.0.1", + "scope": "required", + "purl": "pkg:npm/dep1@0.0.1", + "properties": [], + "externalReferences": [] + } + ], + "dependencies": [ + { + "ref": "root@1.0.0", + "dependsOn": [ + "dep1@0.0.1" + ] + }, + { + "ref": "dep1@0.0.1", + "dependsOn": [] + } + ] +} +` + exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - application package type > must match snapshot 1`] = ` { "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", @@ -126,12 +171,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - application package "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [] } }, @@ -175,10 +215,6 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - bundled > must match "author": "Author", "purl": "pkg:npm/root@1.0.0", "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - }, { "name": "cdx:npm:package:bundled", "value": "true" @@ -227,10 +263,6 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - development > must m "author": "Author", "purl": "pkg:npm/root@1.0.0", "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - }, { "name": "cdx:npm:package:development", "value": "true" @@ -279,10 +311,6 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - extraneous > must ma "author": "Author", "purl": "pkg:npm/root@1.0.0", "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - }, { "name": "cdx:npm:package:extraneous", "value": "true" @@ -330,12 +358,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - from git url > must "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0?vcs_url=https://github.com/foo/bar#1234", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [ { "type": "distribution", @@ -382,12 +405,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - no package info > mu "version": "1.0.0", "scope": "required", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [] } }, @@ -430,12 +448,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - optional > must matc "scope": "optional", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [] } }, @@ -478,12 +491,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - package lock only > "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [] } }, @@ -527,10 +535,6 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - private > must match "author": "Author", "purl": "pkg:npm/root@1.0.0", "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - }, { "name": "cdx:npm:package:private", "value": "true" @@ -578,12 +582,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - with author object > "scope": "required", "author": "Arthur", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [] } }, @@ -627,12 +626,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - with description > m "author": "Author", "description": "Package description", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [] } }, @@ -675,12 +669,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - with distribution ur "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [ { "type": "distribution", @@ -728,12 +717,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - with homepage > must "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [ { "type": "website", @@ -781,12 +765,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - with integrity > mus "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [], "hashes": [ { @@ -835,12 +814,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - with issue tracker > "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [ { "type": "issue-tracker", @@ -888,12 +862,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - with license express "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [], "licenses": [ { @@ -941,12 +910,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - with license object "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [], "licenses": [ { @@ -996,12 +960,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - with repository url "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [ { "type": "vcs", @@ -1049,12 +1008,7 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - with single license "scope": "required", "author": "Author", "purl": "pkg:npm/root@1.0.0", - "properties": [ - { - "name": "cdx:npm:package:path", - "value": "" - } - ], + "properties": [], "externalReferences": [], "licenses": [ { diff --git a/tap-snapshots/test/lib/utils/sbom-spdx.js.test.cjs b/tap-snapshots/test/lib/utils/sbom-spdx.js.test.cjs index b887e13ca7dc0..3583c0bc83577 100644 --- a/tap-snapshots/test/lib/utils/sbom-spdx.js.test.cjs +++ b/tap-snapshots/test/lib/utils/sbom-spdx.js.test.cjs @@ -182,6 +182,95 @@ exports[`test/lib/utils/sbom-spdx.js TAP node - with deps > must match snapshot } ` +exports[`test/lib/utils/sbom-spdx.js TAP node - with duplicate deps > must match snapshot 1`] = ` +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "root@1.0.0", + "documentNamespace": "docns", + "creationInfo": { + "created": "2020-01-01T00:00:00.000Z", + "creators": [ + "Tool: npm/cli-10.0.0 " + ] + }, + "documentDescribes": [ + "SPDXRef-Package-root-1.0.0" + ], + "packages": [ + { + "name": "root", + "SPDXID": "SPDXRef-Package-root-1.0.0", + "versionInfo": "1.0.0", + "packageFileName": "", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "homepage": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/root@1.0.0" + } + ] + }, + { + "name": "dep1", + "SPDXID": "SPDXRef-Package-dep1-0.0.1", + "versionInfo": "0.0.1", + "packageFileName": "node_modules/dep1", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "homepage": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/dep1@0.0.1" + } + ] + }, + { + "name": "dep2", + "SPDXID": "SPDXRef-Package-dep2-0.0.2", + "versionInfo": "0.0.2", + "packageFileName": "node_modules/dep2", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "homepage": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/dep2@0.0.2" + } + ] + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-Package-root-1.0.0", + "relationshipType": "DESCRIBES" + }, + { + "spdxElementId": "SPDXRef-Package-dep1-0.0.1", + "relatedSpdxElement": "SPDXRef-Package-root-1.0.0", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-dep2-0.0.2", + "relatedSpdxElement": "SPDXRef-Package-root-1.0.0", + "relationshipType": "DEPENDENCY_OF" + } + ] +} +` + exports[`test/lib/utils/sbom-spdx.js TAP single node - application package type > must match snapshot 1`] = ` { "spdxVersion": "SPDX-2.3", diff --git a/test/lib/commands/sbom.js b/test/lib/commands/sbom.js index 25f6135ef8a14..c08756414d25e 100644 --- a/test/lib/commands/sbom.js +++ b/test/lib/commands/sbom.js @@ -205,6 +205,97 @@ t.test('sbom', async t => { t.matchSnapshot(result()) }) + const dupDepsNmFixture = { + node_modules: { + foo: { + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + dependencies: { + chai: '^1.0.0', + }, + }), + node_modules: { + chai: { + 'package.json': JSON.stringify({ + name: 'chai', + version: '1.0.0', + }), + }, + }, + }, + bar: { + 'package.json': JSON.stringify({ + name: 'bar', + version: '1.0.0', + dependencies: { + chai: '^1.0.0', + }, + }), + node_modules: { + chai: { + 'package.json': JSON.stringify({ + name: 'chai', + version: '1.0.0', + }), + }, + }, + }, + chai: { + 'package.json': JSON.stringify({ + name: 'chai', + version: '2.0.0', + }), + }, + }, + } + + t.test('duplicate deps - spdx', async t => { + const config = { + 'sbom-format': 'spdx', + } + const { result, sbom } = await mockSbom(t, { + config, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'test-npm-sbom', + version: '1.0.0', + dependencies: { + foo: '^1.0.0', + bar: '^1.0.0', + chai: '^2.0.0', + }, + }), + ...dupDepsNmFixture, + }, + }) + await sbom.exec([]) + t.matchSnapshot(result()) + }) + + t.test('duplicate deps - cyclonedx', async t => { + const config = { + 'sbom-format': 'cyclonedx', + } + const { result, sbom } = await mockSbom(t, { + config, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'test-npm-sbom', + version: '1.0.0', + dependencies: { + foo: '^1.0.0', + bar: '^1.0.0', + chai: '^2.0.0', + }, + }), + ...dupDepsNmFixture, + }, + }) + await sbom.exec([]) + t.matchSnapshot(result()) + }) + t.test('missing format', async t => { const config = {} const { result, sbom } = await mockSbom(t, { diff --git a/test/lib/utils/sbom-cyclonedx.js b/test/lib/utils/sbom-cyclonedx.js index da9b3f757988b..42819b8a77431 100644 --- a/test/lib/utils/sbom-cyclonedx.js +++ b/test/lib/utils/sbom-cyclonedx.js @@ -233,6 +233,18 @@ t.test('node - with deps', t => { t.end() }) +t.test('node - with duplicate deps', t => { + const node = { + ...root, + edgesOut: [ + { to: dep1 }, + ], + } + const res = cyclonedxOutput({ npm, nodes: [node, dep1, dep1] }) + t.matchSnapshot(JSON.stringify(res)) + t.end() +}) + // Check that all of the generated test snapshots validate against the CycloneDX schema t.test('schema validation', t => { // Load schemas diff --git a/test/lib/utils/sbom-spdx.js b/test/lib/utils/sbom-spdx.js index d69e85667dc85..ffa92f0e3a30f 100644 --- a/test/lib/utils/sbom-spdx.js +++ b/test/lib/utils/sbom-spdx.js @@ -199,6 +199,17 @@ t.test('node - with deps', t => { t.end() }) +t.test('node - with duplicate deps', t => { + const node = { ...root, + edgesOut: [ + { to: dep1 }, + { to: dep2 }, + ] } + const res = spdxOutput({ npm, nodes: [node, dep1, dep2, dep1, dep2] }) + t.matchSnapshot(JSON.stringify(res)) + t.end() +}) + // Check that all of the generated test snapshots validate against the SPDX schema t.test('schema validation', t => { const ajv = new Ajv()