From 31e1fc932b30074806b7309edde53f293d62ee19 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 10 Aug 2026 13:49:13 +0300 Subject: [PATCH 1/7] feat(providers): add SHA-256 hash computation for Maven dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute SHA-256 hashes from artifact files in the local Maven repository cache (~/.m2/repository/) and include them in Maven SBOM components. - Add _buildMavenHashMap() that parses dependency tree lines to extract groupId, artifactId, packaging, version, and optional classifier, then constructs the correct .m2 file path and computes the hash - Handle packaging-to-extension mapping (bundle/eclipse-plugin → .jar) - Skip POM-only artifacts (no hash for metadata-only dependencies) - Support classified dependencies with correct file path construction - Support custom Maven repo path via TRUSTIFY_DA_MVN_REPO env var - Gracefully omit hashes when artifact files are not in the local cache - Pass hash map through parseDependencyTree() to sbom.addDependency() Implements TC-5549 Assisted-by: Claude Code --- src/providers/base_java.js | 8 +- src/providers/java_maven.js | 78 ++++++++++++++- test/providers/java_maven.test.js | 157 +++++++++++++++++++++++++++++- 3 files changed, 233 insertions(+), 10 deletions(-) diff --git a/src/providers/base_java.js b/src/providers/base_java.js index ced65ce5..46343370 100644 --- a/src/providers/base_java.js +++ b/src/providers/base_java.js @@ -49,8 +49,9 @@ export default class Base_Java { * @param {number} srcDepth - Current depth in the graph for the given source * @param {Array} lines - Array containing the text files being parsed * @param {Sbom} sbom - The SBOM where the dependencies are being added + * @param {Map>} [hashMap] - Optional PURL→hashes map */ - parseDependencyTree(src, srcDepth, lines, sbom) { + parseDependencyTree(src, srcDepth, lines, sbom, hashMap) { if (lines.length === 0) { return; } @@ -68,10 +69,11 @@ export default class Base_Java { let matchedScopeSrc = src.match(/:compile|:provided|:runtime|:test|:system|:import/g) // only add dependency to sbom if it's not with test scope or if it's root if ((matchedScope && matchedScope[0] !== ":test" && (matchedScopeSrc && matchedScopeSrc[0] !== ":test")) || (srcDepth === 0 && matchedScope && matchedScope[0] !== ":test")) { - sbom.addDependency(from, to) + const hashes = hashMap?.get(to.toString()) + sbom.addDependency(from, to, undefined, hashes) } } else { - this.parseDependencyTree(lines[index - 1], this._getDepth(lines[index - 1]), lines.slice(index), sbom) + this.parseDependencyTree(lines[index - 1], this._getDepth(lines[index - 1]), lines.slice(index), sbom, hashMap) } target = lines[++index]; targetDepth = this._getDepth(target); diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index 1f0d6ccf..44b0c63e 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -1,3 +1,4 @@ +import crypto from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -145,21 +146,90 @@ 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} Packaging types that produce .jar files despite non-jar packaging names. */ + static PACKAGING_TO_JAR = { 'bundle': 'jar', 'eclipse-plugin': 'jar' } + + /** @type {string[]} */ + static MAVEN_SCOPES = ['compile', 'provided', 'runtime', 'test', 'system', 'import'] + + /** + * Build a Map of PURL string → CycloneDX hash entries by reading artifact files from the local Maven repository. + * @param {string} depTreeText Raw dependency tree text from mvn dependency:tree + * @param {{}} [opts={}] Options bag (may contain TRUSTIFY_DA_MVN_REPO) + * @returns {Map>} + */ + _buildMavenHashMap(depTreeText, opts = {}) { + const m2Repo = getCustom('TRUSTIFY_DA_MVN_REPO', path.join(os.homedir(), '.m2', 'repository'), opts) + const hashMap = new Map() + const lines = depTreeText.split(EOL) + + for (const rawLine of lines) { + const trimmed = rawLine.trim() + if (!trimmed || trimmed.startsWith('(')) { continue } + + const parts = trimmed.split(':').map(p => p ? p.match(this.DEP_REGEX)?.[0] ?? '' : '') + if (parts.length < 4) { continue } + + const groupId = parts[0] + const artifactId = parts[1] + const packaging = parts[2] + + if (packaging === 'pom') { continue } + + let version, classifier + if (parts.length >= 6 && Java_maven.MAVEN_SCOPES.includes(parts[5])) { + classifier = parts[3] + version = parts[4] + } else { + version = parts[3] + classifier = null + } + + // Handle conflict overrides the same way parseDep does + const override = rawLine.match(this.CONFLICT_REGEX) + if (override) { version = override[1] } + + const ext = Java_maven.PACKAGING_TO_JAR[packaging] || packaging + const groupPath = groupId.replaceAll('.', path.sep) + const fileName = classifier + ? `${artifactId}-${version}-${classifier}.${ext}` + : `${artifactId}-${version}.${ext}` + const artifactPath = path.join(m2Repo, groupPath, artifactId, version, fileName) + + try { + const fileContent = fs.readFileSync(artifactPath) + const digest = crypto.createHash('sha256').update(fileContent).digest('hex') + // Key by the PURL that parseDep() will produce for this line + const purlVersion = classifier ? `${version}-${classifier}` : version + const purl = this.toPurl(groupId, artifactId, purlVersion).toString() + hashMap.set(purl, [{ alg: 'SHA-256', content: digest }]) + } catch { + if (process.env['TRUSTIFY_DA_DEBUG'] === 'true') { + console.error(`Maven hash: artifact not found at ${artifactPath}, omitting hash`) + } + } + } + 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>} [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]; @@ -167,7 +237,7 @@ export default class Java_maven extends Base_java { const license = this.readLicenseFromManifest(manifestPath); let sbom = new Sbom(); sbom.addRoot(rootPurl, license); - this.parseDependencyTree(root, 0, lines.slice(1), sbom); + this.parseDependencyTree(root, 0, lines.slice(1), sbom, hashMap); return sbom.filterIgnoredDeps(ignoredDeps).getAsJsonString(opts); } diff --git a/test/providers/java_maven.test.js b/test/providers/java_maven.test.js index 94b7b109..3703224d 100644 --- a/test/providers/java_maven.test.js +++ b/test/providers/java_maven.test.js @@ -1,6 +1,7 @@ -import fs from 'fs' -import { platform } from 'os'; -import path from 'path'; +import crypto from 'node:crypto' +import fs from 'node:fs' +import os, { platform } from 'node:os' +import path from 'node:path' import { expect } from 'chai' import esmock from 'esmock'; @@ -181,3 +182,153 @@ suite('testing the java-maven version parsing in getDependencies', () => { expect(bouncyCastleDependency.ref).to.equal('pkg:maven/org.bouncycastle/bcprov-jdk18on@1.80'); }); }); + +suite('testing the java-maven SHA-256 hash computation', () => { + let tmpM2Repo + const jarContent = Buffer.from('mock-jar-content-for-testing') + const expectedDigest = crypto.createHash('sha256').update(jarContent).digest('hex') + + suiteSetup(() => { + tmpM2Repo = fs.mkdtempSync(path.join(os.tmpdir(), 'trustify_da_m2_test_')) + // Create a mock .m2 directory structure with a jar file + const jarDir = path.join(tmpM2Repo, 'log4j', 'log4j', '1.2.17') + fs.mkdirSync(jarDir, { recursive: true }) + fs.writeFileSync(path.join(jarDir, 'log4j-1.2.17.jar'), jarContent) + }) + + suiteTeardown(() => { + fs.rmSync(tmpM2Repo, { recursive: true, force: true }) + }) + + /** Verifies that SHA-256 hashes are computed from jar files in the local .m2 repository. */ + test('verify _buildMavenHashMap computes SHA-256 from jar files', () => { + // Given a dependency tree with a dependency whose jar exists in the mock .m2 repo + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- log4j:log4j:jar:1.2.17:compile\n' + + // When building the hash map using the mock .m2 repo + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the hash map contains the correct SHA-256 digest for log4j + const purl = 'pkg:maven/log4j/log4j@1.2.17' + expect(hashMap.has(purl)).to.equal(true) + expect(hashMap.get(purl)).to.deep.equal([{ alg: 'SHA-256', content: expectedDigest }]) + }) + + /** Verifies that missing jar files result in omitted hashes rather than errors. */ + test('verify _buildMavenHashMap omits hash when jar file is not in cache', () => { + // Given a dependency tree referencing an artifact not in the mock repo + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- org.missing:artifact:jar:1.0.0:compile\n' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the hash map is empty — no error thrown + expect(hashMap.size).to.equal(0) + }) + + /** Verifies that custom Maven repository path via TRUSTIFY_DA_MVN_REPO is respected. */ + test('verify _buildMavenHashMap uses custom TRUSTIFY_DA_MVN_REPO path', () => { + // Given the env var points to our mock .m2 repo + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- log4j:log4j:jar:1.2.17:compile\n' + + // When building hash map with TRUSTIFY_DA_MVN_REPO set via opts + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then hash is found (proving the custom path was used) + expect(hashMap.has('pkg:maven/log4j/log4j@1.2.17')).to.equal(true) + }) + + /** Verifies that packaging types like 'bundle' are mapped to .jar file extension. */ + test('verify _buildMavenHashMap maps bundle packaging to .jar extension', () => { + // Given a mock jar under a groupId that uses 'bundle' packaging in the tree + const bundleDir = path.join(tmpM2Repo, 'org', 'osgi', 'core', '6.0.0') + fs.mkdirSync(bundleDir, { recursive: true }) + fs.writeFileSync(path.join(bundleDir, 'core-6.0.0.jar'), jarContent) + + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- org.osgi:core:bundle:6.0.0:compile\n' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the bundle dependency gets a hash (mapped to .jar) + expect(hashMap.has('pkg:maven/org.osgi/core@6.0.0')).to.equal(true) + expect(hashMap.get('pkg:maven/org.osgi/core@6.0.0')[0].content).to.equal(expectedDigest) + }) + + /** Verifies that POM-only artifacts are skipped and no hash is computed. */ + test('verify _buildMavenHashMap skips pom packaging type', () => { + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- org.example:bom:pom:1.0.0:compile\n' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then no hash entry for the pom-only artifact + expect(hashMap.has('pkg:maven/org.example/bom@1.0.0')).to.equal(false) + }) + + /** Verifies that classified dependencies produce the correct file path with classifier in the filename. */ + test('verify _buildMavenHashMap handles classified dependencies', () => { + // Given a jar with classifier in the expected path + const classifiedDir = path.join(tmpM2Repo, 'io', 'netty', 'netty-transport', '4.1.0') + fs.mkdirSync(classifiedDir, { recursive: true }) + fs.writeFileSync(path.join(classifiedDir, 'netty-transport-4.1.0-linux-x86_64.jar'), jarContent) + + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- io.netty:netty-transport:jar:linux-x86_64:4.1.0:compile\n' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the classified dependency gets a hash keyed by the mangled PURL + const purl = 'pkg:maven/io.netty/netty-transport@4.1.0-linux-x86_64' + expect(hashMap.has(purl)).to.equal(true) + expect(hashMap.get(purl)[0].content).to.equal(expectedDigest) + }) + + /** Verifies that hashes flow through createSbomFileFromTextFormat into SBOM components. */ + test('verify hashes appear in SBOM components via createSbomFileFromTextFormat', () => { + // Given a dependency tree and a hash map with an entry + const clock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z')) + try { + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- log4j:log4j:jar:1.2.17:compile' + const hashMap = new Map() + hashMap.set('pkg:maven/log4j/log4j@1.2.17', [{ alg: 'SHA-256', content: 'abcdef1234567890' }]) + + // When creating the SBOM with the hash map + const sbomJson = provider.createSbomFileFromTextFormat( + depTree, [], {}, + 'test/providers/tst_manifests/maven/pom_deps_with_no_ignore/pom.xml', + hashMap + ) + const sbom = JSON.parse(sbomJson) + + // Then the log4j component includes the hash + const log4jComponent = sbom.components.find(c => c.name === 'log4j') + expect(log4jComponent).to.exist + expect(log4jComponent.hashes).to.deep.equal([{ alg: 'SHA-256', content: 'abcdef1234567890' }]) + } finally { + clock.restore() + } + }) + + /** Verifies that parenthesized (omitted/duplicate) lines in the dependency tree are skipped. */ + test('verify _buildMavenHashMap skips parenthesized duplicate entries', () => { + const provider = new Java_maven() + const depTree = [ + 'com.example:root:jar:1.0.0', + '\\- log4j:log4j:jar:1.2.17:compile', + ' \\- (org.slf4j:slf4j-api:jar:1.7.36:compile - omitted for duplicate)' + ].join('\n') + + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // slf4j entry should not be in the hash map since it's a parenthesized duplicate + expect(hashMap.has('pkg:maven/org.slf4j/slf4j-api@1.7.36')).to.equal(false) + }) +}); From 0690f65202223bdb27681dd331d5f0bf238c71ac Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 10 Aug 2026 14:20:16 +0300 Subject: [PATCH 2/7] fix(providers): guard against empty DEP_REGEX parts in Maven hash map Skip dependency tree lines where groupId, artifactId, or packaging is empty after DEP_REGEX matching to prevent malformed .m2 paths. Implements TC-5581 Assisted-by: Claude Code --- src/providers/java_maven.js | 1 + test/providers/java_maven.test.js | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index 44b0c63e..cb59af5c 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -183,6 +183,7 @@ export default class Java_maven extends Base_java { const artifactId = parts[1] const packaging = parts[2] + if (!groupId || !artifactId || !packaging) { continue } if (packaging === 'pom') { continue } let version, classifier diff --git a/test/providers/java_maven.test.js b/test/providers/java_maven.test.js index 3703224d..6a941db8 100644 --- a/test/providers/java_maven.test.js +++ b/test/providers/java_maven.test.js @@ -317,6 +317,18 @@ suite('testing the java-maven SHA-256 hash computation', () => { } }) + /** Verifies that lines with empty parts from DEP_REGEX mismatch are skipped without error. */ + test('verify _buildMavenHashMap skips lines with empty parsed fields', () => { + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- :log4j:jar:1.2.17:compile\n\\- log4j::jar:1.2.17:compile\n' + + // When building the hash map with lines that have empty groupId or artifactId + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the hash map is empty — malformed lines are skipped + expect(hashMap.size).to.equal(0) + }) + /** Verifies that parenthesized (omitted/duplicate) lines in the dependency tree are skipped. */ test('verify _buildMavenHashMap skips parenthesized duplicate entries', () => { const provider = new Java_maven() From 1a7592278574cb158af1b477e0529f1c4b214348 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Sun, 16 Aug 2026 16:02:17 +0300 Subject: [PATCH 3/7] fix(providers): unify Maven coordinate parsing to prevent hash-key drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract a shared parseCoordinate() helper plus _coordinateToPurl() in Base_Java, and route both parseDep() and _buildMavenHashMap() through them. Previously the hash-map key builder duplicated the coordinate/PURL logic with a divergent scope list and re-appended the classifier after a conflict override, so classified Maven dependencies (and any whose version lost a conflict) produced a hash-map key that did not match the PURL parseDep() emits — silently dropping their SHA-256 hashes from the SBOM. The hash-map key is now derived from the same canonical builder as parseDep(), guaranteeing they cannot diverge. MAVEN_SCOPES moves to Base_Java as the single source of truth for scope detection. Implements TC-5646 Assisted-by: Claude Code --- src/providers/base_java.js | 83 ++++++++++++++++++++++------ src/providers/java_maven.js | 53 +++++++----------- test/providers/java_maven.test.js | 89 +++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 50 deletions(-) diff --git a/src/providers/base_java.js b/src/providers/base_java.js index 46343370..dc97d2d4 100644 --- a/src/providers/base_java.js +++ b/src/providers/base_java.js @@ -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 @@ -92,30 +100,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); } /** diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index cb59af5c..6fd5aac0 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -158,11 +158,15 @@ export default class Java_maven extends Base_java { /** @type {Object} Packaging types that produce .jar files despite non-jar packaging names. */ static PACKAGING_TO_JAR = { 'bundle': 'jar', 'eclipse-plugin': 'jar' } - /** @type {string[]} */ - static MAVEN_SCOPES = ['compile', 'provided', 'runtime', 'test', 'system', 'import'] - /** * 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>} @@ -176,42 +180,23 @@ export default class Java_maven extends Base_java { const trimmed = rawLine.trim() if (!trimmed || trimmed.startsWith('(')) { continue } - const parts = trimmed.split(':').map(p => p ? p.match(this.DEP_REGEX)?.[0] ?? '' : '') - if (parts.length < 4) { continue } - - const groupId = parts[0] - const artifactId = parts[1] - const packaging = parts[2] - - if (!groupId || !artifactId || !packaging) { continue } - if (packaging === 'pom') { continue } - - let version, classifier - if (parts.length >= 6 && Java_maven.MAVEN_SCOPES.includes(parts[5])) { - classifier = parts[3] - version = parts[4] - } else { - version = parts[3] - classifier = null - } - - // Handle conflict overrides the same way parseDep does - const override = rawLine.match(this.CONFLICT_REGEX) - if (override) { version = override[1] } + const coord = this.parseCoordinate(rawLine) + if (!coord.groupId || !coord.artifactId || !coord.packaging) { continue } + if (coord.packaging === 'pom') { continue } - const ext = Java_maven.PACKAGING_TO_JAR[packaging] || packaging - const groupPath = groupId.replaceAll('.', path.sep) - const fileName = classifier - ? `${artifactId}-${version}-${classifier}.${ext}` - : `${artifactId}-${version}.${ext}` - const artifactPath = path.join(m2Repo, groupPath, artifactId, version, fileName) + 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) try { const fileContent = fs.readFileSync(artifactPath) const digest = crypto.createHash('sha256').update(fileContent).digest('hex') - // Key by the PURL that parseDep() will produce for this line - const purlVersion = classifier ? `${version}-${classifier}` : version - const purl = this.toPurl(groupId, artifactId, purlVersion).toString() + // Key by the exact PURL parseDep() produces, so the lookup in + // parseDependencyTree (hashMap.get(to.toString())) always hits. + const purl = this._coordinateToPurl(coord).toString() hashMap.set(purl, [{ alg: 'SHA-256', content: digest }]) } catch { if (process.env['TRUSTIFY_DA_DEBUG'] === 'true') { diff --git a/test/providers/java_maven.test.js b/test/providers/java_maven.test.js index 6a941db8..86c09601 100644 --- a/test/providers/java_maven.test.js +++ b/test/providers/java_maven.test.js @@ -343,4 +343,93 @@ suite('testing the java-maven SHA-256 hash computation', () => { // slf4j entry should not be in the hash map since it's a parenthesized duplicate expect(hashMap.has('pkg:maven/org.slf4j/slf4j-api@1.7.36')).to.equal(false) }) + + /** + * Verifies that a classified dependency whose version is replaced by a conflict + * override is keyed by the same PURL parseDep() produces — with the classifier + * dropped — so the hash attaches to the SBOM component instead of being lost. + */ + test('verify classified dependency with conflict override attaches hash to SBOM component', () => { + // Given a classified jar stored under its resolved (override) version + const overrideDir = path.join(tmpM2Repo, 'io', 'netty', 'netty-transport', '4.2.0') + fs.mkdirSync(overrideDir, { recursive: true }) + fs.writeFileSync(path.join(overrideDir, 'netty-transport-4.2.0-linux-x86_64.jar'), jarContent) + + const provider = new Java_maven() + // A verbose-tree line where the classified dep loses a conflict to 4.2.0 + const depTree = 'com.example:root:jar:1.0.0\n\\- (io.netty:netty-transport:jar:linux-x86_64:4.1.0:compile - omitted for conflict with 4.2.0)' + + // When building the hash map and the SBOM from the same tree + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the key drops the classifier and matches parseDep's override PURL + const purl = 'pkg:maven/io.netty/netty-transport@4.2.0' + expect(provider.parseDep(depTree.split('\n')[1]).toString()).to.equal(purl) + expect(hashMap.has(purl)).to.equal(true) + expect(hashMap.has('pkg:maven/io.netty/netty-transport@4.2.0-linux-x86_64')).to.equal(false) + + // And the hash flows through to the netty-transport SBOM component + const clock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z')) + try { + const sbomJson = provider.createSbomFileFromTextFormat( + depTree, [], {}, + 'test/providers/tst_manifests/maven/pom_deps_with_no_ignore/pom.xml', + hashMap + ) + const nettyComponent = JSON.parse(sbomJson).components.find(c => c.name === 'netty-transport') + expect(nettyComponent).to.exist + expect(nettyComponent.hashes).to.deep.equal([{ alg: 'SHA-256', content: expectedDigest }]) + } finally { + clock.restore() + } + }) + + /** + * Verifies that a classified dependency declared with a non-compile scope + * (system) is detected as classified — the hash-map key and parseDep PURL + * agree and the hash attaches. Guards against scope-list drift between the + * two code paths. + */ + test('verify classified dependency with system scope produces matching keys and attaches hash', () => { + // Given a classified jar for a system-scoped dependency + const systemDir = path.join(tmpM2Repo, 'com', 'sun', 'tools', '1.8.0') + fs.mkdirSync(systemDir, { recursive: true }) + fs.writeFileSync(path.join(systemDir, 'tools-1.8.0-jdk8.jar'), jarContent) + + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- com.sun:tools:jar:jdk8:1.8.0:system' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the classifier is folded into the version and both paths agree + const purl = 'pkg:maven/com.sun/tools@1.8.0-jdk8' + expect(provider.parseDep(depTree.split('\n')[1]).toString()).to.equal(purl) + expect(hashMap.has(purl)).to.equal(true) + expect(hashMap.get(purl)[0].content).to.equal(expectedDigest) + }) + + /** + * Drift guard: the hash-map key derivation must stay identical to the PURL + * parseDep() emits for the same line, across plain, classified, override, and + * scoped coordinate shapes. Both must route through the shared parseCoordinate + * / _coordinateToPurl helpers, so this equality holds by construction. + */ + test('verify parseDep and hash-map key derivation agree for all coordinate shapes', () => { + const provider = new Java_maven() + const lines = [ + '\\- log4j:log4j:jar:1.2.17:compile', + '\\- io.netty:netty-transport:jar:linux-x86_64:4.1.0:compile', + '\\- (io.netty:netty-transport:jar:linux-x86_64:4.1.0:compile - omitted for conflict with 4.2.0)', + '\\- com.sun:tools:jar:jdk8:1.8.0:system', + '\\- (org.foo:bar:jar:1.0.0:compile - omitted for conflict with 2.0.0)' + ] + + // For each shape, the key the hash map would store equals parseDep's PURL + for (const line of lines) { + const viaHashMap = provider._coordinateToPurl(provider.parseCoordinate(line)).toString() + const viaParseDep = provider.parseDep(line).toString() + expect(viaHashMap).to.equal(viaParseDep) + } + }) }); From ecbf2c1d4eeb73519bb5fa2084866e491e60df9f Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Sun, 16 Aug 2026 16:55:36 +0300 Subject: [PATCH 4/7] fix(providers): strip tree-drawing chars before parenthesized-line guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parenthesized-line guard in _buildMavenHashMap checked trimmed.startsWith('('), but after trim() the omitted duplicate/conflict lines begin with tree-drawing characters (\-, +-, |), so the check never fired — the guard was dead code and the test passed only because the referenced jar was absent from the mock .m2 fixture. Strip the leading tree-drawing characters before the check so omitted entries are skipped by the guard itself, before any file I/O. Update the duplicate/conflict tests to place the referenced jar in the mock .m2 so they prove the guard (not a missing file) is what skips the entry. Implements TC-5647 Co-Authored-By: Claude Opus 4.8 Assisted-by: Claude Code --- src/providers/java_maven.js | 7 +++- test/providers/java_maven.test.js | 66 +++++++++++++++++-------------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index 6fd5aac0..17475454 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -178,7 +178,12 @@ export default class Java_maven extends Base_java { for (const rawLine of lines) { const trimmed = rawLine.trim() - if (!trimmed || trimmed.startsWith('(')) { continue } + // 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 } diff --git a/test/providers/java_maven.test.js b/test/providers/java_maven.test.js index 86c09601..db782847 100644 --- a/test/providers/java_maven.test.js +++ b/test/providers/java_maven.test.js @@ -329,8 +329,20 @@ suite('testing the java-maven SHA-256 hash computation', () => { expect(hashMap.size).to.equal(0) }) - /** Verifies that parenthesized (omitted/duplicate) lines in the dependency tree are skipped. */ - test('verify _buildMavenHashMap skips parenthesized duplicate entries', () => { + /** + * Verifies that parenthesized (omitted/duplicate) lines are skipped by the + * guard itself — before any file I/O — even when the referenced artifact IS + * present in the mock .m2 repository. This proves the tree-character-stripping + * guard is what filters the line, not an incidentally missing fixture jar. + */ + test('verify _buildMavenHashMap skips parenthesized duplicate entries via the guard', () => { + // Given the "omitted for duplicate" artifact's jar exists in the mock repo, + // so a missing file cannot be the reason the entry is skipped + const slf4jDir = path.join(tmpM2Repo, 'org', 'slf4j', 'slf4j-api', '1.7.36') + fs.mkdirSync(slf4jDir, { recursive: true }) + fs.writeFileSync(path.join(slf4jDir, 'slf4j-api-1.7.36.jar'), jarContent) + expect(fs.existsSync(path.join(slf4jDir, 'slf4j-api-1.7.36.jar'))).to.equal(true) + const provider = new Java_maven() const depTree = [ 'com.example:root:jar:1.0.0', @@ -338,50 +350,46 @@ suite('testing the java-maven SHA-256 hash computation', () => { ' \\- (org.slf4j:slf4j-api:jar:1.7.36:compile - omitted for duplicate)' ].join('\n') + // When building the hash map const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) - // slf4j entry should not be in the hash map since it's a parenthesized duplicate + // Then the parenthesized slf4j entry is absent (skipped by the guard) even + // though its jar exists, while the non-parenthesized log4j entry is hashed expect(hashMap.has('pkg:maven/org.slf4j/slf4j-api@1.7.36')).to.equal(false) + expect(hashMap.has('pkg:maven/log4j/log4j@1.2.17')).to.equal(true) }) /** - * Verifies that a classified dependency whose version is replaced by a conflict - * override is keyed by the same PURL parseDep() produces — with the classifier - * dropped — so the hash attaches to the SBOM component instead of being lost. + * Verifies that an "omitted for conflict" line — which carries a classifier and + * a conflict override — is skipped by the guard before any file I/O, even when + * the referenced jar is present in the mock .m2 repository. The resolved + * version's hash comes from the real (non-parenthesized) winner node elsewhere + * in the tree, never from the omitted loser line itself. */ - test('verify classified dependency with conflict override attaches hash to SBOM component', () => { - // Given a classified jar stored under its resolved (override) version + test('verify _buildMavenHashMap skips omitted-for-conflict lines via the guard', () => { + // Given the omitted classified artifact's jar exists in the mock repo, + // so a missing file cannot be the reason the entry is skipped const overrideDir = path.join(tmpM2Repo, 'io', 'netty', 'netty-transport', '4.2.0') fs.mkdirSync(overrideDir, { recursive: true }) fs.writeFileSync(path.join(overrideDir, 'netty-transport-4.2.0-linux-x86_64.jar'), jarContent) const provider = new Java_maven() // A verbose-tree line where the classified dep loses a conflict to 4.2.0 - const depTree = 'com.example:root:jar:1.0.0\n\\- (io.netty:netty-transport:jar:linux-x86_64:4.1.0:compile - omitted for conflict with 4.2.0)' + const depTree = [ + 'com.example:root:jar:1.0.0', + '\\- log4j:log4j:jar:1.2.17:compile', + ' \\- (io.netty:netty-transport:jar:linux-x86_64:4.1.0:compile - omitted for conflict with 4.2.0)' + ].join('\n') - // When building the hash map and the SBOM from the same tree + // When building the hash map const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) - // Then the key drops the classifier and matches parseDep's override PURL - const purl = 'pkg:maven/io.netty/netty-transport@4.2.0' - expect(provider.parseDep(depTree.split('\n')[1]).toString()).to.equal(purl) - expect(hashMap.has(purl)).to.equal(true) + // Then the parenthesized conflict line yields no entry (guard-skipped before + // I/O) under either the override PURL or the classified PURL, while the + // non-parenthesized log4j entry is still hashed + expect(hashMap.has('pkg:maven/io.netty/netty-transport@4.2.0')).to.equal(false) expect(hashMap.has('pkg:maven/io.netty/netty-transport@4.2.0-linux-x86_64')).to.equal(false) - - // And the hash flows through to the netty-transport SBOM component - const clock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z')) - try { - const sbomJson = provider.createSbomFileFromTextFormat( - depTree, [], {}, - 'test/providers/tst_manifests/maven/pom_deps_with_no_ignore/pom.xml', - hashMap - ) - const nettyComponent = JSON.parse(sbomJson).components.find(c => c.name === 'netty-transport') - expect(nettyComponent).to.exist - expect(nettyComponent.hashes).to.deep.equal([{ alg: 'SHA-256', content: expectedDigest }]) - } finally { - clock.restore() - } + expect(hashMap.has('pkg:maven/log4j/log4j@1.2.17')).to.equal(true) }) /** From c8a54aaa78dbf904bc62a6133025b85e7a76bd74 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 17 Aug 2026 10:31:09 +0300 Subject: [PATCH 5/7] perf(providers): dedup Maven artifact reads before hashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same artifact recurs across dependency-tree branches — notably in multi-module reactor builds where every module re-lists shared deps. _buildMavenHashMap read and SHA-256-hashed the jar once per occurrence, producing the identical digest every time. Hoist the PURL computation above the file I/O and skip the redundant read + hash when the PURL is already in the map (the digest is deterministic per PURL). Addresses review feedback on PR #612 (efficiency: no deduplication guard before file I/O). Co-Authored-By: Claude Opus 4.8 Assisted-by: Claude Code --- src/providers/java_maven.js | 12 ++++++++--- test/providers/java_maven.test.js | 36 ++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index 17475454..af6e1c13 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -189,6 +189,15 @@ export default class Java_maven extends Base_java { 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 @@ -199,9 +208,6 @@ export default class Java_maven extends Base_java { try { const fileContent = fs.readFileSync(artifactPath) const digest = crypto.createHash('sha256').update(fileContent).digest('hex') - // Key by the exact PURL parseDep() produces, so the lookup in - // parseDependencyTree (hashMap.get(to.toString())) always hits. - const purl = this._coordinateToPurl(coord).toString() hashMap.set(purl, [{ alg: 'SHA-256', content: digest }]) } catch { if (process.env['TRUSTIFY_DA_DEBUG'] === 'true') { diff --git a/test/providers/java_maven.test.js b/test/providers/java_maven.test.js index db782847..17b68313 100644 --- a/test/providers/java_maven.test.js +++ b/test/providers/java_maven.test.js @@ -5,7 +5,7 @@ import path from 'node:path' import { expect } from 'chai' import esmock from 'esmock'; -import { useFakeTimers } from "sinon"; +import { spy, useFakeTimers } from "sinon"; import which from 'which'; import Java_maven from '../../src/providers/java_maven.js' @@ -329,6 +329,40 @@ suite('testing the java-maven SHA-256 hash computation', () => { expect(hashMap.size).to.equal(0) }) + /** + * Verifies that an artifact recurring across dependency-tree branches (as in a + * multi-module reactor build) is read and hashed only once — the dedup guard + * skips the redundant file read + SHA-256 computation for the already-hashed PURL. + */ + test('verify _buildMavenHashMap reads each artifact once despite repeated tree lines', () => { + // Given the same resolved artifact listed on three separate tree branches + const readSpy = spy(fs, 'readFileSync') + try { + const provider = new Java_maven() + const depTree = [ + 'com.example:root:jar:1.0.0', + '+- com.example:module-a:jar:1.0.0:compile', + '| \\- log4j:log4j:jar:1.2.17:compile', + '+- com.example:module-b:jar:1.0.0:compile', + '| \\- log4j:log4j:jar:1.2.17:compile', + '\\- com.example:module-c:jar:1.0.0:compile', + ' \\- log4j:log4j:jar:1.2.17:compile' + ].join('\n') + const log4jJar = path.join(tmpM2Repo, 'log4j', 'log4j', '1.2.17', 'log4j-1.2.17.jar') + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the log4j jar is read exactly once even though it appears three times, + // and its hash is still present with the correct digest + const log4jReads = readSpy.getCalls().filter(c => c.args[0] === log4jJar) + expect(log4jReads.length).to.equal(1) + expect(hashMap.get('pkg:maven/log4j/log4j@1.2.17')).to.deep.equal([{ alg: 'SHA-256', content: expectedDigest }]) + } finally { + readSpy.restore() + } + }) + /** * Verifies that parenthesized (omitted/duplicate) lines are skipped by the * guard itself — before any file I/O — even when the referenced artifact IS From 09347e3b2f6ca3ca57f5cdb136bae30a2433e0b4 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 17 Aug 2026 10:53:49 +0300 Subject: [PATCH 6/7] feat(providers): warn on incomplete .m2 hash coverage in Maven provider The catch block in _buildMavenHashMap swallowed every read failure unless TRUSTIFY_DA_DEBUG was set. In CI environments with partial .m2 caches (ephemeral containers, resolve-only phases), every readFileSync can fail silently, producing an SBOM with zero hashes and no visible signal. Track attempted vs. missed reads and emit a single summary warning at the end when any artifact could not be read, so hash coverage is visible even in non-debug mode. Mirrors the pip provider's unconditional console.warn convention (python_controller.js). The dedup guard already runs before the counter, so recurring reactor deps do not inflate the totals. Co-Authored-By: Claude Opus 4.8 Assisted-by: Claude Code --- src/providers/java_maven.js | 13 ++++++++ test/providers/java_maven.test.js | 51 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index af6e1c13..be2b543a 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -175,6 +175,11 @@ export default class Java_maven extends Base_java { 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() @@ -205,16 +210,24 @@ export default class Java_maven extends Base_java { : `${coord.artifactId}-${coord.version}.${ext}` const artifactPath = path.join(m2Repo, groupPath, coord.artifactId, coord.version, fileName) + attempted++ try { const fileContent = fs.readFileSync(artifactPath) const digest = crypto.createHash('sha256').update(fileContent).digest('hex') hashMap.set(purl, [{ alg: 'SHA-256', content: digest }]) } catch { + 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 } diff --git a/test/providers/java_maven.test.js b/test/providers/java_maven.test.js index 17b68313..680e22e2 100644 --- a/test/providers/java_maven.test.js +++ b/test/providers/java_maven.test.js @@ -363,6 +363,57 @@ suite('testing the java-maven SHA-256 hash computation', () => { } }) + /** + * Verifies that an incomplete .m2 cache surfaces a summary warning (even + * without TRUSTIFY_DA_DEBUG) reporting how many of the attempted artifacts + * could not be read, so degraded hash coverage is visible rather than silent. + */ + test('verify _buildMavenHashMap warns with a coverage summary when artifacts are missing', () => { + // Given a tree with one cached artifact (log4j) and one absent from the mock repo + const warnSpy = spy(console, 'warn') + try { + const provider = new Java_maven() + const depTree = [ + 'com.example:root:pom:1.0.0', + '+- log4j:log4j:jar:1.2.17:compile', + '\\- com.example:missing-lib:jar:9.9.9:compile' + ].join('\n') + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the cached artifact is still hashed, and exactly one summary + // warning reports the single miss out of the two attempted reads + expect(hashMap.has('pkg:maven/log4j/log4j@1.2.17')).to.equal(true) + expect(hashMap.has('pkg:maven/com.example/missing-lib@9.9.9')).to.equal(false) + expect(warnSpy.callCount).to.equal(1) + expect(warnSpy.firstCall.args[0]).to.equal( + 'Maven hash: 1 of 2 artifacts could not be read from the local .m2 cache; SBOM will be generated without hashes for those components.' + ) + } finally { + warnSpy.restore() + } + }) + + /** Verifies that no coverage warning is emitted when every attempted artifact is hashed. */ + test('verify _buildMavenHashMap stays silent when all artifacts are hashed', () => { + // Given a tree whose only artifact (log4j) exists in the mock repo + const warnSpy = spy(console, 'warn') + try { + const provider = new Java_maven() + const depTree = 'com.example:root:pom:1.0.0\n\\- log4j:log4j:jar:1.2.17:compile' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the artifact is hashed and no coverage warning is emitted + expect(hashMap.has('pkg:maven/log4j/log4j@1.2.17')).to.equal(true) + expect(warnSpy.called).to.equal(false) + } finally { + warnSpy.restore() + } + }) + /** * Verifies that parenthesized (omitted/duplicate) lines are skipped by the * guard itself — before any file I/O — even when the referenced artifact IS From dbb861812f3d7dfbf761e125bae8f2bb72f05edc Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 17 Aug 2026 11:46:57 +0300 Subject: [PATCH 7/7] refactor(providers): keep Maven hash concern out of shared tree parser The hashMap parameter threaded through Base_java.parseDependencyTree was a Maven-only concern leaking into the shared abstraction: Gradle extends Base_java, uses its own tree parser, and never passes a hash map. Remove hashMap from parseDependencyTree and attach Maven artifact hashes as a post-processing step instead. A new generic Sbom.attachHashes(hashMap) walks the built components and sets hashes by matching PURL, without overwriting hashes already present. The generic component-hash capability on addDependency (CycloneDX 1.4) is unchanged; only the base parser is cleaned. Co-Authored-By: Claude Opus 4.8 Assisted-by: Claude Code --- src/cyclone_dx_sbom.js | 25 ++++++++++ src/providers/base_java.js | 8 ++-- src/providers/java_maven.js | 5 +- src/sbom.js | 11 +++++ test/cyclone_dx_sbom_hashes.test.js | 71 +++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/cyclone_dx_sbom.js b/src/cyclone_dx_sbom.js index 2b290dae..dee19538 100644 --- a/src/cyclone_dx_sbom.js +++ b/src/cyclone_dx_sbom.js @@ -164,6 +164,31 @@ export default class CycloneDxSbom { 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>} 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 */ diff --git a/src/providers/base_java.js b/src/providers/base_java.js index dc97d2d4..88711fa5 100644 --- a/src/providers/base_java.js +++ b/src/providers/base_java.js @@ -57,9 +57,8 @@ export default class Base_Java { * @param {number} srcDepth - Current depth in the graph for the given source * @param {Array} lines - Array containing the text files being parsed * @param {Sbom} sbom - The SBOM where the dependencies are being added - * @param {Map>} [hashMap] - Optional PURL→hashes map */ - parseDependencyTree(src, srcDepth, lines, sbom, hashMap) { + parseDependencyTree(src, srcDepth, lines, sbom) { if (lines.length === 0) { return; } @@ -77,11 +76,10 @@ export default class Base_Java { let matchedScopeSrc = src.match(/:compile|:provided|:runtime|:test|:system|:import/g) // only add dependency to sbom if it's not with test scope or if it's root if ((matchedScope && matchedScope[0] !== ":test" && (matchedScopeSrc && matchedScopeSrc[0] !== ":test")) || (srcDepth === 0 && matchedScope && matchedScope[0] !== ":test")) { - const hashes = hashMap?.get(to.toString()) - sbom.addDependency(from, to, undefined, hashes) + sbom.addDependency(from, to) } } else { - this.parseDependencyTree(lines[index - 1], this._getDepth(lines[index - 1]), lines.slice(index), sbom, hashMap) + this.parseDependencyTree(lines[index - 1], this._getDepth(lines[index - 1]), lines.slice(index), sbom) } target = lines[++index]; targetDepth = this._getDepth(target); diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index be2b543a..4f881cde 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -247,7 +247,10 @@ export default class Java_maven extends Base_java { const license = this.readLicenseFromManifest(manifestPath); let sbom = new Sbom(); sbom.addRoot(rootPurl, license); - this.parseDependencyTree(root, 0, lines.slice(1), sbom, hashMap); + 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); } diff --git a/src/sbom.js b/src/sbom.js index 1c423f14..042a45c6 100644 --- a/src/sbom.js +++ b/src/sbom.js @@ -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>} hashMap - PURL→hashes map + * @return {Sbom} + */ + attachHashes(hashMap){ + return this.sbomModel.attachHashes(hashMap) + } + /** * @return String sbom json in a string format */ diff --git a/test/cyclone_dx_sbom_hashes.test.js b/test/cyclone_dx_sbom_hashes.test.js index 286b5e9a..a2b01dd1 100644 --- a/test/cyclone_dx_sbom_hashes.test.js +++ b/test/cyclone_dx_sbom_hashes.test.js @@ -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