Skip to content

Commit d623988

Browse files
fix(sbom): dedupe per-node dependsOn / relationships (npm#9311)
## Summary Closes the per-node duplication gap left by npm#7992. A node can have multiple outgoing edges resolving to the same `name@version` — typically when a package declares both a direct dependency and an npm alias to the same package, e.g.: ```json { "dependencies": { "lodash": "^4.17.21", "lodash-aliased": "npm:lodash@^4.17.21" } } ``` `toCyclonedxDependency` and the SPDX relationship loop both map each edge through `name@version` ID generation without deduplicating, so the per-node `dependsOn` array (CycloneDX) and `DEPENDENCY_OF` relationships (SPDX) end up with duplicate entries. CycloneDX 1.5 requires `dependsOn` items to be unique, so downstream validators (e.g. Dependency Track) reject the SBOM with: ``` $.dependencies[N].dependsOn: must have only unique items in the array ``` ## Changes - `lib/utils/sbom-cyclonedx.js`: wrap the `dependsOn` array in `[...new Set(...)]` after mapping edges to refs. - `lib/utils/sbom-spdx.js`: dedupe per source-node relationships by the `(spdxElementId, relatedSpdxElement, relationshipType)` triple. - Test cases added to both `test/lib/utils/sbom-cyclonedx.js` and `test/lib/utils/sbom-spdx.js` covering the duplicate-edges-to-same-target scenario, with explicit assertions plus snapshot updates. ## Test plan - [x] `node . run test -- test/lib/utils/sbom-cyclonedx.js test/lib/utils/sbom-spdx.js` — passes - [x] 100% coverage on both touched files - [x] Snapshot diff is purely additive (no existing snapshots changed) - [x] Schema-validation tests in both files still pass for all snapshots - [x] Reproduced original issue locally with the alias example, ran patched npm against it, confirmed both CycloneDX `dependsOn` and SPDX relationships are now deduped Fixes npm#9310
1 parent 076551b commit d623988

6 files changed

Lines changed: 190 additions & 4 deletions

File tree

lib/utils/sbom-cyclonedx.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,20 @@ const toCyclonedxItem = (node, { packageType }) => {
170170
}
171171

172172
const toCyclonedxDependency = (node, nodes) => {
173-
return {
174-
ref: toCyclonedxID(node),
175-
dependsOn: [...node.edgesOut.values()]
173+
// A node can have multiple outgoing edges resolving to the same
174+
// `name@version` (e.g. via npm aliases like `foo: npm:bar@1` alongside a
175+
// direct `bar: ^1` dep), which would produce duplicate entries in
176+
// `dependsOn`. CycloneDX 1.5 requires unique items, so dedupe by ref.
177+
const dependsOn = [...new Set(
178+
[...node.edgesOut.values()]
176179
// Filter out edges that are linking to nodes not in the list
177180
.filter(edge => nodes.find(n => n === edge.to))
178181
.map(edge => toCyclonedxID(edge.to))
179-
.filter(id => id),
182+
.filter(id => id)
183+
)]
184+
return {
185+
ref: toCyclonedxID(node),
186+
dependsOn,
180187
}
181188
}
182189

lib/utils/sbom-spdx.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,23 @@ const spdxOutput = ({ npm, nodes, packageType }) => {
4848
}
4949
seen.add(node)
5050

51+
// A node can have multiple outgoing edges resolving to the same
52+
// `name@version` of the same edge type (e.g. via npm aliases), which
53+
// would produce identical relationship triples. Dedupe per source node.
54+
const seenRels = new Set()
5155
const rels = [...node.edgesOut.values()]
5256
// Filter out edges that are linking to nodes not in the list
5357
.filter(edge => nodes.find(n => n === edge.to))
5458
.map(edge => toSpdxRelationship(node, edge))
5559
.filter(rel => rel)
60+
.filter(rel => {
61+
const key = `${rel.spdxElementId}|${rel.relatedSpdxElement}|${rel.relationshipType}`
62+
if (seenRels.has(key)) {
63+
return false
64+
}
65+
seenRels.add(key)
66+
return true
67+
})
5668

5769
relationships.push(...rels)
5870
}

tap-snapshots/test/lib/utils/sbom-cyclonedx.js.test.cjs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,66 @@ exports[`test/lib/utils/sbom-cyclonedx.js TAP node - with duplicate deps > must
142142
}
143143
`
144144

145+
exports[`test/lib/utils/sbom-cyclonedx.js TAP node - with duplicate edges to same dep > must match snapshot 1`] = `
146+
{
147+
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
148+
"bomFormat": "CycloneDX",
149+
"specVersion": "1.5",
150+
"serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000000",
151+
"version": 1,
152+
"metadata": {
153+
"timestamp": "2020-01-01T00:00:00.000Z",
154+
"lifecycles": [
155+
{
156+
"phase": "build"
157+
}
158+
],
159+
"tools": [
160+
{
161+
"vendor": "npm",
162+
"name": "cli",
163+
"version": "10.0.0 "
164+
}
165+
],
166+
"component": {
167+
"bom-ref": "root@1.0.0",
168+
"type": "library",
169+
"name": "root",
170+
"version": "1.0.0",
171+
"scope": "required",
172+
"author": "Author",
173+
"purl": "pkg:npm/root@1.0.0",
174+
"properties": [],
175+
"externalReferences": []
176+
}
177+
},
178+
"components": [
179+
{
180+
"bom-ref": "dep1@0.0.1",
181+
"type": "library",
182+
"name": "dep1",
183+
"version": "0.0.1",
184+
"scope": "required",
185+
"purl": "pkg:npm/dep1@0.0.1",
186+
"properties": [],
187+
"externalReferences": []
188+
}
189+
],
190+
"dependencies": [
191+
{
192+
"ref": "root@1.0.0",
193+
"dependsOn": [
194+
"dep1@0.0.1"
195+
]
196+
},
197+
{
198+
"ref": "dep1@0.0.1",
199+
"dependsOn": []
200+
}
201+
]
202+
}
203+
`
204+
145205
exports[`test/lib/utils/sbom-cyclonedx.js TAP single node - application package type > must match snapshot 1`] = `
146206
{
147207
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",

tap-snapshots/test/lib/utils/sbom-spdx.js.test.cjs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,73 @@ exports[`test/lib/utils/sbom-spdx.js TAP node - with duplicate deps > must match
271271
}
272272
`
273273

274+
exports[`test/lib/utils/sbom-spdx.js TAP node - with duplicate edges to same dep > must match snapshot 1`] = `
275+
{
276+
"spdxVersion": "SPDX-2.3",
277+
"dataLicense": "CC0-1.0",
278+
"SPDXID": "SPDXRef-DOCUMENT",
279+
"name": "root@1.0.0",
280+
"documentNamespace": "docns",
281+
"creationInfo": {
282+
"created": "2020-01-01T00:00:00.000Z",
283+
"creators": [
284+
"Tool: npm/cli-10.0.0 "
285+
]
286+
},
287+
"documentDescribes": [
288+
"SPDXRef-Package-root-1.0.0"
289+
],
290+
"packages": [
291+
{
292+
"name": "root",
293+
"SPDXID": "SPDXRef-Package-root-1.0.0",
294+
"versionInfo": "1.0.0",
295+
"packageFileName": "",
296+
"downloadLocation": "NOASSERTION",
297+
"filesAnalyzed": false,
298+
"homepage": "NOASSERTION",
299+
"licenseDeclared": "NOASSERTION",
300+
"externalRefs": [
301+
{
302+
"referenceCategory": "PACKAGE-MANAGER",
303+
"referenceType": "purl",
304+
"referenceLocator": "pkg:npm/root@1.0.0"
305+
}
306+
]
307+
},
308+
{
309+
"name": "dep1",
310+
"SPDXID": "SPDXRef-Package-dep1-0.0.1",
311+
"versionInfo": "0.0.1",
312+
"packageFileName": "node_modules/dep1",
313+
"downloadLocation": "NOASSERTION",
314+
"filesAnalyzed": false,
315+
"homepage": "NOASSERTION",
316+
"licenseDeclared": "NOASSERTION",
317+
"externalRefs": [
318+
{
319+
"referenceCategory": "PACKAGE-MANAGER",
320+
"referenceType": "purl",
321+
"referenceLocator": "pkg:npm/dep1@0.0.1"
322+
}
323+
]
324+
}
325+
],
326+
"relationships": [
327+
{
328+
"spdxElementId": "SPDXRef-DOCUMENT",
329+
"relatedSpdxElement": "SPDXRef-Package-root-1.0.0",
330+
"relationshipType": "DESCRIBES"
331+
},
332+
{
333+
"spdxElementId": "SPDXRef-Package-dep1-0.0.1",
334+
"relatedSpdxElement": "SPDXRef-Package-root-1.0.0",
335+
"relationshipType": "DEPENDENCY_OF"
336+
}
337+
]
338+
}
339+
`
340+
274341
exports[`test/lib/utils/sbom-spdx.js TAP single node - application package type > must match snapshot 1`] = `
275342
{
276343
"spdxVersion": "SPDX-2.3",

test/lib/utils/sbom-cyclonedx.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,25 @@ t.test('node - with duplicate deps', t => {
304304
t.end()
305305
})
306306

307+
t.test('node - with duplicate edges to same dep', t => {
308+
// A node can have multiple outgoing edges resolving to the same
309+
// `name@version` (e.g. a direct `dep1: ^1` plus an alias
310+
// `dep1-aliased: npm:dep1@^1`). The resulting `dependsOn` array must
311+
// still contain each ref at most once, since CycloneDX 1.5 requires
312+
// unique items.
313+
const node = {
314+
...root,
315+
edgesOut: [
316+
{ to: dep1 },
317+
{ to: dep1 },
318+
],
319+
}
320+
const res = cyclonedxOutput({ npm, nodes: [node, dep1] })
321+
t.same(res.dependencies[0].dependsOn, ['dep1@0.0.1'])
322+
t.matchSnapshot(JSON.stringify(res))
323+
t.end()
324+
})
325+
307326
// Check that all of the generated test snapshots validate against the CycloneDX schema
308327
t.test('schema validation', t => {
309328
// Load schemas

test/lib/utils/sbom-spdx.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,27 @@ t.test('node - with duplicate deps', t => {
256256
t.end()
257257
})
258258

259+
t.test('node - with duplicate edges to same dep', t => {
260+
// A node can have multiple outgoing edges resolving to the same
261+
// `name@version` of the same edge type (e.g. a direct `dep1: ^1` plus an
262+
// alias `dep1-aliased: npm:dep1@^1`). The resulting relationships must
263+
// still be unique per (source, target, type) triple.
264+
const node = { ...root,
265+
edgesOut: [
266+
{ to: dep1 },
267+
{ to: dep1 },
268+
] }
269+
const res = spdxOutput({ npm, nodes: [node, dep1] })
270+
const depRels = res.relationships.filter(
271+
r => r.spdxElementId === 'SPDXRef-Package-dep1-0.0.1'
272+
&& r.relatedSpdxElement === 'SPDXRef-Package-root-1.0.0'
273+
&& r.relationshipType === 'DEPENDENCY_OF'
274+
)
275+
t.equal(depRels.length, 1)
276+
t.matchSnapshot(JSON.stringify(res))
277+
t.end()
278+
})
279+
259280
// Check that all of the generated test snapshots validate against the SPDX schema
260281
t.test('schema validation', t => {
261282
const ajv = new Ajv()

0 commit comments

Comments
 (0)