diff --git a/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts index 7e46495c3..9f601a304 100644 --- a/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts @@ -21,16 +21,22 @@ export async function handleManifestDynamicSbomInference({ verbose, }) - const result: CResult = outcomes.some( - o => o.status === 'failed', - ) + const result: CResult = !outcomes.length ? { ok: false, code: 1, - message: 'One or more build roots failed to generate Socket facts.', + message: + 'No Gradle, sbt, or Maven build root was found beneath the given directory.', data: outcomes, } - : { ok: true, data: outcomes } + : outcomes.some(o => o.status === 'failed') + ? { + ok: false, + code: 1, + message: 'One or more build roots failed to generate Socket facts.', + data: outcomes, + } + : { ok: true, data: outcomes } await outputManifestDynamicSbomInference(result, outputKind) } diff --git a/src/commands/manifest/handle-manifest-dynamic-sbom-inference.test.mts b/src/commands/manifest/handle-manifest-dynamic-sbom-inference.test.mts new file mode 100644 index 000000000..58fe7c219 --- /dev/null +++ b/src/commands/manifest/handle-manifest-dynamic-sbom-inference.test.mts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' + +const { + mockGenerateRecursiveManifests, + mockOutputManifestDynamicSbomInference, +} = vi.hoisted(() => ({ + mockGenerateRecursiveManifests: vi.fn(), + mockOutputManifestDynamicSbomInference: vi.fn(), +})) + +vi.mock('./generate-recursive-manifests.mts', () => ({ + generateRecursiveManifests: mockGenerateRecursiveManifests, +})) + +vi.mock('./output-manifest-dynamic-sbom-inference.mts', () => ({ + outputManifestDynamicSbomInference: mockOutputManifestDynamicSbomInference, +})) + +import { handleManifestDynamicSbomInference } from './handle-manifest-dynamic-sbom-inference.mts' + +describe('handleManifestDynamicSbomInference', () => { + it('fails closed when no build root is found anywhere', async () => { + mockGenerateRecursiveManifests.mockResolvedValue([]) + + await handleManifestDynamicSbomInference({ + cwd: '/repo', + excludePaths: [], + outputKind: 'text', + verbose: false, + }) + + expect(mockOutputManifestDynamicSbomInference).toHaveBeenCalledWith( + { + ok: false, + code: 1, + message: + 'No Gradle, sbt, or Maven build root was found beneath the given directory.', + data: [], + }, + 'text', + ) + }) + + it('fails when one or more discovered build roots failed to generate facts', async () => { + const outcomes = [ + { + dir: '/repo/service-a', + ecosystem: 'maven', + factsPath: '/repo/service-a/.socket.facts.json', + status: 'generated', + }, + { dir: '/repo/service-b', ecosystem: 'maven', status: 'failed' }, + ] + mockGenerateRecursiveManifests.mockResolvedValue(outcomes) + + await handleManifestDynamicSbomInference({ + cwd: '/repo', + excludePaths: [], + outputKind: 'text', + verbose: false, + }) + + expect(mockOutputManifestDynamicSbomInference).toHaveBeenCalledWith( + { + ok: false, + code: 1, + message: 'One or more build roots failed to generate Socket facts.', + data: outcomes, + }, + 'text', + ) + }) + + it('succeeds when every discovered build root generated facts', async () => { + const outcomes = [ + { + dir: '/repo/service-a', + ecosystem: 'maven', + factsPath: '/repo/service-a/.socket.facts.json', + status: 'generated', + }, + ] + mockGenerateRecursiveManifests.mockResolvedValue(outcomes) + + await handleManifestDynamicSbomInference({ + cwd: '/repo', + excludePaths: [], + outputKind: 'json', + verbose: false, + }) + + expect(mockOutputManifestDynamicSbomInference).toHaveBeenCalledWith( + { ok: true, data: outcomes }, + 'json', + ) + }) +}) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index a82ede0fe..952fd41ad 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -749,6 +749,20 @@ export async function setupRecursiveManifestConfig( ) } else { logger.log(`No gradle/maven/sbt build roots found beneath ${cwd}.`) + // Distinct from "found some, but --exclude-paths excluded them all" - + // that's a deliberate choice and setup still proceeds normally below. + // Zero build roots anywhere means --dynamic-sbom-inference doesn't apply + // to this target at all, so fail rather than silently do nothing. + const hasAnyBuildRoot = ROOT_ECOSYSTEMS.some( + ecosystem => (fullByTool.get(ecosystem)?.length ?? 0) > 0, + ) + if (!hasAnyBuildRoot) { + return { + ok: false, + code: 1, + message: `No Gradle, sbt, or Maven build root was found beneath ${cwd}.`, + } + } } logger.log('') diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index c96e0750a..0036481f3 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -747,10 +747,14 @@ describe('setupRecursiveManifestConfig', () => { vi.mocked(enumerateWorkspaces).mockResolvedValue({ projects: [] }) }) - it('asks about nothing and finishes immediately when the scan finds no build roots anywhere', async () => { + it('fails closed when the scan finds no build roots anywhere', async () => { const result = await setupRecursiveManifestConfig(cwd, false) - expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(result).toEqual({ + ok: false, + code: 1, + message: 'No Gradle, sbt, or Maven build root was found beneath /repo.', + }) expect(select).not.toHaveBeenCalled() expect(setupGradle).not.toHaveBeenCalled() expect(setupMaven).not.toHaveBeenCalled() @@ -759,6 +763,19 @@ describe('setupRecursiveManifestConfig', () => { expect(findBuildToolCandidates).toHaveBeenCalled() }) + it('still proceeds normally when build roots exist but --exclude-paths excludes all of them (a deliberate choice, not "none exist")', async () => { + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ excludePaths }) => + excludePaths?.length + ? new Map() + : new Map([['maven', [`${cwd}/service`]]]), + ) + + const result = await setupRecursiveManifestConfig(cwd, false, ['service']) + + expect(result.ok).toBe(true) + }) + it('only asks about ecosystems detected somewhere in the tree, with plain phrasing', async () => { // Maven is detected (a candidate exists, elsewhere in the tree); gradle // and sbt have none anywhere, so neither should ever be asked about. diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 86928460d..1b2109d68 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -187,6 +187,19 @@ export async function handleCreateNewScan({ verbose: false, withFiles: reach.runReachabilityAnalysis, }) + // No candidates discovered at all (distinct from candidates that were + // found but produced no generated facts - empty/skippedDisabled are + // already warned about elsewhere and are not this kind of mistake). + if (!outcomes.length) { + throw new InputError( + [ + 'No Gradle, sbt, or Maven build root was found.', + '', + '- Remove --dynamic-sbom-inference; it only applies to these ecosystems.', + '- Make sure to run it from the correct dir (use --cwd to target another dir).', + ].join('\n'), + ) + } // Fail loud rather than silently upload a partial multi-root scan: // matches handleManifestDynamicSbomInference's own check. if (outcomes.some(o => o.status === 'failed')) { @@ -197,11 +210,9 @@ export async function handleCreateNewScan({ const generatedFactsPaths = outcomes .filter(o => o.status === 'generated') .map(o => o.factsPath!) - if (generatedFactsPaths.length) { - scanTargets = Array.from( - new Set([...scanTargets, ...generatedFactsPaths]), - ) - } + scanTargets = Array.from( + new Set([...scanTargets, ...generatedFactsPaths]), + ) if (sidecarAcc && hasSidecarEntries(sidecarAcc)) { resolvedPathsSidecar = serializeSidecar(sidecarAcc) } diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 2befdaf16..fdd5fe5f9 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -252,6 +252,33 @@ describe('handleCreateNewScan excludePaths', () => { expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() }) + it('aborts when --dynamic-sbom-inference finds no Gradle/sbt/Maven build root', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([]) + + const config = createConfig({ autoManifest: true, targets: ['/repo'] }) + config.reach.dynamicSbomInference = true + + await expect(handleCreateNewScan(config)).rejects.toThrow( + /No Gradle, sbt, or Maven build root was found/, + ) + expect(mockGetPackageFilesForScan).not.toHaveBeenCalled() + expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() + }) + + it('does not abort when build roots were found but none generated facts (empty/skippedDisabled), unlike genuinely finding none', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + { dir: '/repo/service-a', ecosystem: 'maven', status: 'empty' }, + { dir: '/repo/service-b', ecosystem: 'maven', status: 'skippedDisabled' }, + ]) + + const config = createConfig({ autoManifest: true, targets: ['/repo'] }) + config.reach.dynamicSbomInference = true + + await handleCreateNewScan(config) + + expect(mockGetPackageFilesForScan).toHaveBeenCalled() + }) + it('accumulates a sidecar across recursively discovered build roots and forwards it to reachability analysis', async () => { mockGenerateRecursiveManifests.mockImplementationOnce( async ({ sidecarAcc }) => { diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 80614a0b7..72017e5e6 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -185,12 +185,14 @@ export async function performReachabilityAnalysis( const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON - // Write the sidecar to a temp file for `--compute-artifacts-sidecar`; cleaned - // up in the finally below. + // Temp file for --compute-artifacts-sidecar, removed in the finally below. + // Written even when empty under dynamicSbomInference, since the + // --maven-use-only-socket-facts flag below requires one to be present. let sidecarPath: string | undefined if ( - resolvedPathsSidecar && - hasResolvedPathsSidecarEntries(resolvedPathsSidecar) + reachabilityOptions.dynamicSbomInference || + (resolvedPathsSidecar && + hasResolvedPathsSidecarEntries(resolvedPathsSidecar)) ) { sidecarPath = path.join( tmpdir(), @@ -198,7 +200,7 @@ export async function performReachabilityAnalysis( ) await fs.writeFile( sidecarPath, - JSON.stringify(resolvedPathsSidecar), + JSON.stringify(resolvedPathsSidecar ?? {}), 'utf8', ) } @@ -256,6 +258,8 @@ export async function performReachabilityAnalysis( ...(reachabilityOptions.reachExcludePaths.length ? ['--exclude-dirs', ...reachabilityOptions.reachExcludePaths] : []), + // sidecarPath is always set above when this is true - Coana rejects this + // flag without one. ...(reachabilityOptions.dynamicSbomInference ? ['--maven-use-only-socket-facts'] : []), diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index 773dfc301..0d456f0a0 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -12,7 +12,7 @@ * - utils/coana.mts (extractTier1ReachabilityScanId — exercised for real) */ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' @@ -224,6 +224,91 @@ describe('performReachabilityAnalysis timeout/memory forwarding', () => { }) }) +describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () => { + let scanCwd: string + + beforeEach(() => { + vi.clearAllMocks() + mockFetchOrganization.mockResolvedValue({ + ok: true, + data: { organizations: {} }, + }) + mockHasEnterpriseOrgPlan.mockReturnValue(true) + mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: '' }) + scanCwd = mkdtempSync(path.join(tmpdir(), 'socket-reamvomsf-')) + }) + + afterEach(() => { + rmSync(scanCwd, { force: true, recursive: true }) + }) + + it('writes an empty sidecar and still passes both flags when dynamicSbomInference is on but no build root produced one (e.g. none found, or all empty/disabled)', async () => { + let sidecarContentAtSpawnTime: unknown + mockSpawnCoanaDlx.mockImplementationOnce(async (args: string[]) => { + const sidecarPath = args[args.indexOf('--compute-artifacts-sidecar') + 1]! + sidecarContentAtSpawnTime = JSON.parse(readFileSync(sidecarPath, 'utf8')) + return { ok: true, data: '' } + }) + + await performReachabilityAnalysis({ + cwd: scanCwd, + reachabilityOptions: { + ...makeReachabilityOptions(), + dynamicSbomInference: true, + }, + resolvedPathsSidecar: undefined, + target: scanCwd, + }) + + const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] + expect(args).toContain('--maven-use-only-socket-facts') + expect(args).toContain('--compute-artifacts-sidecar') + expect(sidecarContentAtSpawnTime).toEqual({}) + }) + + it('passes --maven-use-only-socket-facts alongside --compute-artifacts-sidecar when dynamicSbomInference is on and a sidecar was generated', async () => { + await performReachabilityAnalysis({ + cwd: scanCwd, + reachabilityOptions: { + ...makeReachabilityOptions(), + dynamicSbomInference: true, + }, + resolvedPathsSidecar: { + '/repo/reactor/.socket.facts.json': { + components: [], + projects: [], + }, + }, + target: scanCwd, + }) + + const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] + expect(args).toContain('--maven-use-only-socket-facts') + expect(args).toContain('--compute-artifacts-sidecar') + }) + + it('never passes --maven-use-only-socket-facts when dynamicSbomInference is off, even if a sidecar happens to be present', async () => { + await performReachabilityAnalysis({ + cwd: scanCwd, + reachabilityOptions: { + ...makeReachabilityOptions(), + dynamicSbomInference: false, + }, + resolvedPathsSidecar: { + '/repo/reactor/.socket.facts.json': { + components: [], + projects: [], + }, + }, + target: scanCwd, + }) + + const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] + expect(args).not.toContain('--maven-use-only-socket-facts') + expect(args).toContain('--compute-artifacts-sidecar') + }) +}) + describe('performReachabilityAnalysis stdio routing by output kind', () => { let scanCwd: string