Skip to content
Open
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
207 changes: 198 additions & 9 deletions src/providers/java_gradle.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,10 @@ export default class Java_gradle extends Base_java {
* @param {Sbom} sbom - the SBOM object to add dependencies to
* @param {Set} processedDeps - set of already processed dependencies
* @param {string} scope - the dependency scope
* @param {Map<string, Array<{alg: string, content: string}>>} [hashMap] - map of "group:name@version" to CycloneDX hashes
* @private
*/
#processDependencyTree(config, parentPurl, sbom, processedDeps, scope) {
#processDependencyTree(config, parentPurl, sbom, processedDeps, scope, hashMap) {
const processedLines = this.#prepareLinesForParsingDependencyTree(config);
let parentStack = [parentPurl];

Expand All @@ -186,7 +187,7 @@ export default class Java_gradle extends Base_java {
// Add dependency to SBOM if not already processed
if (!processedDeps.has(depKey)) {
processedDeps.add(depKey);
sbom.addDependency(currentParent, purl, scope);
sbom.addDependency(currentParent, purl, scope, this.#lookupHashes(purl, hashMap));
}
parentStack.push(purl);
}
Expand All @@ -200,7 +201,7 @@ export default class Java_gradle extends Base_java {
* @returns {string} the Dot Graph content
* @private
*/
#buildSbom(content, properties, manifestPath, opts = {}) {
#buildSbom(content, properties, manifestPath, opts = {}, hashMap) {
let sbom = new Sbom();
let root = `${properties.group}:${properties[ROOT_PROJECT_KEY_NAME].match(/Root project '(.+)'/)[1]}:jar:${properties.version}`
let rootPurl = this.parseDep(root)
Expand All @@ -212,8 +213,8 @@ export default class Java_gradle extends Base_java {

const processedDeps = new Set();

this.#processDependencyTree(runtimeConfig, rootPurl, sbom, processedDeps, 'required');
this.#processDependencyTree(compileConfig, rootPurl, sbom, processedDeps, 'optional');
this.#processDependencyTree(runtimeConfig, rootPurl, sbom, processedDeps, 'required', hashMap);
this.#processDependencyTree(compileConfig, rootPurl, sbom, processedDeps, 'optional', hashMap);

return sbom.filterIgnoredDepsIncludingVersion(ignoredDeps).getAsJsonString(opts);
}
Expand All @@ -228,11 +229,12 @@ export default class Java_gradle extends Base_java {
#createSbomStackAnalysis(manifest, opts = {}) {
let content = this.#getDependencies(manifest, opts)
let properties = this.#extractProperties(manifest, opts)
let hashMap = this.parseGradleHashes(manifest, opts)
// read dependency tree from temp file
if (process.env["TRUSTIFY_DA_DEBUG"] === "true") {
console.log("Dependency tree that will be used as input for creating the BOM =>" + EOL + EOL + content)
}
let sbom = this.#buildSbom(content, properties, manifest, opts)
let sbom = this.#buildSbom(content, properties, manifest, opts, hashMap)
return sbom
}

Expand Down Expand Up @@ -282,8 +284,9 @@ export default class Java_gradle extends Base_java {
#getSbomForComponentAnalysis(manifestPath, opts = {}) {
let content = this.#getDependencies(manifestPath, opts)
let properties = this.#extractProperties(manifestPath, opts)
let hashMap = this.parseGradleHashes(manifestPath, opts)

let sbom = this.#buildDirectDependenciesSbom(content, properties, manifestPath, opts)
let sbom = this.#buildDirectDependenciesSbom(content, properties, manifestPath, opts, hashMap)
return sbom

}
Expand All @@ -305,6 +308,119 @@ export default class Java_gradle extends Base_java {
}
}

/**
* Compute SHA-256 hashes for the resolved artifacts of a Gradle manifest.
*
* Rather than scanning the local Gradle cache, this asks Gradle itself for
* the resolved artifact files via an init script (mirroring the pattern used
* by {@link discoverGradleSubprojects}), then hashes each file with the
* Node.js `crypto` module. The result is keyed by the canonical PURL string
* built via {@link Base_java#toPurl} — the same builder {@link parseDep} uses
* for the lookup — so the stored key and the lookup key cannot drift.
*
* Degrades gracefully: if Gradle cannot be invoked, the init script fails, or
* an artifact has no readable file (e.g. BOM/`platform()` dependencies), the
* hash for that component is omitted rather than throwing. A warning is emitted
* on every degradation path so incomplete hash coverage is visible even without
* `TRUSTIFY_DA_DEBUG` (mirroring the pip/cargo providers).
*
* @param {string} manifest - path to build.gradle[.kts]
* @param {{}} [opts={}] - optional various options to pass along the application
* @returns {Map<string, Array<{alg: string, content: string}>>} map of canonical PURL string to CycloneDX hashes
*/
parseGradleHashes(manifest, opts = {}) {
const hashMap = new Map()
const debug = process.env["TRUSTIFY_DA_DEBUG"] === "true"

let gradle
try {
gradle = this.selectToolBinary(manifest, opts)
} catch (error) {
console.warn('Gradle could not be invoked to compute artifact hashes, SBOM will be generated without hashes')
if (debug) {
console.error(`Gradle hash: selectToolBinary failed => ${error.stack || error.message}`)
}
return hashMap
}

const initScriptPath = path.join(os.tmpdir(), `da-list-hashes-${crypto.randomUUID()}.gradle`)
try {
fs.writeFileSync(initScriptPath, GRADLE_HASH_INIT_SCRIPT)
let output
try {
output = this._invokeCommand(gradle, [
'-q', '--no-daemon',
'--init-script', initScriptPath,
'daListHashes',
], { cwd: path.dirname(manifest) })
} catch (error) {
console.warn('Gradle hash init script failed, SBOM will be generated without hashes')
if (debug) {
console.error(`Gradle hash: init script invocation failed => ${error.stack || error.message}`)
}
return hashMap
}

let attempted = 0
let missed = 0
for (const { id, file } of parseGradleHashScriptOutput(output.toString())) {
const coord = parseComponentId(id)
if (!coord) {
continue
}
// Build the key with the same canonical PURL builder parseDep uses for
// the lookup, so the stored key and the #lookupHashes key cannot drift.
const key = this.toPurl(coord.group, coord.name, coord.version).toString()
if (hashMap.has(key)) {
continue
}
attempted++
try {
const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
hashMap.set(key, [{ alg: 'SHA-256', content: digest }])
} catch (error) {
// artifact file missing/unreadable — omit the hash for this component
missed++
if (debug) {
console.error(`Gradle hash: could not read artifact ${file} => ${error.message}`)
}
}
}
if (missed > 0) {
console.warn(`Gradle hash: ${missed} of ${attempted} resolved artifacts could not be read, SBOM will be generated without hashes for those components`)
}
} catch (error) {
// Unexpected failure (e.g. a programming error) is degraded to keep SBOM
// generation working, but surfaced under debug so it is not mistaken for
// ordinary graceful degradation.
console.warn('Gradle artifact hashing failed, SBOM will be generated without hashes')
if (debug) {
console.error(`Gradle hash: unexpected failure => ${error.stack || error.message}`)
}
return hashMap
} finally {
try { fs.unlinkSync(initScriptPath) } catch { /* ignore */ }
}

return hashMap
}

/**
* Look up the CycloneDX hashes for a dependency purl in the hash map.
* Keys off the canonical PURL string so it matches the key stored by
* {@link parseGradleHashes}.
* @param {PackageURL} purl - the dependency package URL
* @param {Map<string, Array<{alg: string, content: string}>>} [hashMap] - map of canonical PURL string to hashes
* @returns {Array<{alg: string, content: string}>|undefined} the hashes, or undefined when absent
* @private
*/
#lookupHashes(purl, hashMap) {
if (!hashMap) {
return undefined
}
return hashMap.get(purl.toString())
}

/**
* Extracts runtime and compile configurations from the dependency tree
* @param {string} content - the dependency tree content
Expand Down Expand Up @@ -355,7 +471,7 @@ export default class Java_gradle extends Base_java {
* @param properties {Object} - properties of the gradle project.
* @return {string} return sbom json string of the build.gradle manifest file
*/
#buildDirectDependenciesSbom(content, properties, manifestPath, opts = {}) {
#buildDirectDependenciesSbom(content, properties, manifestPath, opts = {}, hashMap) {
let sbom = new Sbom();
let root = `${properties.group}:${properties[ROOT_PROJECT_KEY_NAME].match(/Root project '(.+)'/)[1]}:jar:${properties.version}`
let rootPurl = this.parseDep(root)
Expand All @@ -372,7 +488,7 @@ export default class Java_gradle extends Base_java {
directDependencies.forEach((scope, dep) => {
const purl = this.parseDep(dep);
purl.scope = scope;
sbom.addDependency(rootPurl, purl, scope);
sbom.addDependency(rootPurl, purl, scope, this.#lookupHashes(purl, hashMap));
});

return sbom.filterIgnoredDepsIncludingVersion(ignoredDeps).getAsJsonString(opts);
Expand Down Expand Up @@ -486,6 +602,30 @@ const GRADLE_INIT_SCRIPT = `allprojects {
}
`

/**
* Gradle init script that emits, per resolved module artifact, a structured line
* of the form `::DA_HASH::group:name:version::/absolute/file/path`. It obtains
* files from Gradle's resolution API (the same approach as the CycloneDX Gradle
* plugin) so it is robust to cache-layout changes and correctly reports
* classifiers and non-jar artifacts. Uses a lenient artifact view so
* unresolved/fileless artifacts are skipped rather than failing the build.
*/
const GRADLE_HASH_INIT_SCRIPT = `allprojects {
task daListHashes {
doLast {
configurations.findAll { it.canBeResolved }.each { cfg ->
cfg.incoming.artifactView { lenient = true }.artifacts.each { artifact ->
def cid = artifact.id.componentIdentifier
if (cid instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier) {
println "::DA_HASH::\${cid.group}:\${cid.module}:\${cid.version}::\${artifact.file.absolutePath}"
}
}
}
}
}
}
`

/**
* Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build.
* Uses a custom init script to get structured project listing.
Expand Down Expand Up @@ -584,3 +724,52 @@ export function parseGradleInitScriptOutput(raw) {
}
return projects
}

/**
* Parse the structured output from the Gradle hash init script.
* Each recognised line has the form `::DA_HASH::group:name:version::<file-path>`.
*
* @param {string} raw - Raw stdout from gradle
* @returns {{ id: string, file: string }[]} component id (`group:name:version`) and absolute artifact file path
*/
export function parseGradleHashScriptOutput(raw) {
const artifacts = []
for (const rawLine of raw.split('\n')) {
const line = rawLine.trimEnd()
if (!line.startsWith('::DA_HASH::')) {
continue
}
const prefix = '::DA_HASH::'
const remainder = line.substring(prefix.length)
const lastSep = remainder.lastIndexOf('::')
if (lastSep < 0) {
continue
}
const id = remainder.substring(0, lastSep)
const file = remainder.substring(lastSep + 2)
if (id && file) {
artifacts.push({ id, file })
}
}
return artifacts
}

/**
* Parse a Gradle module component id (`group:name:version`) into its coordinate
* parts. The caller builds the canonical PURL key from these parts using the same
* builder {@link parseDep} uses, so the stored key and the lookup key cannot drift.
*
* @param {string} id - the Gradle component id
* @returns {{group: string, name: string, version: string}|null} the parsed coordinate, or null when the id is malformed
*/
export function parseComponentId(id) {
const parts = id.split(':')
if (parts.length < 3) {
return null
}
const [group, name, version] = parts
if (!group || !name || !version) {
return null
}
return { group, name, version }
}
102 changes: 102 additions & 0 deletions test/providers/gradle_hash_test_utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import fs from 'fs'
import os from 'os'
import path from 'path'

/** Directory holding deterministic stand-in artifact files whose content is hashed by the provider. */
export const HASH_FIXTURE_DIR = path.join(os.tmpdir(), 'da-gradle-hash-fixtures')

/**
* Extract a superset of `group:name:version` coordinates from a gradle dependency tree.
* Mirrors the tree-glyph stripping done by the provider so artifact names with hyphens
* are preserved; extra coordinates are harmless because the provider only looks up real ones.
* @param {string} depTree - the `gradle dependencies` output
* @returns {string[]} unique `group:name:version` coordinates
*/
export function extractCoordinates(depTree) {
const coords = new Set()
for (const raw of depTree.split(/\r?\n/)) {
const line = raw.replaceAll('|', ' ').replace(/\\---|\+---/g, ' ').trim()
if (!line || line.startsWith('No dependencies') || line.startsWith('Root project')) {
continue
}
const m = line.match(/^([\w.-]+):([\w.-]+):([\w.-]+)(?:\s*->\s*([\w.-]+))?/)
if (m) {
const version = m[4] || m[3]
coords.add(`${m[1]}:${m[2]}:${version}`)
}
}
return [...coords]
}

/**
* Ensure a deterministic stand-in artifact file exists for a coordinate and return its path.
* The file content is the coordinate itself, so the SHA-256 the provider computes is stable.
* @param {string} coord - a `group:name:version` coordinate
* @returns {string} absolute path to the artifact file
*/
export function artifactFileFor(coord) {
fs.mkdirSync(HASH_FIXTURE_DIR, { recursive: true })
const p = path.join(HASH_FIXTURE_DIR, coord.replace(/[^\w.-]/g, '_') + '.jar')
if (!fs.existsSync(p)) {
fs.writeFileSync(p, coord)
}
return p
}

/**
* Build the `::DA_HASH::group:name:version::<file>` init-script output for a dependency tree.
* @param {string} depTree - the `gradle dependencies` output
* @param {(coord: string) => string|null} [fileFor=artifactFileFor] - resolves a coordinate to an artifact path; return null to omit the line
* @returns {string} the mocked init-script stdout
*/
export function buildHashScriptOutput(depTree, fileFor = artifactFileFor) {
return extractCoordinates(depTree)
.map(c => {
const file = fileFor(c)
return file ? `::DA_HASH::${c}::${file}` : null
})
.filter(Boolean)
.join('\n')
}

/**
* Stub for `_invokeCommand` that answers `dependencies`, `properties`, and the
* `daListHashes` init-script task used to compute artifact hashes.
* @param {string[]} args - the args passed to the gradle binary
* @param {string} dependencyTreeTextContent - mocked `gradle dependencies` output
* @param {string} gradleProperties - mocked `gradle properties` output
* @param {string} [hashScriptOutput] - mocked `daListHashes` output (defaults to hashes for the whole tree)
* @returns {string} the mocked stdout for the requested gradle invocation
*/
export function getStubbedResponse(args, dependencyTreeTextContent, gradleProperties, hashScriptOutput) {
if (args.includes("daListHashes")) {
return hashScriptOutput !== undefined ? hashScriptOutput : buildHashScriptOutput(dependencyTreeTextContent)
} else if (args.includes("dependencies")) {
return dependencyTreeTextContent
} else if (args.includes("properties")) {
return gradleProperties
}
return ''
}

/**
* Install a mocked `_invokeCommand` on the Base_java prototype for a provider.
* @param {object} provider - the gradle provider instance
* @param {string} depTree - mocked dependency tree output
* @param {string} props - mocked properties output
* @param {string} [hashScriptOutput] - optional override for the `daListHashes` output
*/
export function mockInvokeCommand(provider, depTree, props, hashScriptOutput) {
const mockedExecFunction = function (bin, args) {
return getStubbedResponse(args, depTree, props, hashScriptOutput);
}
Object.getPrototypeOf(Object.getPrototypeOf(provider))._invokeCommand = mockedExecFunction
}

/**
* Remove the stand-in artifact files created by {@link artifactFileFor}. Call from
* `suiteTeardown` so long-lived CI agents do not accumulate temp artifacts.
*/
export function cleanupHashFixtures() {
fs.rmSync(HASH_FIXTURE_DIR, { recursive: true, force: true })
}
Loading
Loading