Skip to content

Commit faf295f

Browse files
committed
feat(sbom): add SHA-256 hash support to CycloneDX SBOM component model
Add optional hashes parameter to getComponent() and addDependency() in the CycloneDX SBOM model, allowing downstream providers to attach SHA-256 artifact hashes to components. Update Sbom wrapper, base_pyproject graph structure, python_controller DependencyEntry, and python_pip SBOM creation paths to forward hash data through the full pipeline. Hashes are omitted when not provided, maintaining backward compatibility. Implements TC-4330 Assisted-by: Claude Code
1 parent 2e5fe72 commit faf295f

6 files changed

Lines changed: 140 additions & 15 deletions

File tree

src/cyclone_dx_sbom.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import {PackageURL} from "packageurl-js";
88
* @param type type of package - application or library
99
* @param scope scope of the component - runtime or compile
1010
* @param licenses optional license string or array of licenses for the component
11-
* @return {{"bom-ref": string, name, purl: string, type, version, scope, licenses?}}
11+
* @param hashes optional array of hash objects for the component, e.g. [{alg: "SHA-256", content: "..."}]
12+
* @return {{"bom-ref": string, name, purl: string, type, version, scope, licenses?, hashes?}}
1213
* @private
1314
*/
14-
function getComponent(component, type, scope, licenses) {
15+
function getComponent(component, type, scope, licenses, hashes) {
1516
let componentObject;
1617
if(component instanceof PackageURL)
1718
{
@@ -54,6 +55,11 @@ function getComponent(component, type, scope, licenses) {
5455
});
5556
}
5657

58+
// Add hashes if provided (CycloneDX 1.4 format).
59+
if (hashes && hashes.length > 0) {
60+
componentObject.hashes = hashes;
61+
}
62+
5763
return componentObject
5864
}
5965

@@ -109,17 +115,20 @@ export default class CycloneDxSbom {
109115
* Adds a dependency relationship between two components in the SBOM
110116
* @param {PackageURL} sourceRef - The source component (parent)
111117
* @param {PackageURL} targetRef - The target component (dependency)
118+
* @param {string} [scope] - Scope of the dependency
119+
* @param {Array<{alg: string, content: string}>} [targetHashes] - Optional hashes for the target component
112120
* @return {CycloneDxSbom} The updated SBOM
113121
*/
114-
addDependency(sourceRef, targetRef, scope) {
122+
addDependency(sourceRef, targetRef, scope, targetHashes) {
115123
const sourcePurl = sourceRef.toString();
116124
const targetPurl = targetRef.toString();
117125

118126
// Ensure both components exist in the components list
119127
[sourceRef, targetRef].forEach((ref, index) => {
120128
const purl = index === 0 ? sourcePurl : targetPurl;
121129
if (this.getComponentIndex(purl) < 0) {
122-
this.components.push(getComponent(ref, "library", scope));
130+
const hashes = index === 1 ? targetHashes : undefined;
131+
this.components.push(getComponent(ref, "library", scope, undefined, hashes));
123132
}
124133
});
125134

src/providers/base_pyproject.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const IGNORE_MARKERS = ['exhortignore', 'trustify-da-ignore']
1515
const DEFAULT_ROOT_NAME = 'default-pip-root'
1616
const DEFAULT_ROOT_VERSION = '0.0.0'
1717

18-
/** @typedef {{name: string, version: string, children: string[]}} GraphEntry */
18+
/** @typedef {{name: string, version: string, children: string[], hashes?: Array<{alg: string, content: string}>}} GraphEntry */
1919
/** @typedef {{name: string, version: string, dependencies: DepTreeEntry[]}} DepTreeEntry */
2020
/** @typedef {{directDeps: string[], graph: Map<string, GraphEntry>}} DependencyData */
2121
/** @typedef {{ecosystem: string, content: string, contentType: string}} Provided */
@@ -313,23 +313,23 @@ export default class Base_pyproject {
313313
for (let key of directDeps) {
314314
if (!reachable.has(key)) { continue }
315315
let entry = graph.get(key)
316-
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version))
316+
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version), undefined, entry.hashes)
317317
}
318318
for (let [key, entry] of graph) {
319319
if (!reachable.has(key)) { continue }
320320
let parentPurl = this._toPurl(entry.name, entry.version)
321321
for (let child of entry.children) {
322322
if (!reachable.has(child)) { continue }
323323
let childEntry = graph.get(child)
324-
sbom.addDependency(parentPurl, this._toPurl(childEntry.name, childEntry.version))
324+
sbom.addDependency(parentPurl, this._toPurl(childEntry.name, childEntry.version), undefined, childEntry.hashes)
325325
}
326326
}
327327
} else {
328328
for (let key of directDeps) {
329329
if (ignoredDeps.has(key)) { continue }
330330
let entry = graph.get(key)
331331
if (!entry) { continue }
332-
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version))
332+
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version), undefined, entry.hashes)
333333
}
334334
}
335335

src/providers/python_controller.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ function getPipShowOutput(depNames) {
2222
}
2323
}
2424

25-
/** @typedef {{name: string, version: string, dependencies: DependencyEntry[]}} DependencyEntry */
25+
/** @typedef {{name: string, version: string, dependencies: DependencyEntry[], hashes?: Array<{alg: string, content: string}>}} DependencyEntry */
2626

2727
export default class Python_controller {
2828
pythonEnvDir

src/providers/python_pip.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { getParser, getIgnoreQuery, getPinnedVersionQuery } from './requirements
1616

1717
export default { isSupported, validateLockFile, provideComponent, provideStack, readLicenseFromManifest }
1818

19-
/** @typedef {{name: string, version: string, dependencies: DependencyEntry[]}} DependencyEntry */
19+
/** @typedef {{name: string, version: string, dependencies: DependencyEntry[], hashes?: Array<{alg: string, content: string}>}} DependencyEntry */
2020

2121
/**
2222
* @type {string} ecosystem for python-pip is 'pip'
@@ -73,7 +73,7 @@ async function provideComponent(manifest, opts = {}) {
7373
}
7474
}
7575

76-
/** @typedef {{name: string, , version: string, dependencies: DependencyEntry[]}} DependencyEntry */
76+
/** @typedef {{name: string, version: string, dependencies: DependencyEntry[], hashes?: Array<{alg: string, content: string}>}} DependencyEntry */
7777

7878
/**
7979
*
@@ -84,7 +84,7 @@ async function provideComponent(manifest, opts = {}) {
8484
*/
8585
function addAllDependencies(source, dep, sbom) {
8686
let targetPurl = toPurl(dep["name"], dep["version"])
87-
sbom.addDependency(source, targetPurl)
87+
sbom.addDependency(source, targetPurl, undefined, dep["hashes"])
8888
let directDeps = dep["dependencies"]
8989
if (directDeps !== undefined && directDeps.length > 0) {
9090
directDeps.forEach((dependency) => { addAllDependencies(toPurl(dep["name"], dep["version"]), dependency, sbom) })
@@ -226,7 +226,7 @@ async function getSbomForComponentAnalysis(manifest, opts = {}) {
226226
const license = readLicenseFromManifest(manifest);
227227
sbom.addRoot(rootPurl, license);
228228
dependencies.forEach(dep => {
229-
sbom.addDependency(rootPurl, toPurl(dep.name, dep.version))
229+
sbom.addDependency(rootPurl, toPurl(dep.name, dep.version), undefined, dep.hashes)
230230
})
231231
await handleIgnoredDependencies(manifest, sbom, opts)
232232
// In python there is no root component, then we must remove the dummy root we added, so the sbom json will be accepted by the DA backend

src/sbom.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ export default class Sbom {
4848
/**
4949
* @param {component} sourceRef current source Component ( Starting from root component by clients)
5050
* @param {PackageURL} targetRef current dependency to add to Dependencies list of component sourceRef
51+
* @param {string} [scope] - Scope of the dependency
52+
* @param {Array<{alg: string, content: string}>} [targetHashes] - Optional hashes for the target component
5153
* @return Sbom
5254
*/
53-
addDependency(sourceRef, targetRef, scope){
54-
return this.sbomModel.addDependency(sourceRef, targetRef, scope)
55+
addDependency(sourceRef, targetRef, scope, targetHashes){
56+
return this.sbomModel.addDependency(sourceRef, targetRef, scope, targetHashes)
5557
}
5658

5759
/**
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { expect } from 'chai'
2+
import { PackageURL } from 'packageurl-js'
3+
4+
import CycloneDxSbom from '../src/cyclone_dx_sbom.js'
5+
6+
const sampleHashes = [{ alg: 'SHA-256', content: 'abc123def456' }]
7+
8+
suite('CycloneDX SBOM hash support', () => {
9+
10+
/** Verifies that addDependency with hashes produces a component containing the hashes array. */
11+
test('getComponent with hashes includes hashes array in component', () => {
12+
// Given an SBOM with a root and a dependency with hashes
13+
const sbom = new CycloneDxSbom()
14+
const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined)
15+
const purl = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined)
16+
sbom.addRoot(root)
17+
18+
// When adding a dependency with hashes
19+
sbom.addDependency(root, purl, undefined, sampleHashes)
20+
21+
// Then the target component should include the hashes
22+
const targetComponent = sbom.components.find(c => c.name === 'requests')
23+
expect(targetComponent).to.exist
24+
expect(targetComponent.hashes).to.deep.equal(sampleHashes)
25+
})
26+
27+
/** Verifies that addDependency without hashes produces a component with no hashes field. */
28+
test('getComponent without hashes does not include hashes field', () => {
29+
// Given an SBOM with a root and a dependency without hashes
30+
const sbom = new CycloneDxSbom()
31+
const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined)
32+
const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined)
33+
sbom.addRoot(root)
34+
35+
// When adding a dependency without hashes
36+
sbom.addDependency(root, dep)
37+
38+
// Then the target component should not have a hashes property
39+
const targetComponent = sbom.components.find(c => c.name === 'requests')
40+
expect(targetComponent).to.exist
41+
expect(targetComponent).to.not.have.property('hashes')
42+
})
43+
44+
/** Verifies that hashes are attached only to the target component, not the source. */
45+
test('addDependency forwards hashes only to target component', () => {
46+
// Given an SBOM with a chain: root -> dep1 -> dep2 (only dep2 has hashes)
47+
const sbom = new CycloneDxSbom()
48+
const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined)
49+
const dep1 = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined)
50+
const dep2 = new PackageURL('pypi', undefined, 'numpy', '1.24.0', undefined, undefined)
51+
const dep2Hashes = [{ alg: 'SHA-256', content: '789xyz' }]
52+
sbom.addRoot(root)
53+
sbom.addDependency(root, dep1)
54+
55+
// When adding dep2 as a dependency of dep1 with hashes
56+
sbom.addDependency(dep1, dep2, undefined, dep2Hashes)
57+
58+
// Then dep1 (source) should have no hashes, dep2 (target) should have hashes
59+
const dep1Component = sbom.components.find(c => c.name === 'requests')
60+
expect(dep1Component).to.not.have.property('hashes')
61+
const dep2Component = sbom.components.find(c => c.name === 'numpy')
62+
expect(dep2Component.hashes).to.deep.equal(dep2Hashes)
63+
})
64+
65+
/** Verifies that hashes are included in the serialized CycloneDX JSON output. */
66+
test('hashes appear in serialized SBOM JSON', () => {
67+
// Given an SBOM with a hashed dependency
68+
const sbom = new CycloneDxSbom()
69+
const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined)
70+
const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined)
71+
sbom.addRoot(root)
72+
sbom.addDependency(root, dep, undefined, sampleHashes)
73+
74+
// When serializing to JSON
75+
const json = JSON.parse(sbom.getAsJsonString({}))
76+
77+
// Then the component in JSON should contain hashes
78+
const comp = json.components.find(c => c.name === 'requests')
79+
expect(comp.hashes).to.deep.equal(sampleHashes)
80+
})
81+
82+
/** Verifies that components without hashes have no hashes field in serialized JSON. */
83+
test('component without hashes has no hashes in serialized SBOM JSON', () => {
84+
// Given an SBOM with a dependency without hashes
85+
const sbom = new CycloneDxSbom()
86+
const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined)
87+
const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined)
88+
sbom.addRoot(root)
89+
sbom.addDependency(root, dep)
90+
91+
// When serializing to JSON
92+
const json = JSON.parse(sbom.getAsJsonString({}))
93+
94+
// Then the component in JSON should not have a hashes property
95+
const comp = json.components.find(c => c.name === 'requests')
96+
expect(comp).to.not.have.property('hashes')
97+
})
98+
99+
/** Verifies that passing an empty hashes array is treated the same as no hashes. */
100+
test('empty hashes array does not add hashes field', () => {
101+
// Given an SBOM with a dependency with an empty hashes array
102+
const sbom = new CycloneDxSbom()
103+
const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined)
104+
const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined)
105+
sbom.addRoot(root)
106+
107+
// When adding a dependency with empty hashes
108+
sbom.addDependency(root, dep, undefined, [])
109+
110+
// Then the component should not have a hashes property
111+
const targetComponent = sbom.components.find(c => c.name === 'requests')
112+
expect(targetComponent).to.not.have.property('hashes')
113+
})
114+
})

0 commit comments

Comments
 (0)