diff --git a/CHANGELOG.md b/CHANGELOG.md index f9bd82e0e..dc1d9252b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - `socket fix --allow-overrides` fixes a vulnerability that a parent package's version range blocks by writing an override or resolution that forces the fixed version under that parent, in npm, pnpm, Yarn Berry and Rush projects. +- Socket facts for multi-module Maven, Gradle and sbt builds now attribute each direct dependency to the subproject build files that pull it in, so the dashboard shows which module brought it in. ### Changed - Updated the Coana CLI to v `15.12.1`. diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 5634146c1..e95fd6b9c 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { type ResolvedArtifactPaths, + type SocketFactsManifestReference, type SocketFactsSbom, type SocketFactsSbomComponent, type SocketFactsSbomMetadata, @@ -9,6 +10,7 @@ import { mavenCoordinateKey, projectClasspathKey, } from './facts.mts' +import constants from '../../../constants.mts' import type { ParsedRecords, RawCoord, RawProject } from './records.mts' import type { ResolutionReport } from './resolution-report.mts' @@ -58,7 +60,11 @@ export function assembleFacts( for (const p of parsed.projects.values()) { projectsByGav.set(gav(p.group, p.name, p.version), p) } - const components = buildComponents(finalNodes, projectsByGav) + const components = buildComponents( + finalNodes, + projectsByGav, + buildManifestFilesByCoord(parsed, directByRoot, perRoot), + ) const projects = opts.emitProjects === false ? [] @@ -176,9 +182,40 @@ function mergeByCoordinate(perRoot: Map): { return { finalNodes, directByRoot } } +function buildManifestFilesByCoord( + parsed: ParsedRecords, + directByRoot: Map>, + perRoot: Map, +): Map { + const buildFilesByCoord = new Map>() + for (const [rootId, ids] of directByRoot) { + const projectKey = perRoot.get(rootId)?.projectKey ?? '' + const buildFiles = parsed.projects.get(projectKey)?.buildFiles ?? [] + for (const id of ids) { + let set = buildFilesByCoord.get(id) + if (!set) { + set = new Set() + buildFilesByCoord.set(id, set) + } + for (const f of buildFiles) { + set.add(f) + } + } + } + return new Map( + [...buildFilesByCoord].map(({ 0: id, 1: buildFiles }) => [ + id, + [constants.DOT_SOCKET_DOT_FACTS_JSON, ...[...buildFiles].sort()].map( + file => ({ file }), + ), + ]), + ) +} + function buildComponents( finalNodes: Map, projectsByGav: Map, + manifestFilesByCoord: Map, ): SocketFactsSbomComponent[] { return [...finalNodes.keys()].sort().map(id => { const fn = finalNodes.get(id)! @@ -212,6 +249,10 @@ function buildComponents( if (fn.children.size) { comp.dependencies = [...fn.children].sort() } + const manifestFiles = manifestFilesByCoord.get(id) + if (manifestFiles) { + comp.manifestFiles = manifestFiles + } return comp }) } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 52a01a073..40fc827e7 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -149,4 +149,41 @@ describe('records → assemble → sidecar', () => { expect(project).not.toHaveProperty('firstParty') } }) + it('marks direct dependencies with the facts file and the build files of the subprojects they are direct in', () => { + const records = [ + 'meta\tmaven\t3.9.6\t17', + 'project\ta\tg\ta\t1\ta', + 'projectBuild\ta\ta/pom.xml', + 'project\tb\tg\tb\t1\tb', + 'projectBuild\tb\tb/pom.xml', + // No build file of its own, e.g. configured from the root build. + 'project\tc\tg\tc\t1\tc', + 'root\tr1\ta\truntimeClasspath\t1', + 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t1', + 'node\tr1\tg:dep:jar:3\tg\tdep\t3\tjar\t\t0', + 'edge\tr1\tg:ext:jar:2\tg:dep:jar:3', + 'root\tr2\tb\ttestRuntimeClasspath\t0', + 'node\tr2\tg:a:jar:1\tg\ta\t1\tjar\t\t1', + 'node\tr2\tg:ext:jar:2\tg\text\t2\tjar\t\t1', + 'edge\tr2\tg:a:jar:1\tg:ext:jar:2', + 'root\tr3\tc\truntimeClasspath\t1', + 'node\tr3\tg:solo:jar:1\tg\tsolo\t1\tjar\t\t1', + ].join('\n') + const { facts } = assembleFacts(parseRecords(records)) + + expect( + Object.fromEntries( + facts.components.map(c => [c.id, c.manifestFiles ?? 'absent']), + ), + ).toEqual({ + 'g:a:jar:1': [{ file: '.socket.facts.json' }, { file: 'b/pom.xml' }], + 'g:dep:jar:3': 'absent', + 'g:ext:jar:2': [ + { file: '.socket.facts.json' }, + { file: 'a/pom.xml' }, + { file: 'b/pom.xml' }, + ], + 'g:solo:jar:1': [{ file: '.socket.facts.json' }], + }) + }) }) diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index aab42c0d2..f3f41010e 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -30,6 +30,14 @@ export type SocketFactsSbomComponent = AnyPURL & { // A module of the scanned build itself (same GAV as a projects[] entry). firstParty?: true | undefined dependencies?: string[] | undefined + // Direct dependencies only: the facts file plus the build files of the subprojects + // pulling it in directly, which need not declare it (e.g. a parent POM does). + manifestFiles?: SocketFactsManifestReference[] | undefined +} + +// Relative to the facts file's directory. +export type SocketFactsManifestReference = { + file: string } export type SocketFactsSbomProject = AnyPURL & { diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java index 76007c104..f5074848b 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java @@ -101,6 +101,8 @@ public void run(MavenSession session, List reactor, File rootDir, String ws = SocketSupport.workspace(rootDir.toPath(), module.getBasedir().toPath()); if (SocketSupport.isExcludedPath(ws, excludes)) continue; rec(lines, "project", ws, module.getGroupId(), module.getArtifactId(), module.getVersion(), ws); + File pom = module.getFile(); + if (pom != null && pom.isFile()) rec(lines, "projectBuild", ws, SocketSupport.relativePath(rootDir.toPath(), pom.toPath())); if (opts.withFiles) { for (String s : collectSources(module)) rec(lines, "projectSrc", ws, s); for (String t : collectTargets(module)) rec(lines, "projectTgt", ws, t); diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java index d0bdca2b4..759df9142 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java @@ -19,6 +19,10 @@ public static String workspace(Path rootDir, Path projectDir) { return rootDir.equals(projectDir) ? "." : rootDir.relativize(projectDir).toString(); } + public static String relativePath(Path rootDir, Path file) { + return rootDir.relativize(file).toString().replace(File.separatorChar, '/'); + } + /** * Full Maven coordinate {@code groupId:artifactId:type:classifier:version} with empty segments * dropped — the per-root node key the assembler uses. {@code type} is the Maven packaging (the diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 7a842812a..14a776e84 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -13,6 +13,7 @@ import type { // project projectKey group name version dir // projectSrc projectKey path (--with-files only) // projectTgt projectKey path (--with-files only) +// projectBuild projectKey path (build-root-relative) // root rootId projectKey config prod(0|1) // node rootId coordId group name version ext classifier direct(0|1) // edge rootId parentCoordId childCoordId @@ -58,6 +59,8 @@ export type RawProject = { dir: string sources: string[] targets: string[] + // The project's own build files, build-root-relative. + buildFiles: string[] } export type ParsedRecords = { @@ -131,6 +134,7 @@ export function parseRecords(text: string): ParsedRecords { dir: '', sources: [], targets: [], + buildFiles: [], } result.projects.set(key, p) } @@ -166,6 +170,11 @@ export function parseRecords(text: string): ParsedRecords { project(f[1] ?? '').targets.push(f[2]) } break + case 'projectBuild': + if (f[2]) { + project(f[1] ?? '').buildFiles.push(f[2]) + } + break case 'root': { const r = root(f[1] ?? '') r.projectKey = f[2] ?? '' diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 96bfb2d7c..184fcfece 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -114,6 +114,13 @@ gradle.projectsEvaluated { g -> } } } catch (Exception ignore) {} + // `buildFile` is the configured script even when absent on disk (a project configured from the + // root or a convention plugin); only a script that exists is the project's own build file. + def buildFiles = [] + try { + def bf = p.buildFile + if (bf != null && bf.isFile()) buildFiles << rel(bf) + } catch (Exception ignore) {} g.socketFactsState.projectsInfo.add([ path : p.path, group : (p.group ?: '').toString(), @@ -122,6 +129,7 @@ gradle.projectsEvaluated { g -> dir : rel(p.projectDir), sources: (sources as List).sort(), targets: (targets as List).sort(), + buildFiles: buildFiles, ]) } } @@ -491,6 +499,7 @@ rootProject { rp -> synchronized (state.projectsInfo) { projectsInfo = new ArrayList(state.projectsInfo) } projectsInfo.each { pi -> rec(['project', pi.path, pi.group ?: '', pi.name, pi.version ?: '', pi.dir]) + pi.buildFiles.each { f -> rec(['projectBuild', pi.path, f]) } if (withFilesProjects) { pi.sources.each { s -> rec(['projectSrc', pi.path, s]) } pi.targets.each { t -> rec(['projectTgt', pi.path, t]) } diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 63aec33fd..e14edfe3a 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -76,6 +76,22 @@ object SocketFactsPlugin extends AutoPlugin { val moduleDirs: Map[String, (Seq[String], Seq[String])] = if (withFiles) buildModuleDirs(allRefs, extracted) else Map.empty + // Where sbt recorded the project's own settings as defined (the root build.sbt for a subproject + // defined there); positions outside the build are sbt defaults, plugins or our injected base. + val buildFilesByRef: Map[ProjectRef, Seq[String]] = + extracted.structure.settings + .flatMap { setting => + (setting.key.scope.project, setting.pos) match { + case (Select(ref: ProjectRef), pos: FilePosition) => + val f = new File(pos.path) + if (f.isAbsolute && f.isFile && f.getCanonicalFile.toPath.startsWith(rootCanonPath)) Some(ref -> relOf(f)) + else None + case _ => None + } + } + .groupBy(_._1) + .map { case (ref, pairs) => ref -> pairs.map(_._2).distinct.sorted } + val sb = new StringBuilder def rec(fields: String*): Unit = { sb.append(fields.map(esc).mkString("\t")); sb.append('\n') @@ -90,6 +106,7 @@ object SocketFactsPlugin extends AutoPlugin { val mid = rootIdOf(extracted, ref) val ver = if (mid.revision == null) "" else mid.revision rec("project", ref.project, mid.organization, mid.name, ver, relOf(extracted.get(baseDirectory.in(ref)))) + buildFilesByRef.getOrElse(ref, Nil).foreach(f => rec("projectBuild", ref.project, f)) if (withFiles) { moduleDirs.get(mid.organization + ":" + mid.name + ":" + ver).foreach { case (sources, targets) =>