Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/cyclone_dx_sbom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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;
}
Comment thread
a-oren marked this conversation as resolved.

return componentObject
}

Expand Down Expand Up @@ -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;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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;
}
});

Expand Down
10 changes: 6 additions & 4 deletions src/providers/base_pyproject.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
/** @typedef {{directDeps: string[], graph: Map<string, GraphEntry>}} DependencyData */
/** @typedef {{ecosystem: string, content: string, contentType: string}} Provided */
Expand Down Expand Up @@ -313,23 +315,23 @@ 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 }
let parentPurl = this._toPurl(entry.name, entry.version)
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 {
for (let key of directDeps) {
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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/providers/python_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions src/providers/python_pip.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ 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 */
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

/**
* @type {string} ecosystem for python-pip is 'pip'
* @private
*/
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
Expand Down Expand Up @@ -73,8 +75,6 @@ async function provideComponent(manifest, opts = {}) {
}
}

/** @typedef {{name: string, , version: string, dependencies: DependencyEntry[]}} DependencyEntry */

/**
*
* @param {PackageURL}source
Expand All @@ -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) })
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/sbom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down
153 changes: 153 additions & 0 deletions test/cyclone_dx_sbom_hashes.test.js
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

// 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')
})
})
Loading