diff --git a/lib/utils/sbom-cyclonedx.js b/lib/utils/sbom-cyclonedx.js index ffa2d35e9f5e0..6e976f4714e2b 100644 --- a/lib/utils/sbom-cyclonedx.js +++ b/lib/utils/sbom-cyclonedx.js @@ -170,13 +170,20 @@ const toCyclonedxItem = (node, { packageType }) => { } const toCyclonedxDependency = (node, nodes) => { - return { - ref: toCyclonedxID(node), - dependsOn: [...node.edgesOut.values()] + // A node can have multiple outgoing edges resolving to the same + // `name@version` (e.g. via npm aliases like `foo: npm:bar@1` alongside a + // direct `bar: ^1` dep), which would produce duplicate entries in + // `dependsOn`. CycloneDX 1.5 requires unique items, so dedupe by ref. + const dependsOn = [...new Set( + [...node.edgesOut.values()] // Filter out edges that are linking to nodes not in the list .filter(edge => nodes.find(n => n === edge.to)) .map(edge => toCyclonedxID(edge.to)) - .filter(id => id), + .filter(id => id) + )] + return { + ref: toCyclonedxID(node), + dependsOn, } } diff --git a/lib/utils/sbom-spdx.js b/lib/utils/sbom-spdx.js index 38824f263681d..8ea75c688bc86 100644 --- a/lib/utils/sbom-spdx.js +++ b/lib/utils/sbom-spdx.js @@ -48,11 +48,23 @@ const spdxOutput = ({ npm, nodes, packageType }) => { } seen.add(node) + // A node can have multiple outgoing edges resolving to the same + // `name@version` of the same edge type (e.g. via npm aliases), which + // would produce identical relationship triples. Dedupe per source node. + const seenRels = new Set() const rels = [...node.edgesOut.values()] // Filter out edges that are linking to nodes not in the list .filter(edge => nodes.find(n => n === edge.to)) .map(edge => toSpdxRelationship(node, edge)) .filter(rel => rel) + .filter(rel => { + const key = `${rel.spdxElementId}|${rel.relatedSpdxElement}|${rel.relationshipType}` + if (seenRels.has(key)) { + return false + } + seenRels.add(key) + return true + }) relationships.push(...rels) } 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 8bc81cc4f69c1..124478bc82993 100644 --- a/tap-snapshots/test/lib/utils/sbom-cyclonedx.js.test.cjs +++ b/tap-snapshots/test/lib/utils/sbom-cyclonedx.js.test.cjs @@ -142,6 +142,66 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP node - with duplicate deps > must } ` +exports[`test/lib/utils/sbom-cyclonedx.js TAP node - with duplicate edges to same dep > 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", 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 26931d78124a7..6adb6d26de143 100644 --- a/tap-snapshots/test/lib/utils/sbom-spdx.js.test.cjs +++ b/tap-snapshots/test/lib/utils/sbom-spdx.js.test.cjs @@ -271,6 +271,73 @@ exports[`test/lib/utils/sbom-spdx.js TAP node - with duplicate deps > must match } ` +exports[`test/lib/utils/sbom-spdx.js TAP node - with duplicate edges to same dep > 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" + } + ] + } + ], + "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" + } + ] +} +` + 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/utils/sbom-cyclonedx.js b/test/lib/utils/sbom-cyclonedx.js index 5edd478ab0b46..5dd5bf606e704 100644 --- a/test/lib/utils/sbom-cyclonedx.js +++ b/test/lib/utils/sbom-cyclonedx.js @@ -304,6 +304,25 @@ t.test('node - with duplicate deps', t => { t.end() }) +t.test('node - with duplicate edges to same dep', t => { + // A node can have multiple outgoing edges resolving to the same + // `name@version` (e.g. a direct `dep1: ^1` plus an alias + // `dep1-aliased: npm:dep1@^1`). The resulting `dependsOn` array must + // still contain each ref at most once, since CycloneDX 1.5 requires + // unique items. + const node = { + ...root, + edgesOut: [ + { to: dep1 }, + { to: dep1 }, + ], + } + const res = cyclonedxOutput({ npm, nodes: [node, dep1] }) + t.same(res.dependencies[0].dependsOn, ['dep1@0.0.1']) + 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 cdeb68218ee33..d2599b0824510 100644 --- a/test/lib/utils/sbom-spdx.js +++ b/test/lib/utils/sbom-spdx.js @@ -256,6 +256,27 @@ t.test('node - with duplicate deps', t => { t.end() }) +t.test('node - with duplicate edges to same dep', t => { + // A node can have multiple outgoing edges resolving to the same + // `name@version` of the same edge type (e.g. a direct `dep1: ^1` plus an + // alias `dep1-aliased: npm:dep1@^1`). The resulting relationships must + // still be unique per (source, target, type) triple. + const node = { ...root, + edgesOut: [ + { to: dep1 }, + { to: dep1 }, + ] } + const res = spdxOutput({ npm, nodes: [node, dep1] }) + const depRels = res.relationships.filter( + r => r.spdxElementId === 'SPDXRef-Package-dep1-0.0.1' + && r.relatedSpdxElement === 'SPDXRef-Package-root-1.0.0' + && r.relationshipType === 'DEPENDENCY_OF' + ) + t.equal(depRels.length, 1) + 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()