Skip to content
25 changes: 25 additions & 0 deletions src/cyclone_dx_sbom.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
if (typeof lic === 'string') {
return { license: { id: lic } };
}
if (typeof lic === 'object' && lic !== null && ('license' in lic || 'expression' in lic)) {

Check warning on line 55 in src/cyclone_dx_sbom.js

View workflow job for this annotation

GitHub Actions / Lint and test project (24)

Expected '!=' and instead saw '!=='

Check warning on line 55 in src/cyclone_dx_sbom.js

View workflow job for this annotation

GitHub Actions / Lint and test project (22)

Expected '!=' and instead saw '!=='
return lic;
}
return null;
Expand Down Expand Up @@ -164,6 +164,31 @@
return this;
}

/**
* Attach hashes to already-added components by matching their PURL. This is a
* post-processing step so ecosystem-specific hash sources (e.g. Maven reading
* the local .m2 cache) can enrich the SBOM without threading their concern
* through the shared dependency-tree parser. Components without a matching
* entry, or whose hashes are already set, are left untouched.
* @param {Map<string, Array<{alg: string, content: string}>>} hashMap - PURL→hashes map
* @return {CycloneDxSbom} the updated SBOM
*/
attachHashes(hashMap) {
if (!hashMap || hashMap.size === 0) {
return this
}
for (const component of this.components) {
if (component.hashes) {
continue
}
const hashes = hashMap.get(component.purl)
if (hashes && hashes.length > 0) {
component.hashes = hashes
}
}
return this
}

/** @param {{}} opts - various options, settings and configuration of application.
* @return String CycloneDx Sbom json object in a string format
*/
Expand Down
83 changes: 67 additions & 16 deletions src/providers/base_java.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export default class Base_Java {
DEP_REGEX = /(([-a-zA-Z0-9._]{2,})|[0-9])/g
CONFLICT_REGEX = /.*omitted for conflict with (\S+)\)/

/**
* Maven dependency scopes. Used to detect whether the trailing column of a
* dependency-tree line is a scope keyword — which in turn signals that an
* optional classifier column is present between the packaging and version.
* @type {string[]}
*/
static MAVEN_SCOPES = ['compile', 'provided', 'runtime', 'test', 'system', 'import']

globalBinary
localWrapper

Expand Down Expand Up @@ -90,30 +98,73 @@ export default class Base_Java {
return ((line.indexOf('-') - 1) / 3) + 1;
}

/**
* Parse a single dependency-tree line into its Maven coordinate parts.
*
* This is the single source of truth for interpreting a dependency-tree
* line. Both {@link parseDep} (used to build SBOM component PURLs) and the
* Maven hash-map builder rely on it, so the PURL key and the artifact file
* path can never drift from one another.
*
* A line has the shape `groupId:artifactId:packaging[:classifier]:version[:scope]`.
* The classifier column is only present when a sixth column holds a known
* Maven scope keyword; otherwise the fourth column is the version.
*
* @param {string} line - line to parse from a dependency tree
* @returns {{groupId: string, artifactId: string, packaging: string, classifier: (string|null), version: string, scope: (string|null), overridden: boolean}}
* Parsed coordinate. `version` is the resolved concrete version — when the
* line carries a conflict override the override version replaces it and
* `overridden` is set, mirroring how Maven records the winning version.
*/
parseCoordinate(line) {
const parts = line.split(':').map(part => part ? part.match(this.DEP_REGEX)?.[0] ?? '' : '')
const groupId = parts[0] ?? ''
const artifactId = parts[1] ?? ''
const packaging = parts[2] ?? ''
// A classifier column exists only when a sixth column holds a scope keyword.
const hasClassifier = parts.length >= 6 && Base_Java.MAVEN_SCOPES.includes(parts[5])
const classifier = hasClassifier ? parts[3] : null
let version = (hasClassifier ? parts[4] : parts[3]) ?? ''
const scope = hasClassifier ? parts[5] : (parts.length >= 5 ? parts[4] : null)
// A conflict override replaces the resolved version entirely.
const override = line.match(this.CONFLICT_REGEX)
const overridden = Boolean(override)
if (overridden) {
version = override[1]
}
return { groupId, artifactId, packaging, classifier, version, scope, overridden }
}

/**
* Build the canonical PackageURL for a parsed coordinate.
*
* The classifier is folded into the version component (e.g.
* `4.1.0-linux-x86_64`) except when the version came from a conflict
* override, in which case the override version stands alone — matching the
* historical behavior of {@link parseDep}.
*
* @param {{groupId: string, artifactId: string, classifier: (string|null), version: string, overridden: boolean}} coord
* @returns {PackageURL} The canonical packageURL for the coordinate
* @protected
*/
_coordinateToPurl(coord) {
const purlVersion = (coord.classifier && !coord.overridden)
? `${coord.version}-${coord.classifier}`
: coord.version
return this.toPurl(coord.groupId, coord.artifactId, purlVersion)
}

/**
* Create a PackageURL from any line in a Text Graph dependency tree for a manifest path.
* @param {string} line - line to parse from a dependencies.txt file
* @returns {PackageURL} The parsed packageURL
*/
parseDep(line) {
let match = line.split(':').map(part => part ? part.match(this.DEP_REGEX)[0] : '');
if (!match) {
throw new Error(`Unable generate SBOM from dependency tree. Line: ${line} cannot be parsed into a PackageURL`);
}
let version
if (match.length >= 5 && ['compile', 'provided', 'runtime'].includes(match[5])) {
version = `${match[4]}-${match[3]}`
} else {
version = match[3]
}
let override = line.match(this.CONFLICT_REGEX);
if (override) {
version = override[1];
}
if (match[0].trim() === '') {
const coord = this.parseCoordinate(line)
if (coord.groupId.trim() === '') {
throw new Error(`Artifact coordinates should have a non-empty group ID: ${line}`);
}
return this.toPurl(match[0], match[1], version);
return this._coordinateToPurl(coord);
}

/**
Expand Down
87 changes: 85 additions & 2 deletions src/providers/java_maven.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
Expand Down Expand Up @@ -145,21 +146,100 @@ export default class Java_maven extends Base_java {
if (process.env["TRUSTIFY_DA_DEBUG"] === "true") {
console.error("Dependency tree that will be used as input for creating the BOM =>" + EOL + EOL + content.toString())
}
let sbom = this.createSbomFileFromTextFormat(content.toString(), ignoredDeps, opts, manifest);
const depTreeContent = content.toString()
const hashMap = this._buildMavenHashMap(depTreeContent, opts)
let sbom = this.createSbomFileFromTextFormat(depTreeContent, ignoredDeps, opts, manifest, hashMap);
// delete temp file and directory
fs.rmSync(tmpDir, { recursive: true, force: true })
// return dependency graph as string
return sbom
}

/** @type {Object<string, string>} Packaging types that produce .jar files despite non-jar packaging names. */
static PACKAGING_TO_JAR = { 'bundle': 'jar', 'eclipse-plugin': 'jar' }

/**
* Build a Map of PURL string → CycloneDX hash entries by reading artifact files from the local Maven repository.
*
* Both the artifact file path and the hash-map key are derived from the
* shared {@link Base_java#parseCoordinate} parser: the file path uses the
* resolved version and raw classifier, while the key uses the same canonical
* PURL builder as {@link Base_java#parseDep}. This guarantees the key always
* matches the PURL that {@link Base_java#parseDependencyTree} looks up.
*
* @param {string} depTreeText Raw dependency tree text from mvn dependency:tree
* @param {{}} [opts={}] Options bag (may contain TRUSTIFY_DA_MVN_REPO)
* @returns {Map<string, Array<{alg: string, content: string}>>}
*/
_buildMavenHashMap(depTreeText, opts = {}) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design: Duplicated coordinate parsing will silently drift

This method re-implements the split-by-colon, DEP_REGEX application, classifier detection, and conflict-override handling that parseDep in base_java.js:98-117 already does. The two implementations already diverge (scope lists, conflict-override classifier handling).

Any future change to parseDep that isn't mirrored here will silently break hash lookups with no test failure, because the test mocks don't cover the cross-function invariant.

Suggestion: Extract a shared parseCoordinate(rawLine) helper that both parseDep and _buildMavenHashMap call, returning {groupId, artifactId, version, classifier, packaging, scope}. This eliminates the entire class of drift bugs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[sdlc-workflow/verify-pr] Classified as suggestion — proposes extracting a shared parseCoordinate helper. This is the recommended fix approach and has been captured in the Implementation Notes of sub-task TC-5646, which addresses the concrete drift bugs. No separate sub-task created.

const m2Repo = getCustom('TRUSTIFY_DA_MVN_REPO', path.join(os.homedir(), '.m2', 'repository'), opts)
const hashMap = new Map()
const lines = depTreeText.split(EOL)
// Track hash coverage so an incomplete .m2 cache (e.g. ephemeral CI
// containers or resolve-only phases) surfaces a summary warning instead
// of silently producing an SBOM with missing hashes.
let attempted = 0
let missed = 0

for (const rawLine of lines) {
const trimmed = rawLine.trim()
// Strip leading tree-drawing characters (e.g. "\-", "+-", "|") so the
// parenthesized-line check sees the coordinate itself. Omitted
// duplicate/conflict lines start with these characters after trim(),
// then an opening "(" — without stripping, the "(" check never fires.
const cleaned = trimmed.replace(/^[|+\\\- ]+/, '')
if (!cleaned || cleaned.startsWith('(')) { continue }

const coord = this.parseCoordinate(rawLine)
if (!coord.groupId || !coord.artifactId || !coord.packaging) { continue }
if (coord.packaging === 'pom') { continue }

// Key by the exact PURL parseDep() produces, so the lookup in
// parseDependencyTree (hashMap.get(to.toString())) always hits.
const purl = this._coordinateToPurl(coord).toString()
// The same artifact recurs across dependency-tree branches — notably in
// multi-module reactor builds, where every module re-lists shared deps.
// The digest is deterministic per PURL, so skip the redundant file read
// and SHA-256 computation once this PURL is already hashed.
if (hashMap.has(purl)) { continue }

const ext = Java_maven.PACKAGING_TO_JAR[coord.packaging] || coord.packaging
const groupPath = coord.groupId.replaceAll('.', path.sep)
const fileName = coord.classifier
? `${coord.artifactId}-${coord.version}-${coord.classifier}.${ext}`
: `${coord.artifactId}-${coord.version}.${ext}`
const artifactPath = path.join(m2Repo, groupPath, coord.artifactId, coord.version, fileName)

attempted++
try {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Efficiency: No deduplication guard before file I/O

The same artifact can appear in multiple branches of the dependency tree. Combined with the dead startsWith('(') guard, parenthesized duplicate lines also get processed. Each occurrence triggers a separate fs.readFileSync + SHA-256 computation.

In a multi-module project with 5 modules sharing 50 deps, that's up to 250 redundant file reads.

Fix: Add a check before the try block:

const purl = this.toPurl(groupId, artifactId, purlVersion).toString()
if (hashMap.has(purl)) { continue }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[sdlc-workflow/verify-pr] Classified as suggestion — a deduplication/performance optimization not required for correctness and not backed by a documented CONVENTIONS.md convention or an established codebase pattern. No sub-task created.

const fileContent = fs.readFileSync(artifactPath)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Efficiency: readFileSync loads entire file into memory

Large artifacts (e.g., aws-java-sdk-bundle ~300MB) are fully loaded into a Node.js buffer. Combined with the lack of deduplication, peak memory can spike significantly. In memory-constrained CI containers this could trigger OOM kills.

Fix: Use streaming hash:

const stream = fs.createReadStream(artifactPath)
const hash = crypto.createHash('sha256')
for await (const chunk of stream) { hash.update(chunk) }
const digest = hash.digest('hex')

Note: this would make _buildMavenHashMap async.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[sdlc-workflow/verify-pr] Classified as suggestion — proposes streaming instead of readFileSync; a performance optimization with no matching project convention or codebase pattern. No sub-task created.

const digest = crypto.createHash('sha256').update(fileContent).digest('hex')
hashMap.set(purl, [{ alg: 'SHA-256', content: digest }])
} catch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observability: Silent degradation with incomplete .m2 cache

The catch block swallows all errors unless TRUSTIFY_DA_DEBUG is set. In CI environments with partial caches (ephemeral containers, resolve-only phases), every readFileSync can fail silently, producing an SBOM with zero hashes and no visible signal.

Consider logging a summary warning at the end (e.g., "N of M artifacts could not be hashed") even in non-debug mode, so users have visibility into hash coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[sdlc-workflow/verify-pr] Classified as suggestion — proposes logging a hash-coverage summary (observability); no logging/observability convention is documented and the current silent omission is intended graceful degradation. No sub-task created.

missed++
if (process.env['TRUSTIFY_DA_DEBUG'] === 'true') {
console.error(`Maven hash: artifact not found at ${artifactPath}, omitting hash`)
}
}
}
// Mirror the pip provider's convention (python_controller.js): surface an
// unconditional warning when hashes could not be computed, so incomplete
// hash coverage is visible even without TRUSTIFY_DA_DEBUG.
if (missed > 0) {
console.warn(`Maven hash: ${missed} of ${attempted} artifacts could not be read from the local .m2 cache; SBOM will be generated without hashes for those components.`)
}
return hashMap
}

/**
* @param {String} textGraphList Text graph String of the manifest
* @param {[String]} ignoredDeps List of ignored dependencies to be omitted from sbom
* @param {{}} opts Options
* @param {String} manifestPath Path to the pom.xml manifest
* @param {Map<string, Array<{alg: string, content: string}>>} [hashMap] PURL→hashes map
* @return {String} formatted sbom Json String with all dependencies
*/
createSbomFileFromTextFormat(textGraphList, ignoredDeps, opts, manifestPath) {
createSbomFileFromTextFormat(textGraphList, ignoredDeps, opts, manifestPath, hashMap) {
let lines = textGraphList.split(EOL);
// get root component
let root = lines[0];
Expand All @@ -168,6 +248,9 @@ export default class Java_maven extends Base_java {
let sbom = new Sbom();
sbom.addRoot(rootPurl, license);
this.parseDependencyTree(root, 0, lines.slice(1), sbom);
// Attach Maven artifact hashes as a post-processing step, keeping this
// Maven-specific concern out of the shared dependency-tree parser.
sbom.attachHashes(hashMap);
return sbom.filterIgnoredDeps(ignoredDeps).getAsJsonString(opts);
}

Expand Down
11 changes: 11 additions & 0 deletions src/sbom.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ export default class Sbom {
return this.sbomModel.addDependency(sourceRef, targetRef, scope, targetHashes)
}

/**
* Attach hashes to existing components by matching their PURL. Post-processing
* step used by ecosystem-specific providers (e.g. Maven) to enrich the SBOM
* with artifact hashes without leaking their concern into the shared parser.
* @param {Map<string, Array<{alg: string, content: string}>>} hashMap - PURL→hashes map
* @return {Sbom}
*/
attachHashes(hashMap){
return this.sbomModel.attachHashes(hashMap)
}

/**
* @return String sbom json in a string format
*/
Expand Down
71 changes: 71 additions & 0 deletions test/cyclone_dx_sbom_hashes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,77 @@ suite('CycloneDX SBOM hash support', () => {
expect(depComponents[0].hashes).to.deep.equal(depHashes)
})

/** Verifies that attachHashes enriches an existing component matched by PURL. */
test('attachHashes attaches hashes to a matching component by PURL', () => {
// Given an SBOM with a dependency added without hashes
const sbom = new CycloneDxSbom()
const root = new PackageURL('maven', 'com.example', 'root', '1.0.0', undefined, undefined)
const dep = new PackageURL('maven', 'log4j', 'log4j', '1.2.17', undefined, undefined)
sbom.addRoot(root)
sbom.addDependency(root, dep)

// When attaching hashes keyed by the dependency's PURL
sbom.attachHashes(new Map([[dep.toString(), sampleHashes]]))

// Then the matching component carries the hashes
const depComponent = sbom.components.find(c => c.name === 'log4j')
expect(depComponent.hashes).to.deep.equal(sampleHashes)
})

/** Verifies that attachHashes leaves components without a map entry untouched. */
test('attachHashes leaves unmatched components without a hashes field', () => {
// Given an SBOM whose dependency has no entry in the hash map
const sbom = new CycloneDxSbom()
const root = new PackageURL('maven', 'com.example', 'root', '1.0.0', undefined, undefined)
const dep = new PackageURL('maven', 'log4j', 'log4j', '1.2.17', undefined, undefined)
sbom.addRoot(root)
sbom.addDependency(root, dep)

// When attaching a map that references a different PURL
const other = new PackageURL('maven', 'com.other', 'lib', '9.9.9', undefined, undefined)
sbom.attachHashes(new Map([[other.toString(), sampleHashes]]))

// Then the unmatched component has no hashes property
const depComponent = sbom.components.find(c => c.name === 'log4j')
expect(depComponent).to.not.have.property('hashes')
})

/** Verifies that attachHashes does not overwrite hashes already present on a component. */
test('attachHashes does not overwrite existing hashes', () => {
// Given a component that already has hashes
const sbom = new CycloneDxSbom()
const root = new PackageURL('maven', 'com.example', 'root', '1.0.0', undefined, undefined)
const dep = new PackageURL('maven', 'log4j', 'log4j', '1.2.17', undefined, undefined)
const originalHashes = [{ alg: 'SHA-256', content: 'original' }]
sbom.addRoot(root)
sbom.addDependency(root, dep, undefined, originalHashes)

// When attaching a different hash for the same PURL
sbom.attachHashes(new Map([[dep.toString(), sampleHashes]]))

// Then the original hashes are preserved
const depComponent = sbom.components.find(c => c.name === 'log4j')
expect(depComponent.hashes).to.deep.equal(originalHashes)
})

/** Verifies that attachHashes tolerates an undefined or empty map without error. */
test('attachHashes is a no-op for an empty or undefined map', () => {
// Given an SBOM with a dependency
const sbom = new CycloneDxSbom()
const root = new PackageURL('maven', 'com.example', 'root', '1.0.0', undefined, undefined)
const dep = new PackageURL('maven', 'log4j', 'log4j', '1.2.17', undefined, undefined)
sbom.addRoot(root)
sbom.addDependency(root, dep)

// When attaching an empty map and an undefined map
sbom.attachHashes(new Map())
sbom.attachHashes(undefined)

// Then no hashes are added and no error is thrown
const depComponent = sbom.components.find(c => c.name === 'log4j')
expect(depComponent).to.not.have.property('hashes')
})

/** 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
Expand Down
Loading
Loading