diff --git a/CHANGELOG.md b/CHANGELOG.md index a6f5e548a..8bc13e599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,24 @@ 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/). -## [1.1.154](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.154) - 2026-08-06 - -### Changed -- Updated the Coana CLI to v `15.10.4`. - ## [Unreleased] +### Added +- `socket scan create --reach --dynamic-sbom-inference` splits full application reachability analysis per project/module for Gradle, sbt, and Maven monorepos, using a Socket facts SBOM generated directly by each package manager for every build root, instead of one synthetic root. +- `socket manifest setup --dynamic-sbom-inference` extends the interactive `socket.json` configurator to walk every independent Gradle, sbt, and Maven build root in your project. +- `socket manifest dynamic-sbom-inference`: generate a Socket facts SBOM for every independent Gradle, sbt, and Maven build root directly, without creating a scan. + ### Changed -- Updated the Coana CLI to v `15.10.3`. +- Updated the Coana CLI to v `15.10.8`. ### Fixed - Declared `form-data` as a dependency, so a fresh install no longer throws `Cannot find module 'form-data'` on its first upload. +## [1.1.154](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.154) - 2026-08-06 + +### Changed +- Updated the Coana CLI to v `15.10.4`. + ## [1.1.153](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.153) - 2026-08-04 ### Changed diff --git a/package.json b/package.json index 8b4dd2549..dfef4f43a 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@babel/preset-typescript": "7.27.1", "@babel/runtime": "7.28.4", "@biomejs/biome": "2.2.4", - "@coana-tech/cli": "15.10.4", + "@coana-tech/cli": "15.10.8", "@cyclonedx/cdxgen": "12.1.2", "@dotenvx/dotenvx": "1.49.0", "@eslint/compat": "1.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e51cd63cb..a97007eb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,8 +135,8 @@ importers: specifier: 2.2.4 version: 2.2.4 '@coana-tech/cli': - specifier: 15.10.4 - version: 15.10.4 + specifier: 15.10.8 + version: 15.10.8 '@cyclonedx/cdxgen': specifier: 12.1.2 version: 12.1.2 @@ -824,8 +824,8 @@ packages: resolution: {integrity: sha512-hAs5PPKPCQ3/Nha+1fo4A4/gL85fIfxZwHPehsjCJ+BhQH2/yw6/xReuaPA/RfNQr6iz1PcD7BZcE3ctyyl3EA==} cpu: [x64] - '@coana-tech/cli@15.10.4': - resolution: {integrity: sha512-/XHXYM5wQo45JVDtTkDiYKr8JMajRH76xDq8fKVBLfaalZfPcrHQWGVfTip9H68gflGidCdGY2PiUWYGbrMbCw==} + '@coana-tech/cli@15.10.8': + resolution: {integrity: sha512-aOYrfp+sGiqWo8e2QrCDORvcNd/8w4zZT3bYMZYQhzXri38UGYVmVgaNeb8L+DNOacMWsftwCv+dETefa1vF6g==} hasBin: true '@colors/colors@1.5.0': @@ -5698,7 +5698,7 @@ snapshots: '@cdxgen/cdxgen-plugins-bin@2.0.2': optional: true - '@coana-tech/cli@15.10.4': {} + '@coana-tech/cli@15.10.8': {} '@colors/colors@1.5.0': optional: true diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts index 61fc28864..980c198ca 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts @@ -22,10 +22,7 @@ const config: CliCommandConfig = { commandName: 'dynamic-sbom-inference', description: 'Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each', - // Hidden: `--dynamic-sbom-inference` already names an unrelated, root-only - // scan create/reach flag (see reachability-flags.mts). Keep this hidden - // until the naming collision between the two is resolved. - hidden: true, + hidden: false, flags: { ...commonFlags, ...outputFlags, diff --git a/src/commands/manifest/cmd-manifest-setup.mts b/src/commands/manifest/cmd-manifest-setup.mts index 4d0b3b9b6..d7dd024e3 100644 --- a/src/commands/manifest/cmd-manifest-setup.mts +++ b/src/commands/manifest/cmd-manifest-setup.mts @@ -22,11 +22,10 @@ const config: CliCommandConfig = { hidden: false, flags: { ...commonFlags, - // Only meaningful alongside the hidden --dynamic-sbom-inference below; kept hidden too. + // Only meaningful alongside --dynamic-sbom-inference below. excludePaths: { type: 'string', isMultiple: true, - hidden: true, description: 'Build roots matching these glob patterns (and everything beneath them) are marked disabled. Patterns are anchored micromatch globs matched relative to CWD: `legacy` matches only `/legacy`; use `**/legacy` to match at any depth. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', }, @@ -36,9 +35,8 @@ const config: CliCommandConfig = { }, dynamicSbomInference: { type: 'boolean', - hidden: true, description: - 'Recursively scans for every gradle/sbt/maven build root beneath CWD first, so the CWD config step only asks about ecosystems actually found somewhere in the tree. A build root matching --exclude-paths is bulk-disabled with no prompt, applied unconditionally; eligible build roots found afterward can be configured individually', + 'Generates dynamic SBOMs via the Gradle/sbt/Maven package manager tools for more accurate results than static resolution. Scans every build root under CWD first, so the configurator only asks about ecosystems actually found; --exclude-paths matches are bulk-disabled, others configured individually.', }, }, help: (command, config) => ` diff --git a/src/commands/manifest/cmd-manifest-setup.test.mts b/src/commands/manifest/cmd-manifest-setup.test.mts index bd2fb489d..780fe482e 100644 --- a/src/commands/manifest/cmd-manifest-setup.test.mts +++ b/src/commands/manifest/cmd-manifest-setup.test.mts @@ -24,6 +24,8 @@ describe('socket manifest setup', async () => { Options --default-on-read-error If reading the socket.json fails, just use a default config? Warning: This might override the existing json file! + --dynamic-sbom-inference Generates dynamic SBOMs via the Gradle/sbt/Maven package manager tools for more accurate results than static resolution. Scans every build root under CWD first, so the configurator only asks about ecosystems actually found; --exclude-paths matches are bulk-disabled, others configured individually. + --exclude-paths Build roots matching these glob patterns (and everything beneath them) are marked disabled. Patterns are anchored micromatch globs matched relative to CWD: \`legacy\` matches only \`/legacy\`; use \`**/legacy\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. This command will try to detect all supported ecosystems in given CWD. Then it starts a configurator where you can setup default values for certain flags diff --git a/src/commands/manifest/cmd-manifest.test.mts b/src/commands/manifest/cmd-manifest.test.mts index 93c264770..124258616 100644 --- a/src/commands/manifest/cmd-manifest.test.mts +++ b/src/commands/manifest/cmd-manifest.test.mts @@ -27,6 +27,7 @@ describe('socket manifest', async () => { bazel [beta] Bazel SBOM support \\u2014 generate manifest files for a Bazel project (Maven, PyPI) cdxgen Run cdxgen for SBOM generation conda [beta] Convert a Conda environment.yml file to a python requirements.txt + dynamic-sbom-inference Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each gradle [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Gradle/Java/Kotlin/etc project kotlin [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Kotlin project maven [beta] Generate a Socket facts file from a Maven \`pom.xml\` project diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts index cee91b543..e8ff4f901 100644 --- a/src/commands/manifest/discover-manifest-roots.mts +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -1,6 +1,6 @@ -import { promises as fs } from 'node:fs' import path from 'node:path' +import { realpathOrResolved } from '../../utils/fs.mts' import { globWithGitIgnore } from '../../utils/glob.mts' import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' @@ -55,13 +55,7 @@ export function withoutDisabledFlags(sockJson: SocketJson): SocketJson { } as SocketJson } -export async function realpathOrResolved(dir: string): Promise { - try { - return await fs.realpath(dir) - } catch { - return path.resolve(dir) - } -} +export { realpathOrResolved } function sortByDepthThenPath(dirs: readonly string[], cwd: string): string[] { return [...dirs].sort((a, b) => { diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index ca3246cd9..fd329f20c 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -10,6 +10,7 @@ import { import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { runManifestFacts } from './run-manifest-facts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' +import { withTmpDir } from '../../utils/fs.mts' import { readOrDefaultSocketJson, readSocketJsonCascade, @@ -17,6 +18,7 @@ import { import { projectIgnorePathsToReachExcludePaths } from '../scan/exclude-paths.mts' import type { BuildTool } from './scripts/build-tool.mts' +import type { SidecarAccumulator } from './scripts/sidecar.mts' import type { SocketJson } from '../../utils/socket-json.mts' export type RecursiveManifestOutcomeStatus = @@ -83,87 +85,29 @@ function nearestDisabledRoot( return nearest } -// A wrapper-preferred `bin` default is resolved per-root (`dir`, not `cwd`) -// since a wrapper script only exists at the actual build root. Exported for -// reuse by the recursive setup wizard's reactor-coverage pruning. -export function resolveEcosystemConfig( - ecosystem: BuildTool, - dir: string, - sockJson: SocketJson, -): EcosystemBuildConfig { - if (ecosystem === 'sbt') { - const config = sockJson.defaults?.manifest?.sbt - const bin = config?.bin ?? undefined - return { - bin: bin ?? 'sbt', - buildOpts: parseBuildToolOpts(config?.sbtOpts ?? undefined), - excludeConfigs: config?.excludeConfigs ?? '', - ignoreUnresolved: Boolean(config?.ignoreUnresolved), - includeConfigs: config?.includeConfigs ?? '', - javaHome: config?.javaHome ?? undefined, - skipReason: getSkipReason(config?.disabled, config?.facts), - } - } - if (ecosystem === 'gradle') { - const config = sockJson.defaults?.manifest?.gradle - const bin = config?.bin ?? undefined - return { - bin: bin ? path.resolve(dir, bin) : resolveBuildToolBin('gradle', dir), - buildOpts: parseBuildToolOpts(config?.gradleOpts ?? undefined), - excludeConfigs: config?.excludeConfigs ?? '', - ignoreUnresolved: Boolean(config?.ignoreUnresolved), - includeConfigs: config?.includeConfigs ?? '', - javaHome: config?.javaHome ?? undefined, - skipReason: getSkipReason(config?.disabled, config?.facts), - } - } - const config = sockJson.defaults?.manifest?.maven - const bin = config?.bin ?? undefined - return { - bin: bin ?? resolveBuildToolBin('maven', dir), - buildOpts: parseBuildToolOpts(config?.mavenOpts ?? undefined), - excludeConfigs: config?.excludeConfigs ?? '', - ignoreUnresolved: Boolean(config?.ignoreUnresolved), - includeConfigs: config?.includeConfigs ?? '', - javaHome: config?.javaHome ?? undefined, - skipReason: getSkipReason(config?.disabled), - } -} - -// Generates one .socket.facts.json 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 -// own invocation - and only a properly nested subprojectDir counts as -// coverage at all; one that escapes its declaring reactor's own directory -// still gets its own independent invocation too (see the covered.add call -// below). Fail-closed per ecosystem, not globally: a root whose -// workspace layout can't be determined aborts only that ecosystem's own -// remaining walk (marking its untried candidates 'aborted'), since coverage -// is tracked per ecosystem and an unrelated one has nothing to lose from it. -export async function generateRecursiveManifests({ - cwd, +async function runEcosystemCandidates({ + candidatesByTool, excludePaths, + realCwd, + rootSockJson, + sbtTmpDir, + sidecarAcc, verbose, + withFiles, }: { - cwd: string - excludePaths?: string[] | undefined + candidatesByTool: Map + excludePaths: string[] | undefined + realCwd: string + rootSockJson: SocketJson + // sbt only: a shared global base reused across every sbt root in this run, + // so sbt's own Scala-toolchain cache under /boot survives between + // invocations instead of being reprovisioned per root. Undefined when no + // sbt root was discovered, matching runManifestFacts' own ephemeral default. + sbtTmpDir: string | undefined + sidecarAcc: SidecarAccumulator | undefined verbose: boolean + withFiles: boolean | undefined }): Promise { - const rootSockJson = readOrDefaultSocketJson(cwd) - // Candidate dirs come back realpath-resolved (findBuildToolCandidates); cwd - // must match or every boundary/relative-path comparison below breaks as - // soon as cwd contains a symlink (macOS /tmp -> /private/tmp, etc.). - const realCwd = await realpathOrResolved(cwd) - // A root-disabled ecosystem must still be scanned for - a nested socket.json - // may re-enable it - so the per-directory cascade below, not this scan, is - // what actually decides skip vs. include. - const candidatesByTool = await findBuildToolCandidates({ - cwd, - excludePaths, - sockJson: withoutDisabledFlags(rootSockJson), - }) - const outcomes: RecursiveManifestOutcome[] = [] for (const [ecosystem, dirs] of candidatesByTool) { const covered = new Set() @@ -219,7 +163,10 @@ export async function generateRecursiveManifests({ ignoreUnresolved, includeConfigs, javaHome, + sidecarAcc, + tmpDir: ecosystem === 'sbt' ? sbtTmpDir : undefined, verbose, + withFiles, }) if (result === null) { @@ -268,6 +215,84 @@ export async function generateRecursiveManifests({ }) } } + return outcomes +} + +// Generates one .socket.facts.json 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 +// own invocation - and only a properly nested subprojectDir counts as +// coverage at all; one that escapes its declaring reactor's own directory +// still gets its own independent invocation too (see the covered.add call +// below). Fail-closed per ecosystem, not globally: a root whose +// workspace layout can't be determined aborts only that ecosystem's own +// remaining walk (marking its untried candidates 'aborted'), since coverage +// is tracked per ecosystem and an unrelated one has nothing to lose from it. +export async function generateRecursiveManifests({ + cwd, + excludePaths, + sbtTmpDir, + sidecarAcc, + verbose, + withFiles, +}: { + cwd: string + excludePaths?: string[] | undefined + // Reachability path only: caller-supplied directory to use as sbt's shared + // global base across every sbt root in this run. sbt provisions the Scala + // toolchain under `/boot`, which withFiles' artifactPaths point into, + // so when the caller intends to consume those paths after this call + // returns (e.g. reachability analysis), it must supply a directory that + // outlives this call and delete it once it's done - mirrors + // ManifestScriptOptions.tmpDir one level up. Unset ⇒ an ephemeral shared + // dir is allocated and cleaned up before this call returns, matching + // runManifestFacts' own withFiles-less default. + sbtTmpDir?: string | undefined + // Reachability path only: run build tools with files and fold resolved + // artifact paths into sidecarAcc, keyed by each root's own factsPath. + sidecarAcc?: SidecarAccumulator | undefined + verbose: boolean + withFiles?: boolean | undefined +}): Promise { + const rootSockJson = readOrDefaultSocketJson(cwd) + // Candidate dirs come back realpath-resolved (findBuildToolCandidates); cwd + // must match or every boundary/relative-path comparison below breaks as + // soon as cwd contains a symlink (macOS /tmp -> /private/tmp, etc.). + const realCwd = await realpathOrResolved(cwd) + // A root-disabled ecosystem must still be scanned for - a nested socket.json + // may re-enable it - so the per-directory cascade below, not this scan, is + // what actually decides skip vs. include. + const candidatesByTool = await findBuildToolCandidates({ + cwd, + excludePaths, + sockJson: withoutDisabledFlags(rootSockJson), + }) + + const runAll = (resolvedSbtTmpDir: string | undefined) => + runEcosystemCandidates({ + candidatesByTool, + excludePaths, + realCwd, + rootSockJson, + sbtTmpDir: resolvedSbtTmpDir, + sidecarAcc, + verbose, + withFiles, + }) + + // A shared global base across every sbt root in this run lets sbt's own + // Scala-toolchain cache under /boot survive between invocations + // instead of being reprovisioned per root (the plugin file is rewritten and + // records.tsv is fully overwritten - not appended - on every invocation, so + // reuse is safe). A caller-supplied sbtTmpDir is reused as-is (the caller + // owns its lifetime); otherwise one is allocated and cleaned up here, but + // only when there's an sbt root to benefit from sharing it at all. + const outcomes = sbtTmpDir + ? await runAll(sbtTmpDir) + : candidatesByTool.get('sbt')?.length + ? await withTmpDir('socket-sbt-facts-shared-', runAll) + : await runAll(undefined) if (verbose) { logger.info(`Discovered ${outcomes.length} build-tool candidate(s).`) @@ -275,3 +300,50 @@ export async function generateRecursiveManifests({ return outcomes } + +// A wrapper-preferred `bin` default is resolved per-root (`dir`, not `cwd`) +// since a wrapper script only exists at the actual build root. Exported for +// reuse by the recursive setup wizard's reactor-coverage pruning. +export function resolveEcosystemConfig( + ecosystem: BuildTool, + dir: string, + sockJson: SocketJson, +): EcosystemBuildConfig { + if (ecosystem === 'sbt') { + const config = sockJson.defaults?.manifest?.sbt + const bin = config?.bin ?? undefined + return { + bin: bin ?? 'sbt', + buildOpts: parseBuildToolOpts(config?.sbtOpts ?? undefined), + excludeConfigs: config?.excludeConfigs ?? '', + ignoreUnresolved: Boolean(config?.ignoreUnresolved), + includeConfigs: config?.includeConfigs ?? '', + javaHome: config?.javaHome ?? undefined, + skipReason: getSkipReason(config?.disabled, config?.facts), + } + } + if (ecosystem === 'gradle') { + const config = sockJson.defaults?.manifest?.gradle + const bin = config?.bin ?? undefined + return { + bin: bin ? path.resolve(dir, bin) : resolveBuildToolBin('gradle', dir), + buildOpts: parseBuildToolOpts(config?.gradleOpts ?? undefined), + excludeConfigs: config?.excludeConfigs ?? '', + ignoreUnresolved: Boolean(config?.ignoreUnresolved), + includeConfigs: config?.includeConfigs ?? '', + javaHome: config?.javaHome ?? undefined, + skipReason: getSkipReason(config?.disabled, config?.facts), + } + } + const config = sockJson.defaults?.manifest?.maven + const bin = config?.bin ?? undefined + return { + bin: bin ?? resolveBuildToolBin('maven', dir), + buildOpts: parseBuildToolOpts(config?.mavenOpts ?? undefined), + excludeConfigs: config?.excludeConfigs ?? '', + ignoreUnresolved: Boolean(config?.ignoreUnresolved), + includeConfigs: config?.includeConfigs ?? '', + javaHome: config?.javaHome ?? undefined, + skipReason: getSkipReason(config?.disabled), + } +} diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index e81d65bab..f2cf09d93 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -602,4 +602,122 @@ describe('generateRecursiveManifests', () => { await fs.rm(outer, { recursive: true, force: true }) } }) + + it('shares one sbt tmpDir across every discovered sbt root, but passes tmpDir: undefined for gradle/maven', async () => { + const outer = await fs.mkdtemp(path.join(tmpdir(), 'sbt-shared-tmpdir-')) + const sbtA = path.join(outer, 'sbt-a') + const sbtB = path.join(outer, 'sbt-b') + const mavenRoot = path.join(outer, 'maven-root') + try { + await fs.mkdir(sbtA, { recursive: true }) + await fs.mkdir(sbtB, { recursive: true }) + await fs.mkdir(mavenRoot, { recursive: true }) + await fs.writeFile(path.join(sbtA, 'build.sbt'), '') + await fs.writeFile(path.join(sbtB, 'build.sbt'), '') + await fs.writeFile(path.join(mavenRoot, 'pom.xml'), '') + + const tmpDirsSeen: Record> = {} + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, ecosystem, tmpDir }) => { + ;(tmpDirsSeen[ecosystem] ??= []).push(tmpDir) + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + } + }, + ) + + await generateRecursiveManifests({ cwd: outer, verbose: false }) + + expect(tmpDirsSeen['sbt']).toHaveLength(2) + expect(tmpDirsSeen['sbt']![0]).toBeDefined() + expect(tmpDirsSeen['sbt']![0]).toBe(tmpDirsSeen['sbt']![1]) + expect(tmpDirsSeen['maven']).toEqual([undefined]) + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }) + + it('does not allocate a shared tmpDir at all when no sbt root is discovered', async () => { + const outer = await fs.mkdtemp(path.join(tmpdir(), 'no-sbt-tmpdir-')) + try { + await fs.writeFile(path.join(outer, 'pom.xml'), '') + + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + await generateRecursiveManifests({ cwd: outer, verbose: false }) + + expect( + vi.mocked(runManifestFacts).mock.calls[0]?.[0].tmpDir, + ).toBeUndefined() + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }) + + it('reuses a caller-supplied sbtTmpDir as-is for every sbt root, and does not delete it', async () => { + const outer = await fs.mkdtemp(path.join(tmpdir(), 'sbt-caller-tmpdir-')) + const sbtA = path.join(outer, 'sbt-a') + const sbtB = path.join(outer, 'sbt-b') + const callerOwnedDir = await fs.mkdtemp( + path.join(tmpdir(), 'caller-owned-sbt-base-'), + ) + try { + await fs.mkdir(sbtA, { recursive: true }) + await fs.mkdir(sbtB, { recursive: true }) + await fs.writeFile(path.join(sbtA, 'build.sbt'), '') + await fs.writeFile(path.join(sbtB, 'build.sbt'), '') + + const tmpDirsSeen: Array = [] + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, tmpDir }) => { + tmpDirsSeen.push(tmpDir) + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + } + }, + ) + + await generateRecursiveManifests({ + cwd: outer, + sbtTmpDir: callerOwnedDir, + verbose: false, + }) + + expect(tmpDirsSeen).toEqual([callerOwnedDir, callerOwnedDir]) + // generateRecursiveManifests must not clean up a directory it didn't + // allocate - the caller (e.g. handleCreateNewScan keeping it alive + // until reachability analysis consumes the sidecar's resolved paths) + // owns that lifetime. + await expect(fs.access(callerOwnedDir)).resolves.toBeUndefined() + } finally { + await fs.rm(outer, { recursive: true, force: true }) + await fs.rm(callerOwnedDir, { recursive: true, force: true }) + } + }) + + it('threads sidecarAcc/withFiles through to every build root, not just the first', async () => { + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + const sidecarAcc = new Map() + await generateRecursiveManifests({ + cwd: monorepo, + sidecarAcc, + verbose: false, + withFiles: true, + }) + + expect(vi.mocked(runManifestFacts).mock.calls.length).toBeGreaterThan(1) + for (const [opts] of vi.mocked(runManifestFacts).mock.calls) { + expect(opts.sidecarAcc).toBe(sidecarAcc) + expect(opts.withFiles).toBe(true) + } + }) }) diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 947df2983..f063e018b 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -11,7 +11,7 @@ import { convertSbtToMaven } from './convert_sbt_to_maven.mts' import { handleManifestConda } from './handle-manifest-conda.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' -import { serializeSidecar } from './scripts/sidecar.mts' +import { hasSidecarEntries, serializeSidecar } from './scripts/sidecar.mts' import { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { InputError } from '../../utils/errors.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' @@ -263,6 +263,8 @@ export async function generateAutoManifest({ return { generatedFiles, resolvedPathsSidecar: - sidecarAcc && sidecarAcc.size ? serializeSidecar(sidecarAcc) : undefined, + sidecarAcc && hasSidecarEntries(sidecarAcc) + ? serializeSidecar(sidecarAcc) + : undefined, } } diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index a3816840f..f4e39a70a 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -12,6 +12,7 @@ import { runManifestScript } from './scripts/run.mts' import { accumulateSidecar } from './scripts/sidecar.mts' import constants from '../../constants.mts' import { getErrorMessageOr } from '../../utils/errors.mts' +import { realpathOrResolved } from '../../utils/fs.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { SocketFactsSbomProject } from './scripts/facts.mts' @@ -223,7 +224,16 @@ export async function runManifestFacts({ await fs.writeFile(factsPath, JSON.stringify(facts, null, 2), 'utf8') if (withFiles && sidecarAcc) { - accumulateSidecar(sidecarAcc, facts, artifactPaths) + // Key by the symlink-resolved path so the sidecar's keys are comparable + // regardless of which caller's cwd it was joined against (the recursive + // discovery path already resolves symlinks before this point; the plain + // single-root path does not). + accumulateSidecar( + sidecarAcc, + facts, + artifactPaths, + await realpathOrResolved(factsPath), + ) } logger.success('Generated Socket facts') diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index 3320707fe..7f185d277 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -12,6 +12,7 @@ import { runManifestFacts } from './run-manifest-facts.mts' import { runManifestScript } from './scripts/run.mts' import type { ManifestRunResult } from './scripts/run.mts' +import type { SidecarAccumulator } from './scripts/sidecar.mts' const ENV_VAR = 'SOCKET_TEST_JAVA_HOME' @@ -97,3 +98,43 @@ describe('runManifestFacts - javaHome', () => { expect(opts?.env).toBeUndefined() }) }) + +describe('runManifestFacts - sidecar', () => { + 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('keys the sidecar by the symlink-resolved factsPath, not the raw cwd-joined one', async () => { + const result = okResult() + result.facts.projects = [ + { + type: 'maven', + namespace: 'com.example', + name: 'app', + version: '1.0', + subprojectDir: '.', + dependencies: [], + resolvedAs: [], + }, + ] + vi.mocked(runManifestScript).mockResolvedValue(result) + + const sidecarAcc: SidecarAccumulator = new Map() + await runManifestFacts({ ...baseArgs, cwd, sidecarAcc, withFiles: true }) + + const expectedFactsFile = await fs.realpath( + path.join(cwd, '.socket.facts.json'), + ) + expect([...sidecarAcc.keys()]).toEqual([expectedFactsFile]) + const bucket = sidecarAcc.get(expectedFactsFile) + expect(bucket?.projects.find(m => m.name === 'app')).toBeDefined() + }) +}) diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index be2e1b0eb..0d896f4a2 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -37,26 +37,36 @@ describe('records → assemble → sidecar', () => { expect(facts.metadata).not.toHaveProperty('schemaVersion') const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths) - const byName = new Map(serializeSidecar(acc).map(r => [r.name, r])) + accumulateSidecar(acc, facts, artifactPaths, '/abs/.socket.facts.json') + const resolved = serializeSidecar(acc) + const bucket = resolved['/abs/.socket.facts.json']! + const byName = new Map(bucket.components.map(r => [r.name, r])) // First-party module: project-only (not a node), yet its source/output - // roots reach the sidecar. - expect(byName.get('app')).toEqual({ - group: 'com.example', - name: 'app', - version: '1.0', - ext: '', - classifier: null, - targets: ['/abs/app/build/classes'], - sources: ['/abs/app/src/main/java'], - }) + // roots reach the sidecar, keyed by its own facts file. + expect(bucket.projects).toEqual([ + { + type: 'maven', + namespace: 'com.example', + name: 'app', + version: '1.0', + subprojectDir: '/abs/app', + dependencies: ['com.example:bom:2.0', 'com.example:lib:jar:1.0'], + resolvedAs: [], + targets: ['/abs/app/build/classes'], + sources: ['/abs/app/src/main/java'], + }, + ]) - // External dependency: jar target, no sources. + // External dependency: jar target, empty (not undefined) sources - it was + // resolved, it just has no first-party source roots. expect(byName.get('lib')?.targets).toEqual(['/abs/lib.jar']) expect(byName.get('lib')?.sources).toEqual([]) - // Artifactless BOM: present with empty arrays (resolved, no artifact). - expect(byName.get('bom')).toMatchObject({ targets: [], sources: [] }) + // Artifactless BOM: present with explicit empty arrays (resolved, no + // artifact) - [] means resolved-and-empty, not "not resolved". + const bom = byName.get('bom') + expect(bom?.targets).toEqual([]) + expect(bom?.sources).toEqual([]) }) }) diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index d4cd81a27..f5363fde1 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -1,111 +1,147 @@ import { mavenCoordinateKey } from './facts.mts' -import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts' +import type { + AnyPURL, + ResolvedArtifactPaths, + SocketFactsSbom, + SocketFactsSbomComponent, + SocketFactsSbomProject, +} from './facts.mts' -// Frozen contract with `coana run --compute-artifacts-sidecar`; change only in -// sync with the coana consumer. Per coordinate: targets/sources present → -// resolved (coana uses the paths); both empty → resolved with no artifact -// (pom/BOM), not a failure; absent → coana degrades that vuln to precomputed. -export type ResolvedComponent = { - group: string - name: string - version: string - ext: string - classifier: string | null - // Classpath entries (jars / first-party output dirs). - targets: string[] - // First-party source roots; [] for external deps. - sources: string[] +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). + targets?: string[] | undefined + // First-party source roots; `[]` for a genuinely external dependency (still + // attempted, nothing to find), not undefined. + sources?: string[] | undefined } -// Bare array, no schema version: socket-cli pins the coana version, so producer -// and consumer never drift. -export type ResolvedPathsSidecar = ResolvedComponent[] - -// Keyed by full coordinate; unions paths so multiple build roots merge into one. -export type SidecarAccumulator = Map +export type SidecarProjectEntry = SocketFactsSbomProject & { + targets?: string[] | undefined + sources?: string[] | undefined +} -function pushUnique(into: string[], from: string[]): void { - for (const f of from) { - if (!into.includes(f)) { - into.push(f) - } +// 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 +// 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 +// deduplication - the same external dependency resolved by several +// independent reactors is intentionally duplicated across all of their +// components[]. +export type ResolvedPathsSidecar = Record< + string, + { + // This facts file's own first-party modules. + projects: SidecarProjectEntry[] + // This reactor's dependency-position entries: genuinely external + // artifacts, and dependency edges that resolve to a sibling first-party + // project (reported via that project's own source/target roots instead + // of a jar path). + components: SidecarComponentEntry[] } -} +> -function addEntry( - acc: SidecarAccumulator, +export type SidecarAccumulator = Map< + string, + { 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( + entry: T, artifactPaths: ResolvedArtifactPaths, - group: string, - name: string, - version: string, - ext: string, - classifier: string | null, -): void { +): T & { targets?: string[] | undefined; sources?: string[] | undefined } { const coordKey = mavenCoordinateKey( - group, - name, - ext || undefined, - classifier ?? undefined, - version || undefined, + entry.namespace, + entry.name, + entry.qualifiers?.['ext'], + entry.qualifiers?.['classifier'], + entry.version, ) if (!coordKey) { - return + return { ...entry } } - let entry = acc.get(coordKey) - if (!entry) { - entry = { group, name, version, ext, classifier, targets: [], sources: [] } - acc.set(coordKey, entry) + return { + ...entry, + targets: [...(artifactPaths.targetsByCoord.get(coordKey) ?? [])].sort(), + sources: [...(artifactPaths.sourcesByCoord.get(coordKey) ?? [])].sort(), } - pushUnique(entry.targets, artifactPaths.targetsByCoord.get(coordKey) ?? []) - pushUnique(entry.sources, artifactPaths.sourcesByCoord.get(coordKey) ?? []) +} + +function purlSortKey(entry: AnyPURL): string { + return `${entry.type}:${entry.namespace ?? ''}:${entry.name}:${entry.version ?? ''}:${entry.qualifiers?.['ext'] ?? ''}:${entry.qualifiers?.['classifier'] ?? ''}` +} + +function sortByPurl(entries: T[]): T[] { + return entries.sort((a, b) => { + const ka = purlSortKey(a) + const kb = purlSortKey(b) + return ka < kb ? -1 : ka > kb ? 1 : 0 + }) } // 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. export function accumulateSidecar( acc: SidecarAccumulator, facts: SocketFactsSbom, artifactPaths: ResolvedArtifactPaths, + factsFile: string, ): void { - for (const comp of facts.components) { - addEntry( - acc, - artifactPaths, - comp.namespace ?? '', - comp.name, - comp.version ?? '', - comp.qualifiers?.['ext'] ?? '', - comp.qualifiers?.['classifier'] ?? null, - ) - } - // First-party modules have no ext/classifier. - for (const proj of facts.projects ?? []) { - addEntry( - acc, - artifactPaths, - proj.namespace ?? '', - proj.name, - proj.version ?? '', - '', - null, - ) - } + acc.set(factsFile, { + components: facts.components.map(comp => attachPaths(comp, artifactPaths)), + projects: (facts.projects ?? []).map(proj => + attachPaths(proj, artifactPaths), + ), + }) +} + +export function hasResolvedPathsSidecarEntries( + sidecar: ResolvedPathsSidecar, +): boolean { + return Object.keys(sidecar).length > 0 +} + +export function hasSidecarEntries(acc: SidecarAccumulator): boolean { + return acc.size > 0 +} + +// Combines two already-serialized sidecars (e.g. the recursive-discovery path +// and the plain conda/bazel auto-manifest path). Keys are already scoped to +// one facts file each and can't collide between the two inputs in practice, +// so this is a plain merge; the later input wins on a genuine key collision. +export function mergeResolvedPathsSidecars( + a: ResolvedPathsSidecar, + b: ResolvedPathsSidecar, +): ResolvedPathsSidecar { + return { __proto__: null, ...a, ...b } as unknown as ResolvedPathsSidecar } export function serializeSidecar( acc: SidecarAccumulator, ): ResolvedPathsSidecar { - const resolved = [...acc.values()] - for (const entry of resolved) { - entry.targets.sort() - entry.sources.sort() + const result = { __proto__: null } as unknown as ResolvedPathsSidecar + for (const factsFile of [...acc.keys()].sort()) { + const bucket = acc.get(factsFile)! + result[factsFile] = { + projects: sortByPurl(bucket.projects), + components: sortByPurl(bucket.components), + } } - resolved.sort((a, b) => { - const ka = `${a.group}:${a.name}:${a.ext}:${a.classifier ?? ''}:${a.version}` - const kb = `${b.group}:${b.name}:${b.ext}:${b.classifier ?? ''}:${b.version}` - return ka < kb ? -1 : ka > kb ? 1 : 0 - }) - return resolved + return result } diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index 86a74ff2e..2e894a4f2 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { accumulateSidecar, serializeSidecar } from './sidecar.mts' +import { + accumulateSidecar, + hasResolvedPathsSidecarEntries, + hasSidecarEntries, + mergeResolvedPathsSidecars, + serializeSidecar, +} from './sidecar.mts' import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts' import type { SidecarAccumulator } from './sidecar.mts' @@ -14,7 +20,7 @@ function emptyArtifactPaths(): ResolvedArtifactPaths { } } -function mkRootFixture(target: string): { +function mkComponentFixture(target: string): { facts: SocketFactsSbom paths: ResolvedArtifactPaths } { @@ -38,7 +44,7 @@ function mkRootFixture(target: string): { } describe('compute-artifacts sidecar', () => { - it('emits the frozen ResolvedComponent[] contract', () => { + it('carries a component through with resolved targets/sources attached, keyed by its own facts file', () => { const facts: SocketFactsSbom = { components: [ { @@ -60,23 +66,29 @@ describe('compute-artifacts sidecar', () => { ]) const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths) + accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') const resolved = serializeSidecar(acc) - expect(resolved).toEqual([ - { - group: 'com.example', - name: 'lib', - version: 'da517db', - ext: 'jar', - classifier: null, - targets: ['/abs/lib.jar'], - sources: ['/abs/lib/src/main/java'], + expect(resolved).toEqual({ + '/root/.socket.facts.json': { + projects: [], + components: [ + { + type: 'maven', + namespace: 'com.example', + name: 'lib', + version: 'da517db', + qualifiers: { ext: 'jar' }, + id: 'com.example:lib:jar:da517db', + targets: ['/abs/lib.jar'], + sources: ['/abs/lib/src/main/java'], + }, + ], }, - ]) + }) }) - it('emits empty target/source arrays for a resolved-but-artifactless coord (pom/BOM)', () => { + it('emits explicit empty targets/sources for a resolved-but-artifactless coord (pom/BOM) - [] means resolved, not "not resolved"', () => { const facts: SocketFactsSbom = { components: [ { @@ -90,15 +102,39 @@ describe('compute-artifacts sidecar', () => { ], } const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, emptyArtifactPaths()) + accumulateSidecar( + acc, + facts, + emptyArtifactPaths(), + '/root/.socket.facts.json', + ) const resolved = serializeSidecar(acc) - expect(resolved).toHaveLength(1) - expect(resolved[0]!.targets).toEqual([]) - expect(resolved[0]!.sources).toEqual([]) + const entry = resolved['/root/.socket.facts.json']!.components[0]! + expect(entry.targets).toEqual([]) + expect(entry.sources).toEqual([]) }) - it('preserves a classifier qualifier and defaults it to null when absent', () => { + it('leaves targets/sources undefined (not []) when the entry has no computable coordinate at all', () => { + const facts: SocketFactsSbom = { + components: [ + { type: 'maven', namespace: '', name: '', id: 'degenerate' }, + ], + } + 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() + }) + + it('preserves the original component fields (id, qualifiers) untouched', () => { const facts: SocketFactsSbom = { components: [ { @@ -108,15 +144,27 @@ describe('compute-artifacts sidecar', () => { version: '1', qualifiers: { ext: 'jar', classifier: 'sources' }, id: 'g:a:jar:sources:1', + direct: true, + dependencies: ['x'], }, ], } const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, emptyArtifactPaths()) - expect(serializeSidecar(acc)[0]!.classifier).toBe('sources') + accumulateSidecar( + acc, + facts, + emptyArtifactPaths(), + '/root/.socket.facts.json', + ) + const entry = + serializeSidecar(acc)['/root/.socket.facts.json']!.components[0]! + expect(entry.qualifiers?.['classifier']).toBe('sources') + expect(entry.id).toBe('g:a:jar:sources:1') + expect(entry.direct).toBe(true) + expect(entry.dependencies).toEqual(['x']) }) - it('carries a first-party module (project, not a component) source/target roots', () => { + it('carries a first-party module (project, not a component) source/target roots, keyed by its own facts file', () => { const facts: SocketFactsSbom = { // The app module is a project but nothing depends on it, so it is absent // from components — its source roots must still reach the sidecar. @@ -142,31 +190,134 @@ describe('compute-artifacts sidecar', () => { ]) const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths) + accumulateSidecar(acc, facts, artifactPaths, '/root/app/.socket.facts.json') const resolved = serializeSidecar(acc) - expect(resolved).toEqual([ + expect(resolved['/root/app/.socket.facts.json']!.components).toEqual([]) + expect(resolved['/root/app/.socket.facts.json']!.projects).toEqual([ { - group: 'com.example', + type: 'maven', + namespace: 'com.example', name: 'app', version: '1.0', - ext: '', - classifier: null, + subprojectDir: 'app', + dependencies: [], + resolvedAs: [], targets: ['/abs/app/build/classes'], sources: ['/abs/app/src/main/java'], }, ]) }) - it('merges the same coordinate across build roots, unioning paths', () => { + it('does NOT reunion the same external coordinate across build roots - duplication across reactors is intentional', () => { const acc: SidecarAccumulator = new Map() - const a = mkRootFixture('/root-a/a.jar') - const b = mkRootFixture('/root-b/a.jar') - accumulateSidecar(acc, a.facts, a.paths) - accumulateSidecar(acc, b.facts, b.paths) + const a = mkComponentFixture('/root-a/a.jar') + const b = mkComponentFixture('/root-b/a.jar') + accumulateSidecar(acc, a.facts, a.paths, '/root-a/.socket.facts.json') + accumulateSidecar(acc, b.facts, b.paths, '/root-b/.socket.facts.json') const resolved = serializeSidecar(acc) - expect(resolved).toHaveLength(1) - expect(resolved[0]!.targets).toEqual(['/root-a/a.jar', '/root-b/a.jar']) + expect( + resolved['/root-a/.socket.facts.json']!.components[0]!.targets, + ).toEqual(['/root-a/a.jar']) + expect( + resolved['/root-b/.socket.facts.json']!.components[0]!.targets, + ).toEqual(['/root-b/a.jar']) + }) + + it('keeps first-party modules from two independent roots fully separate, even with the same purl identity', () => { + const sharedModuleFacts: SocketFactsSbom = { + components: [], + projects: [ + { + type: 'maven', + namespace: 'com.example', + name: 'shared', + version: '1.0', + subprojectDir: '.', + dependencies: [], + resolvedAs: [], + }, + ], + } + const pathsA = emptyArtifactPaths() + pathsA.sourcesByCoord.set('com.example:shared:1.0', [ + '/root-a/src/main/java', + ]) + const pathsB = emptyArtifactPaths() + pathsB.sourcesByCoord.set('com.example:shared:1.0', [ + '/root-b/src/main/java', + ]) + + const acc: SidecarAccumulator = new Map() + accumulateSidecar( + acc, + sharedModuleFacts, + pathsA, + '/root-a/.socket.facts.json', + ) + accumulateSidecar( + acc, + sharedModuleFacts, + pathsB, + '/root-b/.socket.facts.json', + ) + const resolved = serializeSidecar(acc) + + expect(Object.keys(resolved)).toEqual([ + '/root-a/.socket.facts.json', + '/root-b/.socket.facts.json', + ]) + expect( + resolved['/root-a/.socket.facts.json']!.projects[0]!.sources, + ).toEqual(['/root-a/src/main/java']) + expect( + resolved['/root-b/.socket.facts.json']!.projects[0]!.sources, + ).toEqual(['/root-b/src/main/java']) + }) + + it('hasSidecarEntries reports empty until a facts file is accumulated', () => { + const acc: SidecarAccumulator = new Map() + expect(hasSidecarEntries(acc)).toBe(false) + + accumulateSidecar( + acc, + { components: [] }, + emptyArtifactPaths(), + '/root/.socket.facts.json', + ) + expect(hasSidecarEntries(acc)).toBe(true) + }) + + it('mergeResolvedPathsSidecars unions distinct facts-file keys from two already-serialized sidecars', () => { + const accA: SidecarAccumulator = new Map() + accumulateSidecar( + accA, + { components: [] }, + emptyArtifactPaths(), + '/root-a/.socket.facts.json', + ) + const sidecarA = serializeSidecar(accA) + + const accB: SidecarAccumulator = new Map() + accumulateSidecar( + accB, + { components: [] }, + emptyArtifactPaths(), + '/root-b/.socket.facts.json', + ) + const sidecarB = serializeSidecar(accB) + + const merged = mergeResolvedPathsSidecars(sidecarA, sidecarB) + + expect(Object.keys(merged)).toEqual([ + '/root-a/.socket.facts.json', + '/root-b/.socket.facts.json', + ]) + expect(hasResolvedPathsSidecarEntries(merged)).toBe(true) + }) + + it('hasResolvedPathsSidecarEntries reports false for a wholly empty sidecar', () => { + expect(hasResolvedPathsSidecarEntries({})).toBe(false) }) }) diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index d1b1a888d..39e2ab3b8 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -56,6 +56,7 @@ describe('socket scan create', async () => { --workspace The workspace in the Socket Organization that the repository is in to associate with the full scan. Reachability Options (when --reach is used) + --dynamic-sbom-inference For Gradle, sbt, and Maven: splits reachability analysis per project/module using a Socket facts SBOM (generated directly by each package manager) per build root, instead of one synthetic root. Reachability analysis only; implies --auto-manifest. --reach-analysis-memory-limit The maximum memory for the reachability analysis as a whole number optionally followed by MB or GB (e.g. 512MB, 8GB). The default is 8GB. --reach-analysis-timeout Set the timeout for the reachability analysis as a whole number optionally followed by s, m or h (e.g. 90s, 10m, 1h). Defaults to 10m. Split analysis runs may cause the total scan time to exceed this timeout significantly. --reach-concurrency Set the maximum number of concurrent reachability analysis runs. It is recommended to choose a concurrency level that ensures each analysis run has at least the --reach-analysis-memory-limit amount of memory available. diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index 42f910be7..70c280842 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -33,6 +33,21 @@ const description = 'Compute full application reachability' const hidden = true +// dynamicSbomInference relies on --auto-manifest generating per-workspace +// Socket facts first, which this command never runs (see the hardcoded +// `false` passed to handleScanReach below) - hidden here even though it's +// otherwise public on `scan create`, since advertising a flag this command +// silently ignores would be misleading. +const reachabilityFlagsForReach: MeowFlags = { + ...reachabilityFlags, + dynamicSbomInference: { + type: 'boolean', + default: false, + hidden: true, + description: reachabilityFlags['dynamicSbomInference']!.description, + }, +} + const generalFlags: MeowFlags = { ...commonFlags, ...outputFlags, @@ -74,7 +89,7 @@ async function run( flags: { ...generalFlags, ...excludePathsFlag, - ...reachabilityFlags, + ...reachabilityFlagsForReach, }, help: command => ` @@ -88,7 +103,7 @@ async function run( ${getFlagListOutput(generalFlags)} Reachability Options - ${getFlagListOutput({ ...excludePathsFlag, ...reachabilityFlags })} + ${getFlagListOutput({ ...excludePathsFlag, ...reachabilityFlagsForReach })} Runs the Socket reachability analysis without creating a scan in Socket. The output is written to .socket.facts.json in the current working directory diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 0f4d3a7fa..86928460d 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -18,17 +18,27 @@ import constants from '../../constants.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { compressSocketFactsForUpload } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' +import { InputError } from '../../utils/errors.mts' import { withTmpDir } from '../../utils/fs.mts' import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { socketDocsLink } from '../../utils/terminal-link.mts' import { detectManifestActions } from '../manifest/detect-manifest-actions.mts' +import { generateRecursiveManifests } from '../manifest/generate-recursive-manifests.mts' import { generateAutoManifest } from '../manifest/generate_auto_manifest.mts' +import { + hasSidecarEntries, + mergeResolvedPathsSidecars, + serializeSidecar, +} from '../manifest/scripts/sidecar.mts' import type { ReachabilityOptions } from './perform-reachability-analysis.mts' import type { REPORT_LEVEL } from './types.mts' import type { OutputKind } from '../../types.mts' -import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' +import type { + ResolvedPathsSidecar, + SidecarAccumulator, +} from '../manifest/scripts/sidecar.mts' import type { Remap } from '@socketsecurity/registry/lib/objects' import type { SocketSdkSuccessResult } from '@socketsecurity/sdk' @@ -153,6 +163,50 @@ export async function handleCreateNewScan({ const sockJson = readOrDefaultSocketJson(cwd) const detected = await detectManifestActions(sockJson, cwd) debugDir('inspect', { detected }) + + if (reach.dynamicSbomInference) { + // Recursively discover and generate Socket facts for every + // independent gradle/sbt/maven build root instead of only the one at + // cwd; generateAutoManifest below is left to handle conda/bazel only. + detected.gradle = false + detected.sbt = false + detected.maven = false + + const sidecarAcc: SidecarAccumulator | undefined = + reach.runReachabilityAnalysis ? new Map() : undefined + const outcomes = await generateRecursiveManifests({ + cwd, + excludePaths: reach.excludePaths, + // sbt's Scala toolchain lives under its shared global base; + // withFiles' resolved paths point into it, so when reachability + // will consume them afterward, reuse manifestTmpDir (kept alive + // until reach finishes below) instead of letting this call clean + // its own ephemeral base up before reach ever reads those paths. + sbtTmpDir: reach.runReachabilityAnalysis ? manifestTmpDir : undefined, + sidecarAcc, + verbose: false, + withFiles: reach.runReachabilityAnalysis, + }) + // Fail loud rather than silently upload a partial multi-root scan: + // matches handleManifestDynamicSbomInference's own check. + if (outcomes.some(o => o.status === 'failed')) { + throw new InputError( + 'One or more independent build roots failed to generate Socket facts; aborting (see the errors above).', + ) + } + const generatedFactsPaths = outcomes + .filter(o => o.status === 'generated') + .map(o => o.factsPath!) + if (generatedFactsPaths.length) { + scanTargets = Array.from( + new Set([...scanTargets, ...generatedFactsPaths]), + ) + } + if (sidecarAcc && hasSidecarEntries(sidecarAcc)) { + resolvedPathsSidecar = serializeSidecar(sidecarAcc) + } + } + const autoManifestResult = await generateAutoManifest({ computeArtifactsSidecar: reach.runReachabilityAnalysis, cwd, @@ -162,10 +216,17 @@ export async function handleCreateNewScan({ tmpDir: manifestTmpDir, verbose: false, }) - resolvedPathsSidecar = autoManifestResult.resolvedPathsSidecar + if (autoManifestResult.resolvedPathsSidecar) { + resolvedPathsSidecar = resolvedPathsSidecar + ? mergeResolvedPathsSidecars( + resolvedPathsSidecar, + autoManifestResult.resolvedPathsSidecar, + ) + : autoManifestResult.resolvedPathsSidecar + } if (autoManifestResult.generatedFiles.length) { scanTargets = Array.from( - new Set([...targets, ...autoManifestResult.generatedFiles]), + new Set([...scanTargets, ...autoManifestResult.generatedFiles]), ) } logger.info('Auto-generation finished. Proceeding with Scan creation.') diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 2c1cc624c..2befdaf16 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -12,6 +12,7 @@ const { mockFetchSupportedScanFileNames, mockFindSocketYmlSync, mockGenerateAutoManifest, + mockGenerateRecursiveManifests, mockGetPackageFilesForScan, mockPerformReachabilityAnalysis, mockReadOrDefaultSocketJson, @@ -20,6 +21,7 @@ const { mockFetchSupportedScanFileNames: vi.fn(), mockFindSocketYmlSync: vi.fn(), mockGenerateAutoManifest: vi.fn(), + mockGenerateRecursiveManifests: vi.fn(), mockGetPackageFilesForScan: vi.fn(), mockPerformReachabilityAnalysis: vi.fn(), mockReadOrDefaultSocketJson: vi.fn(), @@ -65,6 +67,10 @@ vi.mock('../manifest/detect-manifest-actions.mts', () => ({ detectManifestActions: vi.fn(() => Promise.resolve({ count: 0 })), })) +vi.mock('../manifest/generate-recursive-manifests.mts', () => ({ + generateRecursiveManifests: mockGenerateRecursiveManifests, +})) + vi.mock('../manifest/generate_auto_manifest.mts', () => ({ generateAutoManifest: mockGenerateAutoManifest, })) @@ -136,6 +142,7 @@ describe('handleCreateNewScan excludePaths', () => { ok: true, }) mockGenerateAutoManifest.mockResolvedValue({ generatedFiles: [] }) + mockGenerateRecursiveManifests.mockResolvedValue([]) mockGetPackageFilesForScan.mockResolvedValue(['package.json']) mockPerformReachabilityAnalysis.mockResolvedValue({ data: { @@ -171,6 +178,134 @@ describe('handleCreateNewScan excludePaths', () => { expect(mockFetchCreateOrgFullScan).toHaveBeenCalled() }) + it('drives JVM facts generation through generateRecursiveManifests under --dynamic-sbom-inference, merging generated facts into scan targets', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + { + dir: '/repo/service-a', + ecosystem: 'maven', + factsPath: '/repo/service-a/.socket.facts.json', + status: 'generated', + }, + { + dir: '/repo/service-b', + ecosystem: 'gradle', + factsPath: '/repo/service-b/.socket.facts.json', + status: 'generated', + }, + { dir: '/repo/service-c', ecosystem: 'maven', status: 'empty' }, + ]) + + const config = createConfig({ autoManifest: true, targets: ['/repo'] }) + config.reach.dynamicSbomInference = true + + await handleCreateNewScan(config) + + expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/repo', + sbtTmpDir: undefined, + withFiles: false, + }), + ) + expect(mockGenerateAutoManifest).toHaveBeenCalledWith( + expect.objectContaining({ + detected: expect.objectContaining({ + gradle: false, + maven: false, + sbt: false, + }), + }), + ) + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + [ + '/repo', + '/repo/service-a/.socket.facts.json', + '/repo/service-b/.socket.facts.json', + ], + { size: 1 }, + { + additionalIgnores: [], + config: { projectIgnorePaths: ['fixtures/**'] }, + cwd: '/repo', + }, + ) + }) + + it('aborts instead of silently uploading a partial scan when a recursive build root fails', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + { + dir: '/repo/service-a', + ecosystem: 'maven', + factsPath: '/repo/service-a/.socket.facts.json', + status: 'generated', + }, + { dir: '/repo/service-b', ecosystem: 'maven', status: 'failed' }, + ]) + + const config = createConfig({ autoManifest: true, targets: ['/repo'] }) + config.reach.dynamicSbomInference = true + + await expect(handleCreateNewScan(config)).rejects.toThrow( + /one or more independent build roots failed/i, + ) + expect(mockGetPackageFilesForScan).not.toHaveBeenCalled() + expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() + }) + + it('accumulates a sidecar across recursively discovered build roots and forwards it to reachability analysis', async () => { + mockGenerateRecursiveManifests.mockImplementationOnce( + async ({ sidecarAcc }) => { + sidecarAcc?.set('/repo/service-a/.socket.facts.json', { + projects: [ + { + type: 'maven', + namespace: 'com.example', + name: 'app', + version: '1.0', + subprojectDir: '.', + dependencies: [], + resolvedAs: [], + targets: ['/repo/service-a/build/classes'], + sources: ['/repo/service-a/src/main/java'], + }, + ], + components: [], + }) + return [ + { + dir: '/repo/service-a', + ecosystem: 'maven', + factsPath: '/repo/service-a/.socket.facts.json', + status: 'generated', + }, + ] + }, + ) + + const config = createConfig({ autoManifest: true, targets: ['/repo'] }) + config.reach.dynamicSbomInference = true + config.reach.runReachabilityAnalysis = true + + await handleCreateNewScan(config) + + expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( + expect.objectContaining({ + sbtTmpDir: expect.any(String), + withFiles: true, + }), + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + resolvedPathsSidecar: { + '/repo/service-a/.socket.facts.json': { + projects: [expect.objectContaining({ name: 'app' })], + components: [], + }, + }, + }), + ) + }) + it('aborts before scan creation when auto-manifest generation fails', async () => { mockGenerateAutoManifest.mockRejectedValueOnce( new Error('Bazel auto-manifest generation failed'), diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 4db3a2ad4..80614a0b7 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -13,6 +13,7 @@ import { spawnCoanaDlx } from '../../utils/dlx.mts' import { hasEnterpriseOrgPlan } from '../../utils/organization.mts' import { setupSdk } from '../../utils/sdk.mts' import { socketDevLink } from '../../utils/terminal-link.mts' +import { hasResolvedPathsSidecarEntries } from '../manifest/scripts/sidecar.mts' import { fetchOrganization } from '../organization/fetch-organization-list.mts' import type { CResult, OutputKind } from '../../types.mts' @@ -187,7 +188,10 @@ export async function performReachabilityAnalysis( // Write the sidecar to a temp file for `--compute-artifacts-sidecar`; cleaned // up in the finally below. let sidecarPath: string | undefined - if (resolvedPathsSidecar?.length) { + if ( + resolvedPathsSidecar && + hasResolvedPathsSidecarEntries(resolvedPathsSidecar) + ) { sidecarPath = path.join( tmpdir(), `socket-compute-artifacts-sidecar-${randomUUID()}.json`, @@ -253,7 +257,7 @@ export async function performReachabilityAnalysis( ? ['--exclude-dirs', ...reachabilityOptions.reachExcludePaths] : []), ...(reachabilityOptions.dynamicSbomInference - ? ['--maven-use-only-root-socket-facts'] + ? ['--maven-use-only-socket-facts'] : []), ...(reachabilityOptions.reachLazyMode ? ['--lazy-mode'] : []), ...(reachabilityOptions.reachSkipCache ? ['--skip-cache-usage'] : []), diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index 8fcea365b..14b9f1523 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -7,9 +7,8 @@ export const reachabilityFlags: MeowFlags = { dynamicSbomInference: { type: 'boolean', default: false, - hidden: true, description: - 'Internal: enables dynamic SBOM inference for full application reachability analysis. Passes --maven-use-only-root-socket-facts to Coana and implies --auto-manifest.', + 'For Gradle, sbt, and Maven: splits reachability analysis per project/module using a Socket facts SBOM (generated directly by each package manager) per build root, instead of one synthetic root. Reachability analysis only; implies --auto-manifest.', }, reachVersion: { type: 'string', diff --git a/src/utils/fs.mts b/src/utils/fs.mts index 44c91c631..229792010 100644 --- a/src/utils/fs.mts +++ b/src/utils/fs.mts @@ -79,3 +79,14 @@ export async function withTmpDir( await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) } } + +// Symlink-resolved absolute path when the target exists (e.g. macOS symlinks +// /tmp -> /private/tmp); falls back to a plain resolve so a not-yet-existing +// path still gets a usable absolute value instead of throwing. +export async function realpathOrResolved(target: string): Promise { + try { + return await fs.realpath(target) + } catch { + return path.resolve(target) + } +}