Skip to content

Commit 5348b9e

Browse files
a-orenclaude
andauthored
feat(sbom): add SHA-256 hash support to CycloneDX SBOM component model (guacsec#503)
* 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 * fix(sbom): address review feedback for hash support - Update existing component hashes when first seen without them, handling cases where Map iteration order causes a package to appear as a source before it appears as a target with hashes - Remove duplicate DependencyEntry typedef in python_pip.js - Add tests for hash update on pre-existing components Implements TC-4330 Assisted-by: Claude Code * refactor(sbom): introduce NO_SCOPE constant for addDependency readability Replace bare `undefined` scope arguments with a named `NO_SCOPE` constant to make the intent explicit at call sites. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4558b63 commit 5348b9e

6 files changed

Lines changed: 187 additions & 17 deletions

File tree

src/cyclone_dx_sbom.js

Lines changed: 18 additions & 5 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,24 @@ 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;
121-
if (this.getComponentIndex(purl) < 0) {
122-
this.components.push(getComponent(ref, "library", scope));
129+
const existingIndex = this.getComponentIndex(purl);
130+
if (existingIndex < 0) {
131+
const hashes = index === 1 ? targetHashes : undefined;
132+
this.components.push(getComponent(ref, "library", scope, undefined, hashes));
133+
} else if (index === 1 && targetHashes && targetHashes.length > 0 && !this.components[existingIndex].hashes) {
134+
// Update hashes if the component was first seen without them (e.g. as a source)
135+
this.components[existingIndex].hashes = targetHashes;
123136
}
124137
});
125138

src/providers/base_pyproject.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ const ecosystem = 'pip'
1212

1313
const IGNORE_MARKERS = ['exhortignore', 'trustify-da-ignore']
1414

15+
const NO_SCOPE = undefined
16+
1517
const DEFAULT_ROOT_NAME = 'default-pip-root'
1618
const DEFAULT_ROOT_VERSION = '0.0.0'
1719

18-
/** @typedef {{name: string, version: string, children: string[]}} GraphEntry */
20+
/** @typedef {{name: string, version: string, children: string[], hashes?: Array<{alg: string, content: string}>}} GraphEntry */
1921
/** @typedef {{name: string, version: string, dependencies: DepTreeEntry[]}} DepTreeEntry */
2022
/** @typedef {{directDeps: string[], graph: Map<string, GraphEntry>}} DependencyData */
2123
/** @typedef {{ecosystem: string, content: string, contentType: string}} Provided */
@@ -313,23 +315,23 @@ export default class Base_pyproject {
313315
for (let key of directDeps) {
314316
if (!reachable.has(key)) { continue }
315317
let entry = graph.get(key)
316-
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version))
318+
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version), NO_SCOPE, entry.hashes)
317319
}
318320
for (let [key, entry] of graph) {
319321
if (!reachable.has(key)) { continue }
320322
let parentPurl = this._toPurl(entry.name, entry.version)
321323
for (let child of entry.children) {
322324
if (!reachable.has(child)) { continue }
323325
let childEntry = graph.get(child)
324-
sbom.addDependency(parentPurl, this._toPurl(childEntry.name, childEntry.version))
326+
sbom.addDependency(parentPurl, this._toPurl(childEntry.name, childEntry.version), NO_SCOPE, childEntry.hashes)
325327
}
326328
}
327329
} else {
328330
for (let key of directDeps) {
329331
if (ignoredDeps.has(key)) { continue }
330332
let entry = graph.get(key)
331333
if (!entry) { continue }
332-
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version))
334+
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version), NO_SCOPE, entry.hashes)
333335
}
334336
}
335337

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: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@ 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'
2323
* @private
2424
*/
2525
const ecosystem = 'pip'
2626

27+
const NO_SCOPE = undefined
28+
2729
/**
2830
* @param {string} manifestName - the subject manifest name-type
2931
* @returns {boolean} - return true if `requirements.txt` is the manifest name-type
@@ -73,8 +75,6 @@ async function provideComponent(manifest, opts = {}) {
7375
}
7476
}
7577

76-
/** @typedef {{name: string, , version: string, dependencies: DependencyEntry[]}} DependencyEntry */
77-
7878
/**
7979
*
8080
* @param {PackageURL}source
@@ -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, NO_SCOPE, 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), NO_SCOPE, 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: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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 hashes are applied to an existing component that was first created without them as a source. */
100+
test('hashes are updated on component first seen as source without hashes', () => {
101+
// Given a component first created as a source (without hashes)
102+
const sbom = new CycloneDxSbom()
103+
const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined)
104+
const mid = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined)
105+
const leaf = new PackageURL('pypi', undefined, 'urllib3', '2.0.0', undefined, undefined)
106+
const midHashes = [{ alg: 'SHA-256', content: 'abc123' }]
107+
sbom.addRoot(root)
108+
109+
// When mid is first seen as a source (no hashes), then as a target with hashes
110+
sbom.addDependency(mid, leaf)
111+
sbom.addDependency(root, mid, undefined, midHashes)
112+
113+
// Then mid should have hashes despite being created first without them
114+
const midComponent = sbom.components.find(c => c.name === 'requests')
115+
expect(midComponent.hashes).to.deep.equal(midHashes)
116+
})
117+
118+
/** Verifies that hashes are applied when a component already exists as a target without hashes. */
119+
test('hashes are updated on component first seen as target without hashes', () => {
120+
// Given a component first added as a target without hashes
121+
const sbom = new CycloneDxSbom()
122+
const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined)
123+
const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined)
124+
const depHashes = [{ alg: 'SHA-256', content: 'abc123' }]
125+
sbom.addRoot(root)
126+
sbom.addDependency(root, dep)
127+
128+
// When the same component is added again as a target with hashes
129+
const other = new PackageURL('pypi', undefined, 'other', '1.0.0', undefined, undefined)
130+
sbom.addDependency(other, dep, undefined, depHashes)
131+
132+
// Then the component should have hashes and not be duplicated
133+
const depComponents = sbom.components.filter(c => c.name === 'requests')
134+
expect(depComponents).to.have.lengthOf(1)
135+
expect(depComponents[0].hashes).to.deep.equal(depHashes)
136+
})
137+
138+
/** Verifies that passing an empty hashes array is treated the same as no hashes. */
139+
test('empty hashes array does not add hashes field', () => {
140+
// Given an SBOM with a dependency with an empty hashes array
141+
const sbom = new CycloneDxSbom()
142+
const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined)
143+
const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined)
144+
sbom.addRoot(root)
145+
146+
// When adding a dependency with empty hashes
147+
sbom.addDependency(root, dep, undefined, [])
148+
149+
// Then the component should not have a hashes property
150+
const targetComponent = sbom.components.find(c => c.name === 'requests')
151+
expect(targetComponent).to.not.have.property('hashes')
152+
})
153+
})

0 commit comments

Comments
 (0)