From 93f420bf4b58e5bf48a5cbab2382fc3036d600de Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 7 Oct 2026 20:47:40 +0200 Subject: [PATCH 1/9] feat(manifest): name JVM Socket facts files after their build Every Maven, Gradle and sbt build now writes its own `.socket.facts.json` in its build root, so builds sharing a directory no longer overwrite each other's facts: - A file-addressed build is named after the file the tool ran on (`pom.xml.socket.facts.json`, `other-pom.xml.socket.facts.json` for `mvn -f other-pom.xml`); a directory-addressed one after the tool (`gradle.socket.facts.json`, `sbt.socket.facts.json`). - Each build script reports its root and Maven its entry POM, so the file lands where its relative paths resolve, also under `mvn -f sub/x.xml` and `gradle -p dir`. - Facts projects carry the build tool's own unique identifier (`projects[].id`: Maven GAV, Gradle project path, sbt project id) and their own build files (`projects[].manifestFiles`). The sidecar keys project classpaths by that id, and the Maven extension keys modules by GAV, so two modules sharing a directory stay separate. - Scan, reach and fix recognise any `*.socket.facts.json`. Reachability no longer uploads an earlier bare `.socket.facts.json` report as input. --- CHANGELOG.md | 6 + src/commands/fix/coana-fix.mts | 19 +-- src/commands/manifest/README.md | 2 +- .../cmd-manifest-dynamic-sbom-inference.mts | 5 +- ...d-manifest-dynamic-sbom-inference.test.mts | 5 +- src/commands/manifest/cmd-manifest-gradle.mts | 10 +- .../manifest/cmd-manifest-gradle.test.mts | 10 +- src/commands/manifest/cmd-manifest-kotlin.mts | 10 +- .../manifest/cmd-manifest-kotlin.test.mts | 10 +- src/commands/manifest/cmd-manifest-maven.mts | 4 +- .../manifest/cmd-manifest-maven.test.mts | 4 +- src/commands/manifest/cmd-manifest-scala.mts | 14 +- .../manifest/cmd-manifest-scala.test.mts | 10 +- .../manifest/convert-gradle-to-facts.mts | 3 +- .../manifest/convert-maven-to-facts.mts | 3 +- .../manifest/convert-sbt-to-facts.mts | 2 +- .../manifest/generate-recursive-manifests.mts | 5 +- src/commands/manifest/run-manifest-facts.mts | 42 +++++- .../manifest/run-manifest-facts.test.mts | 136 ++++++++++++++++-- src/commands/manifest/scripts/assemble.mts | 32 +++-- .../manifest/scripts/assemble.test.mts | 63 +++++++- src/commands/manifest/scripts/build-tool.mts | 29 +++- .../manifest/scripts/build-tool.test.mts | 31 ++++ src/commands/manifest/scripts/facts.mts | 13 +- .../socket/SocketFactsRecordsEngine.java | 21 ++- src/commands/manifest/scripts/records.mts | 17 +++ src/commands/manifest/scripts/run.mts | 21 ++- src/commands/manifest/scripts/sidecar.mts | 14 +- .../manifest/scripts/sidecar.test.mts | 14 +- .../manifest/scripts/socket-facts.init.gradle | 2 + .../scripts/socket-facts.plugin.scala | 1 + .../manifest/setup-manifest-config.mts | 2 +- src/commands/scan/cmd-scan-create.mts | 17 ++- src/commands/scan/cmd-scan-create.test.mts | 2 +- src/commands/scan/cmd-scan-reach.test.mts | 2 +- src/commands/scan/handle-create-new-scan.mts | 34 ++--- .../scan/handle-create-new-scan.test.mts | 60 ++++++++ .../scan/perform-reachability-analysis.mts | 27 ++-- .../perform-reachability-analysis.test.mts | 30 ++++ src/commands/scan/reachability-flags.mts | 2 +- src/utils/coana.mts | 28 ++++ src/utils/coana.test.mts | 55 +++++++ 42 files changed, 652 insertions(+), 165 deletions(-) create mode 100644 src/commands/manifest/scripts/build-tool.test.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a3ae52a2b..91c4aa903c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed +- Socket facts for Maven, Gradle and sbt builds are now written per build — `pom.xml.socket.facts.json` (named after the POM Maven runs on, so `-f other-pom.xml` gets its own), `gradle.socket.facts.json` and `sbt.socket.facts.json` — so builds sharing a directory no longer overwrite each other. Delete any `.socket.facts.json` an earlier run left behind. +- Reachability scans no longer feed a leftover `.socket.facts.json` report from an earlier run back into the analysis. + ## [1.6.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.0) - 2026-10-07 ### Added diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 6b91914901..3ed835c592 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -22,12 +22,9 @@ import { import { generateSocketFactsForFix } from './generated-socket-facts.mts' import { getSocketFixBranchName, getSocketFixCommitMessage } from './git.mts' import { getSocketFixPrs, openSocketFixPr } from './pull-request.mts' -import { - DOT_SOCKET_DOT_FACTS_JSON, - FLAG_DRY_RUN, - GQL_PR_STATE_OPEN, -} from '../../constants.mts' +import { FLAG_DRY_RUN, GQL_PR_STATE_OPEN } from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' +import { isSocketFactsFile } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { getErrorCause } from '../../utils/errors.mts' @@ -194,10 +191,6 @@ async function discoverGhsaIds( } } -function isFactsFile(filepath: string): boolean { - return path.basename(filepath).toLowerCase() === DOT_SOCKET_DOT_FACTS_JSON -} - type GitWorkingTreeChanges = { modified: string[] untracked: string[] @@ -350,16 +343,16 @@ async function coanaFixWithFacts( cwd, }) const scanFilepaths = await findScanFilepaths() - // Fail if any .socket.facts.json files are present in the scan folder. + // Fail if any Socket facts files are present in the scan folder. // These are analysis artifacts and must be removed before re-running fix. - const factsFiles = scanFilepaths.filter(isFactsFile) + const factsFiles = scanFilepaths.filter(isSocketFactsFile) if (factsFiles.length) { if (!silence) { spinner?.stop() } return { ok: false, - message: `Found ${DOT_SOCKET_DOT_FACTS_JSON} in manifest files`, + message: 'Found Socket facts files in manifest files', cause: `Delete the following ${pluralize('file', factsFiles.length)} before running socket fix again:\n` + factsFiles.map(p => ` - ${p}`).join('\n'), @@ -380,7 +373,7 @@ async function coanaFixWithFacts( }) } catch (e) { // A failed build root aborts inference after others wrote their facts. - const partial = (await findScanFilepaths()).filter(isFactsFile) + const partial = (await findScanFilepaths()).filter(isSocketFactsFile) await Promise.all(partial.map(p => fs.rm(p, { force: true }))) throw e } diff --git a/src/commands/manifest/README.md b/src/commands/manifest/README.md index 0f54b10a76..63527aa035 100644 --- a/src/commands/manifest/README.md +++ b/src/commands/manifest/README.md @@ -153,7 +153,7 @@ underlying flow is identical to the gradle subcommand. ## socket manifest maven [beta] -Generates a Socket facts file (`.socket.facts.json`) from a Maven `pom.xml` +Generates a Socket facts file (`pom.xml.socket.facts.json`) from a Maven `pom.xml` project, using `mvn` (override with `--bin`, e.g. a project `./mvnw` wrapper). Pass extra options through to maven with `--maven-opts` (e.g. `--maven-opts="-P release -s settings.xml"`). diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts index 980c198cab..6ec3b684b9 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts @@ -38,7 +38,10 @@ const config: CliCommandConfig = { $ ${command} [options] [CWD=.] Recursively walks CWD, discovers independent gradle, sbt, and maven build - roots, and generates a Socket facts SBOM (.socket.facts.json) for each, + roots, and generates a Socket facts SBOM for each + (pom.xml.socket.facts.json, gradle.socket.facts.json, or + sbt.socket.facts.json, so builds sharing a directory never overwrite each + other), skipping subproject/reactor-module directories a parent build root already covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself. diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts index 4207a94035..fa31714a3c 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts @@ -24,7 +24,10 @@ describe('socket manifest dynamic-sbom-inference', async () => { $ socket manifest dynamic-sbom-inference [options] [CWD=.] Recursively walks CWD, discovers independent gradle, sbt, and maven build - roots, and generates a Socket facts SBOM (.socket.facts.json) for each, + roots, and generates a Socket facts SBOM for each + (pom.xml.socket.facts.json, gradle.socket.facts.json, or + sbt.socket.facts.json, so builds sharing a directory never overwrite each + other), skipping subproject/reactor-module directories a parent build root already covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself. diff --git a/src/commands/manifest/cmd-manifest-gradle.mts b/src/commands/manifest/cmd-manifest-gradle.mts index 876790efca..c30bd27243 100644 --- a/src/commands/manifest/cmd-manifest-gradle.mts +++ b/src/commands/manifest/cmd-manifest-gradle.mts @@ -38,12 +38,12 @@ const config: CliCommandConfig = { facts: { type: 'boolean', description: - 'Emit a Socket facts JSON file (`.socket.facts.json`) describing the resolved dependency graph. This is the default; pass `--pom` to generate `pom.xml` files instead', + 'Emit a Socket facts JSON file (`gradle.socket.facts.json`) describing the resolved dependency graph. This is the default; pass `--pom` to generate `pom.xml` files instead', }, pom: { type: 'boolean', description: - 'Generate `pom.xml` manifest file(s) instead of the default Socket facts file (`.socket.facts.json`)', + 'Generate `pom.xml` manifest file(s) instead of the default Socket facts file (`gradle.socket.facts.json`)', }, includeConfigs: { type: 'string', @@ -78,9 +78,9 @@ const config: CliCommandConfig = { Options ${getFlagListOutput(config.flags)} - By default, emits a single \`.socket.facts.json\` describing the resolved - dependency graph of the whole build, using gradle (preferably your local - \`gradlew\`). An unresolved dependency is a fatal error. You can pass + By default, emits a single \`gradle.socket.facts.json\` describing the + resolved dependency graph of the whole build, using gradle (preferably your + local \`gradlew\`). An unresolved dependency is a fatal error. You can pass --include-configs / --exclude-configs (comma-separated glob patterns) to control which configurations are resolved (e.g. --include-configs=\`*CompileClasspath,*RuntimeClasspath\`), and diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index fd2a4c23b0..9d3b43e7bc 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -26,16 +26,16 @@ describe('socket manifest gradle', async () => { --bin Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH --exclude-configs When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applied after --include-configs) --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. - --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead + --facts Emit a Socket facts JSON file (\`gradle.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) --include-configs When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only configurations matching at least one pattern are resolved. e.g. \`*CompileClasspath,*RuntimeClasspath\`. Default: every resolvable configuration - --pom Generate \`pom.xml\` manifest file(s) instead of the default Socket facts file (\`.socket.facts.json\`) + --pom Generate \`pom.xml\` manifest file(s) instead of the default Socket facts file (\`gradle.socket.facts.json\`) --verbose Print debug messages - By default, emits a single \`.socket.facts.json\` describing the resolved - dependency graph of the whole build, using gradle (preferably your local - \`gradlew\`). An unresolved dependency is a fatal error. You can pass + By default, emits a single \`gradle.socket.facts.json\` describing the + resolved dependency graph of the whole build, using gradle (preferably your + local \`gradlew\`). An unresolved dependency is a fatal error. You can pass --include-configs / --exclude-configs (comma-separated glob patterns) to control which configurations are resolved (e.g. --include-configs=\`*CompileClasspath,*RuntimeClasspath\`), and diff --git a/src/commands/manifest/cmd-manifest-kotlin.mts b/src/commands/manifest/cmd-manifest-kotlin.mts index 3f3c5df4c0..030ca6a893 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.mts @@ -43,12 +43,12 @@ const config: CliCommandConfig = { facts: { type: 'boolean', description: - 'Emit a Socket facts JSON file (`.socket.facts.json`) describing the resolved dependency graph. This is the default; pass `--pom` to generate `pom.xml` files instead', + 'Emit a Socket facts JSON file (`gradle.socket.facts.json`) describing the resolved dependency graph. This is the default; pass `--pom` to generate `pom.xml` files instead', }, pom: { type: 'boolean', description: - 'Generate `pom.xml` manifest file(s) instead of the default Socket facts file (`.socket.facts.json`)', + 'Generate `pom.xml` manifest file(s) instead of the default Socket facts file (`gradle.socket.facts.json`)', }, includeConfigs: { type: 'string', @@ -83,9 +83,9 @@ const config: CliCommandConfig = { Options ${getFlagListOutput(config.flags)} - By default, emits a single \`.socket.facts.json\` describing the resolved - dependency graph of the whole build, using gradle (preferably your local - \`gradlew\`). An unresolved dependency is a fatal error. You can pass + By default, emits a single \`gradle.socket.facts.json\` describing the + resolved dependency graph of the whole build, using gradle (preferably your + local \`gradlew\`). An unresolved dependency is a fatal error. You can pass --include-configs / --exclude-configs (comma-separated glob patterns) to control which configurations are resolved (e.g. --include-configs=\`*CompileClasspath,*RuntimeClasspath\`), and diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index 81906dd3e4..8ec957758d 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -26,16 +26,16 @@ describe('socket manifest kotlin', async () => { --bin Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH --exclude-configs When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applied after --include-configs) --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. - --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead + --facts Emit a Socket facts JSON file (\`gradle.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) --include-configs When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only configurations matching at least one pattern are resolved. e.g. \`*CompileClasspath,*RuntimeClasspath\`. Default: every resolvable configuration - --pom Generate \`pom.xml\` manifest file(s) instead of the default Socket facts file (\`.socket.facts.json\`) + --pom Generate \`pom.xml\` manifest file(s) instead of the default Socket facts file (\`gradle.socket.facts.json\`) --verbose Print debug messages - By default, emits a single \`.socket.facts.json\` describing the resolved - dependency graph of the whole build, using gradle (preferably your local - \`gradlew\`). An unresolved dependency is a fatal error. You can pass + By default, emits a single \`gradle.socket.facts.json\` describing the + resolved dependency graph of the whole build, using gradle (preferably your + local \`gradlew\`). An unresolved dependency is a fatal error. You can pass --include-configs / --exclude-configs (comma-separated glob patterns) to control which configurations are resolved (e.g. --include-configs=\`*CompileClasspath,*RuntimeClasspath\`), and diff --git a/src/commands/manifest/cmd-manifest-maven.mts b/src/commands/manifest/cmd-manifest-maven.mts index 99f62029b3..e9e64447fe 100644 --- a/src/commands/manifest/cmd-manifest-maven.mts +++ b/src/commands/manifest/cmd-manifest-maven.mts @@ -67,8 +67,8 @@ const config: CliCommandConfig = { Options ${getFlagListOutput(config.flags)} - Emits a single \`.socket.facts.json\` describing the resolved dependency - graph of your Maven project, using maven (\`mvn\` on PATH by default). It + Emits a single \`pom.xml.socket.facts.json\` (named after the POM Maven + runs on) describing the resolved dependency graph of your Maven project, using maven (\`mvn\` on PATH by default). It reads dependency metadata only and never downloads artifacts; an unresolved dependency is a fatal error. You can pass --include-configs / --exclude-configs (comma-separated glob patterns) to control which Maven diff --git a/src/commands/manifest/cmd-manifest-maven.test.mts b/src/commands/manifest/cmd-manifest-maven.test.mts index 6388bb311d..470f046d60 100644 --- a/src/commands/manifest/cmd-manifest-maven.test.mts +++ b/src/commands/manifest/cmd-manifest-maven.test.mts @@ -30,8 +30,8 @@ describe('socket manifest maven', async () => { --maven-opts Additional options to pass on to maven, e.g. \`-P -s \` --verbose Print debug messages - Emits a single \`.socket.facts.json\` describing the resolved dependency - graph of your Maven project, using maven (\`mvn\` on PATH by default). It + Emits a single \`pom.xml.socket.facts.json\` (named after the POM Maven + runs on) describing the resolved dependency graph of your Maven project, using maven (\`mvn\` on PATH by default). It reads dependency metadata only and never downloads artifacts; an unresolved dependency is a fatal error. You can pass --include-configs / --exclude-configs (comma-separated glob patterns) to control which Maven diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts index d2e4f5695a..c9b2152dbd 100644 --- a/src/commands/manifest/cmd-manifest-scala.mts +++ b/src/commands/manifest/cmd-manifest-scala.mts @@ -37,12 +37,12 @@ const config: CliCommandConfig = { facts: { type: 'boolean', description: - 'Emit a Socket facts JSON file (`.socket.facts.json`) describing the resolved dependency graph. This is the default; pass `--pom` to generate `pom.xml` files instead', + 'Emit a Socket facts JSON file (`sbt.socket.facts.json`) describing the resolved dependency graph. This is the default; pass `--pom` to generate `pom.xml` files instead', }, pom: { type: 'boolean', description: - 'Generate `pom.xml` manifest file(s) instead of the default Socket facts file (`.socket.facts.json`)', + 'Generate `pom.xml` manifest file(s) instead of the default Socket facts file (`sbt.socket.facts.json`)', }, includeConfigs: { type: 'string', @@ -63,7 +63,7 @@ const config: CliCommandConfig = { out: { type: 'string', description: - 'Only with --pom: path of the output `pom.xml`, see also --stdout. Does not apply when generating Socket facts (always written to the project root as `.socket.facts.json`)', + 'Only with --pom: path of the output `pom.xml`, see also --stdout. Does not apply when generating Socket facts (always written to the project root as `sbt.socket.facts.json`)', }, stdout: { type: 'boolean', @@ -86,8 +86,8 @@ const config: CliCommandConfig = { Options ${getFlagListOutput(config.flags)} - By default, emits a single \`.socket.facts.json\` describing the resolved - dependency graph of the whole build. It reads dependency metadata only and + By default, emits a single \`sbt.socket.facts.json\` describing the + resolved dependency graph of the whole build. It reads dependency metadata only and never downloads artifacts; an unresolved dependency is a fatal error. You can pass --include-configs / --exclude-configs (comma-separated glob patterns) to control which sbt configurations are resolved (e.g. @@ -304,7 +304,7 @@ async function run( // would the file name be? // --out / --stdout only affect the pom path. Socket facts are always written - // to the project root as `.socket.facts.json` so that `socket scan create` + // to the project root as `sbt.socket.facts.json` so that `socket scan create` // picks them up, so reject these flags in facts mode rather than silently // ignoring an explicitly-passed output location. const wasValidInput = checkCommandInput( @@ -322,7 +322,7 @@ async function run( (cli.flags['out'] !== undefined || cli.flags['stdout'] !== undefined) ), message: - 'The `--out` and `--stdout` options only apply with `--pom`; Socket facts are always written to the project root as `.socket.facts.json`', + 'The `--out` and `--stdout` options only apply with `--pom`; Socket facts are always written to the project root as `sbt.socket.facts.json`', fail: 'remove --out/--stdout, or pass --pom', }, ) diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index 96941b8884..24ffaffa50 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -26,17 +26,17 @@ describe('socket manifest scala', async () => { --bin Location of sbt binary to use --exclude-configs When generating facts: comma-separated glob patterns; sbt configurations matching any pattern are skipped (applied after --include-configs) --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. - --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead + --facts Emit a Socket facts JSON file (\`sbt.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) --include-configs When generating facts: comma-separated glob patterns matched against sbt configuration names (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only configurations matching at least one pattern are resolved. e.g. \`compile,test\`. Default: compile,optional,provided,runtime,test - --out Only with --pom: path of the output \`pom.xml\`, see also --stdout. Does not apply when generating Socket facts (always written to the project root as \`.socket.facts.json\`) - --pom Generate \`pom.xml\` manifest file(s) instead of the default Socket facts file (\`.socket.facts.json\`) + --out Only with --pom: path of the output \`pom.xml\`, see also --stdout. Does not apply when generating Socket facts (always written to the project root as \`sbt.socket.facts.json\`) + --pom Generate \`pom.xml\` manifest file(s) instead of the default Socket facts file (\`sbt.socket.facts.json\`) --sbt-opts Additional options to pass on to sbt, as per \`sbt --help\` --stdout Only with --pom: print the resulting \`pom.xml\` to stdout (supersedes --out). Does not apply when generating Socket facts --verbose Print debug messages - By default, emits a single \`.socket.facts.json\` describing the resolved - dependency graph of the whole build. It reads dependency metadata only and + By default, emits a single \`sbt.socket.facts.json\` describing the + resolved dependency graph of the whole build. It reads dependency metadata only and never downloads artifacts; an unresolved dependency is a fatal error. You can pass --include-configs / --exclude-configs (comma-separated glob patterns) to control which sbt configurations are resolved (e.g. diff --git a/src/commands/manifest/convert-gradle-to-facts.mts b/src/commands/manifest/convert-gradle-to-facts.mts index 7f8f852ab1..1c5dccc6cf 100644 --- a/src/commands/manifest/convert-gradle-to-facts.mts +++ b/src/commands/manifest/convert-gradle-to-facts.mts @@ -2,7 +2,8 @@ import { runManifestFacts } from './run-manifest-facts.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' -// Generates `.socket.facts.json` for a Gradle project via the bundled init script. +// Generates `gradle.socket.facts.json` for a Gradle project via the bundled +// init script. export async function convertGradleToFacts({ bin, cwd, diff --git a/src/commands/manifest/convert-maven-to-facts.mts b/src/commands/manifest/convert-maven-to-facts.mts index a41769fcfe..d325814622 100644 --- a/src/commands/manifest/convert-maven-to-facts.mts +++ b/src/commands/manifest/convert-maven-to-facts.mts @@ -2,7 +2,8 @@ import { runManifestFacts } from './run-manifest-facts.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' -// Generates `.socket.facts.json` for a Maven project via the bundled extension. +// Generates `pom.xml.socket.facts.json` (named after the POM Maven runs on) +// for a Maven project via the bundled extension. export async function convertMavenToFacts({ bin, cwd, diff --git a/src/commands/manifest/convert-sbt-to-facts.mts b/src/commands/manifest/convert-sbt-to-facts.mts index 649b684441..07ee34eb17 100644 --- a/src/commands/manifest/convert-sbt-to-facts.mts +++ b/src/commands/manifest/convert-sbt-to-facts.mts @@ -2,7 +2,7 @@ import { runManifestFacts } from './run-manifest-facts.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' -// Generates `.socket.facts.json` for an sbt project via the bundled sbt plugin. +// Generates `sbt.socket.facts.json` for an sbt project via the bundled sbt plugin. // sbt 0.13/early 1.x can't run on modern JDKs — pass a compatible JDK via // `--sbt-opts "--java-home "` or `JAVA_HOME`. export async function convertSbtToFacts({ diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index fd329f20c2..25d6f20217 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -185,10 +185,11 @@ async function runEcosystemCandidates({ } covered.add(dir) + const buildRoot = path.dirname(result.factsPath) // eslint-disable-next-line no-await-in-loop const resolvedSubprojectDirs = await Promise.all( result.projects.map(project => - realpathOrResolved(path.resolve(dir, project.subprojectDir)), + realpathOrResolved(path.resolve(buildRoot, project.subprojectDir)), ), ) for (const subprojectDir of resolvedSubprojectDirs) { @@ -218,7 +219,7 @@ async function runEcosystemCandidates({ return outcomes } -// Generates one .socket.facts.json per independent gradle/sbt/maven build +// Generates one Socket facts file per independent gradle/sbt/maven build // root under `cwd`. Coverage is tracked per ecosystem via the facts SBOM's // own projects[].subprojectDir, not by pruning the whole discovered subtree, // so an unrelated nested project a reactor doesn't declare still gets its diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index cecf4f9d3b..a6d496806c 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -1,4 +1,4 @@ -import { promises as fs } from 'node:fs' +import { existsSync, promises as fs } from 'node:fs' import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' @@ -44,8 +44,9 @@ function tailBuildOutput(stdout: string, stderr: string): string { export type RunManifestFactsOutcome = RunManifestFactsResult | null | undefined // Runs the bundled build-tool resolution script for a JVM project and writes -// `.socket.facts.json`. `withFiles` (reachability only) additionally folds -// resolved artifact paths into `sidecarAcc`. A blocking resolution failure sets +// its `.socket.facts.json` (see socketFactsFileName). `withFiles` +// (reachability only) additionally folds resolved artifact paths into +// `sidecarAcc`. A blocking resolution failure sets // a non-zero exit code and returns (matching the `--pom` generator) unless // `ignoreUnresolved`; a crashed build — a process failure, not an unresolved // dependency — always fails. @@ -79,8 +80,6 @@ export async function runManifestFacts({ verbose: boolean withFiles?: boolean | undefined }): Promise { - const factsPath = path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON) - let resolvedJavaHome: string | undefined if (javaHome) { const expanded = expandEnvVarRefs(javaHome) @@ -160,7 +159,16 @@ export async function runManifestFacts({ ) return null } - const { artifactPaths, code, facts, report, stderr, stdout } = result + const { + artifactPaths, + buildRoot, + code, + facts, + factsFileName, + report, + stderr, + stdout, + } = result const rendered = renderResolutionErrorReport( report.failures, @@ -230,6 +238,28 @@ export async function runManifestFacts({ return } + if (!buildRoot || !factsFileName) { + process.exitCode = 1 + logger.fail( + `The ${ecosystem} build did not report its ${buildRoot ? 'entry build file' : 'root directory'}, so its Socket facts file cannot be placed.`, + ) + return null + } + // Every path in the facts is relative to the build root, which `-f`/`-p` + // can move away from cwd. + const factsPath = path.join(buildRoot, factsFileName) + // Not a name producers write, so a copy here is stale and would be uploaded + // alongside the new file. + const legacyFactsPath = path.join( + buildRoot, + constants.DOT_SOCKET_DOT_FACTS_JSON, + ) + if (existsSync(legacyFactsPath)) { + logger.warn( + `Found \`${legacyFactsPath}\`, which is uploaded alongside \`${factsFileName}\`. Delete it if an earlier \`socket manifest\` run left it behind.`, + ) + } + const socketCliVersion = constants.ENV.INLINED_SOCKET_CLI_VERSION if (facts.metadata && socketCliVersion) { facts.metadata.socketCliVersion = socketCliVersion diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index 9c735cfc03..f60300cd08 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -17,13 +17,15 @@ import type { SidecarAccumulator } from './scripts/sidecar.mts' const ENV_VAR = 'SOCKET_TEST_JAVA_HOME' -function okResult(): ManifestRunResult { +function okResult(buildRoot: string): ManifestRunResult { return { + buildRoot, code: 0, facts: { components: [{ id: 'a', type: 'maven', name: 'a' }], projects: [], }, + factsFileName: 'pom.xml.socket.facts.json', report: { failures: [], scannedConfigs: [], unscannable: [] }, artifactPaths: { targetsByCoord: new Map(), @@ -63,7 +65,7 @@ describe('runManifestFacts - javaHome', () => { }) it('passes a literal javaHome straight through as JAVA_HOME', async () => { - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd, javaHome: '/opt/jdk-17' }) const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-17') @@ -71,7 +73,7 @@ describe('runManifestFacts - javaHome', () => { it('expands $VAR and ${VAR} references against the CLI process env', async () => { process.env[ENV_VAR] = '/opt/jdk-11' - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd, @@ -82,7 +84,7 @@ describe('runManifestFacts - javaHome', () => { }) it('fails closed without invoking the build tool when the referenced var is unset', async () => { - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) const result = await runManifestFacts({ ...baseArgs, cwd, @@ -94,7 +96,7 @@ describe('runManifestFacts - javaHome', () => { }) it('leaves the environment untouched when javaHome is unset', async () => { - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd }) const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] expect(opts?.env).toBeUndefined() @@ -115,7 +117,7 @@ describe('runManifestFacts - sidecar', () => { }) it('keys the sidecar by the symlink-resolved factsPath, not the raw cwd-joined one', async () => { - const result = okResult() + const result = okResult(cwd) result.facts.projects = [ { type: 'maven', @@ -132,14 +134,14 @@ describe('runManifestFacts - sidecar', () => { await runManifestFacts({ ...baseArgs, cwd, sidecarAcc, withFiles: true }) const expectedFactsFile = await fs.realpath( - path.join(cwd, '.socket.facts.json'), + path.join(cwd, 'pom.xml.socket.facts.json'), ) expect([...sidecarAcc.keys()]).toEqual([expectedFactsFile]) const bucket = sidecarAcc.get(expectedFactsFile) expect(bucket?.projects.find(m => m.name === 'app')).toBeDefined() }) it('stamps the inlined socket-cli version into the written facts metadata', async () => { - const result = okResult() + const result = okResult(cwd) result.facts.metadata = { format: 'socket-facts-sbom', tool: 'maven', @@ -150,7 +152,7 @@ describe('runManifestFacts - sidecar', () => { await runManifestFacts({ ...baseArgs, cwd }) const written = JSON.parse( - await fs.readFile(path.join(cwd, '.socket.facts.json'), 'utf8'), + await fs.readFile(path.join(cwd, 'pom.xml.socket.facts.json'), 'utf8'), ) // Unit tests run unbuilt, where the version isn't inlined; the field is // then omitted rather than written empty. @@ -160,6 +162,118 @@ describe('runManifestFacts - sidecar', () => { }) }) +describe('runManifestFacts - facts file naming', () => { + let cwd = '' + + beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-')) + vi.mocked(runManifestScript).mockReset() + process.exitCode = undefined + }) + afterEach(async () => { + await fs.rm(cwd, { recursive: true, force: true }) + process.exitCode = undefined + }) + + it('gives builds sharing a directory distinct facts files', async () => { + const sidecarAcc: SidecarAccumulator = new Map() + const outcomes = [] + for (const factsFileName of [ + 'pom.xml.socket.facts.json', + 'other-pom.xml.socket.facts.json', + ]) { + vi.mocked(runManifestScript).mockResolvedValueOnce({ + ...okResult(cwd), + factsFileName, + }) + // eslint-disable-next-line no-await-in-loop + outcomes.push(await runManifestFacts({ ...baseArgs, cwd, sidecarAcc })) + } + vi.mocked(runManifestScript).mockResolvedValueOnce({ + ...okResult(cwd), + factsFileName: 'gradle.socket.facts.json', + }) + outcomes.push( + await runManifestFacts({ + ...baseArgs, + cwd, + ecosystem: 'gradle', + sidecarAcc, + }), + ) + + expect(outcomes.map(o => o && path.basename(o.factsPath))).toEqual([ + 'pom.xml.socket.facts.json', + 'other-pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + ]) + expect((await fs.readdir(cwd)).sort()).toEqual([ + 'gradle.socket.facts.json', + 'other-pom.xml.socket.facts.json', + 'pom.xml.socket.facts.json', + ]) + expect(sidecarAcc.size).toBe(3) + }) + + it('writes the facts file into the build root the tool reports', async () => { + const buildRoot = path.join(cwd, 'sub') + await fs.mkdir(buildRoot) + vi.mocked(runManifestScript).mockResolvedValue({ + ...okResult(buildRoot), + factsFileName: 'other-pom.xml.socket.facts.json', + }) + + const outcome = await runManifestFacts({ ...baseArgs, cwd }) + + expect(outcome?.factsPath).toBe( + path.join(buildRoot, 'other-pom.xml.socket.facts.json'), + ) + expect(await fs.readdir(buildRoot)).toEqual([ + 'other-pom.xml.socket.facts.json', + ]) + }) + + it('fails without writing when the build did not report its root', async () => { + vi.mocked(runManifestScript).mockResolvedValue({ + ...okResult(cwd), + buildRoot: undefined, + }) + + const outcome = await runManifestFacts({ ...baseArgs, cwd }) + + expect(outcome).toBeNull() + expect(process.exitCode).toBe(1) + expect(await fs.readdir(cwd)).toEqual([]) + }) + + it('fails without writing when the build did not report its entry file', async () => { + vi.mocked(runManifestScript).mockResolvedValue({ + ...okResult(cwd), + factsFileName: undefined, + }) + + const outcome = await runManifestFacts({ ...baseArgs, cwd }) + + expect(outcome).toBeNull() + expect(process.exitCode).toBe(1) + expect(await fs.readdir(cwd)).toEqual([]) + }) + + it('leaves a legacy .socket.facts.json in place', async () => { + const legacy = path.join(cwd, '.socket.facts.json') + await fs.writeFile(legacy, '{}') + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) + + await runManifestFacts({ ...baseArgs, cwd }) + + expect(await fs.readFile(legacy, 'utf8')).toBe('{}') + expect((await fs.readdir(cwd)).sort()).toEqual([ + '.socket.facts.json', + 'pom.xml.socket.facts.json', + ]) + }) +}) + describe('runManifestFacts - sbt build detection', () => { let cwd = '' @@ -184,7 +298,7 @@ describe('runManifestFacts - sbt build detection', () => { expect(process.exitCode).toBe(1) expect(runManifestScript).not.toHaveBeenCalled() await expect( - fs.access(path.join(cwd, '.socket.facts.json')), + fs.access(path.join(cwd, 'pom.xml.socket.facts.json')), ).rejects.toThrow() }) @@ -196,7 +310,7 @@ describe('runManifestFacts - sbt build detection', () => { } else { await fs.writeFile(path.join(cwd, marker), '') } - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd, ecosystem: 'sbt' }) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index e95fd6b9c8..7d5537db3c 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -8,9 +8,7 @@ import { type SocketFactsSbomMetadata, type SocketFactsSbomProject, 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' @@ -25,6 +23,9 @@ export type AssembleResult = { export type AssembleOptions = { emitProjects?: boolean | undefined + // Basename the facts file is written under; direct dependencies reference + // it. Undefined only when it cannot be named, and so will not be written. + factsFileName: string | undefined // Injectable for tests; an uncompiled module's output dir is dropped (module // stays resolvable via its sources). fileExists?: ((path: string) => boolean) | undefined @@ -49,7 +50,7 @@ type PerRoot = { export function assembleFacts( parsed: ParsedRecords, - opts: AssembleOptions = {}, + opts: AssembleOptions, ): AssembleResult { const fileExists = opts.fileExists ?? existsSync const perRoot = buildPerRoot(parsed) @@ -63,7 +64,12 @@ export function assembleFacts( const components = buildComponents( finalNodes, projectsByGav, - buildManifestFilesByCoord(parsed, directByRoot, perRoot), + buildManifestFilesByCoord( + parsed, + directByRoot, + perRoot, + opts.factsFileName, + ), ) const projects = opts.emitProjects === false @@ -186,6 +192,7 @@ function buildManifestFilesByCoord( parsed: ParsedRecords, directByRoot: Map>, perRoot: Map, + factsFileName: string | undefined, ): Map { const buildFilesByCoord = new Map>() for (const [rootId, ids] of directByRoot) { @@ -205,9 +212,10 @@ function buildManifestFilesByCoord( return new Map( [...buildFilesByCoord].map(({ 0: id, 1: buildFiles }) => [ id, - [constants.DOT_SOCKET_DOT_FACTS_JSON, ...[...buildFiles].sort()].map( - file => ({ file }), - ), + [ + ...(factsFileName ? [factsFileName] : []), + ...[...buildFiles].sort(), + ].map(file => ({ file })), ]), ) } @@ -277,6 +285,7 @@ function buildProjects( const projects = [...parsed.projects.values()].map(p => { const entry: SocketFactsSbomProject = { + id: p.projectKey, type: PURL_TYPE_MAVEN, namespace: p.group, name: p.name, @@ -284,6 +293,9 @@ function buildProjects( subprojectDir: p.dir, dependencies: [...(directByProject.get(p.projectKey) ?? [])].sort(), } + if (p.buildFiles.length) { + entry.manifestFiles = [...p.buildFiles].sort().map(file => ({ file })) + } return entry }) projects.sort((a, b) => { @@ -331,11 +343,7 @@ function buildClasspathByProject( } const classpathByProject = new Map>() for (const p of projects) { - const key = projectClasspathKey({ - name: p.name, - namespace: p.group, - subprojectDir: p.dir, - }) + const key = p.projectKey let set = classpathByProject.get(key) if (!set) { set = new Set() diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 40fc827e7c..1e31b34ad8 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -27,6 +27,7 @@ describe('records → assemble → sidecar', () => { it('carries first-party project paths, external jars, and artifactless BOMs', () => { // Inject fileExists so the synthetic absolute paths aren't filtered out. const { artifactPaths, facts } = assembleFacts(parseRecords(RECORDS), { + factsFileName: 'gradle.socket.facts.json', fileExists: () => true, }) @@ -46,6 +47,7 @@ describe('records → assemble → sidecar', () => { // roots reach the sidecar, keyed by its own facts file. expect(bucket.projects).toEqual([ { + id: ':app', type: 'maven', namespace: 'com.example', name: 'app', @@ -88,6 +90,7 @@ describe('records → assemble → sidecar', () => { 'node\tr3\tg:junit:jar:4\tg\tjunit\t4\tjar\t\t1', ].join('\n') const { artifactPaths, facts } = assembleFacts(parseRecords(records), { + factsFileName: 'gradle.socket.facts.json', fileExists: () => true, }) @@ -129,7 +132,9 @@ describe('records → assemble → sidecar', () => { 'edge\tr2\tg:a:jar:1.0-SNAPSHOT\tg:ext:jar:2', 'node\tr2\tg:b:jar:0.9\tg\tb\t0.9\tjar\t\t1', ].join('\n') - const { artifactPaths, facts } = assembleFacts(parseRecords(records)) + const { artifactPaths, facts } = assembleFacts(parseRecords(records), { + factsFileName: 'pom.xml.socket.facts.json', + }) expect(facts.components.map(c => [c.id, c.firstParty ?? 'absent'])).toEqual( [ @@ -169,21 +174,69 @@ describe('records → assemble → sidecar', () => { 'root\tr3\tc\truntimeClasspath\t1', 'node\tr3\tg:solo:jar:1\tg\tsolo\t1\tjar\t\t1', ].join('\n') - const { facts } = assembleFacts(parseRecords(records)) + const { facts } = assembleFacts(parseRecords(records), { + factsFileName: 'pom.xml.socket.facts.json', + }) 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:a:jar:1': [ + { file: 'pom.xml.socket.facts.json' }, + { file: 'b/pom.xml' }, + ], 'g:dep:jar:3': 'absent', 'g:ext:jar:2': [ - { file: '.socket.facts.json' }, + { file: 'pom.xml.socket.facts.json' }, { file: 'a/pom.xml' }, { file: 'b/pom.xml' }, ], - 'g:solo:jar:1': [{ file: '.socket.facts.json' }], + 'g:solo:jar:1': [{ file: 'pom.xml.socket.facts.json' }], + }) + }) + it("records each project's own build files, relative to the build root", () => { + const records = [ + 'meta\tmaven\t3.9.6\t17', + 'buildRoot\t/repo/sub', + 'project\tg:agg:1\tg\tagg\t1\t.', + 'projectBuild\tg:agg:1\tother-pom.xml', + 'project\tg:mod-a:1\tg\tmod-a\t1\tmod', + 'projectBuild\tg:mod-a:1\tmod/a.xml', + 'project\tg:mod-b:1\tg\tmod-b\t1\tmod', + 'projectBuild\tg:mod-b:1\tmod/b.xml', + 'project\tg:bare:1\tg\tbare\t1\tbare', + ].join('\n') + const parsed = parseRecords(records) + const { facts } = assembleFacts(parsed, { + factsFileName: 'other-pom.xml.socket.facts.json', + }) + + expect(parsed.buildRoot).toBe('/repo/sub') + expect( + Object.fromEntries( + facts.projects!.map(p => [p.id, p.manifestFiles ?? 'absent']), + ), + ).toEqual({ + 'g:agg:1': [{ file: 'other-pom.xml' }], + 'g:bare:1': 'absent', + 'g:mod-a:1': [{ file: 'mod/a.xml' }], + 'g:mod-b:1': [{ file: 'mod/b.xml' }], + }) + }) + it('omits the facts file reference when the facts file cannot be named', () => { + const records = [ + 'meta\tmaven\t3.9.6\t17', + 'project\ta\tg\ta\t1\t.', + 'projectBuild\ta\tpom.xml', + 'root\tr1\ta\truntimeClasspath\t1', + 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t1', + ].join('\n') + const { facts } = assembleFacts(parseRecords(records), { + factsFileName: undefined, }) + + expect(facts.components[0]?.manifestFiles).toEqual([{ file: 'pom.xml' }]) }) }) diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index 152df102df..39deb76bb9 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -1,5 +1,7 @@ import { existsSync } from 'node:fs' -import { resolve } from 'node:path' +import { basename, resolve } from 'node:path' + +import constants from '../../../constants.mts' export type BuildTool = 'gradle' | 'maven' | 'sbt' @@ -19,6 +21,15 @@ const BUILD_TOOL_WRAPPER = { maven: 'mvnw', } as unknown as Partial> +// Gradle (8+) and sbt hold one build per directory; Maven builds are addressed +// by POM file, so `mvn -f other-pom.xml` puts a second build in the directory. +const ADDRESSED_BY_FILE: Record = { + __proto__: null, + gradle: false, + maven: true, + sbt: false, +} as unknown as Record + // sbt happily runs in any directory, synthesizing a default project from its // name, so an sbt run outside a build yields a plausible but bogus SBOM. Maven // and Gradle refuse such a directory themselves. @@ -43,3 +54,19 @@ export function resolveBuildToolBin( } return DEFAULT_BUILD_TOOL_BIN[tool] } + +// `.socket.facts.json`, distinct for every build sharing a directory: +// the entry file's name (`pom.xml`) for a file-addressed build, the tool's +// name (`gradle`) for a directory-addressed one. Undefined when a +// file-addressed build did not report its entry file. +export function socketFactsFileName( + tool: BuildTool, + entryFile: string | undefined, +): string | undefined { + if (!ADDRESSED_BY_FILE[tool]) { + return `${tool}${constants.DOT_SOCKET_DOT_FACTS_JSON}` + } + return entryFile + ? `${basename(entryFile)}${constants.DOT_SOCKET_DOT_FACTS_JSON}` + : undefined +} diff --git a/src/commands/manifest/scripts/build-tool.test.mts b/src/commands/manifest/scripts/build-tool.test.mts new file mode 100644 index 0000000000..7e8fb7a0fc --- /dev/null +++ b/src/commands/manifest/scripts/build-tool.test.mts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { socketFactsFileName } from './build-tool.mts' +import { parseRecords } from './records.mts' + +describe('socketFactsFileName', () => { + it('names a directory-addressed build after its tool', () => { + expect(socketFactsFileName('gradle', undefined)).toBe( + 'gradle.socket.facts.json', + ) + expect(socketFactsFileName('sbt', undefined)).toBe('sbt.socket.facts.json') + }) + + it('names a file-addressed build after the entry file it reported', () => { + expect( + socketFactsFileName('maven', parseRecords('entry\tpom.xml').entry), + ).toBe('pom.xml.socket.facts.json') + expect( + socketFactsFileName('maven', parseRecords('entry\tother-pom.xml').entry), + ).toBe('other-pom.xml.socket.facts.json') + }) + + it('cannot name a file-addressed build that reported no entry file', () => { + expect( + socketFactsFileName( + 'maven', + parseRecords('meta\tmaven\t3.9.6\t17').entry || undefined, + ), + ).toBeUndefined() + }) +}) diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index f3f41010e1..bf5f73f4f8 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -41,8 +41,13 @@ export type SocketFactsManifestReference = { } export type SocketFactsSbomProject = AnyPURL & { + // The build tool's own project identity, unique within the facts file: + // Maven's GAV, Gradle's project path, sbt's project id. + id: string subprojectDir: string dependencies: string[] + // The module's own build files, e.g. a POM other than `/pom.xml`. + manifestFiles?: SocketFactsManifestReference[] | undefined } // Resolved on-disk paths for a --with-files run, keyed by coordinate. `targets` @@ -56,16 +61,10 @@ export type ResolvedArtifactPaths = { sourcesByCoord: Map coords: Set // Component ids on each project's resolved classpath (union over its - // configurations), keyed by projectClasspathKey. + // configurations), keyed by project id. classpathByProject: Map } -export function projectClasspathKey( - project: Pick, -): string { - return `${project.subprojectDir} ${project.namespace ?? ''}:${project.name}` -} - // Coordinate-based (not `id`-based) so it also matches foreign SBOMs like // CycloneDX. Empty segments dropped. export function mavenCoordinateKey( 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 f5074848b1..f67e71ef46 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 @@ -93,6 +93,10 @@ public void run(MavenSession session, List reactor, File rootDir, List lines = new ArrayList<>(); rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); + rec(lines, "buildRoot", rootDir.getAbsolutePath()); + // The POM Maven was invoked on (`-f`, else the default it located), which names the facts file. + File entryPom = session.getRequest().getPom(); + if (entryPom != null) rec(lines, "entry", SocketSupport.relativePath(rootDir.toPath(), entryPom.getAbsoluteFile().toPath())); for (MavenProject module : reactor) { // No basedir: Maven's stand-in project for a directory without a POM. Skipping it lets Maven's @@ -100,12 +104,13 @@ public void run(MavenSession session, List reactor, File rootDir, if (module.getBasedir() == null) continue; 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); + String key = projectKey(module); + rec(lines, "project", key, 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 (pom != null && pom.isFile()) rec(lines, "projectBuild", key, 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); + for (String s : collectSources(module)) rec(lines, "projectSrc", key, s); + for (String t : collectTargets(module)) rec(lines, "projectTgt", key, t); } } @@ -123,7 +128,7 @@ public void run(MavenSession session, List reactor, File rootDir, // Which direct dependencies are prod-scoped: seeds the prod/dev root split (see emitModuleRoots). Set directProdIds = new HashSet<>(); collectModule(session, module, passingScopes, reactorGavs, populateGavs, opts, nodes, directIds, directProdIds, failures); - rootIdx = emitModuleRoots(lines, rootIdx, ws, nodes, directIds, directProdIds); + rootIdx = emitModuleRoots(lines, rootIdx, projectKey(module), nodes, directIds, directProdIds); } for (Failure f : failures) rec(lines, "failure", f.coord, f.detail, f.config); @@ -131,6 +136,12 @@ public void run(MavenSession session, List reactor, File rootDir, write(opts.recordsFile, lines); } + // Not the directory, which `x/a.xml` and `x/b.xml` share; Maven + // rejects a reactor with a duplicate GAV. Surfaces as the facts project id. + private static String projectKey(MavenProject module) { + return module.getGroupId() + ":" + module.getArtifactId() + ":" + module.getVersion(); + } + // ---- resolution ---- private void collectModule( diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 14a776e845..5e59e41623 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -10,6 +10,8 @@ import type { // \t\t... // // meta tool toolVersion javaVersion +// buildRoot path (absolute; the facts file's directory) +// entry path (file-addressed builds; build-root-relative) // project projectKey group name version dir // projectSrc projectKey path (--with-files only) // projectTgt projectKey path (--with-files only) @@ -22,6 +24,8 @@ import type { // failure coord detail config // unscannable config detail // +// `projectKey` is the tool's unique project identity (Maven: GAV; Gradle: +// project path; sbt: project id), emitted as the project id. // A `root` is one (subproject, configuration) resolution root; `coordId` is the // coordinate key (`group:name:ext:classifier:version`, empty segments dropped), // used opaquely as the per-root node key. Unknown tags are ignored. @@ -67,6 +71,11 @@ export type ParsedRecords = { tool: string toolVersion: string javaVersion: string + // Absolute directory the build is rooted at; the facts file is written there. + buildRoot: string + // The file the build was invoked on (Maven's top-level POM); empty for a + // directory-addressed build. + entry: string projects: Map roots: Map scannedConfigs: string[] @@ -100,6 +109,8 @@ export function parseRecords(text: string): ParsedRecords { tool: '', toolVersion: '', javaVersion: '', + buildRoot: '', + entry: '', projects: new Map(), roots: new Map(), scannedConfigs: [], @@ -152,6 +163,12 @@ export function parseRecords(text: string): ParsedRecords { result.toolVersion = f[2] ?? '' result.javaVersion = f[3] ?? '' break + case 'buildRoot': + result.buildRoot = f[1] ?? '' + break + case 'entry': + result.entry = f[1] ?? '' + break case 'project': { const p = project(f[1] ?? '') p.group = f[2] ?? '' diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index fda242661e..49b0a9475c 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -4,7 +4,7 @@ import path from 'node:path' import { spawn } from '@socketsecurity/registry/lib/spawn' import { assembleFacts } from './assemble.mts' -import { resolveBuildToolBin } from './build-tool.mts' +import { resolveBuildToolBin, socketFactsFileName } from './build-tool.mts' import { serializeExcludePathPatterns } from './exclude-paths-glob.mts' import { parseRecords } from './records.mts' import constants from '../../../constants.mts' @@ -49,6 +49,10 @@ export type ManifestScriptOptions = { export type ManifestRunResult = { code: number facts: SocketFactsSbom + // Undefined when the build did not report it. + buildRoot: string | undefined + // Undefined when a file-addressed build did not report its entry file. + factsFileName: string | undefined report: ResolutionReport artifactPaths: ResolvedArtifactPaths // Captured build-tool output (empty when stdio is 'inherit'). @@ -133,16 +137,23 @@ async function writeSbtPlugin( } async function assembleFromRecords( + tool: BuildTool, out: RunOutput, recordsFile: string, ): Promise { const text = existsSync(recordsFile) ? await fs.readFile(recordsFile, 'utf8') : '' - const { artifactPaths, facts, report } = assembleFacts(parseRecords(text)) + const parsed = parseRecords(text) + const factsFileName = socketFactsFileName(tool, parsed.entry || undefined) + const { artifactPaths, facts, report } = assembleFacts(parsed, { + factsFileName, + }) return { + buildRoot: parsed.buildRoot || undefined, code: out.code, facts, + factsFileName, report, artifactPaths, stderr: out.stderr, @@ -247,7 +258,7 @@ async function invokeGradle( '--console=plain', ] const out = await runNeverThrow(bin, args, opts) - return await assembleFromRecords(out, recordsFile) + return await assembleFromRecords('gradle', out, recordsFile) }) } @@ -310,7 +321,7 @@ async function invokeSbtIn( task, ] const out = await runNeverThrow(bin, args, opts) - return await assembleFromRecords(out, recordsFile) + return await assembleFromRecords('sbt', out, recordsFile) } async function runSbt(opts: ManifestScriptOptions): Promise { @@ -373,7 +384,7 @@ async function invokeMaven( 'validate', ] const out = await runNeverThrow(bin, args, opts) - return await assembleFromRecords(out, recordsFile) + return await assembleFromRecords('maven', out, recordsFile) }) } diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index f0fd5fddf8..10d704e8e2 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -1,4 +1,4 @@ -import { mavenCoordinateKey, projectClasspathKey } from './facts.mts' +import { mavenCoordinateKey } from './facts.mts' import type { AnyPURL, @@ -29,7 +29,7 @@ export type SidecarProjectEntry = SocketFactsSbomProject & { // Frozen contract with `coana run --compute-artifacts-sidecar`; change only // in sync with the coana consumer. Keyed by the absolute path of the -// `.socket.facts.json` file whose own projects[]/components[] these entries +// `*.socket.facts.json` file whose own projects[]/components[] these entries // describe - the key IS the scope, so two independent reactors that happen to // emit the same purl identity (e.g. a shared internal module name) can never // collide: each is only ever looked up within its own key. No cross-reactor @@ -97,9 +97,8 @@ function sortByPurl(entries: T[]): T[] { // Emit an entry for every SBOM component AND every first-party project: a // top-level module is a project, not a dependency component, yet its source // roots are where reachability starts, so the sidecar must carry them. -// A second call for the same factsFile (a dual-marker directory where two -// build tools both target it) overwrites rather than merges, matching the -// existing last-writer-wins convention for that case. +// Every build writes its own facts file, so a key is accumulated once; a +// repeated call for the same factsFile (the same build run again) overwrites. export function accumulateSidecar( acc: SidecarAccumulator, facts: SocketFactsSbom, @@ -114,10 +113,7 @@ export function accumulateSidecar( components: facts.components.map(paths), projects: (facts.projects ?? []).map(proj => ({ ...paths(proj), - classpath: [ - ...(artifactPaths.classpathByProject.get(projectClasspathKey(proj)) ?? - []), - ], + classpath: [...(artifactPaths.classpathByProject.get(proj.id) ?? [])], })), }) } diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index e818972e7c..b7ec523fb2 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -49,6 +49,7 @@ describe('compute-artifacts sidecar', () => { const facts: SocketFactsSbom = { projects: [ { + id: ':app', type: 'maven', namespace: 'g', name: 'app', @@ -68,7 +69,7 @@ describe('compute-artifacts sidecar', () => { ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.classpathByProject.set('app g:app', ['g:a:jar:1']) + artifactPaths.classpathByProject.set(':app', ['g:a:jar:1']) const acc: SidecarAccumulator = new Map() accumulateSidecar( @@ -251,23 +252,24 @@ describe('compute-artifacts sidecar', () => { ]) }) - it('attaches each project its own classpath ids, keyed by subprojectDir and name', () => { + it('attaches each project its own classpath ids, keyed by project id even within one directory', () => { const project = { type: 'maven', namespace: 'com.example', version: '1.0', dependencies: [], + subprojectDir: 'x', } const facts: SocketFactsSbom = { components: [], projects: [ - { ...project, name: 'a', subprojectDir: 'a' }, - { ...project, name: 'b', subprojectDir: 'b' }, + { ...project, id: 'x/a.xml', name: 'a' }, + { ...project, id: 'x/b.xml', name: 'b' }, ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.classpathByProject.set('a com.example:a', ['g:x:jar:1']) - artifactPaths.classpathByProject.set('b com.example:b', ['g:x:jar:2']) + artifactPaths.classpathByProject.set('x/a.xml', ['g:x:jar:1']) + artifactPaths.classpathByProject.set('x/b.xml', ['g:x:jar:2']) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 184fcfecef..299f1fd562 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -473,6 +473,7 @@ rootProject { rp -> // task actions. The Socket CLI disables the cache for this run, but hoisting is cheap insurance. def recordsFileOverride = gradle.socketProp.call(rp, 'socket.recordsFile')?.toString() def defaultRecordsFile = new File(rp.projectDir, '.socket.facts.records.tsv').absolutePath + def buildRootPath = rp.projectDir.absolutePath // `sources`/`targets` are --with-files-only; a plain run emits only the graph fields. def withFilesProjects = gradle.socketProp.call(rp, 'socket.withFiles')?.toString()?.toLowerCase() == 'true' @@ -493,6 +494,7 @@ rootProject { rp -> def rec = { List fields -> lines << fields.collect { esc(it) }.join('\t') } rec(['meta', 'gradle', gradle.gradleVersion, System.getProperty('java.version')]) + rec(['buildRoot', buildRootPath]) // One `project` record per build module (sources/targets only with --with-files). def projectsInfo diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index e14edfe3aa..993aa8ddd3 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -98,6 +98,7 @@ object SocketFactsPlugin extends AutoPlugin { } rec("meta", "sbt", extracted.getOpt(sbtVersion).getOrElse(""), sys.props.getOrElse("java.version", "")) + rec("buildRoot", rootCanonPath.toString) // One `project` record per build module (sources/targets only with --with-files). Excluded // subprojects are omitted (they were also skipped during resolution above). diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index ee68a0fdc6..f8578a76dd 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -670,7 +670,7 @@ async function askForFactsFlag( name: 'Socket facts (default)', value: 'yes', description: - 'Generate a .socket.facts.json file describing the resolved dependency graph', + 'Generate a Socket facts file (*.socket.facts.json) describing the resolved dependency graph', }, { name: 'pom.xml', diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 7768cfd1df..5634e54cad 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { readdirSync } from 'node:fs' import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' @@ -26,6 +26,7 @@ import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags, outputFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { isSocketFactsFile } from '../../utils/coana.mts' import { determineOrgSlug } from '../../utils/determine-org-slug.mts' import { parseReachEcosystems } from '../../utils/ecosystem.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' @@ -184,6 +185,14 @@ const generalFlags: MeowFlags = { }, } +function hasSocketFactsFileIn(dir: string): boolean { + try { + return readdirSync(dir).some(isSocketFactsFile) + } catch { + return false + } +} + export const cmdScanCreate = { description, hidden, @@ -472,14 +481,12 @@ async function run( } const detected = await detectManifestActions(sockJson, cwd) - // Suppress the --auto-manifest suggestion when a `.socket.facts.json` is + // Suppress the --auto-manifest suggestion when a Socket facts file is // already present at cwd. That file is the output of `socket manifest auto` // (and `--facts` mode of the per-ecosystem manifest commands), so suggesting // to regenerate it would be misleading; the manifest data is already there // and will be picked up by the scan. - const hasFactsFile = existsSync( - path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON), - ) + const hasFactsFile = hasSocketFactsFileIn(cwd) if ( detected.count > 0 && !autoManifest && diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 8d22ebf8ed..98d7bc0320 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -136,7 +136,7 @@ describe('socket scan create', async () => { --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supported: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. --reach-fallback-to-regular-scan If reachability analysis fails, continue with a regular SCA scan (without reachability results) instead of halting. By default, the CLI halts on reachability errors. - --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale \`.socket.facts.json\` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable. + --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 80c677b1ad..4917a96ce8 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -52,7 +52,7 @@ describe('socket scan reach', async () => { --reach-disable-external-tool-checks Disable external tool checks during reachability analysis. --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supported: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. - --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale \`.socket.facts.json\` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable. + --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 3a19b6a220..98ecd3152e 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -19,6 +19,8 @@ import constants from '../../constants.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { compressSocketFactsForUpload, + isReachabilityReportPath, + isSocketFactsFile, snapshotSocketFacts, } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' @@ -197,7 +199,7 @@ async function createNewScan( if (reach.dynamicSbomInference) { // Already generated recursively above; resolving cwd's own build - // root a second time would race on the same .socket.facts.json. + // root a second time would race on the same facts file. detected.gradle = false detected.sbt = false detected.maven = false @@ -358,16 +360,17 @@ async function createNewScan( reachabilityReport = reachResult.data?.reachabilityReport - // When using only pre-generated SBOMs, build the scan from those inputs — - // CycloneDX, SPDX, and Socket facts (`.socket.facts.json`) — matching - // Coana's `--use-only-pregenerated-sboms` selection. Otherwise drop any - // stray `.socket.facts.json`; coana's fresh reachability report (appended - // below) is the authoritative facts file for the scan. + // Mirror the SBOM inputs Coana analyzed; otherwise its fresh report + // (appended below) supersedes every facts file. const pathsForScan = reach.reachUseOnlyPregeneratedSboms - ? filterToPregeneratedSboms(packagePaths, supportedFiles) - : packagePaths.filter( - p => path.basename(p) !== constants.DOT_SOCKET_DOT_FACTS_JSON, + ? filterToPregeneratedSboms(packagePaths, supportedFiles).filter( + p => + !isReachabilityReportPath(p, { + cwd, + outputPath: constants.DOT_SOCKET_DOT_FACTS_JSON, + }), ) + : packagePaths.filter(p => !isSocketFactsFile(p)) // Append coana's reachability report, but not twice: a pre-generated facts // input can resolve to the same path coana wrote its report to. @@ -439,17 +442,8 @@ async function createNewScan( ) } - // On a successful scan, clean up the `.socket.facts.json` coana wrote at - // the path we instructed it to write to (via `--socket-mode`). Failed - // scans leave the file in place for debugging. Producer-written files - // (e.g. from `socket manifest gradle --facts`) are NOT touched here — - // those are user-owned input that the user can clean up themselves; in - // the --reach path coana overwrites that file with its enriched output - // anyway, so it's the same path that gets removed. `--reach-retain-facts-file` - // opts out of this cleanup so the report can be inspected; the user is then - // responsible for deleting it before the next full application reachability - // scan (a stale file is picked up as pre-generated input and would make those - // results unreliable). + // A scan without --reach would upload a leftover report as an SBOM with + // stale reachability results; a failed scan keeps it for debugging. if ( fullScanCResult.ok && scanId && diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 7a750749a2..6a86ff9502 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -263,6 +263,66 @@ describe('handleCreateNewScan excludePaths', () => { expect(cleanup).toHaveBeenCalledOnce() }) + it('replaces every facts file input with the reachability report', async () => { + const cleanup = vi.fn() + const files = [ + '/repo/pom.xml.socket.facts.json', + '/repo/gradle.socket.facts.json', + '/repo/service/.socket.facts.json', + '/repo/package-lock.json', + ] + const config = createConfig({ + generateScanFiles: async () => ({ cleanup, files }), + }) + config.reach.runReachabilityAnalysis = true + mockPerformReachabilityAnalysis.mockResolvedValueOnce({ + data: { + reachabilityReport: '.socket.facts.json', + tier1ReachabilityScanId: 'tier1-id', + }, + ok: true, + }) + await handleCreateNewScan(config) + expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( + ['/repo/package-lock.json', '.socket.facts.json'], + 'fakeOrg', + expect.anything(), + expect.anything(), + ) + }) + + it('keeps build facts but not earlier reports when using only pre-generated SBOMs', async () => { + const cleanup = vi.fn() + const files = [ + '/repo/pom.xml.socket.facts.json', + '/repo/service/.socket.facts.json', + '/repo/package-lock.json', + ] + const config = createConfig({ + generateScanFiles: async () => ({ cleanup, files }), + }) + config.reach.runReachabilityAnalysis = true + config.reach.reachUseOnlyPregeneratedSboms = true + mockFetchSupportedScanFileNames.mockResolvedValueOnce({ + data: { socket: { facts: { pattern: '*.socket.facts.json' } } }, + ok: true, + }) + mockPerformReachabilityAnalysis.mockResolvedValueOnce({ + data: { + reachabilityReport: '.socket.facts.json', + tier1ReachabilityScanId: 'tier1-id', + }, + ok: true, + }) + await handleCreateNewScan(config) + expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( + ['/repo/pom.xml.socket.facts.json', '.socket.facts.json'], + 'fakeOrg', + expect.anything(), + expect.anything(), + ) + }) + it('includes generated auto-manifest files in SCA discovery targets', async () => { mockGenerateAutoManifest.mockResolvedValueOnce({ generatedFiles: ['/repo/.socket-auto-manifest/maven_install.json'], diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 3314e1bc98..95d8c3b38b 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -8,7 +8,10 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { isOmittedReachValue } from './reachability-units.mts' import constants from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' -import { extractTier1ReachabilityScanId } from '../../utils/coana.mts' +import { + extractTier1ReachabilityScanId, + isReachabilityReportPath, +} from '../../utils/coana.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { hasEnterpriseOrgPlan } from '../../utils/organization.mts' import { setupSdk } from '../../utils/sdk.mts' @@ -134,17 +137,19 @@ export async function performReachabilityAnalysis( spinner?.start('Uploading manifests for reachability analysis...') + const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON + // Ensure uploaded manifest files are relative to analysis target as coana resolves SBOM manifest files relative to this path - // NOTE: previously stripped any `.socket.facts.json` from packagePaths - // here to avoid uploading leftover post-reachability output. With the - // producer flow (`socket manifest gradle --facts`) those files are - // legitimate INPUT to compute-artifacts, so we now upload them. Stale - // facts files are cleaned up downstream — see the post-success - // deletion in handle-create-new-scan.mts. const uploadCResult = await handleApiCall( - sockSdk.uploadManifestFiles(orgSlug, packagePaths, { - pathsRelativeTo: path.resolve(cwd, analysisTarget), - }), + sockSdk.uploadManifestFiles( + orgSlug, + packagePaths.filter( + p => !isReachabilityReportPath(p, { cwd, outputPath: outputFilePath }), + ), + { + pathsRelativeTo: path.resolve(cwd, analysisTarget), + }, + ), { description: 'upload manifests', spinner, @@ -179,8 +184,6 @@ export async function performReachabilityAnalysis( spinner?.start() spinner?.infoAndStop('Running reachability analysis with Coana...') - const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON - // Temp file for --compute-artifacts-sidecar, removed in the finally below. // Written even when empty under dynamicSbomInference, since the // --maven-use-only-socket-facts flag below requires one to be present. diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index bf24a6dbcb..d8dfd220da 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -220,6 +220,36 @@ describe('performReachabilityAnalysis manifests tar hash', () => { expect(args[args.indexOf('--manifests-tar-hash') + 1]).toBe(TEST_TAR_HASH) }) + it('uploads build facts but not earlier reachability reports', async () => { + const uploadManifestFiles = vi.fn() + mockSetupSdk.mockResolvedValueOnce({ + ok: true, + data: { uploadManifestFiles }, + }) + + await performReachabilityAnalysis({ + cwd: scanCwd, + orgSlug: TEST_ORG_SLUG, + outputPath: 'out/report.json', + packagePaths: [ + 'package.json', + 'pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + '.socket.facts.json', + 'nested/.socket.facts.json', + path.join(scanCwd, 'out/report.json'), + ], + reachabilityOptions: makeReachabilityOptions(), + target: scanCwd, + }) + + expect(uploadManifestFiles.mock.calls[0]![1]).toEqual([ + 'package.json', + 'pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + ]) + }) + it('fails without spawning Coana when the upload returns no tar hash', async () => { mockHandleApiCall.mockResolvedValueOnce({ ok: true, data: {} } as never) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index a2c4c2c657..ffe3ca2a05 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -121,7 +121,7 @@ export const reachabilityFlags: MeowFlags = { type: 'boolean', default: false, description: - 'Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale `.socket.facts.json` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable.', + 'Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results.', }, reachSkipCache: { type: 'boolean', diff --git a/src/utils/coana.mts b/src/utils/coana.mts index 8b130e097b..add373b846 100644 --- a/src/utils/coana.mts +++ b/src/utils/coana.mts @@ -86,6 +86,8 @@ export async function compressSocketFactsForUpload( // remove a `.br` only to have it re-created after we returned. const results = await Promise.allSettled( scanPaths.map(async p => { + // depscan decodes only the bare `.socket.facts.json.br`; a named facts + // file is uploaded as plain JSON. if (path.basename(p) !== DOT_SOCKET_DOT_FACTS_JSON) { return p } @@ -119,6 +121,32 @@ export async function compressSocketFactsForUpload( return { paths, cleanup } } +// `.socket.facts.json` or a named `.socket.facts.json`, matching +// depscan's case-insensitive `*.socket.facts.json`. +export function isSocketFactsFile(filepath: string): boolean { + return path + .basename(filepath) + .toLowerCase() + .endsWith(DOT_SOCKET_DOT_FACTS_JSON) +} + +// A Coana reachability report, never input to a new analysis: the bare +// `.socket.facts.json` (Coana's default name; producers name theirs after the +// build) or the path this run tells Coana to write to. +export function isReachabilityReportPath( + filepath: string, + options: { cwd: string; outputPath: string }, +): boolean { + const { cwd, outputPath } = { __proto__: null, ...options } as { + cwd: string + outputPath: string + } + return ( + path.basename(filepath).toLowerCase() === DOT_SOCKET_DOT_FACTS_JSON || + path.resolve(cwd, filepath) === path.resolve(cwd, outputPath) + ) +} + export type ReachabilityError = { componentName: string componentVersion: string diff --git a/src/utils/coana.test.mts b/src/utils/coana.test.mts index 9e2126fae9..d4fd3a2d0d 100644 --- a/src/utils/coana.test.mts +++ b/src/utils/coana.test.mts @@ -33,6 +33,8 @@ import { extractReachabilityErrors, extractTier1ReachabilityScanId, getFullWorkspacePath, + isReachabilityReportPath, + isSocketFactsFile, snapshotSocketFacts, } from './coana.mts' @@ -53,6 +55,44 @@ describe('coana facts-file utils', () => { return filePath } + describe('isSocketFactsFile', () => { + it.each([ + '.socket.facts.json', + 'a/pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + 'Foo.sln.SOCKET.FACTS.JSON', + ])('matches %s', p => { + expect(isSocketFactsFile(p)).toBe(true) + }) + it.each([ + 'socket.facts.json', + '.socket.facts.json.br', + 'pom.xml', + 'a/.socket.facts.json/pom.xml', + ])('rejects %s', p => { + expect(isSocketFactsFile(p)).toBe(false) + }) + }) + + describe('isReachabilityReportPath', () => { + const options = { cwd: '/repo', outputPath: 'out/report.json' } + it.each([ + '.socket.facts.json', + 'a/.SOCKET.FACTS.JSON', + '/repo/out/report.json', + 'out/report.json', + ])('matches %s', p => { + expect(isReachabilityReportPath(p, options)).toBe(true) + }) + it.each([ + 'pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + 'report.json', + ])('rejects %s', p => { + expect(isReachabilityReportPath(p, options)).toBe(false) + }) + }) + describe('compressSocketFactsForUpload', () => { it('writes brotli .br as a sibling of the source file', async () => { const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) @@ -99,6 +139,21 @@ describe('coana facts-file utils', () => { } }) + it('uploads a named facts file uncompressed', async () => { + const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) + const facts = path.join(wrapDir, 'pom.xml.socket.facts.json') + writeFileSync(facts, '{}') + + const result = await compressSocketFactsForUpload([facts]) + try { + expect(result.paths).toEqual([facts]) + expect(existsSync(`${facts}.br`)).toBe(false) + } finally { + await result.cleanup() + rmSync(wrapDir, { recursive: true, force: true }) + } + }) + it('leaves a missing .socket.facts.json path unchanged', async () => { const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) const missingFacts = path.join(wrapDir, '.socket.facts.json') From afeceb0ea34e51ba329f1da65d089bf716ab7dcf Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 7 Oct 2026 22:17:16 +0200 Subject: [PATCH 2/9] feat(manifest): give each build project its own facts component A dependency on one of the build's own projects now resolves to a component whose id is that project's id, linked by each build tool's own resolution: Gradle's project component identifier, Maven's reactor GAV, and sbt's dependsOn closure. Projects sharing a coordinate (Gradle `:a:util` and `:b:util` both `ex:util:1`) no longer merge into one component, and a project's variants (test fixtures, test-jar) collapse into it. Resolved artifact paths are keyed by component/project id instead of by coordinate, so the sidecar gives each sibling component its own project's sources and targets. An sbt module ID reachable through more than one dependsOn project is reported as a resolution failure. --- .../manifest/run-manifest-facts.test.mts | 5 +- src/commands/manifest/scripts/assemble.mts | 206 ++++++------------ .../manifest/scripts/assemble.test.mts | 93 ++++++-- src/commands/manifest/scripts/facts.mts | 27 +-- .../socket/SocketFactsRecordsEngine.java | 16 +- src/commands/manifest/scripts/records.mts | 9 +- src/commands/manifest/scripts/sidecar.mts | 38 +--- .../manifest/scripts/sidecar.test.mts | 86 +++++--- .../manifest/scripts/socket-facts.init.gradle | 45 ++-- .../scripts/socket-facts.plugin.scala | 32 ++- 10 files changed, 285 insertions(+), 272 deletions(-) diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index f60300cd08..ee07ec9d89 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -28,10 +28,7 @@ function okResult(buildRoot: string): ManifestRunResult { factsFileName: 'pom.xml.socket.facts.json', report: { failures: [], scannedConfigs: [], unscannable: [] }, artifactPaths: { - targetsByCoord: new Map(), - targetsByGav: new Map(), - sourcesByCoord: new Map(), - coords: new Set(), + pathsById: new Map(), classpathByProject: new Map(), }, stderr: '', diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 7d5537db3c..0a59d3fd0b 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -7,7 +7,6 @@ import { type SocketFactsSbomComponent, type SocketFactsSbomMetadata, type SocketFactsSbomProject, - mavenCoordinateKey, } from './facts.mts' import type { ParsedRecords, RawCoord, RawProject } from './records.mts' @@ -36,16 +35,23 @@ type MergedNode = { children: Set prod: boolean direct: boolean + // projectKey when this node is a build project, else empty. + project: string targets: Set } type PerRoot = { projectKey: string prod: boolean - nodes: Map< - string, - { coord: RawCoord; children: string[]; direct: boolean; targets: string[] } - > + nodes: Map +} + +type RootNode = { + coord: RawCoord + children: string[] + direct: boolean + project: string + targets: string[] } export function assembleFacts( @@ -54,22 +60,12 @@ export function assembleFacts( ): AssembleResult { const fileExists = opts.fileExists ?? existsSync const perRoot = buildPerRoot(parsed) - const { directByRoot, finalNodes } = mergeByCoordinate(perRoot) + const { directByRoot, finalNodes } = mergeById(perRoot) const tool = (parsed.tool || 'gradle') as SocketFactsSbomMetadata['tool'] - const projectsByGav = new Map() - for (const p of parsed.projects.values()) { - projectsByGav.set(gav(p.group, p.name, p.version), p) - } const components = buildComponents( finalNodes, - projectsByGav, - buildManifestFilesByCoord( - parsed, - directByRoot, - perRoot, - opts.factsFileName, - ), + buildManifestFilesById(parsed, directByRoot, perRoot, opts.factsFileName), ) const projects = opts.emitProjects === false @@ -93,58 +89,60 @@ export function assembleFacts( artifactPaths: buildArtifactPaths( finalNodes, [...parsed.projects.values()], - projectsByGav, perRoot, fileExists, ), } } -function gav(group: string, name: string, version: string): string { - return `${group}:${name}:${version}` +// A node resolving to a build project takes that project's id, so projects +// sharing a coordinate stay apart and the project's variants collapse into it. +function componentId(coordId: string, project: string): string { + return project || coordId } function buildPerRoot(parsed: ParsedRecords): Map { const out = new Map() for (const [rootId, r] of parsed.roots) { - const childrenByParent = new Map>() + const idOf = (coordId: string) => + componentId(coordId, r.nodes.get(coordId)?.project ?? '') + const nodes = new Map() + for (const [coordId, n] of r.nodes) { + const id = idOf(coordId) + let node = nodes.get(id) + if (!node) { + node = { + coord: n.project ? { ...n.coord, classifier: '', ext: '' } : n.coord, + children: [], + direct: false, + project: n.project, + targets: [], + } + nodes.set(id, node) + } + node.direct ||= n.direct + node.targets.push(...n.targets) + } for (const [p, c] of r.edges) { if (!r.nodes.has(p) || !r.nodes.has(c)) { continue } - let set = childrenByParent.get(p) - if (!set) { - set = new Set() - childrenByParent.set(p, set) - } - set.add(c) - } - const nodes = new Map< - string, - { - coord: RawCoord - children: string[] - direct: boolean - targets: string[] + const parentId = idOf(p) + const childId = idOf(c) + const parent = nodes.get(parentId)! + if (childId !== parentId && !parent.children.includes(childId)) { + parent.children.push(childId) } - >() - for (const [coordId, n] of r.nodes) { - nodes.set(coordId, { - coord: n.coord, - children: [...(childrenByParent.get(coordId) ?? [])], - direct: n.direct, - targets: n.targets, - }) } out.set(rootId, { projectKey: r.projectKey, prod: r.prod, nodes }) } return out } -// Components are merged by coordinate across every resolution root; which -// coordinates belong to which subproject is kept separately (classpathByProject) -// for reachability, which needs each subproject's exact classpath. -function mergeByCoordinate(perRoot: Map): { +// Components are merged by id across every resolution root; which ids belong +// to which subproject is kept separately (classpathByProject) for +// reachability, which needs each subproject's exact classpath. +function mergeById(perRoot: Map): { finalNodes: Map directByRoot: Map> } { @@ -159,6 +157,7 @@ function mergeByCoordinate(perRoot: Map): { children: new Set(), prod: false, direct: false, + project: node.project, targets: new Set(), } finalNodes.set(coordId, fn) @@ -188,7 +187,7 @@ function mergeByCoordinate(perRoot: Map): { return { finalNodes, directByRoot } } -function buildManifestFilesByCoord( +function buildManifestFilesById( parsed: ParsedRecords, directByRoot: Map>, perRoot: Map, @@ -222,8 +221,7 @@ function buildManifestFilesByCoord( function buildComponents( finalNodes: Map, - projectsByGav: Map, - manifestFilesByCoord: Map, + manifestFilesById: Map, ): SocketFactsSbomComponent[] { return [...finalNodes.keys()].sort().map(id => { const fn = finalNodes.get(id)! @@ -251,13 +249,13 @@ function buildComponents( if (!fn.prod) { comp.dev = true } - if (projectsByGav.has(gav(c.group, c.name, c.version ?? ''))) { + if (fn.project) { comp.firstParty = true } if (fn.children.size) { comp.dependencies = [...fn.children].sort() } - const manifestFiles = manifestFilesByCoord.get(id) + const manifestFiles = manifestFilesById.get(id) if (manifestFiles) { comp.manifestFiles = manifestFiles } @@ -306,26 +304,6 @@ function buildProjects( return projects } -function unionInto( - map: Map, - key: string, - add: string[], -): void { - if (!add.length) { - return - } - const acc = map.get(key) - if (acc) { - for (const f of add) { - if (!acc.includes(f)) { - acc.push(f) - } - } - } else { - map.set(key, [...add]) - } -} - function buildClasspathByProject( projects: RawProject[], perRoot: Map, @@ -361,84 +339,28 @@ function buildClasspathByProject( function buildArtifactPaths( finalNodes: Map, projects: RawProject[], - projectsByGav: Map, perRoot: Map, fileExists: (path: string) => boolean, ): ResolvedArtifactPaths { - const targetsByCoord = new Map() - const targetsByGav = new Map() - const sourcesByCoord = new Map() - const coords = new Set() - for (const fn of finalNodes.values()) { - const c = fn.coord - const coordKey = mavenCoordinateKey( - c.group, - c.name, - c.ext, - c.classifier, - c.version, - ) - if (!coordKey) { - continue - } - coords.add(coordKey) - const pi = projectsByGav.get(gav(c.group, c.name, c.version ?? '')) - const sources = (pi?.sources ?? []).filter(fileExists).sort() - const targets = [...new Set(pi ? pi.targets : fn.targets)] - .filter(fileExists) - .sort() - if (sources.length) { - sourcesByCoord.set(coordKey, sources) - } - if (!targets.length) { - continue - } - targetsByCoord.set(coordKey, targets) - const gavKey = mavenCoordinateKey( - c.group, - c.name, - undefined, - undefined, - c.version, - ) - if (gavKey) { - const acc = targetsByGav.get(gavKey) - if (acc) { - for (const f of targets) { - if (!acc.includes(f)) { - acc.push(f) - } - } - } else { - targetsByGav.set(gavKey, [...targets]) - } + const pathsById: ResolvedArtifactPaths['pathsById'] = new Map() + for (const [id, fn] of finalNodes) { + if (!fn.project) { + pathsById.set(id, { + sources: [], + targets: [...fn.targets].filter(fileExists).sort(), + }) } } - // A top-level module is a `project` but usually not a dependency node, so its - // source roots (where reachability starts) are missed by the node loop above; - // emit first-party module paths here. + // A project's own component shares its id, so this also covers dependency + // edges onto a sibling project. for (const p of projects) { - const coordKey = mavenCoordinateKey( - p.group, - p.name, - undefined, - undefined, - p.version, - ) - if (!coordKey) { - continue - } - coords.add(coordKey) - unionInto(sourcesByCoord, coordKey, p.sources.filter(fileExists)) - const targets = p.targets.filter(fileExists) - unionInto(targetsByCoord, coordKey, targets) - unionInto(targetsByGav, coordKey, targets) + pathsById.set(p.projectKey, { + sources: [...new Set(p.sources)].filter(fileExists).sort(), + targets: [...new Set(p.targets)].filter(fileExists).sort(), + }) } return { - targetsByCoord, - targetsByGav, - sourcesByCoord, - coords, + pathsById, classpathByProject: buildClasspathByProject(projects, perRoot), } } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 1e31b34ad8..918ce0c4cf 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -119,41 +119,96 @@ describe('records → assemble → sidecar', () => { 'g:lib:jar:1', ]) }) - it('marks only components with the exact coordinate of a build module as firstParty', () => { + it("gives a dependency on a build project that project's id, and marks only it firstParty", () => { const records = [ - 'meta\tmaven\t3.9.6\t17', + 'meta\tgradle\t8.0\t17', 'project\t:a\tg\ta\t1.0-SNAPSHOT\ta', 'project\t:b\tg\tb\t1.0-SNAPSHOT\tb', 'root\tr1\t:a\truntimeClasspath\t1', - 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t1', + 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t1\t', 'root\tr2\t:b\truntimeClasspath\t1', - 'node\tr2\tg:a:jar:1.0-SNAPSHOT\tg\ta\t1.0-SNAPSHOT\tjar\t\t1', - 'node\tr2\tg:ext:jar:2\tg\text\t2\tjar\t\t0', + 'node\tr2\tg:a:jar:1.0-SNAPSHOT\tg\ta\t1.0-SNAPSHOT\tjar\t\t1\t:a', + 'node\tr2\tg:ext:jar:2\tg\text\t2\tjar\t\t0\t', 'edge\tr2\tg:a:jar:1.0-SNAPSHOT\tg:ext:jar:2', - 'node\tr2\tg:b:jar:0.9\tg\tb\t0.9\tjar\t\t1', + // Same name as a build project, but a published artifact, not the project. + 'node\tr2\tg:b:jar:0.9\tg\tb\t0.9\tjar\t\t1\t', ].join('\n') const { artifactPaths, facts } = assembleFacts(parseRecords(records), { - factsFileName: 'pom.xml.socket.facts.json', + factsFileName: 'gradle.socket.facts.json', }) - expect(facts.components.map(c => [c.id, c.firstParty ?? 'absent'])).toEqual( - [ - ['g:a:jar:1.0-SNAPSHOT', true], - ['g:b:jar:0.9', 'absent'], - ['g:ext:jar:2', 'absent'], - ], - ) + expect( + facts.components.map(c => [ + c.id, + c.firstParty ?? 'absent', + c.dependencies, + ]), + ).toEqual([ + [':a', true, ['g:ext:jar:2']], + ['g:b:jar:0.9', 'absent', undefined], + ['g:ext:jar:2', 'absent', undefined], + ]) + expect(facts.projects!.find(p => p.id === ':b')?.dependencies).toEqual([ + ':a', + 'g:b:jar:0.9', + ]) const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths, '/abs/.socket.facts.json') - const bucket = serializeSidecar(acc)['/abs/.socket.facts.json']! - expect( - bucket.components.find(c => c.id === 'g:a:jar:1.0-SNAPSHOT')?.firstParty, - ).toBe(true) + accumulateSidecar( + acc, + facts, + artifactPaths, + '/abs/gradle.socket.facts.json', + ) + const bucket = serializeSidecar(acc)['/abs/gradle.socket.facts.json']! + expect(bucket.components.find(c => c.id === ':a')?.firstParty).toBe(true) for (const project of bucket.projects) { expect(project).not.toHaveProperty('firstParty') } }) + + it("keeps projects that share a coordinate apart, collapsing each project's variants into it", () => { + const records = [ + 'meta\tgradle\t8.0\t17', + 'project\t:a:util\tex\tutil\t1\ta/util', + 'projectSrc\t:a:util\t/abs/a/util/src', + 'project\t:b:util\tex\tutil\t1\tb/util', + 'projectSrc\t:b:util\t/abs/b/util/src', + 'project\t:app\tex\tapp\t1\tapp', + 'root\tr1\t:app\truntimeClasspath\t1', + 'node\tr1\tex:util:jar:1\tex\tutil\t1\tjar\t\t1\t:b:util', + 'node\tr1\tex:util:jar:test-fixtures:1\tex\tutil\t1\tjar\ttest-fixtures\t1\t:b:util', + 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t0\t', + 'edge\tr1\tex:util:jar:test-fixtures:1\tex:util:jar:1', + 'edge\tr1\tex:util:jar:1\tg:ext:jar:2', + 'root\tr2\t:b:util\truntimeClasspath\t1', + 'node\tr2\tex:util:jar:1\tex\tutil\t1\tjar\t\t1\t:a:util', + ].join('\n') + const { artifactPaths, facts } = assembleFacts(parseRecords(records), { + factsFileName: 'gradle.socket.facts.json', + fileExists: () => true, + }) + + expect( + facts.components.map(c => [c.id, c.qualifiers, c.dependencies]), + ).toEqual([ + [':a:util', undefined, undefined], + [':b:util', undefined, ['g:ext:jar:2']], + ['g:ext:jar:2', { ext: 'jar' }, undefined], + ]) + expect(artifactPaths.classpathByProject.get(':app')).toEqual([ + ':b:util', + 'g:ext:jar:2', + ]) + expect(artifactPaths.classpathByProject.get(':b:util')).toEqual([':a:util']) + expect(artifactPaths.pathsById.get(':a:util')?.sources).toEqual([ + '/abs/a/util/src', + ]) + expect(artifactPaths.pathsById.get(':b:util')?.sources).toEqual([ + '/abs/b/util/src', + ]) + }) + 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', diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index bf5f73f4f8..011ee4c43b 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -50,31 +50,12 @@ export type SocketFactsSbomProject = AnyPURL & { manifestFiles?: SocketFactsManifestReference[] | undefined } -// Resolved on-disk paths for a --with-files run, keyed by coordinate. `targets` -// = classpath entries (jars / module output dirs); `sources` = module source -// roots. +// Resolved on-disk paths for a --with-files run, keyed by component or project +// id (a project and its own component share one). `targets` = classpath +// entries (jars / module output dirs); `sources` = module source roots. export type ResolvedArtifactPaths = { - targetsByCoord: Map - // ext/classifier-agnostic, to recover the variant when an ingested ext is - // untrustworthy (Gradle lockfile / version-catalog hardcode ext=jar). - targetsByGav: Map - sourcesByCoord: Map - coords: Set + pathsById: Map // Component ids on each project's resolved classpath (union over its // configurations), keyed by project id. classpathByProject: Map } - -// Coordinate-based (not `id`-based) so it also matches foreign SBOMs like -// CycloneDX. Empty segments dropped. -export function mavenCoordinateKey( - groupId: string | undefined, - artifactId: string | undefined, - type: string | undefined, - classifier: string | undefined, - version: string | undefined, -): string { - return [groupId, artifactId, type, classifier, version] - .filter(Boolean) - .join(':') -} 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 f67e71ef46..4ac60988c5 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 @@ -288,9 +288,9 @@ private String visit( if (!visited.add(id)) return id; Node node = internal - ? upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), "", "", version) + ? upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), "", "", version, gav) : upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), - type == null ? "" : type, classifier == null ? "" : classifier, version); + type == null ? "" : type, classifier == null ? "" : classifier, version, ""); // Maven wrote each accepted node's resolved file back onto the node; a reactor module reports its // own dirs through its `project` record instead of a `file` record. if (!internal && opts.withFiles) { @@ -319,10 +319,11 @@ private static boolean isProd(String scope) { } private static Node upsert( - Map nodes, String id, String groupId, String artifactId, String type, String classifier, String version) { + Map nodes, String id, String groupId, String artifactId, String type, String classifier, String version, + String project) { Node node = nodes.get(id); if (node == null) { - node = new Node(id, groupId, artifactId, type, classifier, version); + node = new Node(id, groupId, artifactId, type, classifier, version, project); nodes.put(id, node); } return node; @@ -375,7 +376,7 @@ private int emitRoot( rec(lines, "root", rootId, projectKey, config, prod ? "1" : "0"); for (Node n : nodeMap.values()) { rec(lines, "node", rootId, n.id, n.groupId, n.artifactId, n.version, n.type, n.classifier, - directIds.contains(n.id) ? "1" : "0"); + directIds.contains(n.id) ? "1" : "0", n.project); for (String child : n.children) { if (nodeMap.containsKey(child)) rec(lines, "edge", rootId, n.id, child); } @@ -531,16 +532,19 @@ private static final class Node { final String type; final String classifier; final String version; + // The reactor module's project key when this is a sibling module, else empty. + final String project; final TreeSet children = new TreeSet<>(); final TreeSet files = new TreeSet<>(); - Node(String id, String groupId, String artifactId, String type, String classifier, String version) { + Node(String id, String groupId, String artifactId, String type, String classifier, String version, String project) { this.id = id; this.groupId = groupId; this.artifactId = artifactId; this.type = type; this.classifier = classifier; this.version = version; + this.project = project; } } } diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 5e59e41623..4cbb387cfd 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -17,7 +17,7 @@ import type { // 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) +// node rootId coordId group name version ext classifier direct(0|1) project // edge rootId parentCoordId childCoordId // file rootId coordId path (--with-files only) // scanned config @@ -25,7 +25,8 @@ import type { // unscannable config detail // // `projectKey` is the tool's unique project identity (Maven: GAV; Gradle: -// project path; sbt: project id), emitted as the project id. +// project path; sbt: project id), emitted as the project id; a `node` whose +// `project` names one resolves to that build project. // A `root` is one (subproject, configuration) resolution root; `coordId` is the // coordinate key (`group:name:ext:classifier:version`, empty segments dropped), // used opaquely as the per-root node key. Unknown tags are ignored. @@ -42,6 +43,9 @@ export type RawNode = { coordId: string coord: RawCoord direct: boolean + // projectKey of the build project this node resolves to; empty for an + // external artifact. + project: string // --with-files only. targets: string[] } @@ -212,6 +216,7 @@ export function parseRecords(text: string): ParsedRecords { classifier: f[7] ?? '', }, direct: bool(f[8]), + project: f[9] ?? '', targets: [], }) break diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index 10d704e8e2..9f2bbf9199 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -1,5 +1,3 @@ -import { mavenCoordinateKey } from './facts.mts' - import type { AnyPURL, ResolvedArtifactPaths, @@ -9,10 +7,9 @@ import type { } from './facts.mts' export type SidecarComponentEntry = SocketFactsSbomComponent & { - // Classpath entries (jars, or a sibling first-party project's own build - // output dirs when this dependency edge resolves to one). `[]` - // means resolution was attempted and found nothing (e.g. a pom/BOM); - // undefined means resolution couldn't be attempted at all (see attachPaths). + // Classpath entries (jars, or a sibling project's own build output dirs + // when this component is that project). `[]` means resolved and found + // nothing (e.g. a pom/BOM); undefined means paths were not resolved. targets?: string[] | undefined // First-party source roots; `[]` for a genuinely external dependency (still // attempted, nothing to find), not undefined. @@ -54,31 +51,16 @@ export type SidecarAccumulator = Map< { projects: SidecarProjectEntry[]; components: SidecarComponentEntry[] } > -// `targets`/`sources` present (possibly `[]`) means resolution was attempted -// for this coordinate - an empty array is a successful resolve that found -// nothing (e.g. a pom/BOM with no artifact), not a failure. Both fields -// omitted (undefined) means resolution couldn't even be attempted - the only -// case here is a degenerate entry with no computable coordinate at all, since -// every entry reaching this function already came from a resolved graph node -// (an unresolved dependency lives in the resolution report, not here). -function attachPaths( +// `[]` means resolved and found nothing (e.g. a pom/BOM with no artifact). +function attachPaths( entry: T, artifactPaths: ResolvedArtifactPaths, -): T & { targets?: string[] | undefined; sources?: string[] | undefined } { - const coordKey = mavenCoordinateKey( - entry.namespace, - entry.name, - entry.qualifiers?.['ext'], - entry.qualifiers?.['classifier'], - entry.version, - ) - if (!coordKey) { - return { ...entry } - } +): T & { targets: string[]; sources: string[] } { + const paths = artifactPaths.pathsById.get(entry.id) return { ...entry, - targets: [...(artifactPaths.targetsByCoord.get(coordKey) ?? [])].sort(), - sources: [...(artifactPaths.sourcesByCoord.get(coordKey) ?? [])].sort(), + targets: [...(paths?.targets ?? [])], + sources: [...(paths?.sources ?? [])], } } @@ -107,7 +89,7 @@ export function accumulateSidecar( // Off when artifact paths were not resolved; entries then omit `targets` and `sources`. withPaths = true, ): void { - const paths = (entry: T) => + const paths = (entry: T) => withPaths ? attachPaths(entry, artifactPaths) : { ...entry } acc.set(factsFile, { components: facts.components.map(paths), diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index b7ec523fb2..35236aca4a 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -13,10 +13,7 @@ import type { SidecarAccumulator } from './sidecar.mts' function emptyArtifactPaths(): ResolvedArtifactPaths { return { - targetsByCoord: new Map(), - targetsByGav: new Map(), - sourcesByCoord: new Map(), - coords: new Set(), + pathsById: new Map(), classpathByProject: new Map(), } } @@ -26,7 +23,7 @@ function mkComponentFixture(target: string): { paths: ResolvedArtifactPaths } { const paths = emptyArtifactPaths() - paths.targetsByCoord.set('g:a:jar:1', [target]) + paths.pathsById.set('g:a:jar:1', { sources: [], targets: [target] }) return { facts: { components: [ @@ -102,12 +99,10 @@ describe('compute-artifacts sidecar', () => { ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.targetsByCoord.set('com.example:lib:jar:da517db', [ - '/abs/lib.jar', - ]) - artifactPaths.sourcesByCoord.set('com.example:lib:jar:da517db', [ - '/abs/lib/src/main/java', - ]) + artifactPaths.pathsById.set('com.example:lib:jar:da517db', { + sources: ['/abs/lib/src/main/java'], + targets: ['/abs/lib.jar'], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') @@ -159,23 +154,49 @@ describe('compute-artifacts sidecar', () => { expect(entry.sources).toEqual([]) }) - it('leaves targets/sources undefined (not []) when the entry has no computable coordinate at all', () => { + it("gives each sibling project's component that project's own paths, even when they share a coordinate", () => { + const util = { + type: 'maven', + namespace: 'ex', + name: 'util', + version: '1', + dependencies: [], + } const facts: SocketFactsSbom = { components: [ - { type: 'maven', namespace: '', name: '', id: 'degenerate' }, + { ...util, id: ':a:util', firstParty: true }, + { ...util, id: ':b:util', firstParty: true }, + ], + projects: [ + { ...util, id: ':a:util', subprojectDir: 'a/util' }, + { ...util, id: ':b:util', subprojectDir: 'b/util' }, ], } + const artifactPaths = emptyArtifactPaths() + artifactPaths.pathsById.set(':a:util', { + sources: ['/abs/a/util/src'], + targets: ['/abs/a/util/classes'], + }) + artifactPaths.pathsById.set(':b:util', { + sources: ['/abs/b/util/src'], + targets: ['/abs/b/util/classes'], + }) + const acc: SidecarAccumulator = new Map() accumulateSidecar( acc, facts, - emptyArtifactPaths(), - '/root/.socket.facts.json', + artifactPaths, + '/root/gradle.socket.facts.json', ) - const entry = - serializeSidecar(acc)['/root/.socket.facts.json']!.components[0]! - expect(entry.targets).toBeUndefined() - expect(entry.sources).toBeUndefined() + const entry = serializeSidecar(acc)['/root/gradle.socket.facts.json']! + + for (const entries of [entry.components, entry.projects]) { + expect(Object.fromEntries(entries.map(e => [e.id, e.sources]))).toEqual({ + ':a:util': ['/abs/a/util/src'], + ':b:util': ['/abs/b/util/src'], + }) + } }) it('preserves the original component fields (id, qualifiers) untouched', () => { @@ -215,6 +236,7 @@ describe('compute-artifacts sidecar', () => { components: [], projects: [ { + id: 'com.example:app:1.0', type: 'maven', namespace: 'com.example', name: 'app', @@ -225,12 +247,10 @@ describe('compute-artifacts sidecar', () => { ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.sourcesByCoord.set('com.example:app:1.0', [ - '/abs/app/src/main/java', - ]) - artifactPaths.targetsByCoord.set('com.example:app:1.0', [ - '/abs/app/build/classes', - ]) + artifactPaths.pathsById.set('com.example:app:1.0', { + sources: ['/abs/app/src/main/java'], + targets: ['/abs/app/build/classes'], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/root/app/.socket.facts.json') @@ -239,6 +259,7 @@ describe('compute-artifacts sidecar', () => { expect(resolved['/root/app/.socket.facts.json']!.components).toEqual([]) expect(resolved['/root/app/.socket.facts.json']!.projects).toEqual([ { + id: 'com.example:app:1.0', type: 'maven', namespace: 'com.example', name: 'app', @@ -302,6 +323,7 @@ describe('compute-artifacts sidecar', () => { components: [], projects: [ { + id: 'com.example:shared:1.0', type: 'maven', namespace: 'com.example', name: 'shared', @@ -312,13 +334,15 @@ describe('compute-artifacts sidecar', () => { ], } const pathsA = emptyArtifactPaths() - pathsA.sourcesByCoord.set('com.example:shared:1.0', [ - '/root-a/src/main/java', - ]) + pathsA.pathsById.set('com.example:shared:1.0', { + sources: ['/root-a/src/main/java'], + targets: [], + }) const pathsB = emptyArtifactPaths() - pathsB.sourcesByCoord.set('com.example:shared:1.0', [ - '/root-b/src/main/java', - ]) + pathsB.pathsById.set('com.example:shared:1.0', { + sources: ['/root-b/src/main/java'], + targets: [], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar( diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 299f1fd562..1d2e86ed0a 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -58,9 +58,11 @@ gradle.ext.socketFactsState = [ paths : Collections.synchronizedMap([:]), perSub : Collections.synchronizedMap([:]), projectsInfo : Collections.synchronizedList([]), - // "group:name" -> the module's real artifact extension (e.g. jar), so an intra-project dep that + // Project path -> the module's real artifact extension (e.g. jar), so an intra-project dep that // resolves without selecting a published artifact still gets its true coordinate, not ext-less. projectArtifactExt : Collections.synchronizedMap([:]), + // Project path -> "group:name", to tell this build's projects from an included build's. + projectGaByPath : Collections.synchronizedMap([:]), ] // Capture every project's (group:name) before collectors run so they can filter intra-project @@ -86,7 +88,8 @@ gradle.projectsEvaluated { g -> } } } catch (Exception ignore) {} - g.socketFactsState.projectArtifactExt["${p.group ?: ''}:${p.name}".toString()] = artExt + g.socketFactsState.projectArtifactExt[p.path] = artExt + g.socketFactsState.projectGaByPath[p.path] = "${p.group ?: ''}:${p.name}".toString() // A wholly excluded subproject emits no project record and its configs are never resolved (see the // collector). projectKeys/projectArtifactExt above stay populated so a KEPT project depending on it // still recognizes it as a first-party module. @@ -154,6 +157,8 @@ allprojects { project -> // visit/upsertNode closures capture them); `failures`/`scannedConfigs` stay shared. def nodes = [:] def directIds = [] as Set + // "group:name:version" -> path of the build project this config resolved it to. + def projectByGav = [:] def failures = state.failures def scannedConfigs = state.scannedConfigs def projectKeys = state.projectKeys @@ -205,21 +210,21 @@ allprojects { project -> // A first-party module dep can resolve with no published artifact (classes-dir variant, or // variant-ambiguous moduleArtifacts), leaving it ext-less. Stamp the module's real artifact // extension so it keeps a full, stable coordinate instead of an ext-less one. - def effExt = { String group, String name, String ext -> - if ((ext == null || ext.isEmpty()) && isIntraProject(group, name)) { - projectArtifactExt["${group ?: ''}:${name}".toString()] ?: '' + def effExt = { String projPath, String ext -> + if ((ext == null || ext.isEmpty()) && projPath != null) { + projectArtifactExt[projPath] ?: '' } else { ext ?: '' } } // A node is created once; its prod flag accumulates (OR) across the configs that reach it. - def upsertNode = { Map coord, boolean isProd -> + def upsertNode = { Map coord, boolean isProd, String projPath -> def id = coordId(coord) synchronized (nodes) { def node = nodes[id] if (node == null) { - node = [coord: coord, children: [] as Set, prod: false] + node = [coord: coord, children: [] as Set, prod: false, project: projPath] nodes[id] = node } if (isProd) { @@ -253,18 +258,19 @@ allprojects { project -> } catch (Exception e) { artifacts = [] as Set } + def projPath = projectByGav["${dep.moduleGroup ?: ''}:${dep.moduleName}:${dep.moduleVersion ?: ''}".toString()] if (artifacts.isEmpty()) { - def ext = effExt(dep.moduleGroup, dep.moduleName, '') + def ext = effExt(projPath, '') // Skip a no-artifact first-party module (aggregator / build root): it builds no archive, so // it's fully described by `projects` and is never a real artifact dependency. - if (!(ext.isEmpty() && isIntraProject(dep.moduleGroup, dep.moduleName))) { + if (!(ext.isEmpty() && projPath != null)) { producedIds << upsertNode([ groupId : dep.moduleGroup ?: '', artifactId: dep.moduleName, version : dep.moduleVersion ?: '', classifier: '', ext : ext, - ], isProd) + ], isProd, projPath) } } else { // Build the GAV scope key only when a scope is set: the no-scope path never reads it and @@ -281,16 +287,16 @@ allprojects { project -> artifacts.each { a -> // Directory variants (java-classes-directory etc.) carry no extension; for a first-party // module fall back to its real artifact ext (never artifact.type, a Gradle variant attr). - def ext = effExt(dep.moduleGroup, dep.moduleName, a.extension) + def ext = effExt(projPath, a.extension) // Skip a no-artifact first-party module (see the empty-artifacts branch above). - if (ext.isEmpty() && isIntraProject(dep.moduleGroup, dep.moduleName)) return + if (ext.isEmpty() && projPath != null) return def aid = upsertNode([ groupId : dep.moduleGroup ?: '', artifactId: dep.moduleName, version : dep.moduleVersion ?: '', classifier: a.classifier ?: '', ext : ext, - ], isProd) + ], isProd, projPath) producedIds << aid // `a.file` downloads the artifact if not already cached, so scoping avoids fetching // artifacts the SBOM doesn't reference. Per-artifact try/catch: a single download can @@ -421,10 +427,21 @@ allprojects { project -> nodes = [:] directIds = [] as Set paths = [:] + projectByGav = [:] // Per-config try/catch: AGP-style configs can fail variant ambiguity from an init-script // context lacking the consumer attributes AGP sets internally. try { def lenient = cfg.resolvedConfiguration.lenientConfiguration + // The resolution graph names a project dependency by project path; within one graph a + // module resolves to a single component, so its GAV identifies that project here. + cfg.incoming.resolutionResult.allComponents.each { comp -> + def cid = comp.id + def mv = comp.moduleVersion + if (cid instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier && mv != null && + state.projectGaByPath[cid.projectPath] == "${mv.group ?: ''}:${mv.name}".toString()) { + projectByGav["${mv.group ?: ''}:${mv.name}:${mv.version ?: ''}".toString()] = cid.projectPath + } + } def cache = [:] // No-arg getter only since Gradle 3.3; older Gradle has just the Spec-taking overload. def firstLevel @@ -519,7 +536,7 @@ rootProject { rp -> tree.nodes.each { coordId, node -> def c = node.coord rec(['node', rootId, coordId, c.groupId ?: '', c.artifactId ?: '', c.version ?: '', c.ext ?: '', - c.classifier ?: '', tree.direct.contains(coordId) ? '1' : '0']) + c.classifier ?: '', tree.direct.contains(coordId) ? '1' : '0', node.project ?: '']) node.children.each { childId -> rec(['edge', rootId, coordId, childId]) } def fs = tree.paths[coordId] if (fs) { (fs as List).sort().each { p -> rec(['file', rootId, coordId, p]) } } diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 993aa8ddd3..09668c217c 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -62,11 +62,18 @@ object SocketFactsPlugin extends AutoPlugin { // Real artifact ext per build module, so an ext-less inter-project dep gets its true coordinate. val moduleExts = buildModuleExts(allRefs, extracted) + val projectIdsByGav: Map[String, Seq[String]] = + allRefs.groupBy(r => gavKey(rootIdOf(extracted, r))).map { case (k, rs) => k -> rs.map(_.project) } allRefs.foreach { ref => if (!isExcludedRef(ref)) { + // The update report names an inter-project dependency only by module ID; the projects this + // one reaches through `dependsOn` say which build project that ID is. + val reachable = dependsOnClosure(ref, extracted) + val projectsOf = (gav: String) => projectIdsByGav.getOrElse(gav, Nil).filter(reachable) runUpdateResilient(updateTaskName, ref, extracted, st, failures).foreach { report => - foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach { + foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts, + projectsOf, failures).foreach { case (rootKey, tree) => perSub(rootKey) = tree } } @@ -129,7 +136,8 @@ object SocketFactsPlugin extends AutoPlugin { tree.nodes.foreach { case (coordId, node) => val c = node.coord - rec("node", rootId, coordId, c.org, c.name, c.version, c.ext, c.classifier, if (node.direct) "1" else "0") + rec("node", rootId, coordId, c.org, c.name, c.version, c.ext, c.classifier, if (node.direct) "1" else "0", + node.project) node.children.foreach(ch => rec("edge", rootId, coordId, ch)) node.targets.foreach(p => rec("file", rootId, coordId, p)) } @@ -151,6 +159,16 @@ object SocketFactsPlugin extends AutoPlugin { // ---- resolution --------------------------------------------------------- + private def dependsOnClosure(ref: ProjectRef, extracted: Extracted): Set[String] = { + val seen = mutable.LinkedHashSet.empty[ProjectRef] + def walk(r: ProjectRef): Unit = + extracted.getOpt(thisProject.in(r)).toList.flatMap(_.dependencies).map(_.project).foreach { d => + if (seen.add(d)) walk(d) + } + walk(ref) + seen.map(_.project).toSet + } + private def rootIdOf(extracted: Extracted, ref: ProjectRef): ModuleID = { val sv = extracted.get(scalaVersion.in(ref)) val sbv = extracted.get(scalaBinaryVersion.in(ref)) @@ -276,7 +294,9 @@ object SocketFactsPlugin extends AutoPlugin { scannedConfigs: mutable.LinkedHashSet[String], withFiles: Boolean, populateScope: Option[Set[String]], - moduleExts: Map[String, String] + moduleExts: Map[String, String], + projectsOf: String => Seq[String], + failures: mutable.LinkedHashSet[Failure] ): mutable.LinkedHashMap[String, RootTree] = { val perRoot = mutable.LinkedHashMap.empty[String, RootTree] val rootGav = gavKey(rootIdOf(extracted, ref)) @@ -300,8 +320,12 @@ object SocketFactsPlugin extends AutoPlugin { cr.modules.foreach { m => if (emittable(m)) { val ids = midToIds.getOrElseUpdate(gavKey(m.module), mutable.LinkedHashSet.empty[String]) + val projects = projectsOf(gavKey(m.module)) + if (projects.size > 1) + failures += Failure(coordOf(m.module), "ambiguous inter-project dependency: " + projects.mkString(", "), cfg) variantsOf(m, moduleExts).foreach { case (coord, fileOpt) => val node = nodes.getOrElseUpdate(coord.id, new Node(coord)) + if (projects.size == 1) node.project = projects.head ids += coord.id if (withFiles && inScope(m.module)) fileOpt.foreach(f => node.targets += f.getAbsolutePath) } @@ -518,6 +542,8 @@ object SocketFactsPlugin extends AutoPlugin { private final class Node(val coord: Coord) { val children = mutable.TreeSet.empty[String] var direct = false + // Id of the build project this node is, when it is one. + var project = "" // External artifact's resolved jar(s); --with-files only. val targets = mutable.TreeSet.empty[String] } From 649eb2f554996769b5d0f415cf1f98f4d7877865 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 11:29:59 +0200 Subject: [PATCH 3/9] chore(manifest): trim comments that restate the code Co-Authored-By: Claude Opus 5.5 --- src/commands/fix/coana-fix.mts | 3 +-- src/commands/manifest/scripts/build-tool.mts | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 282fdfba85..e27284cc22 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -372,8 +372,7 @@ async function coanaFixWithFacts( cwd, }) const scanFilepaths = await findScanFilepaths() - // Fail if any Socket facts files are present in the scan folder. - // These are analysis artifacts and must be removed before re-running fix. + // Facts files are analysis artifacts and must be removed before re-running fix. const factsFiles = scanFilepaths.filter(isSocketFactsFile) if (factsFiles.length) { if (!silence) { diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index 39deb76bb9..53c6268f06 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -55,10 +55,7 @@ export function resolveBuildToolBin( return DEFAULT_BUILD_TOOL_BIN[tool] } -// `.socket.facts.json`, distinct for every build sharing a directory: -// the entry file's name (`pom.xml`) for a file-addressed build, the tool's -// name (`gradle`) for a directory-addressed one. Undefined when a -// file-addressed build did not report its entry file. +// Distinct for every build sharing a directory. export function socketFactsFileName( tool: BuildTool, entryFile: string | undefined, From 6eb4c617064ab159a1ef7217e7585cb2b987ae2a Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 11:40:19 +0200 Subject: [PATCH 4/9] fix(manifest): judge recursive coverage against the build root A reactor rooted away from its discovery directory by -f/-p could mark a module that escapes it as covered, skipping that module's own build. Co-Authored-By: Claude Opus 5.5 --- .../manifest/generate-recursive-manifests.mts | 9 +++- .../generate-recursive-manifests.test.mts | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 25d6f20217..70f76047e3 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -185,7 +185,9 @@ async function runEcosystemCandidates({ } covered.add(dir) - const buildRoot = path.dirname(result.factsPath) + // `-f`/`-p` can root the reactor away from `dir`. + // eslint-disable-next-line no-await-in-loop + const buildRoot = await realpathOrResolved(path.dirname(result.factsPath)) // eslint-disable-next-line no-await-in-loop const resolvedSubprojectDirs = await Promise.all( result.projects.map(project => @@ -204,7 +206,10 @@ async function runEcosystemCandidates({ // meaningful data point, not a redundant one. Never suppress its own // build-root invocation, regardless of which reactor(s) also // incorporate it or the order candidates happen to be discovered in. - if (subprojectDir.startsWith(`${dir}${path.sep}`)) { + if ( + subprojectDir === buildRoot || + subprojectDir.startsWith(`${buildRoot}${path.sep}`) + ) { covered.add(subprojectDir) } } diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 041a223aaf..fa90eb4cc4 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -164,6 +164,59 @@ describe('generateRecursiveManifests', () => { }, ) + it('judges coverage against the reported build root, not the discovery directory', async () => { + const outer = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'relocated-build-root-')), + ) + const buildRoot = path.join(outer, 'build') + const member = path.join(buildRoot, 'member') + const escaped = path.join(outer, 'escaped') + try { + for (const dir of [outer, member, escaped]) { + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(dir, { recursive: true }) + // eslint-disable-next-line no-await-in-loop + await fs.writeFile(path.join(dir, 'pom.xml'), '') + } + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => { + if (cwd === outer) { + return { + factsPath: path.join(buildRoot, 'x.xml.socket.facts.json'), + projects: [ + { + type: 'maven', + name: 'member', + subprojectDir: 'member', + dependencies: [], + }, + { + type: 'maven', + name: 'escaped', + subprojectDir: '../escaped', + dependencies: [], + }, + ], + } + } + return { + factsPath: path.join(cwd, 'pom.xml.socket.facts.json'), + projects: [], + } + }) + + const outcomes = await generateRecursiveManifests({ + cwd: outer, + verbose: false, + }) + + const byDir = new Map(outcomes.map(o => [o.dir, o.status])) + expect(byDir.get(member)).toBe('skippedCovered') + expect(byDir.get(escaped)).toBe('generated') + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }) + it("runs both ecosystems unconditionally at a dual-marker directory (matches auto's existing behavior)", async () => { vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ factsPath: path.join(cwd, '.socket.facts.json'), From 7594c03dfa68fba5fb4703c6bc62c3f9b3035a21 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 11:51:43 +0200 Subject: [PATCH 5/9] feat(scan): recognise any *.socket.facts.json and keep reachability reports out of input Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ src/commands/fix/coana-fix.mts | 20 ++----- src/commands/scan/cmd-scan-create.mts | 17 ++++-- src/commands/scan/cmd-scan-create.test.mts | 2 +- src/commands/scan/cmd-scan-reach.test.mts | 2 +- src/commands/scan/handle-create-new-scan.mts | 34 +++++------ .../scan/handle-create-new-scan.test.mts | 60 +++++++++++++++++++ .../scan/perform-reachability-analysis.mts | 27 +++++---- .../perform-reachability-analysis.test.mts | 30 ++++++++++ src/commands/scan/reachability-flags.mts | 2 +- src/utils/coana.mts | 28 +++++++++ src/utils/coana.test.mts | 55 +++++++++++++++++ 12 files changed, 228 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa85e021f..0ce6450968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed +- Reachability scans no longer feed a leftover `.socket.facts.json` report from an earlier run back into the analysis. + ## [1.6.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.1) - 2026-10-08 ### Changed diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 7d1ec971e0..e27284cc22 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -22,12 +22,9 @@ import { import { generateSocketFactsForFix } from './generated-socket-facts.mts' import { getSocketFixBranchName, getSocketFixCommitMessage } from './git.mts' import { getSocketFixPrs, openSocketFixPr } from './pull-request.mts' -import { - DOT_SOCKET_DOT_FACTS_JSON, - FLAG_DRY_RUN, - GQL_PR_STATE_OPEN, -} from '../../constants.mts' +import { FLAG_DRY_RUN, GQL_PR_STATE_OPEN } from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' +import { isSocketFactsFile } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { getErrorCause } from '../../utils/errors.mts' @@ -194,10 +191,6 @@ async function discoverGhsaIds( } } -function isFactsFile(filepath: string): boolean { - return path.basename(filepath).toLowerCase() === DOT_SOCKET_DOT_FACTS_JSON -} - type GitWorkingTreeChanges = { modified: string[] untracked: string[] @@ -379,16 +372,15 @@ async function coanaFixWithFacts( cwd, }) const scanFilepaths = await findScanFilepaths() - // Fail if any .socket.facts.json files are present in the scan folder. - // These are analysis artifacts and must be removed before re-running fix. - const factsFiles = scanFilepaths.filter(isFactsFile) + // Facts files are analysis artifacts and must be removed before re-running fix. + const factsFiles = scanFilepaths.filter(isSocketFactsFile) if (factsFiles.length) { if (!silence) { spinner?.stop() } return { ok: false, - message: `Found ${DOT_SOCKET_DOT_FACTS_JSON} in manifest files`, + message: 'Found Socket facts files in manifest files', cause: `Delete the following ${pluralize('file', factsFiles.length)} before running socket fix again:\n` + factsFiles.map(p => ` - ${p}`).join('\n'), @@ -409,7 +401,7 @@ async function coanaFixWithFacts( }) } catch (e) { // A failed build root aborts inference after others wrote their facts. - const partial = (await findScanFilepaths()).filter(isFactsFile) + const partial = (await findScanFilepaths()).filter(isSocketFactsFile) await Promise.all(partial.map(p => fs.rm(p, { force: true }))) throw e } diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 7768cfd1df..5634e54cad 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { readdirSync } from 'node:fs' import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' @@ -26,6 +26,7 @@ import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags, outputFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { isSocketFactsFile } from '../../utils/coana.mts' import { determineOrgSlug } from '../../utils/determine-org-slug.mts' import { parseReachEcosystems } from '../../utils/ecosystem.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' @@ -184,6 +185,14 @@ const generalFlags: MeowFlags = { }, } +function hasSocketFactsFileIn(dir: string): boolean { + try { + return readdirSync(dir).some(isSocketFactsFile) + } catch { + return false + } +} + export const cmdScanCreate = { description, hidden, @@ -472,14 +481,12 @@ async function run( } const detected = await detectManifestActions(sockJson, cwd) - // Suppress the --auto-manifest suggestion when a `.socket.facts.json` is + // Suppress the --auto-manifest suggestion when a Socket facts file is // already present at cwd. That file is the output of `socket manifest auto` // (and `--facts` mode of the per-ecosystem manifest commands), so suggesting // to regenerate it would be misleading; the manifest data is already there // and will be picked up by the scan. - const hasFactsFile = existsSync( - path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON), - ) + const hasFactsFile = hasSocketFactsFileIn(cwd) if ( detected.count > 0 && !autoManifest && diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 8d22ebf8ed..98d7bc0320 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -136,7 +136,7 @@ describe('socket scan create', async () => { --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supported: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. --reach-fallback-to-regular-scan If reachability analysis fails, continue with a regular SCA scan (without reachability results) instead of halting. By default, the CLI halts on reachability errors. - --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale \`.socket.facts.json\` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable. + --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 80c677b1ad..4917a96ce8 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -52,7 +52,7 @@ describe('socket scan reach', async () => { --reach-disable-external-tool-checks Disable external tool checks during reachability analysis. --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supported: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. - --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale \`.socket.facts.json\` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable. + --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 3a19b6a220..98ecd3152e 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -19,6 +19,8 @@ import constants from '../../constants.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { compressSocketFactsForUpload, + isReachabilityReportPath, + isSocketFactsFile, snapshotSocketFacts, } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' @@ -197,7 +199,7 @@ async function createNewScan( if (reach.dynamicSbomInference) { // Already generated recursively above; resolving cwd's own build - // root a second time would race on the same .socket.facts.json. + // root a second time would race on the same facts file. detected.gradle = false detected.sbt = false detected.maven = false @@ -358,16 +360,17 @@ async function createNewScan( reachabilityReport = reachResult.data?.reachabilityReport - // When using only pre-generated SBOMs, build the scan from those inputs — - // CycloneDX, SPDX, and Socket facts (`.socket.facts.json`) — matching - // Coana's `--use-only-pregenerated-sboms` selection. Otherwise drop any - // stray `.socket.facts.json`; coana's fresh reachability report (appended - // below) is the authoritative facts file for the scan. + // Mirror the SBOM inputs Coana analyzed; otherwise its fresh report + // (appended below) supersedes every facts file. const pathsForScan = reach.reachUseOnlyPregeneratedSboms - ? filterToPregeneratedSboms(packagePaths, supportedFiles) - : packagePaths.filter( - p => path.basename(p) !== constants.DOT_SOCKET_DOT_FACTS_JSON, + ? filterToPregeneratedSboms(packagePaths, supportedFiles).filter( + p => + !isReachabilityReportPath(p, { + cwd, + outputPath: constants.DOT_SOCKET_DOT_FACTS_JSON, + }), ) + : packagePaths.filter(p => !isSocketFactsFile(p)) // Append coana's reachability report, but not twice: a pre-generated facts // input can resolve to the same path coana wrote its report to. @@ -439,17 +442,8 @@ async function createNewScan( ) } - // On a successful scan, clean up the `.socket.facts.json` coana wrote at - // the path we instructed it to write to (via `--socket-mode`). Failed - // scans leave the file in place for debugging. Producer-written files - // (e.g. from `socket manifest gradle --facts`) are NOT touched here — - // those are user-owned input that the user can clean up themselves; in - // the --reach path coana overwrites that file with its enriched output - // anyway, so it's the same path that gets removed. `--reach-retain-facts-file` - // opts out of this cleanup so the report can be inspected; the user is then - // responsible for deleting it before the next full application reachability - // scan (a stale file is picked up as pre-generated input and would make those - // results unreliable). + // A scan without --reach would upload a leftover report as an SBOM with + // stale reachability results; a failed scan keeps it for debugging. if ( fullScanCResult.ok && scanId && diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 7a750749a2..6a86ff9502 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -263,6 +263,66 @@ describe('handleCreateNewScan excludePaths', () => { expect(cleanup).toHaveBeenCalledOnce() }) + it('replaces every facts file input with the reachability report', async () => { + const cleanup = vi.fn() + const files = [ + '/repo/pom.xml.socket.facts.json', + '/repo/gradle.socket.facts.json', + '/repo/service/.socket.facts.json', + '/repo/package-lock.json', + ] + const config = createConfig({ + generateScanFiles: async () => ({ cleanup, files }), + }) + config.reach.runReachabilityAnalysis = true + mockPerformReachabilityAnalysis.mockResolvedValueOnce({ + data: { + reachabilityReport: '.socket.facts.json', + tier1ReachabilityScanId: 'tier1-id', + }, + ok: true, + }) + await handleCreateNewScan(config) + expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( + ['/repo/package-lock.json', '.socket.facts.json'], + 'fakeOrg', + expect.anything(), + expect.anything(), + ) + }) + + it('keeps build facts but not earlier reports when using only pre-generated SBOMs', async () => { + const cleanup = vi.fn() + const files = [ + '/repo/pom.xml.socket.facts.json', + '/repo/service/.socket.facts.json', + '/repo/package-lock.json', + ] + const config = createConfig({ + generateScanFiles: async () => ({ cleanup, files }), + }) + config.reach.runReachabilityAnalysis = true + config.reach.reachUseOnlyPregeneratedSboms = true + mockFetchSupportedScanFileNames.mockResolvedValueOnce({ + data: { socket: { facts: { pattern: '*.socket.facts.json' } } }, + ok: true, + }) + mockPerformReachabilityAnalysis.mockResolvedValueOnce({ + data: { + reachabilityReport: '.socket.facts.json', + tier1ReachabilityScanId: 'tier1-id', + }, + ok: true, + }) + await handleCreateNewScan(config) + expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( + ['/repo/pom.xml.socket.facts.json', '.socket.facts.json'], + 'fakeOrg', + expect.anything(), + expect.anything(), + ) + }) + it('includes generated auto-manifest files in SCA discovery targets', async () => { mockGenerateAutoManifest.mockResolvedValueOnce({ generatedFiles: ['/repo/.socket-auto-manifest/maven_install.json'], diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 3314e1bc98..95d8c3b38b 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -8,7 +8,10 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { isOmittedReachValue } from './reachability-units.mts' import constants from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' -import { extractTier1ReachabilityScanId } from '../../utils/coana.mts' +import { + extractTier1ReachabilityScanId, + isReachabilityReportPath, +} from '../../utils/coana.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { hasEnterpriseOrgPlan } from '../../utils/organization.mts' import { setupSdk } from '../../utils/sdk.mts' @@ -134,17 +137,19 @@ export async function performReachabilityAnalysis( spinner?.start('Uploading manifests for reachability analysis...') + const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON + // Ensure uploaded manifest files are relative to analysis target as coana resolves SBOM manifest files relative to this path - // NOTE: previously stripped any `.socket.facts.json` from packagePaths - // here to avoid uploading leftover post-reachability output. With the - // producer flow (`socket manifest gradle --facts`) those files are - // legitimate INPUT to compute-artifacts, so we now upload them. Stale - // facts files are cleaned up downstream — see the post-success - // deletion in handle-create-new-scan.mts. const uploadCResult = await handleApiCall( - sockSdk.uploadManifestFiles(orgSlug, packagePaths, { - pathsRelativeTo: path.resolve(cwd, analysisTarget), - }), + sockSdk.uploadManifestFiles( + orgSlug, + packagePaths.filter( + p => !isReachabilityReportPath(p, { cwd, outputPath: outputFilePath }), + ), + { + pathsRelativeTo: path.resolve(cwd, analysisTarget), + }, + ), { description: 'upload manifests', spinner, @@ -179,8 +184,6 @@ export async function performReachabilityAnalysis( spinner?.start() spinner?.infoAndStop('Running reachability analysis with Coana...') - const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON - // Temp file for --compute-artifacts-sidecar, removed in the finally below. // Written even when empty under dynamicSbomInference, since the // --maven-use-only-socket-facts flag below requires one to be present. diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index bf24a6dbcb..d8dfd220da 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -220,6 +220,36 @@ describe('performReachabilityAnalysis manifests tar hash', () => { expect(args[args.indexOf('--manifests-tar-hash') + 1]).toBe(TEST_TAR_HASH) }) + it('uploads build facts but not earlier reachability reports', async () => { + const uploadManifestFiles = vi.fn() + mockSetupSdk.mockResolvedValueOnce({ + ok: true, + data: { uploadManifestFiles }, + }) + + await performReachabilityAnalysis({ + cwd: scanCwd, + orgSlug: TEST_ORG_SLUG, + outputPath: 'out/report.json', + packagePaths: [ + 'package.json', + 'pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + '.socket.facts.json', + 'nested/.socket.facts.json', + path.join(scanCwd, 'out/report.json'), + ], + reachabilityOptions: makeReachabilityOptions(), + target: scanCwd, + }) + + expect(uploadManifestFiles.mock.calls[0]![1]).toEqual([ + 'package.json', + 'pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + ]) + }) + it('fails without spawning Coana when the upload returns no tar hash', async () => { mockHandleApiCall.mockResolvedValueOnce({ ok: true, data: {} } as never) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index a2c4c2c657..ffe3ca2a05 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -121,7 +121,7 @@ export const reachabilityFlags: MeowFlags = { type: 'boolean', default: false, description: - 'Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale `.socket.facts.json` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable.', + 'Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results.', }, reachSkipCache: { type: 'boolean', diff --git a/src/utils/coana.mts b/src/utils/coana.mts index 8b130e097b..add373b846 100644 --- a/src/utils/coana.mts +++ b/src/utils/coana.mts @@ -86,6 +86,8 @@ export async function compressSocketFactsForUpload( // remove a `.br` only to have it re-created after we returned. const results = await Promise.allSettled( scanPaths.map(async p => { + // depscan decodes only the bare `.socket.facts.json.br`; a named facts + // file is uploaded as plain JSON. if (path.basename(p) !== DOT_SOCKET_DOT_FACTS_JSON) { return p } @@ -119,6 +121,32 @@ export async function compressSocketFactsForUpload( return { paths, cleanup } } +// `.socket.facts.json` or a named `.socket.facts.json`, matching +// depscan's case-insensitive `*.socket.facts.json`. +export function isSocketFactsFile(filepath: string): boolean { + return path + .basename(filepath) + .toLowerCase() + .endsWith(DOT_SOCKET_DOT_FACTS_JSON) +} + +// A Coana reachability report, never input to a new analysis: the bare +// `.socket.facts.json` (Coana's default name; producers name theirs after the +// build) or the path this run tells Coana to write to. +export function isReachabilityReportPath( + filepath: string, + options: { cwd: string; outputPath: string }, +): boolean { + const { cwd, outputPath } = { __proto__: null, ...options } as { + cwd: string + outputPath: string + } + return ( + path.basename(filepath).toLowerCase() === DOT_SOCKET_DOT_FACTS_JSON || + path.resolve(cwd, filepath) === path.resolve(cwd, outputPath) + ) +} + export type ReachabilityError = { componentName: string componentVersion: string diff --git a/src/utils/coana.test.mts b/src/utils/coana.test.mts index 9e2126fae9..d4fd3a2d0d 100644 --- a/src/utils/coana.test.mts +++ b/src/utils/coana.test.mts @@ -33,6 +33,8 @@ import { extractReachabilityErrors, extractTier1ReachabilityScanId, getFullWorkspacePath, + isReachabilityReportPath, + isSocketFactsFile, snapshotSocketFacts, } from './coana.mts' @@ -53,6 +55,44 @@ describe('coana facts-file utils', () => { return filePath } + describe('isSocketFactsFile', () => { + it.each([ + '.socket.facts.json', + 'a/pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + 'Foo.sln.SOCKET.FACTS.JSON', + ])('matches %s', p => { + expect(isSocketFactsFile(p)).toBe(true) + }) + it.each([ + 'socket.facts.json', + '.socket.facts.json.br', + 'pom.xml', + 'a/.socket.facts.json/pom.xml', + ])('rejects %s', p => { + expect(isSocketFactsFile(p)).toBe(false) + }) + }) + + describe('isReachabilityReportPath', () => { + const options = { cwd: '/repo', outputPath: 'out/report.json' } + it.each([ + '.socket.facts.json', + 'a/.SOCKET.FACTS.JSON', + '/repo/out/report.json', + 'out/report.json', + ])('matches %s', p => { + expect(isReachabilityReportPath(p, options)).toBe(true) + }) + it.each([ + 'pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + 'report.json', + ])('rejects %s', p => { + expect(isReachabilityReportPath(p, options)).toBe(false) + }) + }) + describe('compressSocketFactsForUpload', () => { it('writes brotli .br as a sibling of the source file', async () => { const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) @@ -99,6 +139,21 @@ describe('coana facts-file utils', () => { } }) + it('uploads a named facts file uncompressed', async () => { + const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) + const facts = path.join(wrapDir, 'pom.xml.socket.facts.json') + writeFileSync(facts, '{}') + + const result = await compressSocketFactsForUpload([facts]) + try { + expect(result.paths).toEqual([facts]) + expect(existsSync(`${facts}.br`)).toBe(false) + } finally { + await result.cleanup() + rmSync(wrapDir, { recursive: true, force: true }) + } + }) + it('leaves a missing .socket.facts.json path unchanged', async () => { const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) const missingFacts = path.join(wrapDir, '.socket.facts.json') From 3e2edefe3c30e84cbd40fec9746050bd9d980fe7 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 11:53:34 +0200 Subject: [PATCH 6/9] fix(manifest): write JVM Socket facts into the build's own root mvn -f sub/x.xml and gradle -p dir wrote the facts file to cwd, where its build-root-relative paths do not resolve. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + .../manifest/generate-recursive-manifests.mts | 12 +++- .../generate-recursive-manifests.test.mts | 53 ++++++++++++++++++ src/commands/manifest/run-manifest-facts.mts | 16 +++++- .../manifest/run-manifest-facts.test.mts | 55 ++++++++++++++++--- .../manifest/scripts/assemble.test.mts | 10 ++++ .../socket/SocketFactsRecordsEngine.java | 1 + src/commands/manifest/scripts/records.mts | 7 +++ src/commands/manifest/scripts/run.mts | 6 +- .../manifest/scripts/socket-facts.init.gradle | 2 + .../scripts/socket-facts.plugin.scala | 1 + 11 files changed, 151 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce6450968..4173cf31e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - Reachability scans no longer feed a leftover `.socket.facts.json` report from an earlier run back into the analysis. +### Fixed +- Socket facts for a Maven or Gradle build pointed elsewhere with `-f` or `-p` are now written into that build's own directory, where their paths resolve. + ## [1.6.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.1) - 2026-10-08 ### Changed diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index fd329f20c2..70f76047e3 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -185,10 +185,13 @@ async function runEcosystemCandidates({ } covered.add(dir) + // `-f`/`-p` can root the reactor away from `dir`. + // eslint-disable-next-line no-await-in-loop + const buildRoot = await realpathOrResolved(path.dirname(result.factsPath)) // eslint-disable-next-line no-await-in-loop const resolvedSubprojectDirs = await Promise.all( result.projects.map(project => - realpathOrResolved(path.resolve(dir, project.subprojectDir)), + realpathOrResolved(path.resolve(buildRoot, project.subprojectDir)), ), ) for (const subprojectDir of resolvedSubprojectDirs) { @@ -203,7 +206,10 @@ async function runEcosystemCandidates({ // meaningful data point, not a redundant one. Never suppress its own // build-root invocation, regardless of which reactor(s) also // incorporate it or the order candidates happen to be discovered in. - if (subprojectDir.startsWith(`${dir}${path.sep}`)) { + if ( + subprojectDir === buildRoot || + subprojectDir.startsWith(`${buildRoot}${path.sep}`) + ) { covered.add(subprojectDir) } } @@ -218,7 +224,7 @@ async function runEcosystemCandidates({ return outcomes } -// Generates one .socket.facts.json per independent gradle/sbt/maven build +// Generates one Socket facts file per independent gradle/sbt/maven build // root under `cwd`. Coverage is tracked per ecosystem via the facts SBOM's // own projects[].subprojectDir, not by pruning the whole discovered subtree, // so an unrelated nested project a reactor doesn't declare still gets its diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 041a223aaf..fa90eb4cc4 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -164,6 +164,59 @@ describe('generateRecursiveManifests', () => { }, ) + it('judges coverage against the reported build root, not the discovery directory', async () => { + const outer = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'relocated-build-root-')), + ) + const buildRoot = path.join(outer, 'build') + const member = path.join(buildRoot, 'member') + const escaped = path.join(outer, 'escaped') + try { + for (const dir of [outer, member, escaped]) { + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(dir, { recursive: true }) + // eslint-disable-next-line no-await-in-loop + await fs.writeFile(path.join(dir, 'pom.xml'), '') + } + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => { + if (cwd === outer) { + return { + factsPath: path.join(buildRoot, 'x.xml.socket.facts.json'), + projects: [ + { + type: 'maven', + name: 'member', + subprojectDir: 'member', + dependencies: [], + }, + { + type: 'maven', + name: 'escaped', + subprojectDir: '../escaped', + dependencies: [], + }, + ], + } + } + return { + factsPath: path.join(cwd, 'pom.xml.socket.facts.json'), + projects: [], + } + }) + + const outcomes = await generateRecursiveManifests({ + cwd: outer, + verbose: false, + }) + + const byDir = new Map(outcomes.map(o => [o.dir, o.status])) + expect(byDir.get(member)).toBe('skippedCovered') + expect(byDir.get(escaped)).toBe('generated') + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }) + it("runs both ecosystems unconditionally at a dual-marker directory (matches auto's existing behavior)", async () => { vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ factsPath: path.join(cwd, '.socket.facts.json'), diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index cecf4f9d3b..520bf68a48 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -79,8 +79,6 @@ export async function runManifestFacts({ verbose: boolean withFiles?: boolean | undefined }): Promise { - const factsPath = path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON) - let resolvedJavaHome: string | undefined if (javaHome) { const expanded = expandEnvVarRefs(javaHome) @@ -160,7 +158,8 @@ export async function runManifestFacts({ ) return null } - const { artifactPaths, code, facts, report, stderr, stdout } = result + const { artifactPaths, buildRoot, code, facts, report, stderr, stdout } = + result const rendered = renderResolutionErrorReport( report.failures, @@ -230,6 +229,17 @@ export async function runManifestFacts({ return } + if (!buildRoot) { + process.exitCode = 1 + logger.fail( + `The ${ecosystem} build did not report its root directory, so its Socket facts file cannot be placed.`, + ) + return null + } + // Every path in the facts is relative to the build root, which `-f`/`-p` + // can move away from cwd. + const factsPath = path.join(buildRoot, constants.DOT_SOCKET_DOT_FACTS_JSON) + const socketCliVersion = constants.ENV.INLINED_SOCKET_CLI_VERSION if (facts.metadata && socketCliVersion) { facts.metadata.socketCliVersion = socketCliVersion diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index 9c735cfc03..492e64d2ae 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -17,8 +17,9 @@ import type { SidecarAccumulator } from './scripts/sidecar.mts' const ENV_VAR = 'SOCKET_TEST_JAVA_HOME' -function okResult(): ManifestRunResult { +function okResult(buildRoot: string): ManifestRunResult { return { + buildRoot, code: 0, facts: { components: [{ id: 'a', type: 'maven', name: 'a' }], @@ -63,7 +64,7 @@ describe('runManifestFacts - javaHome', () => { }) it('passes a literal javaHome straight through as JAVA_HOME', async () => { - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd, javaHome: '/opt/jdk-17' }) const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-17') @@ -71,7 +72,7 @@ describe('runManifestFacts - javaHome', () => { it('expands $VAR and ${VAR} references against the CLI process env', async () => { process.env[ENV_VAR] = '/opt/jdk-11' - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd, @@ -82,7 +83,7 @@ describe('runManifestFacts - javaHome', () => { }) it('fails closed without invoking the build tool when the referenced var is unset', async () => { - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) const result = await runManifestFacts({ ...baseArgs, cwd, @@ -94,7 +95,7 @@ describe('runManifestFacts - javaHome', () => { }) it('leaves the environment untouched when javaHome is unset', async () => { - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd }) const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] expect(opts?.env).toBeUndefined() @@ -115,7 +116,7 @@ describe('runManifestFacts - sidecar', () => { }) it('keys the sidecar by the symlink-resolved factsPath, not the raw cwd-joined one', async () => { - const result = okResult() + const result = okResult(cwd) result.facts.projects = [ { type: 'maven', @@ -139,7 +140,7 @@ describe('runManifestFacts - sidecar', () => { expect(bucket?.projects.find(m => m.name === 'app')).toBeDefined() }) it('stamps the inlined socket-cli version into the written facts metadata', async () => { - const result = okResult() + const result = okResult(cwd) result.facts.metadata = { format: 'socket-facts-sbom', tool: 'maven', @@ -160,6 +161,44 @@ describe('runManifestFacts - sidecar', () => { }) }) +describe('runManifestFacts - build root', () => { + let cwd = '' + + beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-')) + vi.mocked(runManifestScript).mockReset() + process.exitCode = undefined + }) + afterEach(async () => { + await fs.rm(cwd, { recursive: true, force: true }) + process.exitCode = undefined + }) + + it('writes the facts file into the build root the tool reports', async () => { + const buildRoot = path.join(cwd, 'sub') + await fs.mkdir(buildRoot) + vi.mocked(runManifestScript).mockResolvedValue(okResult(buildRoot)) + + const outcome = await runManifestFacts({ ...baseArgs, cwd }) + + expect(outcome?.factsPath).toBe(path.join(buildRoot, '.socket.facts.json')) + expect(await fs.readdir(buildRoot)).toEqual(['.socket.facts.json']) + }) + + it('fails without writing when the build did not report its root', async () => { + vi.mocked(runManifestScript).mockResolvedValue({ + ...okResult(cwd), + buildRoot: undefined, + }) + + const outcome = await runManifestFacts({ ...baseArgs, cwd }) + + expect(outcome).toBeNull() + expect(process.exitCode).toBe(1) + expect(await fs.readdir(cwd)).toEqual([]) + }) +}) + describe('runManifestFacts - sbt build detection', () => { let cwd = '' @@ -196,7 +235,7 @@ describe('runManifestFacts - sbt build detection', () => { } else { await fs.writeFile(path.join(cwd, marker), '') } - vi.mocked(runManifestScript).mockResolvedValue(okResult()) + vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd)) await runManifestFacts({ ...baseArgs, cwd, ecosystem: 'sbt' }) diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 40fc827e7c..284cba72ec 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -187,3 +187,13 @@ describe('records → assemble → sidecar', () => { }) }) }) + +describe('parseRecords', () => { + it('reads the build root the build reports', () => { + expect( + parseRecords( + ['meta\tmaven\t3.9.6\t17', 'buildRoot\t/repo/sub'].join('\n'), + ).buildRoot, + ).toBe('/repo/sub') + }) +}) 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 f5074848b1..8facd1502c 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 @@ -93,6 +93,7 @@ public void run(MavenSession session, List reactor, File rootDir, List lines = new ArrayList<>(); rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); + rec(lines, "buildRoot", rootDir.getAbsolutePath()); for (MavenProject module : reactor) { // No basedir: Maven's stand-in project for a directory without a POM. Skipping it lets Maven's diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 14a776e845..a5207d9071 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -10,6 +10,7 @@ import type { // \t\t... // // meta tool toolVersion javaVersion +// buildRoot path (absolute; the facts file's directory) // project projectKey group name version dir // projectSrc projectKey path (--with-files only) // projectTgt projectKey path (--with-files only) @@ -67,6 +68,8 @@ export type ParsedRecords = { tool: string toolVersion: string javaVersion: string + // Absolute directory the build is rooted at; the facts file is written there. + buildRoot: string projects: Map roots: Map scannedConfigs: string[] @@ -100,6 +103,7 @@ export function parseRecords(text: string): ParsedRecords { tool: '', toolVersion: '', javaVersion: '', + buildRoot: '', projects: new Map(), roots: new Map(), scannedConfigs: [], @@ -152,6 +156,9 @@ export function parseRecords(text: string): ParsedRecords { result.toolVersion = f[2] ?? '' result.javaVersion = f[3] ?? '' break + case 'buildRoot': + result.buildRoot = f[1] ?? '' + break case 'project': { const p = project(f[1] ?? '') p.group = f[2] ?? '' diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index fda242661e..993945e0cd 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -49,6 +49,8 @@ export type ManifestScriptOptions = { export type ManifestRunResult = { code: number facts: SocketFactsSbom + // Undefined when the build did not report it. + buildRoot: string | undefined report: ResolutionReport artifactPaths: ResolvedArtifactPaths // Captured build-tool output (empty when stdio is 'inherit'). @@ -139,8 +141,10 @@ async function assembleFromRecords( const text = existsSync(recordsFile) ? await fs.readFile(recordsFile, 'utf8') : '' - const { artifactPaths, facts, report } = assembleFacts(parseRecords(text)) + const parsed = parseRecords(text) + const { artifactPaths, facts, report } = assembleFacts(parsed) return { + buildRoot: parsed.buildRoot || undefined, code: out.code, facts, report, diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 184fcfecef..299f1fd562 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -473,6 +473,7 @@ rootProject { rp -> // task actions. The Socket CLI disables the cache for this run, but hoisting is cheap insurance. def recordsFileOverride = gradle.socketProp.call(rp, 'socket.recordsFile')?.toString() def defaultRecordsFile = new File(rp.projectDir, '.socket.facts.records.tsv').absolutePath + def buildRootPath = rp.projectDir.absolutePath // `sources`/`targets` are --with-files-only; a plain run emits only the graph fields. def withFilesProjects = gradle.socketProp.call(rp, 'socket.withFiles')?.toString()?.toLowerCase() == 'true' @@ -493,6 +494,7 @@ rootProject { rp -> def rec = { List fields -> lines << fields.collect { esc(it) }.join('\t') } rec(['meta', 'gradle', gradle.gradleVersion, System.getProperty('java.version')]) + rec(['buildRoot', buildRootPath]) // One `project` record per build module (sources/targets only with --with-files). def projectsInfo diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index e14edfe3aa..993aa8ddd3 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -98,6 +98,7 @@ object SocketFactsPlugin extends AutoPlugin { } rec("meta", "sbt", extracted.getOpt(sbtVersion).getOrElse(""), sys.props.getOrElse("java.version", "")) + rec("buildRoot", rootCanonPath.toString) // One `project` record per build module (sources/targets only with --with-files). Excluded // subprojects are omitted (they were also skipped during resolution above). From 3cbc05cc7983cc767c86b55b8028ba98e1610564 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 11:57:06 +0200 Subject: [PATCH 7/9] feat(manifest): give each JVM build project its own id and facts component projects[].id is the build tool's unique project identifier (Maven GAV, Gradle project path, sbt project id) and projects[].manifestFiles lists the project's build files. A dependency on one of the build's own projects is that project's component, sharing its id, so projects sharing a coordinate or a directory stay apart. The compute-artifacts sidecar keys paths and classpaths by id. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../manifest/run-manifest-facts.test.mts | 5 +- src/commands/manifest/scripts/assemble.mts | 213 ++++++------------ .../manifest/scripts/assemble.test.mts | 109 +++++++-- src/commands/manifest/scripts/facts.mts | 40 +--- .../socket/SocketFactsRecordsEngine.java | 33 ++- src/commands/manifest/scripts/records.mts | 9 +- src/commands/manifest/scripts/sidecar.mts | 50 ++-- .../manifest/scripts/sidecar.test.mts | 103 +++++---- .../manifest/scripts/socket-facts.init.gradle | 45 ++-- .../scripts/socket-facts.plugin.scala | 32 ++- 11 files changed, 341 insertions(+), 299 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4173cf31e8..92818a3202 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/). ### Changed - Reachability scans no longer feed a leftover `.socket.facts.json` report from an earlier run back into the analysis. +- Socket facts for Maven, Gradle and sbt builds keep projects that share a coordinate or a directory apart, giving each its own id and build files. ### Fixed - Socket facts for a Maven or Gradle build pointed elsewhere with `-f` or `-p` are now written into that build's own directory, where their paths resolve. diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index 492e64d2ae..2d7aa9c8d3 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -27,10 +27,7 @@ function okResult(buildRoot: string): ManifestRunResult { }, report: { failures: [], scannedConfigs: [], unscannable: [] }, artifactPaths: { - targetsByCoord: new Map(), - targetsByGav: new Map(), - sourcesByCoord: new Map(), - coords: new Set(), + pathsById: new Map(), classpathByProject: new Map(), }, stderr: '', diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index e95fd6b9c8..c9e1dc47a6 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -7,9 +7,8 @@ import { type SocketFactsSbomComponent, type SocketFactsSbomMetadata, type SocketFactsSbomProject, - mavenCoordinateKey, - projectClasspathKey, } from './facts.mts' + import constants from '../../../constants.mts' import type { ParsedRecords, RawCoord, RawProject } from './records.mts' @@ -35,16 +34,23 @@ type MergedNode = { children: Set prod: boolean direct: boolean + // projectKey when this node is a build project, else empty. + project: string targets: Set } type PerRoot = { projectKey: string prod: boolean - nodes: Map< - string, - { coord: RawCoord; children: string[]; direct: boolean; targets: string[] } - > + nodes: Map +} + +type RootNode = { + coord: RawCoord + children: string[] + direct: boolean + project: string + targets: string[] } export function assembleFacts( @@ -53,17 +59,12 @@ export function assembleFacts( ): AssembleResult { const fileExists = opts.fileExists ?? existsSync const perRoot = buildPerRoot(parsed) - const { directByRoot, finalNodes } = mergeByCoordinate(perRoot) + const { directByRoot, finalNodes } = mergeById(perRoot) const tool = (parsed.tool || 'gradle') as SocketFactsSbomMetadata['tool'] - const projectsByGav = new Map() - for (const p of parsed.projects.values()) { - projectsByGav.set(gav(p.group, p.name, p.version), p) - } const components = buildComponents( finalNodes, - projectsByGav, - buildManifestFilesByCoord(parsed, directByRoot, perRoot), + buildManifestFilesById(parsed, directByRoot, perRoot), ) const projects = opts.emitProjects === false @@ -87,58 +88,60 @@ export function assembleFacts( artifactPaths: buildArtifactPaths( finalNodes, [...parsed.projects.values()], - projectsByGav, perRoot, fileExists, ), } } -function gav(group: string, name: string, version: string): string { - return `${group}:${name}:${version}` +// A node resolving to a build project takes that project's id, so projects +// sharing a coordinate stay apart and the project's variants collapse into it. +function componentId(coordId: string, project: string): string { + return project || coordId } function buildPerRoot(parsed: ParsedRecords): Map { const out = new Map() for (const [rootId, r] of parsed.roots) { - const childrenByParent = new Map>() + const idOf = (coordId: string) => + componentId(coordId, r.nodes.get(coordId)?.project ?? '') + const nodes = new Map() + for (const [coordId, n] of r.nodes) { + const id = idOf(coordId) + let node = nodes.get(id) + if (!node) { + node = { + coord: n.project ? { ...n.coord, classifier: '', ext: '' } : n.coord, + children: [], + direct: false, + project: n.project, + targets: [], + } + nodes.set(id, node) + } + node.direct ||= n.direct + node.targets.push(...n.targets) + } for (const [p, c] of r.edges) { if (!r.nodes.has(p) || !r.nodes.has(c)) { continue } - let set = childrenByParent.get(p) - if (!set) { - set = new Set() - childrenByParent.set(p, set) + const parentId = idOf(p) + const childId = idOf(c) + const parent = nodes.get(parentId)! + if (childId !== parentId && !parent.children.includes(childId)) { + parent.children.push(childId) } - set.add(c) - } - const nodes = new Map< - string, - { - coord: RawCoord - children: string[] - direct: boolean - targets: string[] - } - >() - for (const [coordId, n] of r.nodes) { - nodes.set(coordId, { - coord: n.coord, - children: [...(childrenByParent.get(coordId) ?? [])], - direct: n.direct, - targets: n.targets, - }) } out.set(rootId, { projectKey: r.projectKey, prod: r.prod, nodes }) } return out } -// Components are merged by coordinate across every resolution root; which -// coordinates belong to which subproject is kept separately (classpathByProject) -// for reachability, which needs each subproject's exact classpath. -function mergeByCoordinate(perRoot: Map): { +// Components are merged by id across every resolution root; which ids belong +// to which subproject is kept separately (classpathByProject) for +// reachability, which needs each subproject's exact classpath. +function mergeById(perRoot: Map): { finalNodes: Map directByRoot: Map> } { @@ -153,6 +156,7 @@ function mergeByCoordinate(perRoot: Map): { children: new Set(), prod: false, direct: false, + project: node.project, targets: new Set(), } finalNodes.set(coordId, fn) @@ -182,7 +186,7 @@ function mergeByCoordinate(perRoot: Map): { return { finalNodes, directByRoot } } -function buildManifestFilesByCoord( +function buildManifestFilesById( parsed: ParsedRecords, directByRoot: Map>, perRoot: Map, @@ -214,8 +218,7 @@ function buildManifestFilesByCoord( function buildComponents( finalNodes: Map, - projectsByGav: Map, - manifestFilesByCoord: Map, + manifestFilesById: Map, ): SocketFactsSbomComponent[] { return [...finalNodes.keys()].sort().map(id => { const fn = finalNodes.get(id)! @@ -243,13 +246,13 @@ function buildComponents( if (!fn.prod) { comp.dev = true } - if (projectsByGav.has(gav(c.group, c.name, c.version ?? ''))) { + if (fn.project) { comp.firstParty = true } if (fn.children.size) { comp.dependencies = [...fn.children].sort() } - const manifestFiles = manifestFilesByCoord.get(id) + const manifestFiles = manifestFilesById.get(id) if (manifestFiles) { comp.manifestFiles = manifestFiles } @@ -277,6 +280,7 @@ function buildProjects( const projects = [...parsed.projects.values()].map(p => { const entry: SocketFactsSbomProject = { + id: p.projectKey, type: PURL_TYPE_MAVEN, namespace: p.group, name: p.name, @@ -284,6 +288,9 @@ function buildProjects( subprojectDir: p.dir, dependencies: [...(directByProject.get(p.projectKey) ?? [])].sort(), } + if (p.buildFiles.length) { + entry.manifestFiles = [...p.buildFiles].sort().map(file => ({ file })) + } return entry }) projects.sort((a, b) => { @@ -294,26 +301,6 @@ function buildProjects( return projects } -function unionInto( - map: Map, - key: string, - add: string[], -): void { - if (!add.length) { - return - } - const acc = map.get(key) - if (acc) { - for (const f of add) { - if (!acc.includes(f)) { - acc.push(f) - } - } - } else { - map.set(key, [...add]) - } -} - function buildClasspathByProject( projects: RawProject[], perRoot: Map, @@ -331,11 +318,7 @@ function buildClasspathByProject( } const classpathByProject = new Map>() for (const p of projects) { - const key = projectClasspathKey({ - name: p.name, - namespace: p.group, - subprojectDir: p.dir, - }) + const key = p.projectKey let set = classpathByProject.get(key) if (!set) { set = new Set() @@ -353,84 +336,28 @@ function buildClasspathByProject( function buildArtifactPaths( finalNodes: Map, projects: RawProject[], - projectsByGav: Map, perRoot: Map, fileExists: (path: string) => boolean, ): ResolvedArtifactPaths { - const targetsByCoord = new Map() - const targetsByGav = new Map() - const sourcesByCoord = new Map() - const coords = new Set() - for (const fn of finalNodes.values()) { - const c = fn.coord - const coordKey = mavenCoordinateKey( - c.group, - c.name, - c.ext, - c.classifier, - c.version, - ) - if (!coordKey) { - continue - } - coords.add(coordKey) - const pi = projectsByGav.get(gav(c.group, c.name, c.version ?? '')) - const sources = (pi?.sources ?? []).filter(fileExists).sort() - const targets = [...new Set(pi ? pi.targets : fn.targets)] - .filter(fileExists) - .sort() - if (sources.length) { - sourcesByCoord.set(coordKey, sources) - } - if (!targets.length) { - continue - } - targetsByCoord.set(coordKey, targets) - const gavKey = mavenCoordinateKey( - c.group, - c.name, - undefined, - undefined, - c.version, - ) - if (gavKey) { - const acc = targetsByGav.get(gavKey) - if (acc) { - for (const f of targets) { - if (!acc.includes(f)) { - acc.push(f) - } - } - } else { - targetsByGav.set(gavKey, [...targets]) - } + const pathsById: ResolvedArtifactPaths['pathsById'] = new Map() + for (const [id, fn] of finalNodes) { + if (!fn.project) { + pathsById.set(id, { + sources: [], + targets: [...fn.targets].filter(fileExists).sort(), + }) } } - // A top-level module is a `project` but usually not a dependency node, so its - // source roots (where reachability starts) are missed by the node loop above; - // emit first-party module paths here. + // A project's own component shares its id, so this also covers dependency + // edges onto a sibling project. for (const p of projects) { - const coordKey = mavenCoordinateKey( - p.group, - p.name, - undefined, - undefined, - p.version, - ) - if (!coordKey) { - continue - } - coords.add(coordKey) - unionInto(sourcesByCoord, coordKey, p.sources.filter(fileExists)) - const targets = p.targets.filter(fileExists) - unionInto(targetsByCoord, coordKey, targets) - unionInto(targetsByGav, coordKey, targets) + pathsById.set(p.projectKey, { + sources: [...new Set(p.sources)].filter(fileExists).sort(), + targets: [...new Set(p.targets)].filter(fileExists).sort(), + }) } return { - targetsByCoord, - targetsByGav, - sourcesByCoord, - coords, + pathsById, classpathByProject: buildClasspathByProject(projects, perRoot), } } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 284cba72ec..f00630685e 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -46,6 +46,7 @@ describe('records → assemble → sidecar', () => { // roots reach the sidecar, keyed by its own facts file. expect(bucket.projects).toEqual([ { + id: ':app', type: 'maven', namespace: 'com.example', name: 'app', @@ -116,39 +117,88 @@ describe('records → assemble → sidecar', () => { 'g:lib:jar:1', ]) }) - it('marks only components with the exact coordinate of a build module as firstParty', () => { + it("gives a dependency on a build project that project's id, and marks only it firstParty", () => { const records = [ - 'meta\tmaven\t3.9.6\t17', + 'meta\tgradle\t8.0\t17', 'project\t:a\tg\ta\t1.0-SNAPSHOT\ta', 'project\t:b\tg\tb\t1.0-SNAPSHOT\tb', 'root\tr1\t:a\truntimeClasspath\t1', - 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t1', + 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t1\t', 'root\tr2\t:b\truntimeClasspath\t1', - 'node\tr2\tg:a:jar:1.0-SNAPSHOT\tg\ta\t1.0-SNAPSHOT\tjar\t\t1', - 'node\tr2\tg:ext:jar:2\tg\text\t2\tjar\t\t0', + 'node\tr2\tg:a:jar:1.0-SNAPSHOT\tg\ta\t1.0-SNAPSHOT\tjar\t\t1\t:a', + 'node\tr2\tg:ext:jar:2\tg\text\t2\tjar\t\t0\t', 'edge\tr2\tg:a:jar:1.0-SNAPSHOT\tg:ext:jar:2', - 'node\tr2\tg:b:jar:0.9\tg\tb\t0.9\tjar\t\t1', + // Same name as a build project, but a published artifact, not the project. + 'node\tr2\tg:b:jar:0.9\tg\tb\t0.9\tjar\t\t1\t', ].join('\n') const { artifactPaths, facts } = assembleFacts(parseRecords(records)) - expect(facts.components.map(c => [c.id, c.firstParty ?? 'absent'])).toEqual( - [ - ['g:a:jar:1.0-SNAPSHOT', true], - ['g:b:jar:0.9', 'absent'], - ['g:ext:jar:2', 'absent'], - ], - ) + expect( + facts.components.map(c => [ + c.id, + c.firstParty ?? 'absent', + c.dependencies, + ]), + ).toEqual([ + [':a', true, ['g:ext:jar:2']], + ['g:b:jar:0.9', 'absent', undefined], + ['g:ext:jar:2', 'absent', undefined], + ]) + expect(facts.projects!.find(p => p.id === ':b')?.dependencies).toEqual([ + ':a', + 'g:b:jar:0.9', + ]) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/abs/.socket.facts.json') const bucket = serializeSidecar(acc)['/abs/.socket.facts.json']! - expect( - bucket.components.find(c => c.id === 'g:a:jar:1.0-SNAPSHOT')?.firstParty, - ).toBe(true) + expect(bucket.components.find(c => c.id === ':a')?.firstParty).toBe(true) for (const project of bucket.projects) { expect(project).not.toHaveProperty('firstParty') } }) + + it("keeps projects that share a coordinate apart, collapsing each project's variants into it", () => { + const records = [ + 'meta\tgradle\t8.0\t17', + 'project\t:a:util\tex\tutil\t1\ta/util', + 'projectSrc\t:a:util\t/abs/a/util/src', + 'project\t:b:util\tex\tutil\t1\tb/util', + 'projectSrc\t:b:util\t/abs/b/util/src', + 'project\t:app\tex\tapp\t1\tapp', + 'root\tr1\t:app\truntimeClasspath\t1', + 'node\tr1\tex:util:jar:1\tex\tutil\t1\tjar\t\t1\t:b:util', + 'node\tr1\tex:util:jar:test-fixtures:1\tex\tutil\t1\tjar\ttest-fixtures\t1\t:b:util', + 'node\tr1\tg:ext:jar:2\tg\text\t2\tjar\t\t0\t', + 'edge\tr1\tex:util:jar:test-fixtures:1\tex:util:jar:1', + 'edge\tr1\tex:util:jar:1\tg:ext:jar:2', + 'root\tr2\t:b:util\truntimeClasspath\t1', + 'node\tr2\tex:util:jar:1\tex\tutil\t1\tjar\t\t1\t:a:util', + ].join('\n') + const { artifactPaths, facts } = assembleFacts(parseRecords(records), { + fileExists: () => true, + }) + + expect( + facts.components.map(c => [c.id, c.qualifiers, c.dependencies]), + ).toEqual([ + [':a:util', undefined, undefined], + [':b:util', undefined, ['g:ext:jar:2']], + ['g:ext:jar:2', { ext: 'jar' }, undefined], + ]) + expect(artifactPaths.classpathByProject.get(':app')).toEqual([ + ':b:util', + 'g:ext:jar:2', + ]) + expect(artifactPaths.classpathByProject.get(':b:util')).toEqual([':a:util']) + expect(artifactPaths.pathsById.get(':a:util')?.sources).toEqual([ + '/abs/a/util/src', + ]) + expect(artifactPaths.pathsById.get(':b:util')?.sources).toEqual([ + '/abs/b/util/src', + ]) + }) + 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', @@ -186,6 +236,33 @@ describe('records → assemble → sidecar', () => { 'g:solo:jar:1': [{ file: '.socket.facts.json' }], }) }) + it("records each project's own build files, relative to the build root", () => { + const records = [ + 'meta\tmaven\t3.9.6\t17', + 'buildRoot\t/repo/sub', + 'project\tg:agg:1\tg\tagg\t1\t.', + 'projectBuild\tg:agg:1\tother-pom.xml', + 'project\tg:mod-a:1\tg\tmod-a\t1\tmod', + 'projectBuild\tg:mod-a:1\tmod/a.xml', + 'project\tg:mod-b:1\tg\tmod-b\t1\tmod', + 'projectBuild\tg:mod-b:1\tmod/b.xml', + 'project\tg:bare:1\tg\tbare\t1\tbare', + ].join('\n') + const parsed = parseRecords(records) + const { facts } = assembleFacts(parsed) + + expect(parsed.buildRoot).toBe('/repo/sub') + expect( + Object.fromEntries( + facts.projects!.map(p => [p.id, p.manifestFiles ?? 'absent']), + ), + ).toEqual({ + 'g:agg:1': [{ file: 'other-pom.xml' }], + 'g:bare:1': 'absent', + 'g:mod-a:1': [{ file: 'mod/a.xml' }], + 'g:mod-b:1': [{ file: 'mod/b.xml' }], + }) + }) }) describe('parseRecords', () => { diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index f3f41010e1..011ee4c43b 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -41,41 +41,21 @@ export type SocketFactsManifestReference = { } export type SocketFactsSbomProject = AnyPURL & { + // The build tool's own project identity, unique within the facts file: + // Maven's GAV, Gradle's project path, sbt's project id. + id: string subprojectDir: string dependencies: string[] + // The module's own build files, e.g. a POM other than `/pom.xml`. + manifestFiles?: SocketFactsManifestReference[] | undefined } -// Resolved on-disk paths for a --with-files run, keyed by coordinate. `targets` -// = classpath entries (jars / module output dirs); `sources` = module source -// roots. +// Resolved on-disk paths for a --with-files run, keyed by component or project +// id (a project and its own component share one). `targets` = classpath +// entries (jars / module output dirs); `sources` = module source roots. export type ResolvedArtifactPaths = { - targetsByCoord: Map - // ext/classifier-agnostic, to recover the variant when an ingested ext is - // untrustworthy (Gradle lockfile / version-catalog hardcode ext=jar). - targetsByGav: Map - sourcesByCoord: Map - coords: Set + pathsById: Map // Component ids on each project's resolved classpath (union over its - // configurations), keyed by projectClasspathKey. + // configurations), keyed by project id. classpathByProject: Map } - -export function projectClasspathKey( - project: Pick, -): string { - return `${project.subprojectDir} ${project.namespace ?? ''}:${project.name}` -} - -// Coordinate-based (not `id`-based) so it also matches foreign SBOMs like -// CycloneDX. Empty segments dropped. -export function mavenCoordinateKey( - groupId: string | undefined, - artifactId: string | undefined, - type: string | undefined, - classifier: string | undefined, - version: string | undefined, -): string { - return [groupId, artifactId, type, classifier, version] - .filter(Boolean) - .join(':') -} 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 8facd1502c..f7768c26e3 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,12 +101,13 @@ public void run(MavenSession session, List reactor, File rootDir, if (module.getBasedir() == null) continue; 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); + String key = projectKey(module); + rec(lines, "project", key, 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 (pom != null && pom.isFile()) rec(lines, "projectBuild", key, 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); + for (String s : collectSources(module)) rec(lines, "projectSrc", key, s); + for (String t : collectTargets(module)) rec(lines, "projectTgt", key, t); } } @@ -124,7 +125,7 @@ public void run(MavenSession session, List reactor, File rootDir, // Which direct dependencies are prod-scoped: seeds the prod/dev root split (see emitModuleRoots). Set directProdIds = new HashSet<>(); collectModule(session, module, passingScopes, reactorGavs, populateGavs, opts, nodes, directIds, directProdIds, failures); - rootIdx = emitModuleRoots(lines, rootIdx, ws, nodes, directIds, directProdIds); + rootIdx = emitModuleRoots(lines, rootIdx, projectKey(module), nodes, directIds, directProdIds); } for (Failure f : failures) rec(lines, "failure", f.coord, f.detail, f.config); @@ -132,6 +133,12 @@ public void run(MavenSession session, List reactor, File rootDir, write(opts.recordsFile, lines); } + // Not the directory, which `x/a.xml` and `x/b.xml` share; Maven + // rejects a reactor with a duplicate GAV. Surfaces as the facts project id. + private static String projectKey(MavenProject module) { + return module.getGroupId() + ":" + module.getArtifactId() + ":" + module.getVersion(); + } + // ---- resolution ---- private void collectModule( @@ -278,9 +285,9 @@ private String visit( if (!visited.add(id)) return id; Node node = internal - ? upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), "", "", version) + ? upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), "", "", version, gav) : upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), - type == null ? "" : type, classifier == null ? "" : classifier, version); + type == null ? "" : type, classifier == null ? "" : classifier, version, ""); // Maven wrote each accepted node's resolved file back onto the node; a reactor module reports its // own dirs through its `project` record instead of a `file` record. if (!internal && opts.withFiles) { @@ -309,10 +316,11 @@ private static boolean isProd(String scope) { } private static Node upsert( - Map nodes, String id, String groupId, String artifactId, String type, String classifier, String version) { + Map nodes, String id, String groupId, String artifactId, String type, String classifier, String version, + String project) { Node node = nodes.get(id); if (node == null) { - node = new Node(id, groupId, artifactId, type, classifier, version); + node = new Node(id, groupId, artifactId, type, classifier, version, project); nodes.put(id, node); } return node; @@ -365,7 +373,7 @@ private int emitRoot( rec(lines, "root", rootId, projectKey, config, prod ? "1" : "0"); for (Node n : nodeMap.values()) { rec(lines, "node", rootId, n.id, n.groupId, n.artifactId, n.version, n.type, n.classifier, - directIds.contains(n.id) ? "1" : "0"); + directIds.contains(n.id) ? "1" : "0", n.project); for (String child : n.children) { if (nodeMap.containsKey(child)) rec(lines, "edge", rootId, n.id, child); } @@ -521,16 +529,19 @@ private static final class Node { final String type; final String classifier; final String version; + // The reactor module's project key when this is a sibling module, else empty. + final String project; final TreeSet children = new TreeSet<>(); final TreeSet files = new TreeSet<>(); - Node(String id, String groupId, String artifactId, String type, String classifier, String version) { + Node(String id, String groupId, String artifactId, String type, String classifier, String version, String project) { this.id = id; this.groupId = groupId; this.artifactId = artifactId; this.type = type; this.classifier = classifier; this.version = version; + this.project = project; } } } diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index a5207d9071..f757d4bc61 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -16,13 +16,16 @@ import type { // 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) +// node rootId coordId group name version ext classifier direct(0|1) project // edge rootId parentCoordId childCoordId // file rootId coordId path (--with-files only) // scanned config // failure coord detail config // unscannable config detail // +// `projectKey` is the tool's unique project identity (Maven: GAV; Gradle: +// project path; sbt: project id), emitted as the project id; a `node` whose +// `project` names one resolves to that build project. // A `root` is one (subproject, configuration) resolution root; `coordId` is the // coordinate key (`group:name:ext:classifier:version`, empty segments dropped), // used opaquely as the per-root node key. Unknown tags are ignored. @@ -39,6 +42,9 @@ export type RawNode = { coordId: string coord: RawCoord direct: boolean + // projectKey of the build project this node resolves to; empty for an + // external artifact. + project: string // --with-files only. targets: string[] } @@ -202,6 +208,7 @@ export function parseRecords(text: string): ParsedRecords { classifier: f[7] ?? '', }, direct: bool(f[8]), + project: f[9] ?? '', targets: [], }) break diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index f0fd5fddf8..9f2bbf9199 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -1,5 +1,3 @@ -import { mavenCoordinateKey, projectClasspathKey } from './facts.mts' - import type { AnyPURL, ResolvedArtifactPaths, @@ -9,10 +7,9 @@ import type { } from './facts.mts' export type SidecarComponentEntry = SocketFactsSbomComponent & { - // Classpath entries (jars, or a sibling first-party project's own build - // output dirs when this dependency edge resolves to one). `[]` - // means resolution was attempted and found nothing (e.g. a pom/BOM); - // undefined means resolution couldn't be attempted at all (see attachPaths). + // Classpath entries (jars, or a sibling project's own build output dirs + // when this component is that project). `[]` means resolved and found + // nothing (e.g. a pom/BOM); undefined means paths were not resolved. targets?: string[] | undefined // First-party source roots; `[]` for a genuinely external dependency (still // attempted, nothing to find), not undefined. @@ -29,7 +26,7 @@ export type SidecarProjectEntry = SocketFactsSbomProject & { // Frozen contract with `coana run --compute-artifacts-sidecar`; change only // in sync with the coana consumer. Keyed by the absolute path of the -// `.socket.facts.json` file whose own projects[]/components[] these entries +// `*.socket.facts.json` file whose own projects[]/components[] these entries // describe - the key IS the scope, so two independent reactors that happen to // emit the same purl identity (e.g. a shared internal module name) can never // collide: each is only ever looked up within its own key. No cross-reactor @@ -54,31 +51,16 @@ export type SidecarAccumulator = Map< { projects: SidecarProjectEntry[]; components: SidecarComponentEntry[] } > -// `targets`/`sources` present (possibly `[]`) means resolution was attempted -// for this coordinate - an empty array is a successful resolve that found -// nothing (e.g. a pom/BOM with no artifact), not a failure. Both fields -// omitted (undefined) means resolution couldn't even be attempted - the only -// case here is a degenerate entry with no computable coordinate at all, since -// every entry reaching this function already came from a resolved graph node -// (an unresolved dependency lives in the resolution report, not here). -function attachPaths( +// `[]` means resolved and found nothing (e.g. a pom/BOM with no artifact). +function attachPaths( entry: T, artifactPaths: ResolvedArtifactPaths, -): T & { targets?: string[] | undefined; sources?: string[] | undefined } { - const coordKey = mavenCoordinateKey( - entry.namespace, - entry.name, - entry.qualifiers?.['ext'], - entry.qualifiers?.['classifier'], - entry.version, - ) - if (!coordKey) { - return { ...entry } - } +): T & { targets: string[]; sources: string[] } { + const paths = artifactPaths.pathsById.get(entry.id) return { ...entry, - targets: [...(artifactPaths.targetsByCoord.get(coordKey) ?? [])].sort(), - sources: [...(artifactPaths.sourcesByCoord.get(coordKey) ?? [])].sort(), + targets: [...(paths?.targets ?? [])], + sources: [...(paths?.sources ?? [])], } } @@ -97,9 +79,8 @@ function sortByPurl(entries: T[]): T[] { // Emit an entry for every SBOM component AND every first-party project: a // top-level module is a project, not a dependency component, yet its source // roots are where reachability starts, so the sidecar must carry them. -// A second call for the same factsFile (a dual-marker directory where two -// build tools both target it) overwrites rather than merges, matching the -// existing last-writer-wins convention for that case. +// Every build writes its own facts file, so a key is accumulated once; a +// repeated call for the same factsFile (the same build run again) overwrites. export function accumulateSidecar( acc: SidecarAccumulator, facts: SocketFactsSbom, @@ -108,16 +89,13 @@ export function accumulateSidecar( // Off when artifact paths were not resolved; entries then omit `targets` and `sources`. withPaths = true, ): void { - const paths = (entry: T) => + const paths = (entry: T) => withPaths ? attachPaths(entry, artifactPaths) : { ...entry } acc.set(factsFile, { components: facts.components.map(paths), projects: (facts.projects ?? []).map(proj => ({ ...paths(proj), - classpath: [ - ...(artifactPaths.classpathByProject.get(projectClasspathKey(proj)) ?? - []), - ], + classpath: [...(artifactPaths.classpathByProject.get(proj.id) ?? [])], })), }) } diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index e818972e7c..dd34c8c91e 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -13,10 +13,7 @@ import type { SidecarAccumulator } from './sidecar.mts' function emptyArtifactPaths(): ResolvedArtifactPaths { return { - targetsByCoord: new Map(), - targetsByGav: new Map(), - sourcesByCoord: new Map(), - coords: new Set(), + pathsById: new Map(), classpathByProject: new Map(), } } @@ -26,7 +23,7 @@ function mkComponentFixture(target: string): { paths: ResolvedArtifactPaths } { const paths = emptyArtifactPaths() - paths.targetsByCoord.set('g:a:jar:1', [target]) + paths.pathsById.set('g:a:jar:1', { sources: [], targets: [target] }) return { facts: { components: [ @@ -49,6 +46,7 @@ describe('compute-artifacts sidecar', () => { const facts: SocketFactsSbom = { projects: [ { + id: ':app', type: 'maven', namespace: 'g', name: 'app', @@ -68,7 +66,7 @@ describe('compute-artifacts sidecar', () => { ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.classpathByProject.set('app g:app', ['g:a:jar:1']) + artifactPaths.classpathByProject.set(':app', ['g:a:jar:1']) const acc: SidecarAccumulator = new Map() accumulateSidecar( @@ -101,12 +99,10 @@ describe('compute-artifacts sidecar', () => { ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.targetsByCoord.set('com.example:lib:jar:da517db', [ - '/abs/lib.jar', - ]) - artifactPaths.sourcesByCoord.set('com.example:lib:jar:da517db', [ - '/abs/lib/src/main/java', - ]) + artifactPaths.pathsById.set('com.example:lib:jar:da517db', { + sources: ['/abs/lib/src/main/java'], + targets: ['/abs/lib.jar'], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') @@ -158,23 +154,44 @@ describe('compute-artifacts sidecar', () => { expect(entry.sources).toEqual([]) }) - it('leaves targets/sources undefined (not []) when the entry has no computable coordinate at all', () => { + it("gives each sibling project's component that project's own paths, even when they share a coordinate", () => { + const util = { + type: 'maven', + namespace: 'ex', + name: 'util', + version: '1', + dependencies: [], + } const facts: SocketFactsSbom = { components: [ - { type: 'maven', namespace: '', name: '', id: 'degenerate' }, + { ...util, id: ':a:util', firstParty: true }, + { ...util, id: ':b:util', firstParty: true }, + ], + projects: [ + { ...util, id: ':a:util', subprojectDir: 'a/util' }, + { ...util, id: ':b:util', subprojectDir: 'b/util' }, ], } + const artifactPaths = emptyArtifactPaths() + artifactPaths.pathsById.set(':a:util', { + sources: ['/abs/a/util/src'], + targets: ['/abs/a/util/classes'], + }) + artifactPaths.pathsById.set(':b:util', { + sources: ['/abs/b/util/src'], + targets: ['/abs/b/util/classes'], + }) + const acc: SidecarAccumulator = new Map() - accumulateSidecar( - acc, - facts, - emptyArtifactPaths(), - '/root/.socket.facts.json', - ) - const entry = - serializeSidecar(acc)['/root/.socket.facts.json']!.components[0]! - expect(entry.targets).toBeUndefined() - expect(entry.sources).toBeUndefined() + accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') + const entry = serializeSidecar(acc)['/root/.socket.facts.json']! + + for (const entries of [entry.components, entry.projects]) { + expect(Object.fromEntries(entries.map(e => [e.id, e.sources]))).toEqual({ + ':a:util': ['/abs/a/util/src'], + ':b:util': ['/abs/b/util/src'], + }) + } }) it('preserves the original component fields (id, qualifiers) untouched', () => { @@ -214,6 +231,7 @@ describe('compute-artifacts sidecar', () => { components: [], projects: [ { + id: 'com.example:app:1.0', type: 'maven', namespace: 'com.example', name: 'app', @@ -224,12 +242,10 @@ describe('compute-artifacts sidecar', () => { ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.sourcesByCoord.set('com.example:app:1.0', [ - '/abs/app/src/main/java', - ]) - artifactPaths.targetsByCoord.set('com.example:app:1.0', [ - '/abs/app/build/classes', - ]) + artifactPaths.pathsById.set('com.example:app:1.0', { + sources: ['/abs/app/src/main/java'], + targets: ['/abs/app/build/classes'], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/root/app/.socket.facts.json') @@ -238,6 +254,7 @@ describe('compute-artifacts sidecar', () => { expect(resolved['/root/app/.socket.facts.json']!.components).toEqual([]) expect(resolved['/root/app/.socket.facts.json']!.projects).toEqual([ { + id: 'com.example:app:1.0', type: 'maven', namespace: 'com.example', name: 'app', @@ -251,23 +268,24 @@ describe('compute-artifacts sidecar', () => { ]) }) - it('attaches each project its own classpath ids, keyed by subprojectDir and name', () => { + it('attaches each project its own classpath ids, keyed by project id even within one directory', () => { const project = { type: 'maven', namespace: 'com.example', version: '1.0', dependencies: [], + subprojectDir: 'x', } const facts: SocketFactsSbom = { components: [], projects: [ - { ...project, name: 'a', subprojectDir: 'a' }, - { ...project, name: 'b', subprojectDir: 'b' }, + { ...project, id: 'x/a.xml', name: 'a' }, + { ...project, id: 'x/b.xml', name: 'b' }, ], } const artifactPaths = emptyArtifactPaths() - artifactPaths.classpathByProject.set('a com.example:a', ['g:x:jar:1']) - artifactPaths.classpathByProject.set('b com.example:b', ['g:x:jar:2']) + artifactPaths.classpathByProject.set('x/a.xml', ['g:x:jar:1']) + artifactPaths.classpathByProject.set('x/b.xml', ['g:x:jar:2']) const acc: SidecarAccumulator = new Map() accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') @@ -300,6 +318,7 @@ describe('compute-artifacts sidecar', () => { components: [], projects: [ { + id: 'com.example:shared:1.0', type: 'maven', namespace: 'com.example', name: 'shared', @@ -310,13 +329,15 @@ describe('compute-artifacts sidecar', () => { ], } const pathsA = emptyArtifactPaths() - pathsA.sourcesByCoord.set('com.example:shared:1.0', [ - '/root-a/src/main/java', - ]) + pathsA.pathsById.set('com.example:shared:1.0', { + sources: ['/root-a/src/main/java'], + targets: [], + }) const pathsB = emptyArtifactPaths() - pathsB.sourcesByCoord.set('com.example:shared:1.0', [ - '/root-b/src/main/java', - ]) + pathsB.pathsById.set('com.example:shared:1.0', { + sources: ['/root-b/src/main/java'], + targets: [], + }) const acc: SidecarAccumulator = new Map() accumulateSidecar( diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 299f1fd562..1d2e86ed0a 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -58,9 +58,11 @@ gradle.ext.socketFactsState = [ paths : Collections.synchronizedMap([:]), perSub : Collections.synchronizedMap([:]), projectsInfo : Collections.synchronizedList([]), - // "group:name" -> the module's real artifact extension (e.g. jar), so an intra-project dep that + // Project path -> the module's real artifact extension (e.g. jar), so an intra-project dep that // resolves without selecting a published artifact still gets its true coordinate, not ext-less. projectArtifactExt : Collections.synchronizedMap([:]), + // Project path -> "group:name", to tell this build's projects from an included build's. + projectGaByPath : Collections.synchronizedMap([:]), ] // Capture every project's (group:name) before collectors run so they can filter intra-project @@ -86,7 +88,8 @@ gradle.projectsEvaluated { g -> } } } catch (Exception ignore) {} - g.socketFactsState.projectArtifactExt["${p.group ?: ''}:${p.name}".toString()] = artExt + g.socketFactsState.projectArtifactExt[p.path] = artExt + g.socketFactsState.projectGaByPath[p.path] = "${p.group ?: ''}:${p.name}".toString() // A wholly excluded subproject emits no project record and its configs are never resolved (see the // collector). projectKeys/projectArtifactExt above stay populated so a KEPT project depending on it // still recognizes it as a first-party module. @@ -154,6 +157,8 @@ allprojects { project -> // visit/upsertNode closures capture them); `failures`/`scannedConfigs` stay shared. def nodes = [:] def directIds = [] as Set + // "group:name:version" -> path of the build project this config resolved it to. + def projectByGav = [:] def failures = state.failures def scannedConfigs = state.scannedConfigs def projectKeys = state.projectKeys @@ -205,21 +210,21 @@ allprojects { project -> // A first-party module dep can resolve with no published artifact (classes-dir variant, or // variant-ambiguous moduleArtifacts), leaving it ext-less. Stamp the module's real artifact // extension so it keeps a full, stable coordinate instead of an ext-less one. - def effExt = { String group, String name, String ext -> - if ((ext == null || ext.isEmpty()) && isIntraProject(group, name)) { - projectArtifactExt["${group ?: ''}:${name}".toString()] ?: '' + def effExt = { String projPath, String ext -> + if ((ext == null || ext.isEmpty()) && projPath != null) { + projectArtifactExt[projPath] ?: '' } else { ext ?: '' } } // A node is created once; its prod flag accumulates (OR) across the configs that reach it. - def upsertNode = { Map coord, boolean isProd -> + def upsertNode = { Map coord, boolean isProd, String projPath -> def id = coordId(coord) synchronized (nodes) { def node = nodes[id] if (node == null) { - node = [coord: coord, children: [] as Set, prod: false] + node = [coord: coord, children: [] as Set, prod: false, project: projPath] nodes[id] = node } if (isProd) { @@ -253,18 +258,19 @@ allprojects { project -> } catch (Exception e) { artifacts = [] as Set } + def projPath = projectByGav["${dep.moduleGroup ?: ''}:${dep.moduleName}:${dep.moduleVersion ?: ''}".toString()] if (artifacts.isEmpty()) { - def ext = effExt(dep.moduleGroup, dep.moduleName, '') + def ext = effExt(projPath, '') // Skip a no-artifact first-party module (aggregator / build root): it builds no archive, so // it's fully described by `projects` and is never a real artifact dependency. - if (!(ext.isEmpty() && isIntraProject(dep.moduleGroup, dep.moduleName))) { + if (!(ext.isEmpty() && projPath != null)) { producedIds << upsertNode([ groupId : dep.moduleGroup ?: '', artifactId: dep.moduleName, version : dep.moduleVersion ?: '', classifier: '', ext : ext, - ], isProd) + ], isProd, projPath) } } else { // Build the GAV scope key only when a scope is set: the no-scope path never reads it and @@ -281,16 +287,16 @@ allprojects { project -> artifacts.each { a -> // Directory variants (java-classes-directory etc.) carry no extension; for a first-party // module fall back to its real artifact ext (never artifact.type, a Gradle variant attr). - def ext = effExt(dep.moduleGroup, dep.moduleName, a.extension) + def ext = effExt(projPath, a.extension) // Skip a no-artifact first-party module (see the empty-artifacts branch above). - if (ext.isEmpty() && isIntraProject(dep.moduleGroup, dep.moduleName)) return + if (ext.isEmpty() && projPath != null) return def aid = upsertNode([ groupId : dep.moduleGroup ?: '', artifactId: dep.moduleName, version : dep.moduleVersion ?: '', classifier: a.classifier ?: '', ext : ext, - ], isProd) + ], isProd, projPath) producedIds << aid // `a.file` downloads the artifact if not already cached, so scoping avoids fetching // artifacts the SBOM doesn't reference. Per-artifact try/catch: a single download can @@ -421,10 +427,21 @@ allprojects { project -> nodes = [:] directIds = [] as Set paths = [:] + projectByGav = [:] // Per-config try/catch: AGP-style configs can fail variant ambiguity from an init-script // context lacking the consumer attributes AGP sets internally. try { def lenient = cfg.resolvedConfiguration.lenientConfiguration + // The resolution graph names a project dependency by project path; within one graph a + // module resolves to a single component, so its GAV identifies that project here. + cfg.incoming.resolutionResult.allComponents.each { comp -> + def cid = comp.id + def mv = comp.moduleVersion + if (cid instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier && mv != null && + state.projectGaByPath[cid.projectPath] == "${mv.group ?: ''}:${mv.name}".toString()) { + projectByGav["${mv.group ?: ''}:${mv.name}:${mv.version ?: ''}".toString()] = cid.projectPath + } + } def cache = [:] // No-arg getter only since Gradle 3.3; older Gradle has just the Spec-taking overload. def firstLevel @@ -519,7 +536,7 @@ rootProject { rp -> tree.nodes.each { coordId, node -> def c = node.coord rec(['node', rootId, coordId, c.groupId ?: '', c.artifactId ?: '', c.version ?: '', c.ext ?: '', - c.classifier ?: '', tree.direct.contains(coordId) ? '1' : '0']) + c.classifier ?: '', tree.direct.contains(coordId) ? '1' : '0', node.project ?: '']) node.children.each { childId -> rec(['edge', rootId, coordId, childId]) } def fs = tree.paths[coordId] if (fs) { (fs as List).sort().each { p -> rec(['file', rootId, coordId, p]) } } diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 993aa8ddd3..09668c217c 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -62,11 +62,18 @@ object SocketFactsPlugin extends AutoPlugin { // Real artifact ext per build module, so an ext-less inter-project dep gets its true coordinate. val moduleExts = buildModuleExts(allRefs, extracted) + val projectIdsByGav: Map[String, Seq[String]] = + allRefs.groupBy(r => gavKey(rootIdOf(extracted, r))).map { case (k, rs) => k -> rs.map(_.project) } allRefs.foreach { ref => if (!isExcludedRef(ref)) { + // The update report names an inter-project dependency only by module ID; the projects this + // one reaches through `dependsOn` say which build project that ID is. + val reachable = dependsOnClosure(ref, extracted) + val projectsOf = (gav: String) => projectIdsByGav.getOrElse(gav, Nil).filter(reachable) runUpdateResilient(updateTaskName, ref, extracted, st, failures).foreach { report => - foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach { + foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts, + projectsOf, failures).foreach { case (rootKey, tree) => perSub(rootKey) = tree } } @@ -129,7 +136,8 @@ object SocketFactsPlugin extends AutoPlugin { tree.nodes.foreach { case (coordId, node) => val c = node.coord - rec("node", rootId, coordId, c.org, c.name, c.version, c.ext, c.classifier, if (node.direct) "1" else "0") + rec("node", rootId, coordId, c.org, c.name, c.version, c.ext, c.classifier, if (node.direct) "1" else "0", + node.project) node.children.foreach(ch => rec("edge", rootId, coordId, ch)) node.targets.foreach(p => rec("file", rootId, coordId, p)) } @@ -151,6 +159,16 @@ object SocketFactsPlugin extends AutoPlugin { // ---- resolution --------------------------------------------------------- + private def dependsOnClosure(ref: ProjectRef, extracted: Extracted): Set[String] = { + val seen = mutable.LinkedHashSet.empty[ProjectRef] + def walk(r: ProjectRef): Unit = + extracted.getOpt(thisProject.in(r)).toList.flatMap(_.dependencies).map(_.project).foreach { d => + if (seen.add(d)) walk(d) + } + walk(ref) + seen.map(_.project).toSet + } + private def rootIdOf(extracted: Extracted, ref: ProjectRef): ModuleID = { val sv = extracted.get(scalaVersion.in(ref)) val sbv = extracted.get(scalaBinaryVersion.in(ref)) @@ -276,7 +294,9 @@ object SocketFactsPlugin extends AutoPlugin { scannedConfigs: mutable.LinkedHashSet[String], withFiles: Boolean, populateScope: Option[Set[String]], - moduleExts: Map[String, String] + moduleExts: Map[String, String], + projectsOf: String => Seq[String], + failures: mutable.LinkedHashSet[Failure] ): mutable.LinkedHashMap[String, RootTree] = { val perRoot = mutable.LinkedHashMap.empty[String, RootTree] val rootGav = gavKey(rootIdOf(extracted, ref)) @@ -300,8 +320,12 @@ object SocketFactsPlugin extends AutoPlugin { cr.modules.foreach { m => if (emittable(m)) { val ids = midToIds.getOrElseUpdate(gavKey(m.module), mutable.LinkedHashSet.empty[String]) + val projects = projectsOf(gavKey(m.module)) + if (projects.size > 1) + failures += Failure(coordOf(m.module), "ambiguous inter-project dependency: " + projects.mkString(", "), cfg) variantsOf(m, moduleExts).foreach { case (coord, fileOpt) => val node = nodes.getOrElseUpdate(coord.id, new Node(coord)) + if (projects.size == 1) node.project = projects.head ids += coord.id if (withFiles && inScope(m.module)) fileOpt.foreach(f => node.targets += f.getAbsolutePath) } @@ -518,6 +542,8 @@ object SocketFactsPlugin extends AutoPlugin { private final class Node(val coord: Coord) { val children = mutable.TreeSet.empty[String] var direct = false + // Id of the build project this node is, when it is one. + var project = "" // External artifact's resolved jar(s); --with-files only. val targets = mutable.TreeSet.empty[String] } From ef9ed6fce0316dcffefa715436c3c82f2a3f578f Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 12:56:44 +0200 Subject: [PATCH 8/9] fix(scan): keep bare .socket.facts.json in reachability input Producers still write that name, so leaving it out of the reachability upload dropped their dependency graphs. Excluding earlier reports moves to the change that renames producer output. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 --- src/commands/scan/cmd-scan-create.test.mts | 2 +- src/commands/scan/cmd-scan-reach.test.mts | 2 +- src/commands/scan/handle-create-new-scan.mts | 29 ++++++++++------- .../scan/handle-create-new-scan.test.mts | 32 ------------------- .../scan/perform-reachability-analysis.mts | 27 +++++++--------- .../perform-reachability-analysis.test.mts | 30 ----------------- src/commands/scan/reachability-flags.mts | 2 +- src/utils/coana.mts | 17 ---------- src/utils/coana.test.mts | 20 ------------ 10 files changed, 32 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce6450968..daa85e021f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [Unreleased] - -### Changed -- Reachability scans no longer feed a leftover `.socket.facts.json` report from an earlier run back into the analysis. - ## [1.6.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.1) - 2026-10-08 ### Changed diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 98d7bc0320..8d22ebf8ed 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -136,7 +136,7 @@ describe('socket scan create', async () => { --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supported: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. --reach-fallback-to-regular-scan If reachability analysis fails, continue with a regular SCA scan (without reachability results) instead of halting. By default, the CLI halts on reachability errors. - --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results. + --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale \`.socket.facts.json\` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 4917a96ce8..80c677b1ad 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -52,7 +52,7 @@ describe('socket scan reach', async () => { --reach-disable-external-tool-checks Disable external tool checks during reachability analysis. --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supported: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. - --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results. + --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale \`.socket.facts.json\` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 98ecd3152e..29fe7f6145 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -19,7 +19,6 @@ import constants from '../../constants.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { compressSocketFactsForUpload, - isReachabilityReportPath, isSocketFactsFile, snapshotSocketFacts, } from '../../utils/coana.mts' @@ -360,16 +359,13 @@ async function createNewScan( reachabilityReport = reachResult.data?.reachabilityReport - // Mirror the SBOM inputs Coana analyzed; otherwise its fresh report - // (appended below) supersedes every facts file. + // When using only pre-generated SBOMs, build the scan from those inputs — + // CycloneDX, SPDX, and Socket facts — matching Coana's + // `--use-only-pregenerated-sboms` selection. Otherwise drop every facts + // file; coana's fresh reachability report (appended below) is the + // authoritative facts file for the scan. const pathsForScan = reach.reachUseOnlyPregeneratedSboms - ? filterToPregeneratedSboms(packagePaths, supportedFiles).filter( - p => - !isReachabilityReportPath(p, { - cwd, - outputPath: constants.DOT_SOCKET_DOT_FACTS_JSON, - }), - ) + ? filterToPregeneratedSboms(packagePaths, supportedFiles) : packagePaths.filter(p => !isSocketFactsFile(p)) // Append coana's reachability report, but not twice: a pre-generated facts @@ -442,8 +438,17 @@ async function createNewScan( ) } - // A scan without --reach would upload a leftover report as an SBOM with - // stale reachability results; a failed scan keeps it for debugging. + // On a successful scan, clean up the `.socket.facts.json` coana wrote at + // the path we instructed it to write to (via `--socket-mode`). Failed + // scans leave the file in place for debugging. Producer-written files + // (e.g. from `socket manifest gradle --facts`) are NOT touched here — + // those are user-owned input that the user can clean up themselves; in + // the --reach path coana overwrites that file with its enriched output + // anyway, so it's the same path that gets removed. `--reach-retain-facts-file` + // opts out of this cleanup so the report can be inspected; the user is then + // responsible for deleting it before the next full application reachability + // scan (a stale file is picked up as pre-generated input and would make those + // results unreliable). if ( fullScanCResult.ok && scanId && diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 6a86ff9502..aeb6883272 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -291,38 +291,6 @@ describe('handleCreateNewScan excludePaths', () => { ) }) - it('keeps build facts but not earlier reports when using only pre-generated SBOMs', async () => { - const cleanup = vi.fn() - const files = [ - '/repo/pom.xml.socket.facts.json', - '/repo/service/.socket.facts.json', - '/repo/package-lock.json', - ] - const config = createConfig({ - generateScanFiles: async () => ({ cleanup, files }), - }) - config.reach.runReachabilityAnalysis = true - config.reach.reachUseOnlyPregeneratedSboms = true - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - data: { socket: { facts: { pattern: '*.socket.facts.json' } } }, - ok: true, - }) - mockPerformReachabilityAnalysis.mockResolvedValueOnce({ - data: { - reachabilityReport: '.socket.facts.json', - tier1ReachabilityScanId: 'tier1-id', - }, - ok: true, - }) - await handleCreateNewScan(config) - expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( - ['/repo/pom.xml.socket.facts.json', '.socket.facts.json'], - 'fakeOrg', - expect.anything(), - expect.anything(), - ) - }) - it('includes generated auto-manifest files in SCA discovery targets', async () => { mockGenerateAutoManifest.mockResolvedValueOnce({ generatedFiles: ['/repo/.socket-auto-manifest/maven_install.json'], diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 95d8c3b38b..3314e1bc98 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -8,10 +8,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { isOmittedReachValue } from './reachability-units.mts' import constants from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' -import { - extractTier1ReachabilityScanId, - isReachabilityReportPath, -} from '../../utils/coana.mts' +import { extractTier1ReachabilityScanId } from '../../utils/coana.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { hasEnterpriseOrgPlan } from '../../utils/organization.mts' import { setupSdk } from '../../utils/sdk.mts' @@ -137,19 +134,17 @@ export async function performReachabilityAnalysis( spinner?.start('Uploading manifests for reachability analysis...') - const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON - // Ensure uploaded manifest files are relative to analysis target as coana resolves SBOM manifest files relative to this path + // NOTE: previously stripped any `.socket.facts.json` from packagePaths + // here to avoid uploading leftover post-reachability output. With the + // producer flow (`socket manifest gradle --facts`) those files are + // legitimate INPUT to compute-artifacts, so we now upload them. Stale + // facts files are cleaned up downstream — see the post-success + // deletion in handle-create-new-scan.mts. const uploadCResult = await handleApiCall( - sockSdk.uploadManifestFiles( - orgSlug, - packagePaths.filter( - p => !isReachabilityReportPath(p, { cwd, outputPath: outputFilePath }), - ), - { - pathsRelativeTo: path.resolve(cwd, analysisTarget), - }, - ), + sockSdk.uploadManifestFiles(orgSlug, packagePaths, { + pathsRelativeTo: path.resolve(cwd, analysisTarget), + }), { description: 'upload manifests', spinner, @@ -184,6 +179,8 @@ export async function performReachabilityAnalysis( spinner?.start() spinner?.infoAndStop('Running reachability analysis with Coana...') + const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON + // Temp file for --compute-artifacts-sidecar, removed in the finally below. // Written even when empty under dynamicSbomInference, since the // --maven-use-only-socket-facts flag below requires one to be present. diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index d8dfd220da..bf24a6dbcb 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -220,36 +220,6 @@ describe('performReachabilityAnalysis manifests tar hash', () => { expect(args[args.indexOf('--manifests-tar-hash') + 1]).toBe(TEST_TAR_HASH) }) - it('uploads build facts but not earlier reachability reports', async () => { - const uploadManifestFiles = vi.fn() - mockSetupSdk.mockResolvedValueOnce({ - ok: true, - data: { uploadManifestFiles }, - }) - - await performReachabilityAnalysis({ - cwd: scanCwd, - orgSlug: TEST_ORG_SLUG, - outputPath: 'out/report.json', - packagePaths: [ - 'package.json', - 'pom.xml.socket.facts.json', - 'gradle.socket.facts.json', - '.socket.facts.json', - 'nested/.socket.facts.json', - path.join(scanCwd, 'out/report.json'), - ], - reachabilityOptions: makeReachabilityOptions(), - target: scanCwd, - }) - - expect(uploadManifestFiles.mock.calls[0]![1]).toEqual([ - 'package.json', - 'pom.xml.socket.facts.json', - 'gradle.socket.facts.json', - ]) - }) - it('fails without spawning Coana when the upload returns no tar hash', async () => { mockHandleApiCall.mockResolvedValueOnce({ ok: true, data: {} } as never) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index ffe3ca2a05..a2c4c2c657 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -121,7 +121,7 @@ export const reachabilityFlags: MeowFlags = { type: 'boolean', default: false, description: - 'Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results.', + 'Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale `.socket.facts.json` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable.', }, reachSkipCache: { type: 'boolean', diff --git a/src/utils/coana.mts b/src/utils/coana.mts index add373b846..f414ea76a1 100644 --- a/src/utils/coana.mts +++ b/src/utils/coana.mts @@ -130,23 +130,6 @@ export function isSocketFactsFile(filepath: string): boolean { .endsWith(DOT_SOCKET_DOT_FACTS_JSON) } -// A Coana reachability report, never input to a new analysis: the bare -// `.socket.facts.json` (Coana's default name; producers name theirs after the -// build) or the path this run tells Coana to write to. -export function isReachabilityReportPath( - filepath: string, - options: { cwd: string; outputPath: string }, -): boolean { - const { cwd, outputPath } = { __proto__: null, ...options } as { - cwd: string - outputPath: string - } - return ( - path.basename(filepath).toLowerCase() === DOT_SOCKET_DOT_FACTS_JSON || - path.resolve(cwd, filepath) === path.resolve(cwd, outputPath) - ) -} - export type ReachabilityError = { componentName: string componentVersion: string diff --git a/src/utils/coana.test.mts b/src/utils/coana.test.mts index d4fd3a2d0d..aeea3441dd 100644 --- a/src/utils/coana.test.mts +++ b/src/utils/coana.test.mts @@ -33,7 +33,6 @@ import { extractReachabilityErrors, extractTier1ReachabilityScanId, getFullWorkspacePath, - isReachabilityReportPath, isSocketFactsFile, snapshotSocketFacts, } from './coana.mts' @@ -74,25 +73,6 @@ describe('coana facts-file utils', () => { }) }) - describe('isReachabilityReportPath', () => { - const options = { cwd: '/repo', outputPath: 'out/report.json' } - it.each([ - '.socket.facts.json', - 'a/.SOCKET.FACTS.JSON', - '/repo/out/report.json', - 'out/report.json', - ])('matches %s', p => { - expect(isReachabilityReportPath(p, options)).toBe(true) - }) - it.each([ - 'pom.xml.socket.facts.json', - 'gradle.socket.facts.json', - 'report.json', - ])('rejects %s', p => { - expect(isReachabilityReportPath(p, options)).toBe(false) - }) - }) - describe('compressSocketFactsForUpload', () => { it('writes brotli .br as a sibling of the source file', async () => { const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) From 67767fe6bfc6c784858ef8aa781c333cd4808723 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 14:31:01 +0200 Subject: [PATCH 9/9] docs(changelog): describe the project-merging problem the facts ids fix Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3629bb51d9..fa024f8e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -### Changed -- Socket facts for Maven, Gradle and sbt builds keep projects that share a coordinate or a directory apart, giving each its own id and build files. - ### Fixed +- Socket facts no longer merge build projects into one: Maven modules sharing a directory and Gradle or sbt projects sharing a coordinate each keep their own dependencies and build files. - Socket facts for a Maven or Gradle build pointed elsewhere with `-f` or `-p` are now written into that build's own directory, where their paths resolve. ## [1.6.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.1) - 2026-10-08