diff --git a/src/cyclone_dx_sbom.js b/src/cyclone_dx_sbom.js index c56db0cd..e5d057a5 100644 --- a/src/cyclone_dx_sbom.js +++ b/src/cyclone_dx_sbom.js @@ -8,10 +8,11 @@ import {PackageURL} from "packageurl-js"; * @param type type of package - application or library * @param scope scope of the component - runtime or compile * @param licenses optional license string or array of licenses for the component - * @return {{"bom-ref": string, name, purl: string, type, version, scope, licenses?}} + * @param hashes optional array of hash objects for the component, e.g. [{alg: "SHA-256", content: "..."}] + * @return {{"bom-ref": string, name, purl: string, type, version, scope, licenses?, hashes?}} * @private */ -function getComponent(component, type, scope, licenses) { +function getComponent(component, type, scope, licenses, hashes) { let componentObject; if(component instanceof PackageURL) { @@ -54,6 +55,11 @@ function getComponent(component, type, scope, licenses) { }); } + // Add hashes if provided (CycloneDX 1.4 format). + if (hashes && hashes.length > 0) { + componentObject.hashes = hashes; + } + return componentObject } @@ -109,17 +115,24 @@ export default class CycloneDxSbom { * Adds a dependency relationship between two components in the SBOM * @param {PackageURL} sourceRef - The source component (parent) * @param {PackageURL} targetRef - The target component (dependency) + * @param {string} [scope] - Scope of the dependency + * @param {Array<{alg: string, content: string}>} [targetHashes] - Optional hashes for the target component * @return {CycloneDxSbom} The updated SBOM */ - addDependency(sourceRef, targetRef, scope) { + addDependency(sourceRef, targetRef, scope, targetHashes) { const sourcePurl = sourceRef.toString(); const targetPurl = targetRef.toString(); // Ensure both components exist in the components list [sourceRef, targetRef].forEach((ref, index) => { const purl = index === 0 ? sourcePurl : targetPurl; - if (this.getComponentIndex(purl) < 0) { - this.components.push(getComponent(ref, "library", scope)); + const existingIndex = this.getComponentIndex(purl); + if (existingIndex < 0) { + const hashes = index === 1 ? targetHashes : undefined; + this.components.push(getComponent(ref, "library", scope, undefined, hashes)); + } else if (index === 1 && targetHashes && targetHashes.length > 0 && !this.components[existingIndex].hashes) { + // Update hashes if the component was first seen without them (e.g. as a source) + this.components[existingIndex].hashes = targetHashes; } }); diff --git a/src/providers/base_pyproject.js b/src/providers/base_pyproject.js index d745e7bc..1d0f0034 100644 --- a/src/providers/base_pyproject.js +++ b/src/providers/base_pyproject.js @@ -12,10 +12,12 @@ const ecosystem = 'pip' const IGNORE_MARKERS = ['exhortignore', 'trustify-da-ignore'] +const NO_SCOPE = undefined + const DEFAULT_ROOT_NAME = 'default-pip-root' const DEFAULT_ROOT_VERSION = '0.0.0' -/** @typedef {{name: string, version: string, children: string[]}} GraphEntry */ +/** @typedef {{name: string, version: string, children: string[], hashes?: Array<{alg: string, content: string}>}} GraphEntry */ /** @typedef {{name: string, version: string, dependencies: DepTreeEntry[]}} DepTreeEntry */ /** @typedef {{directDeps: string[], graph: Map}} DependencyData */ /** @typedef {{ecosystem: string, content: string, contentType: string}} Provided */ @@ -313,7 +315,7 @@ export default class Base_pyproject { for (let key of directDeps) { if (!reachable.has(key)) { continue } let entry = graph.get(key) - sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version)) + sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version), NO_SCOPE, entry.hashes) } for (let [key, entry] of graph) { if (!reachable.has(key)) { continue } @@ -321,7 +323,7 @@ export default class Base_pyproject { for (let child of entry.children) { if (!reachable.has(child)) { continue } let childEntry = graph.get(child) - sbom.addDependency(parentPurl, this._toPurl(childEntry.name, childEntry.version)) + sbom.addDependency(parentPurl, this._toPurl(childEntry.name, childEntry.version), NO_SCOPE, childEntry.hashes) } } } else { @@ -329,7 +331,7 @@ export default class Base_pyproject { if (ignoredDeps.has(key)) { continue } let entry = graph.get(key) if (!entry) { continue } - sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version)) + sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version), NO_SCOPE, entry.hashes) } } diff --git a/src/providers/python_controller.js b/src/providers/python_controller.js index c8ede79b..53846c43 100644 --- a/src/providers/python_controller.js +++ b/src/providers/python_controller.js @@ -22,7 +22,7 @@ function getPipShowOutput(depNames) { } } -/** @typedef {{name: string, version: string, dependencies: DependencyEntry[]}} DependencyEntry */ +/** @typedef {{name: string, version: string, dependencies: DependencyEntry[], hashes?: Array<{alg: string, content: string}>}} DependencyEntry */ export default class Python_controller { pythonEnvDir diff --git a/src/providers/python_pip.js b/src/providers/python_pip.js index 3cd8fdee..7a740bd0 100644 --- a/src/providers/python_pip.js +++ b/src/providers/python_pip.js @@ -16,7 +16,7 @@ import { getParser, getIgnoreQuery, getPinnedVersionQuery } from './requirements export default { isSupported, validateLockFile, provideComponent, provideStack, readLicenseFromManifest } -/** @typedef {{name: string, version: string, dependencies: DependencyEntry[]}} DependencyEntry */ +/** @typedef {{name: string, version: string, dependencies: DependencyEntry[], hashes?: Array<{alg: string, content: string}>}} DependencyEntry */ /** * @type {string} ecosystem for python-pip is 'pip' @@ -24,6 +24,8 @@ export default { isSupported, validateLockFile, provideComponent, provideStack, */ const ecosystem = 'pip' +const NO_SCOPE = undefined + /** * @param {string} manifestName - the subject manifest name-type * @returns {boolean} - return true if `requirements.txt` is the manifest name-type @@ -73,8 +75,6 @@ async function provideComponent(manifest, opts = {}) { } } -/** @typedef {{name: string, , version: string, dependencies: DependencyEntry[]}} DependencyEntry */ - /** * * @param {PackageURL}source @@ -84,7 +84,7 @@ async function provideComponent(manifest, opts = {}) { */ function addAllDependencies(source, dep, sbom) { let targetPurl = toPurl(dep["name"], dep["version"]) - sbom.addDependency(source, targetPurl) + sbom.addDependency(source, targetPurl, NO_SCOPE, dep["hashes"]) let directDeps = dep["dependencies"] if (directDeps !== undefined && directDeps.length > 0) { directDeps.forEach((dependency) => { addAllDependencies(toPurl(dep["name"], dep["version"]), dependency, sbom) }) @@ -226,7 +226,7 @@ async function getSbomForComponentAnalysis(manifest, opts = {}) { const license = readLicenseFromManifest(manifest); sbom.addRoot(rootPurl, license); dependencies.forEach(dep => { - sbom.addDependency(rootPurl, toPurl(dep.name, dep.version)) + sbom.addDependency(rootPurl, toPurl(dep.name, dep.version), NO_SCOPE, dep.hashes) }) await handleIgnoredDependencies(manifest, sbom, opts) // 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 diff --git a/src/sbom.js b/src/sbom.js index adf1cf4a..1c423f14 100644 --- a/src/sbom.js +++ b/src/sbom.js @@ -48,10 +48,12 @@ export default class Sbom { /** * @param {component} sourceRef current source Component ( Starting from root component by clients) * @param {PackageURL} targetRef current dependency to add to Dependencies list of component sourceRef + * @param {string} [scope] - Scope of the dependency + * @param {Array<{alg: string, content: string}>} [targetHashes] - Optional hashes for the target component * @return Sbom */ - addDependency(sourceRef, targetRef, scope){ - return this.sbomModel.addDependency(sourceRef, targetRef, scope) + addDependency(sourceRef, targetRef, scope, targetHashes){ + return this.sbomModel.addDependency(sourceRef, targetRef, scope, targetHashes) } /** diff --git a/test/cyclone_dx_sbom_hashes.test.js b/test/cyclone_dx_sbom_hashes.test.js new file mode 100644 index 00000000..68f889f5 --- /dev/null +++ b/test/cyclone_dx_sbom_hashes.test.js @@ -0,0 +1,153 @@ +import { expect } from 'chai' +import { PackageURL } from 'packageurl-js' + +import CycloneDxSbom from '../src/cyclone_dx_sbom.js' + +const sampleHashes = [{ alg: 'SHA-256', content: 'abc123def456' }] + +suite('CycloneDX SBOM hash support', () => { + + /** Verifies that addDependency with hashes produces a component containing the hashes array. */ + test('getComponent with hashes includes hashes array in component', () => { + // Given an SBOM with a root and a dependency with hashes + const sbom = new CycloneDxSbom() + const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined) + const purl = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined) + sbom.addRoot(root) + + // When adding a dependency with hashes + sbom.addDependency(root, purl, undefined, sampleHashes) + + // Then the target component should include the hashes + const targetComponent = sbom.components.find(c => c.name === 'requests') + expect(targetComponent).to.exist + expect(targetComponent.hashes).to.deep.equal(sampleHashes) + }) + + /** Verifies that addDependency without hashes produces a component with no hashes field. */ + test('getComponent without hashes does not include hashes field', () => { + // Given an SBOM with a root and a dependency without hashes + const sbom = new CycloneDxSbom() + const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined) + const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined) + sbom.addRoot(root) + + // When adding a dependency without hashes + sbom.addDependency(root, dep) + + // Then the target component should not have a hashes property + const targetComponent = sbom.components.find(c => c.name === 'requests') + expect(targetComponent).to.exist + expect(targetComponent).to.not.have.property('hashes') + }) + + /** Verifies that hashes are attached only to the target component, not the source. */ + test('addDependency forwards hashes only to target component', () => { + // Given an SBOM with a chain: root -> dep1 -> dep2 (only dep2 has hashes) + const sbom = new CycloneDxSbom() + const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined) + const dep1 = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined) + const dep2 = new PackageURL('pypi', undefined, 'numpy', '1.24.0', undefined, undefined) + const dep2Hashes = [{ alg: 'SHA-256', content: '789xyz' }] + sbom.addRoot(root) + sbom.addDependency(root, dep1) + + // When adding dep2 as a dependency of dep1 with hashes + sbom.addDependency(dep1, dep2, undefined, dep2Hashes) + + // Then dep1 (source) should have no hashes, dep2 (target) should have hashes + const dep1Component = sbom.components.find(c => c.name === 'requests') + expect(dep1Component).to.not.have.property('hashes') + const dep2Component = sbom.components.find(c => c.name === 'numpy') + expect(dep2Component.hashes).to.deep.equal(dep2Hashes) + }) + + /** Verifies that hashes are included in the serialized CycloneDX JSON output. */ + test('hashes appear in serialized SBOM JSON', () => { + // Given an SBOM with a hashed dependency + const sbom = new CycloneDxSbom() + const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined) + const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined) + sbom.addRoot(root) + sbom.addDependency(root, dep, undefined, sampleHashes) + + // When serializing to JSON + const json = JSON.parse(sbom.getAsJsonString({})) + + // Then the component in JSON should contain hashes + const comp = json.components.find(c => c.name === 'requests') + expect(comp.hashes).to.deep.equal(sampleHashes) + }) + + /** Verifies that components without hashes have no hashes field in serialized JSON. */ + test('component without hashes has no hashes in serialized SBOM JSON', () => { + // Given an SBOM with a dependency without hashes + const sbom = new CycloneDxSbom() + const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined) + const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined) + sbom.addRoot(root) + sbom.addDependency(root, dep) + + // When serializing to JSON + const json = JSON.parse(sbom.getAsJsonString({})) + + // Then the component in JSON should not have a hashes property + const comp = json.components.find(c => c.name === 'requests') + expect(comp).to.not.have.property('hashes') + }) + + /** Verifies that hashes are applied to an existing component that was first created without them as a source. */ + test('hashes are updated on component first seen as source without hashes', () => { + // Given a component first created as a source (without hashes) + const sbom = new CycloneDxSbom() + const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined) + const mid = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined) + const leaf = new PackageURL('pypi', undefined, 'urllib3', '2.0.0', undefined, undefined) + const midHashes = [{ alg: 'SHA-256', content: 'abc123' }] + sbom.addRoot(root) + + // When mid is first seen as a source (no hashes), then as a target with hashes + sbom.addDependency(mid, leaf) + sbom.addDependency(root, mid, undefined, midHashes) + + // Then mid should have hashes despite being created first without them + const midComponent = sbom.components.find(c => c.name === 'requests') + expect(midComponent.hashes).to.deep.equal(midHashes) + }) + + /** Verifies that hashes are applied when a component already exists as a target without hashes. */ + test('hashes are updated on component first seen as target without hashes', () => { + // Given a component first added as a target without hashes + const sbom = new CycloneDxSbom() + const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined) + const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined) + const depHashes = [{ alg: 'SHA-256', content: 'abc123' }] + sbom.addRoot(root) + sbom.addDependency(root, dep) + + // When the same component is added again as a target with hashes + const other = new PackageURL('pypi', undefined, 'other', '1.0.0', undefined, undefined) + sbom.addDependency(other, dep, undefined, depHashes) + + // Then the component should have hashes and not be duplicated + const depComponents = sbom.components.filter(c => c.name === 'requests') + expect(depComponents).to.have.lengthOf(1) + expect(depComponents[0].hashes).to.deep.equal(depHashes) + }) + + /** Verifies that passing an empty hashes array is treated the same as no hashes. */ + test('empty hashes array does not add hashes field', () => { + // Given an SBOM with a dependency with an empty hashes array + const sbom = new CycloneDxSbom() + const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined) + const dep = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined) + sbom.addRoot(root) + + // When adding a dependency with empty hashes + sbom.addDependency(root, dep, undefined, []) + + // Then the component should not have a hashes property + const targetComponent = sbom.components.find(c => c.name === 'requests') + expect(targetComponent).to.not.have.property('hashes') + }) +})