From 25822818fd046887da305d73c6224a48c4fafe1f Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 5 Aug 2026 12:04:10 +0200 Subject: [PATCH 1/9] feat(scan): drive --dynamic-sbom-inference through the recursive multi-root generator socket scan create --dynamic-sbom-inference (with or without --reach) now generates Socket facts recursively across every independent gradle/sbt/maven build root under the scan target, instead of only the one at the scan root. Redesign the resolved-artifact-paths sidecar passed to Coana (--compute-artifacts-sidecar) to be keyed by build root rather than a single flat, cross-root-deduplicated list, so two independent build roots emitting modules with the same package identity can never collide - each root's data is structurally isolated instead of relying on a filtering convention. Reuse a single sbt toolchain-provisioning directory across every sbt root discovered in one run instead of re-provisioning it per root. REA-704 --- .../manifest/discover-manifest-roots.mts | 10 +- .../manifest/generate-recursive-manifests.mts | 203 ++++++++++------ .../generate-recursive-manifests.test.mts | 76 ++++++ .../manifest/generate_auto_manifest.mts | 6 +- src/commands/manifest/run-manifest-facts.mts | 12 +- .../manifest/run-manifest-facts.test.mts | 41 ++++ .../manifest/scripts/assemble.test.mts | 40 ++-- src/commands/manifest/scripts/sidecar.mts | 196 +++++++++------- .../manifest/scripts/sidecar.test.mts | 221 +++++++++++++++--- src/commands/scan/handle-create-new-scan.mts | 53 ++++- .../scan/handle-create-new-scan.test.mts | 107 +++++++++ .../scan/perform-reachability-analysis.mts | 8 +- src/commands/scan/reachability-flags.mts | 2 +- src/utils/fs.mts | 11 + 14 files changed, 766 insertions(+), 220 deletions(-) diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts index cee91b543d..e8ff4f9014 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 979184f604..c48ebfb747 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,84 +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. 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() @@ -216,7 +163,10 @@ export async function generateRecursiveManifests({ ignoreUnresolved, includeConfigs, javaHome, + sidecarAcc, + tmpDir: ecosystem === 'sbt' ? sbtTmpDir : undefined, verbose, + withFiles, }) if (result === null) { @@ -252,6 +202,66 @@ 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. 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, + sidecarAcc, + verbose, + withFiles, +}: { + cwd: string + excludePaths?: string[] | undefined + // Reachability path only: run build tools with files and fold resolved + // artifact paths into sidecarAcc, tagged with 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 = (sbtTmpDir: string | undefined) => + runEcosystemCandidates({ + candidatesByTool, + excludePaths, + realCwd, + rootSockJson, + sbtTmpDir, + 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). Skipped entirely when there's no sbt root to benefit. + const outcomes = 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).`) @@ -259,3 +269,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 8f4cb97ca1..405e9a68ba 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -541,4 +541,80 @@ 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('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 947df2983b..f063e018b2 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 a3816840f8..20688c68ba 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) + // Tag with the symlink-resolved path so factsFiles is 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 3320707fe7..a0996ede2d 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('tags a project entry with 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 be2e1b0ebf..0d896f4a22 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 d4cd81a272..a88bcbae5f 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 - REA-687). `[]` + // 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 86a74ff2e4..2e894a4f2a 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/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 0f4d3a7fab..42f8e22263 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -23,12 +23,21 @@ 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 +162,37 @@ 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, + sidecarAcc, + verbose: false, + withFiles: reach.runReachabilityAnalysis, + }) + 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 +202,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 2c1cc624ce..818c7a8915 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,106 @@ 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', 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('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({ 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 4db3a2ad40..80614a0b7b 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 8fcea365bc..c7eca92fae 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -9,7 +9,7 @@ export const reachabilityFlags: MeowFlags = { 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.', + 'Internal: enables dynamic SBOM inference for full application reachability analysis. Recursively generates Socket facts for every independent gradle/sbt/maven build root, passes --maven-use-only-socket-facts to Coana, and implies --auto-manifest.', }, reachVersion: { type: 'string', diff --git a/src/utils/fs.mts b/src/utils/fs.mts index 44c91c631e..2297920107 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) + } +} From 73bf9df316fe3ac3c36ab9e4bcf84ded7c41dade Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 5 Aug 2026 12:12:39 +0200 Subject: [PATCH 2/9] chore(scan): fix stale comments left over from an earlier sidecar design A few comments/test titles still referred to "factsFiles" and "tagging" from an intermediate sidecar iteration that keyed entries by tag rather than by top-level facts-file key. Update them to match the shipped design. --- src/commands/manifest/generate-recursive-manifests.mts | 2 +- src/commands/manifest/run-manifest-facts.mts | 2 +- src/commands/manifest/run-manifest-facts.test.mts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index c48ebfb747..041077f679 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -223,7 +223,7 @@ export async function generateRecursiveManifests({ cwd: string excludePaths?: string[] | undefined // Reachability path only: run build tools with files and fold resolved - // artifact paths into sidecarAcc, tagged with each root's own factsPath. + // artifact paths into sidecarAcc, keyed by each root's own factsPath. sidecarAcc?: SidecarAccumulator | undefined verbose: boolean withFiles?: boolean | undefined diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 20688c68ba..f4e39a70a3 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -224,7 +224,7 @@ export async function runManifestFacts({ await fs.writeFile(factsPath, JSON.stringify(facts, null, 2), 'utf8') if (withFiles && sidecarAcc) { - // Tag with the symlink-resolved path so factsFiles is comparable + // 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). diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index a0996ede2d..7f185d277f 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -112,7 +112,7 @@ describe('runManifestFacts - sidecar', () => { process.exitCode = undefined }) - it('tags a project entry with the symlink-resolved factsPath, not the raw cwd-joined one', async () => { + it('keys the sidecar by the symlink-resolved factsPath, not the raw cwd-joined one', async () => { const result = okResult() result.facts.projects = [ { From cc70623f97422f3414e278cba130228a8f76b4b7 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 6 Aug 2026 10:10:39 +0200 Subject: [PATCH 3/9] chore(deps): bump @coana-tech/cli to 15.10.5 Needed for multi-root --dynamic-sbom-inference: the per-facts-file sidecar format this branch produces requires a matching Coana CLI that consumes it. --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 61e3c4fa4e..89ec5b6471 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "@babel/preset-typescript": "7.27.1", "@babel/runtime": "7.28.4", "@biomejs/biome": "2.2.4", - "@coana-tech/cli": "15.10.0", + "@coana-tech/cli": "15.10.5", "@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 85c6670e29..29642d3146 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,8 +132,8 @@ importers: specifier: 2.2.4 version: 2.2.4 '@coana-tech/cli': - specifier: 15.10.0 - version: 15.10.0 + specifier: 15.10.5 + version: 15.10.5 '@cyclonedx/cdxgen': specifier: 12.1.2 version: 12.1.2 @@ -806,8 +806,8 @@ packages: resolution: {integrity: sha512-hAs5PPKPCQ3/Nha+1fo4A4/gL85fIfxZwHPehsjCJ+BhQH2/yw6/xReuaPA/RfNQr6iz1PcD7BZcE3ctyyl3EA==} cpu: [x64] - '@coana-tech/cli@15.10.0': - resolution: {integrity: sha512-iNYyrKyHcUJIMpwjEjSYeKfQityLj35ON+aYnnNpEWkaepW2qbrYhLMw1dcCRU1QOUeuM2Rurp2G3zNALxtH8g==} + '@coana-tech/cli@15.10.5': + resolution: {integrity: sha512-uBQ0BaBIuPXhWatm9Y+kWPA5RZqzhnwQs5++xNlGpuV87BRUa7OGl5FI1CayTIxA09yjr/pPT70qGtPvZ3t2Lg==} hasBin: true '@colors/colors@1.5.0': @@ -5509,7 +5509,7 @@ snapshots: '@cdxgen/cdxgen-plugins-bin@2.0.2': optional: true - '@coana-tech/cli@15.10.0': {} + '@coana-tech/cli@15.10.5': {} '@colors/colors@1.5.0': optional: true From f1f18916920fe63055ecbdaea7bce238e57db733 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 6 Aug 2026 10:55:13 +0200 Subject: [PATCH 4/9] fix(scan): abort on partial recursive manifest failure, keep sbt's shared boot dir alive through reach Address three issues from PR review: - handleCreateNewScan never inspected generateRecursiveManifests' outcomes for failed/aborted build roots, so a partial multi-root JVM run could silently upload and run reachability on an incomplete facts set. Now aborts loudly, matching handleManifestDynamicSbomInference's own check. - generateRecursiveManifests always allocated and cleaned up its own ephemeral shared sbt global base, even when reachability analysis would need to read resolved paths (e.g. the Scala standard library) out of it afterward. It now accepts a caller-supplied sbtTmpDir and reuses it as-is without cleaning it up; handleCreateNewScan passes the existing manifestTmpDir (already kept alive until reach finishes) when reach is on. - Dropped a stray internal ticket reference from a source comment. --- .../manifest/generate-recursive-manifests.mts | 27 +++++++++--- .../generate-recursive-manifests.test.mts | 42 +++++++++++++++++++ src/commands/manifest/scripts/sidecar.mts | 2 +- src/commands/scan/handle-create-new-scan.mts | 14 +++++++ .../scan/handle-create-new-scan.test.mts | 32 +++++++++++++- 5 files changed, 108 insertions(+), 9 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 041077f679..b7be9419ed 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -216,12 +216,23 @@ async function runEcosystemCandidates({ 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 @@ -242,13 +253,13 @@ export async function generateRecursiveManifests({ sockJson: withoutDisabledFlags(rootSockJson), }) - const runAll = (sbtTmpDir: string | undefined) => + const runAll = (resolvedSbtTmpDir: string | undefined) => runEcosystemCandidates({ candidatesByTool, excludePaths, realCwd, rootSockJson, - sbtTmpDir, + sbtTmpDir: resolvedSbtTmpDir, sidecarAcc, verbose, withFiles, @@ -258,10 +269,14 @@ export async function generateRecursiveManifests({ // 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). Skipped entirely when there's no sbt root to benefit. - const outcomes = candidatesByTool.get('sbt')?.length - ? await withTmpDir('socket-sbt-facts-shared-', runAll) - : await runAll(undefined) + // 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).`) diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 405e9a68ba..1b5eaff6a8 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -597,6 +597,48 @@ describe('generateRecursiveManifests', () => { } }) + 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'), diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index a88bcbae5f..f5363fde15 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -10,7 +10,7 @@ import type { 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 - REA-687). `[]` + // 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 diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 42f8e22263..86928460d5 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -18,6 +18,7 @@ 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' @@ -176,10 +177,23 @@ export async function handleCreateNewScan({ 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!) diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 818c7a8915..2befdaf161 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -201,7 +201,11 @@ describe('handleCreateNewScan excludePaths', () => { await handleCreateNewScan(config) expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( - expect.objectContaining({ cwd: '/repo', withFiles: false }), + expect.objectContaining({ + cwd: '/repo', + sbtTmpDir: undefined, + withFiles: false, + }), ) expect(mockGenerateAutoManifest).toHaveBeenCalledWith( expect.objectContaining({ @@ -227,6 +231,27 @@ describe('handleCreateNewScan excludePaths', () => { ) }) + 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 }) => { @@ -264,7 +289,10 @@ describe('handleCreateNewScan excludePaths', () => { await handleCreateNewScan(config) expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( - expect.objectContaining({ withFiles: true }), + expect.objectContaining({ + sbtTmpDir: expect.any(String), + withFiles: true, + }), ) expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( expect.objectContaining({ From 1f291fc82edf10357499031cc9c140e6d9a193bb Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 6 Aug 2026 11:56:07 +0200 Subject: [PATCH 5/9] fix(manifest): never suppress an escaping subprojectDir's own facts generation A subprojectDir that escapes its declaring reactor's own directory (e.g. Maven's ../shared-lib, or a Gradle projectDir relocation) was previously marked covered like any nested member, so whether it got its own independent facts generation depended purely on alphabetical discovery order relative to the reactor(s) that reference it. Such a path is independently locatable and potentially resolved differently on its own (e.g. a dependency version override the referencing reactor applies but a standalone build of the same directory would not), so it's a distinct, meaningful data point, not a redundant one. Only a genuine descendant of the reactor's own directory is now treated as covered. --- .../manifest/generate-recursive-manifests.mts | 20 +++++- .../generate-recursive-manifests.test.mts | 61 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index b7be9419ed..fd329f20c2 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -192,7 +192,20 @@ async function runEcosystemCandidates({ ), ) for (const subprojectDir of resolvedSubprojectDirs) { - covered.add(subprojectDir) + // Only a genuinely nested member (a descendant of this reactor's own + // directory) has no independent existence worth its own standalone + // analysis. A subprojectDir that escapes this reactor's own tree (a + // sibling, e.g. Maven's `../shared-lib` or Gradle's + // relocated projectDir) is independently locatable and potentially + // independently consumed or published - its own un-mediated + // resolution (e.g. a dependency version this reactor's own + // dependency management happens to override) is a distinct, + // meaningful data point, not a redundant one. Never suppress its own + // build-root invocation, regardless of which reactor(s) also + // incorporate it or the order candidates happen to be discovered in. + if (subprojectDir.startsWith(`${dir}${path.sep}`)) { + covered.add(subprojectDir) + } } outcomes.push({ dir, @@ -209,7 +222,10 @@ async function runEcosystemCandidates({ // 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. Fail-closed per ecosystem, not globally: a root whose +// 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. diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 1b5eaff6a8..f2cf09d930 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -106,6 +106,67 @@ describe('generateRecursiveManifests', () => { ) }) + it.each([ + // Escaping references must get their own independent invocation + // regardless of where they happen to sort alphabetically relative to the + // reactor that declares them - before ('aaa-shared-lib') and after + // ('zzz-shared-lib') both have to behave identically. + ['aaa-shared-lib'], + ['zzz-shared-lib'], + ])( + 'never suppresses a sibling subprojectDir that escapes its declaring reactor (name: %s)', + async sharedLibName => { + const outer = await fs.realpath( + await fs.mkdtemp(path.join(tmpdir(), 'escaping-subproject-')), + ) + const reactorA = path.join(outer, 'reactor-a') + const sharedLib = path.join(outer, sharedLibName) + try { + await fs.mkdir(reactorA, { recursive: true }) + await fs.mkdir(sharedLib, { recursive: true }) + await fs.writeFile(path.join(reactorA, 'pom.xml'), '') + await fs.writeFile(path.join(sharedLib, 'pom.xml'), '') + + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => { + if (cwd === reactorA) { + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [ + { + type: 'maven', + name: 'shared-lib', + subprojectDir: `../${sharedLibName}`, + dependencies: [], + resolvedAs: [], + }, + ], + } + } + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + } + }) + + const outcomes = await generateRecursiveManifests({ + cwd: outer, + verbose: false, + }) + + const byDir = new Map(outcomes.map(o => [o.dir, o.status])) + expect(byDir.get(reactorA)).toBe('generated') + expect(byDir.get(sharedLib)).toBe('generated') + expect( + vi + .mocked(runManifestFacts) + .mock.calls.some(([opts]) => opts.cwd === sharedLib), + ).toBe(true) + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }, + ) + it("runs both ecosystems unconditionally at a dual-marker directory (matches auto's existing behavior)", async () => { vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ factsPath: path.join(cwd, '.socket.facts.json'), From 07d0f36c5577e719c709e7d0c0e4ed2438143988 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 6 Aug 2026 20:15:16 +0200 Subject: [PATCH 6/9] feat(scan): un-hide --dynamic-sbom-inference and bump Coana to 15.10.8 The multi-root recursive facts generation has now landed and shipped on the Coana side, so this drops the internal/hidden status and documents the scoping clearly: for Gradle, sbt, and Maven, only dependencies present in the generated Socket facts SBOM files are used for analysis; other ecosystems are unaffected and analyze as normal. --- CHANGELOG.md | 5 ++++- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/commands/scan/cmd-scan-create.test.mts | 1 + src/commands/scan/cmd-scan-reach.test.mts | 1 + src/commands/scan/reachability-flags.mts | 3 +-- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7991e8c7c3..1ab77fdb17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- `socket scan create`/`socket scan reach --dynamic-sbom-inference` recursively discovers every independent Gradle, sbt, and Maven build root and generates a Socket facts SBOM for each, enabling full application reachability analysis across multi-module JVM monorepos. For Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are used for analysis; other ecosystems are analyzed as normal. + ### Changed -- Updated the Coana CLI to v `15.10.5`. +- 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. diff --git a/package.json b/package.json index ebeef7c210..86f3ef3652 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.5", + "@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 c63175dc5a..a97007eb9f 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.5 - version: 15.10.5 + 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.5': - resolution: {integrity: sha512-uBQ0BaBIuPXhWatm9Y+kWPA5RZqzhnwQs5++xNlGpuV87BRUa7OGl5FI1CayTIxA09yjr/pPT70qGtPvZ3t2Lg==} + '@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.5': {} + '@coana-tech/cli@15.10.8': {} '@colors/colors@1.5.0': optional: true diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index d1b1a888de..20151d6fc6 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 Enables dynamic SBOM inference for full application reachability analysis. Recursively generates a Socket facts SBOM for every independent Gradle, sbt, and Maven build root, and implies --auto-manifest. For Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are used for analysis; other ecosystems are analyzed as normal. --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.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 21d925bdf2..e740ba322a 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -37,6 +37,7 @@ describe('socket scan reach', async () => { --output Path to write the reachability report to (must end with .json). Defaults to .socket.facts.json in the current working directory. Reachability Options + --dynamic-sbom-inference Enables dynamic SBOM inference for full application reachability analysis. Recursively generates a Socket facts SBOM for every independent Gradle, sbt, and Maven build root, and implies --auto-manifest. For Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are used for analysis; other ecosystems are analyzed as normal. --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`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. --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. diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index c7eca92fae..c4a31f3a2b 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. Recursively generates Socket facts for every independent gradle/sbt/maven build root, passes --maven-use-only-socket-facts to Coana, and implies --auto-manifest.', + 'Enables dynamic SBOM inference for full application reachability analysis. Recursively generates a Socket facts SBOM for every independent Gradle, sbt, and Maven build root, and implies --auto-manifest. For Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are used for analysis; other ecosystems are analyzed as normal.', }, reachVersion: { type: 'string', From 31341472d596047931c3573038bd7efa82b2f506 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 6 Aug 2026 21:06:19 +0200 Subject: [PATCH 7/9] feat(scan,manifest): un-hide the rest of the dynamic-sbom-inference surface - Un-hide `socket manifest setup --dynamic-sbom-inference` and its companion --exclude-paths flag, and `socket manifest dynamic-sbom-inference` itself, matching the scan create flag. - Re-hide --dynamic-sbom-inference specifically on `socket scan reach`: it's silently ignored there (that command never runs --auto-manifest), so advertising it in --help would be misleading. socket scan reach itself remains internal-only. - Correct the --help and changelog wording: the main benefit is splitting reachability analysis by project/subproject/module/workspace instead of coarsely treating everything as one synthetic root; the "only these dependencies are used" restriction applies to reachability analysis only, not the scan's own dependency detection, which is unaffected. - Document the setup wizard and standalone manifest command in the changelog alongside the scan create flag. --- CHANGELOG.md | 4 +++- .../cmd-manifest-dynamic-sbom-inference.mts | 5 +---- src/commands/manifest/cmd-manifest-setup.mts | 4 +--- .../manifest/cmd-manifest-setup.test.mts | 2 ++ src/commands/manifest/cmd-manifest.test.mts | 1 + src/commands/scan/cmd-scan-create.test.mts | 2 +- src/commands/scan/cmd-scan-reach.mts | 19 +++++++++++++++++-- src/commands/scan/cmd-scan-reach.test.mts | 1 - src/commands/scan/reachability-flags.mts | 2 +- 9 files changed, 27 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab77fdb17..79d345986a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added -- `socket scan create`/`socket scan reach --dynamic-sbom-inference` recursively discovers every independent Gradle, sbt, and Maven build root and generates a Socket facts SBOM for each, enabling full application reachability analysis across multi-module JVM monorepos. For Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are used for analysis; other ecosystems are analyzed as normal. +- `socket scan create --reach --dynamic-sbom-inference` splits full application reachability analysis by project and subproject/module/workspace for Gradle, sbt, and Maven monorepos, instead of coarsely analyzing everything together as one synthetic root. It recursively discovers every independent build root and generates a Socket facts SBOM for each. This applies to reachability analysis only: the scan's own dependency detection is unaffected, and other ecosystems are unaffected too. +- `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, so each can be configured individually. +- `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.8`. diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts index 61fc28864e..980c198cab 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 4d0b3b9b68..60ead27ada 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,7 +35,6 @@ 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', }, diff --git a/src/commands/manifest/cmd-manifest-setup.test.mts b/src/commands/manifest/cmd-manifest-setup.test.mts index bd2fb489d9..5a5eaaa4af 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 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 + --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 93c264770a..124258616b 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/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 20151d6fc6..3a7b3bfd54 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -56,7 +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 Enables dynamic SBOM inference for full application reachability analysis. Recursively generates a Socket facts SBOM for every independent Gradle, sbt, and Maven build root, and implies --auto-manifest. For Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are used for analysis; other ecosystems are analyzed as normal. + --dynamic-sbom-inference Enables per-project, per-subproject/module/workspace reachability analysis for Gradle, sbt, and Maven, instead of the default coarse-grained analysis where everything is treated as one synthetic root. Recursively discovers every independent build root and generates a Socket facts SBOM for each (implies --auto-manifest). Applies to reachability analysis only: for Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are considered reachable; the scan itself still detects all dependencies as normal, and other ecosystems are unaffected. --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 42f910be72..70c2808426 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/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index e740ba322a..21d925bdf2 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -37,7 +37,6 @@ describe('socket scan reach', async () => { --output Path to write the reachability report to (must end with .json). Defaults to .socket.facts.json in the current working directory. Reachability Options - --dynamic-sbom-inference Enables dynamic SBOM inference for full application reachability analysis. Recursively generates a Socket facts SBOM for every independent Gradle, sbt, and Maven build root, and implies --auto-manifest. For Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are used for analysis; other ecosystems are analyzed as normal. --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`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. --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. diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index c4a31f3a2b..57e6714a1b 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -8,7 +8,7 @@ export const reachabilityFlags: MeowFlags = { type: 'boolean', default: false, description: - 'Enables dynamic SBOM inference for full application reachability analysis. Recursively generates a Socket facts SBOM for every independent Gradle, sbt, and Maven build root, and implies --auto-manifest. For Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are used for analysis; other ecosystems are analyzed as normal.', + 'Enables per-project, per-subproject/module/workspace reachability analysis for Gradle, sbt, and Maven, instead of the default coarse-grained analysis where everything is treated as one synthetic root. Recursively discovers every independent build root and generates a Socket facts SBOM for each (implies --auto-manifest). Applies to reachability analysis only: for Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are considered reachable; the scan itself still detects all dependencies as normal, and other ecosystems are unaffected.', }, reachVersion: { type: 'string', From 2265185cf06645fd8da38832673c01fa86e44086 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 7 Aug 2026 09:13:22 +0200 Subject: [PATCH 8/9] docs(scan,manifest): tighten --dynamic-sbom-inference descriptions per review Per mtorp's review: clarify that a Socket facts SBOM is inferred directly by the package manager tools rather than static resolution, and trim both the scan create/reach and manifest setup flag descriptions considerably. --- CHANGELOG.md | 4 ++-- src/commands/manifest/cmd-manifest-setup.mts | 2 +- src/commands/manifest/cmd-manifest-setup.test.mts | 2 +- src/commands/scan/cmd-scan-create.test.mts | 2 +- src/commands/scan/reachability-flags.mts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d345986a..6a1306401d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added -- `socket scan create --reach --dynamic-sbom-inference` splits full application reachability analysis by project and subproject/module/workspace for Gradle, sbt, and Maven monorepos, instead of coarsely analyzing everything together as one synthetic root. It recursively discovers every independent build root and generates a Socket facts SBOM for each. This applies to reachability analysis only: the scan's own dependency detection is unaffected, and other ecosystems are unaffected too. -- `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, so each can be configured individually. +- `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 diff --git a/src/commands/manifest/cmd-manifest-setup.mts b/src/commands/manifest/cmd-manifest-setup.mts index 60ead27ada..d7dd024e39 100644 --- a/src/commands/manifest/cmd-manifest-setup.mts +++ b/src/commands/manifest/cmd-manifest-setup.mts @@ -36,7 +36,7 @@ const config: CliCommandConfig = { dynamicSbomInference: { type: 'boolean', 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 5a5eaaa4af..780fe482e8 100644 --- a/src/commands/manifest/cmd-manifest-setup.test.mts +++ b/src/commands/manifest/cmd-manifest-setup.test.mts @@ -24,7 +24,7 @@ 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 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 + --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 diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 3a7b3bfd54..39e2ab3b82 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -56,7 +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 Enables per-project, per-subproject/module/workspace reachability analysis for Gradle, sbt, and Maven, instead of the default coarse-grained analysis where everything is treated as one synthetic root. Recursively discovers every independent build root and generates a Socket facts SBOM for each (implies --auto-manifest). Applies to reachability analysis only: for Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are considered reachable; the scan itself still detects all dependencies as normal, and other ecosystems are unaffected. + --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/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index 57e6714a1b..14b9f15232 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -8,7 +8,7 @@ export const reachabilityFlags: MeowFlags = { type: 'boolean', default: false, description: - 'Enables per-project, per-subproject/module/workspace reachability analysis for Gradle, sbt, and Maven, instead of the default coarse-grained analysis where everything is treated as one synthetic root. Recursively discovers every independent build root and generates a Socket facts SBOM for each (implies --auto-manifest). Applies to reachability analysis only: for Gradle, sbt, and Maven, only dependencies present in these generated Socket facts SBOM files are considered reachable; the scan itself still detects all dependencies as normal, and other ecosystems are unaffected.', + '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', From 1900655ead1512fb9756bcbc58b0dabcce3432a0 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 7 Aug 2026 09:20:36 +0200 Subject: [PATCH 9/9] clean up changelog --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5e247c96..8bc13e5991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [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 @@ -22,6 +17,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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