diff --git a/CHANGELOG.md b/CHANGELOG.md index d39c6b1d6..48e712e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,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 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. - Updated the Coana CLI to v `15.12.4`. ## [1.6.2](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.2) - 2026-10-09 diff --git a/src/commands/manifest/README.md b/src/commands/manifest/README.md index 0f54b10a7..63527aa03 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 980c198ca..6ec3b684b 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 4207a9403..fa31714a3 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 876790efc..c30bd2724 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 fd2a4c23b..9d3b43e7b 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 3f3c5df4c..030ca6a89 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 81906dd3e..8ec957758 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 99f62029b..e9e64447f 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 6388bb311..470f046d6 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 d2e4f5695..c9b2152db 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 96941b888..24ffaffa5 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 7f8f852ab..1c5dccc6c 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 a41769fcf..d32581462 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 649b68444..07ee34eb1 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/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 520bf68a4..a6d496806 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. @@ -158,8 +159,16 @@ export async function runManifestFacts({ ) return null } - const { artifactPaths, buildRoot, code, facts, report, stderr, stdout } = - result + const { + artifactPaths, + buildRoot, + code, + facts, + factsFileName, + report, + stderr, + stdout, + } = result const rendered = renderResolutionErrorReport( report.failures, @@ -229,16 +238,27 @@ export async function runManifestFacts({ return } - if (!buildRoot) { + if (!buildRoot || !factsFileName) { process.exitCode = 1 logger.fail( - `The ${ecosystem} build did not report its root directory, so its Socket facts file cannot be placed.`, + `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, constants.DOT_SOCKET_DOT_FACTS_JSON) + 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) { diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index 2d7aa9c8d..ee07ec9d8 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -25,6 +25,7 @@ function okResult(buildRoot: string): ManifestRunResult { components: [{ id: 'a', type: 'maven', name: 'a' }], projects: [], }, + factsFileName: 'pom.xml.socket.facts.json', report: { failures: [], scannedConfigs: [], unscannable: [] }, artifactPaths: { pathsById: new Map(), @@ -130,7 +131,7 @@ 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) @@ -148,7 +149,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. @@ -158,7 +159,7 @@ describe('runManifestFacts - sidecar', () => { }) }) -describe('runManifestFacts - build root', () => { +describe('runManifestFacts - facts file naming', () => { let cwd = '' beforeEach(async () => { @@ -171,15 +172,62 @@ describe('runManifestFacts - build root', () => { 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)) + 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, '.socket.facts.json')) - expect(await fs.readdir(buildRoot)).toEqual(['.socket.facts.json']) + 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 () => { @@ -194,6 +242,33 @@ describe('runManifestFacts - build root', () => { 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', () => { @@ -220,7 +295,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() }) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index a478931ec..eb40be43b 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -9,8 +9,6 @@ import { type SocketFactsSbomProject, } from './facts.mts' -import constants from '../../../constants.mts' - import type { ParsedRecords, RawCoord, RawProject } from './records.mts' import type { ResolutionReport } from './resolution-report.mts' @@ -24,6 +22,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 @@ -55,7 +56,7 @@ type RootNode = { export function assembleFacts( parsed: ParsedRecords, - opts: AssembleOptions = {}, + opts: AssembleOptions, ): AssembleResult { const fileExists = opts.fileExists ?? existsSync const perRoot = buildPerRoot(parsed) @@ -64,7 +65,7 @@ export function assembleFacts( const tool = (parsed.tool || 'gradle') as SocketFactsSbomMetadata['tool'] const components = buildComponents( finalNodes, - buildManifestFilesById(parsed, directByRoot, perRoot), + buildManifestFilesById(parsed, directByRoot, perRoot, opts.factsFileName), ) const projects = opts.emitProjects === false @@ -190,6 +191,7 @@ function buildManifestFilesById( parsed: ParsedRecords, directByRoot: Map>, perRoot: Map, + factsFileName: string | undefined, ): Map { const buildFilesByCoord = new Map>() for (const [rootId, ids] of directByRoot) { @@ -218,9 +220,10 @@ function buildManifestFilesById( 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 })), ]), ) } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 2d802d959..23b821762 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, }) @@ -89,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, }) @@ -131,7 +133,9 @@ describe('records → assemble → sidecar', () => { // 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)) + const { artifactPaths, facts } = assembleFacts(parseRecords(records), { + factsFileName: 'gradle.socket.facts.json', + }) expect( facts.components.map(c => [ @@ -150,8 +154,13 @@ describe('records → assemble → sidecar', () => { ]) const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths, '/abs/.socket.facts.json') - const bucket = serializeSidecar(acc)['/abs/.socket.facts.json']! + 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') @@ -176,6 +185,7 @@ describe('records → assemble → sidecar', () => { '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, }) @@ -219,21 +229,26 @@ 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", () => { @@ -249,7 +264,9 @@ describe('records → assemble → sidecar', () => { 'project\tg:bare:1\tg\tbare\t1\tbare', ].join('\n') const parsed = parseRecords(records) - const { facts } = assembleFacts(parsed) + const { facts } = assembleFacts(parsed, { + factsFileName: 'other-pom.xml.socket.facts.json', + }) expect(parsed.buildRoot).toBe('/repo/sub') expect( @@ -263,6 +280,20 @@ describe('records → assemble → sidecar', () => { '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' }]) + }) it('attributes Gradle direct dependencies to the script declaring them', () => { const records = [ 'meta\tgradle\t9.2.1\t21', @@ -282,7 +313,9 @@ describe('records → assemble → sidecar', () => { 'node\tr2\tx:own:jar:1\tx\town\t1\tjar\t\t1', 'node\tr2\tx:by-plugin:jar:1\tx\tby-plugin\t1\tjar\t\t1', ].join('\n') - const { facts } = assembleFacts(parseRecords(records)) + const { facts } = assembleFacts(parseRecords(records), { + factsFileName: 'gradle.socket.facts.json', + }) expect( Object.fromEntries( @@ -315,10 +348,12 @@ describe('records → assemble → sidecar', () => { 'root\tr1\t:a\truntimeClasspath\t1', 'node\tr1\tx:undeclared:jar:1\tx\tundeclared\t1\tjar\t\t1', ].join('\n') - const { facts } = assembleFacts(parseRecords(records)) + const { facts } = assembleFacts(parseRecords(records), { + factsFileName: 'gradle.socket.facts.json', + }) expect(facts.components[0]?.manifestFiles).toEqual([ - { file: '.socket.facts.json' }, + { file: 'gradle.socket.facts.json' }, { file: 'a/build.gradle.kts' }, ]) expect(facts.projects![0]?.manifestFiles).toEqual([ diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index 152df102d..53c6268f0 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,16 @@ export function resolveBuildToolBin( } return DEFAULT_BUILD_TOOL_BIN[tool] } + +// Distinct for every build sharing a directory. +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 000000000..7e8fb7a0f --- /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/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 f7768c26e..4ac60988c 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 @@ -94,6 +94,9 @@ 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 diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 59f3fa84e..f043c1dbd 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -11,6 +11,7 @@ import type { // // 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) @@ -81,6 +82,9 @@ export type ParsedRecords = { 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[] @@ -115,6 +119,7 @@ export function parseRecords(text: string): ParsedRecords { toolVersion: '', javaVersion: '', buildRoot: '', + entry: '', projects: new Map(), roots: new Map(), scannedConfigs: [], @@ -172,6 +177,9 @@ export function parseRecords(text: string): ParsedRecords { 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 993945e0c..49b0a9475 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' @@ -51,6 +51,8 @@ export type ManifestRunResult = { 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'). @@ -135,6 +137,7 @@ async function writeSbtPlugin( } async function assembleFromRecords( + tool: BuildTool, out: RunOutput, recordsFile: string, ): Promise { @@ -142,11 +145,15 @@ async function assembleFromRecords( ? await fs.readFile(recordsFile, 'utf8') : '' const parsed = parseRecords(text) - const { artifactPaths, facts, report } = assembleFacts(parsed) + 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, @@ -251,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) }) } @@ -314,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 { @@ -377,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.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index dd34c8c91..35236aca4 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -183,8 +183,13 @@ describe('compute-artifacts sidecar', () => { }) const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') - const entry = serializeSidecar(acc)['/root/.socket.facts.json']! + accumulateSidecar( + acc, + facts, + artifactPaths, + '/root/gradle.socket.facts.json', + ) + 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({ diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index ee68a0fdc..f8578a76d 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.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 8d22ebf8e..98d7bc032 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 80c677b1a..4917a96ce 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 29fe7f614..98ecd3152 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -19,6 +19,7 @@ import constants from '../../constants.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { compressSocketFactsForUpload, + isReachabilityReportPath, isSocketFactsFile, snapshotSocketFacts, } from '../../utils/coana.mts' @@ -359,13 +360,16 @@ async function createNewScan( reachabilityReport = reachResult.data?.reachabilityReport - // 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. + // Mirror the SBOM inputs Coana analyzed; otherwise its fresh report + // (appended below) supersedes every facts file. const pathsForScan = reach.reachUseOnlyPregeneratedSboms - ? filterToPregeneratedSboms(packagePaths, supportedFiles) + ? 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 @@ -438,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 aeb688327..6a86ff950 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -291,6 +291,38 @@ 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 3314e1bc9..95d8c3b38 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 bf24a6dbc..d8dfd220d 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 a2c4c2c65..ffe3ca2a0 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 f414ea76a..add373b84 100644 --- a/src/utils/coana.mts +++ b/src/utils/coana.mts @@ -130,6 +130,23 @@ 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 aeea3441d..d4fd3a2d0 100644 --- a/src/utils/coana.test.mts +++ b/src/utils/coana.test.mts @@ -33,6 +33,7 @@ import { extractReachabilityErrors, extractTier1ReachabilityScanId, getFullWorkspacePath, + isReachabilityReportPath, isSocketFactsFile, snapshotSocketFacts, } from './coana.mts' @@ -73,6 +74,25 @@ 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-'))