From 84aafcf31c5e1e6ea4aef7c3681e4092db4d0760 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 25 Aug 2026 14:23:25 +0200 Subject: [PATCH] feat(scan): support --dynamic-sbom-inference on scan reach The flag was hidden and hardcoded to false on `socket scan reach`, since it relied on `--auto-manifest` generating the per-build-root Socket facts first and that command has no such flag. Run the recursive facts generation directly instead, so standalone reachability gets the same per-project/module splitting `scan create` does. Without it, Gradle and sbt projects analyzed through this path find no vulnerabilities at all. Extract the recursive-facts step out of handle-create-new-scan into run-dynamic-sbom-inference so both commands share one implementation and one set of error messages, and give the flag a per-command description since only `scan create` implies --auto-manifest. --- src/commands/scan/cmd-scan-reach.mts | 24 ++-- src/commands/scan/cmd-scan-reach.test.mts | 2 + src/commands/scan/handle-create-new-scan.mts | 53 ++----- src/commands/scan/handle-scan-reach.mts | 74 ++++++++-- src/commands/scan/handle-scan-reach.test.mts | 132 ++++++++++++++++++ src/commands/scan/reachability-flags.mts | 9 +- .../scan/run-dynamic-sbom-inference.mts | 75 ++++++++++ .../scan/run-dynamic-sbom-inference.test.mts | 123 ++++++++++++++++ 8 files changed, 420 insertions(+), 72 deletions(-) create mode 100644 src/commands/scan/run-dynamic-sbom-inference.mts create mode 100644 src/commands/scan/run-dynamic-sbom-inference.test.mts diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index 70c2808426..23ca86375c 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -4,7 +4,11 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { assertValidExcludePaths } from './exclude-paths.mts' import { handleScanReach } from './handle-scan-reach.mts' -import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts' +import { + DYNAMIC_SBOM_INFERENCE_DESCRIPTION, + excludePathsFlag, + reachabilityFlags, +} from './reachability-flags.mts' import { suggestTarget } from './suggest_target.mts' import { validateReachabilityTarget } from './validate-reachability-target.mts' import constants from '../../constants.mts' @@ -33,18 +37,15 @@ 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. +// `scan create` gets its per-build-root facts from --auto-manifest, a flag +// this command doesn't have; here the build tools are run directly instead, +// so the flag's description differs. const reachabilityFlagsForReach: MeowFlags = { ...reachabilityFlags, dynamicSbomInference: { type: 'boolean', default: false, - hidden: true, - description: reachabilityFlags['dynamicSbomInference']!.description, + description: `${DYNAMIC_SBOM_INFERENCE_DESCRIPTION} Each discovered build root is built first to generate its SBOM.`, }, } @@ -117,6 +118,7 @@ async function run( $ ${command} $ ${command} ./proj $ ${command} ./proj --reach-ecosystems npm,pypi + $ ${command} ./monorepo --dynamic-sbom-inference $ ${command} --output custom-report.json $ ${command} ./proj --output ./reports/analysis.json `, @@ -131,6 +133,7 @@ async function run( const { cwd: cwdOverride, + dynamicSbomInference, interactive = true, json, markdown, @@ -156,6 +159,7 @@ async function run( reachVersion, } = cli.flags as { cwd: string + dynamicSbomInference: boolean interactive: boolean json: boolean markdown: boolean @@ -282,9 +286,7 @@ async function run( outputKind, outputPath: outputPath || '', reachabilityOptions: { - // Not exposed here: it relies on --auto-manifest generating per-workspace - // Socket facts first, which `socket scan reach` never runs. - dynamicSbomInference: false, + dynamicSbomInference: Boolean(dynamicSbomInference), excludePaths, reachAnalysisMemoryLimit, reachAnalysisTimeout, diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 21d925bdf2..cc769f65bd 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 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. Each discovered build root is built first to generate its SBOM. --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. @@ -68,6 +69,7 @@ describe('socket scan reach', async () => { $ socket scan reach $ socket scan reach ./proj $ socket scan reach ./proj --reach-ecosystems npm,pypi + $ socket scan reach ./monorepo --dynamic-sbom-inference $ socket scan reach --output custom-report.json $ socket scan reach ./proj --output ./reports/analysis.json" `) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 1b2109d68f..587b05ce91 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -14,31 +14,23 @@ import { finalizeTier1Scan } from './finalize-tier1-scan.mts' import { handleScanReport } from './handle-scan-report.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' +import { runDynamicSbomInference } from './run-dynamic-sbom-inference.mts' import constants from '../../constants.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { compressSocketFactsForUpload } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' -import { InputError } from '../../utils/errors.mts' import { withTmpDir } from '../../utils/fs.mts' import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { socketDocsLink } from '../../utils/terminal-link.mts' import { detectManifestActions } from '../manifest/detect-manifest-actions.mts' -import { generateRecursiveManifests } from '../manifest/generate-recursive-manifests.mts' import { generateAutoManifest } from '../manifest/generate_auto_manifest.mts' -import { - hasSidecarEntries, - mergeResolvedPathsSidecars, - serializeSidecar, -} from '../manifest/scripts/sidecar.mts' +import { mergeResolvedPathsSidecars } 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, - SidecarAccumulator, -} from '../manifest/scripts/sidecar.mts' +import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' import type { Remap } from '@socketsecurity/registry/lib/objects' import type { SocketSdkSuccessResult } from '@socketsecurity/sdk' @@ -172,50 +164,21 @@ export async function handleCreateNewScan({ detected.sbt = false detected.maven = false - const sidecarAcc: SidecarAccumulator | undefined = - reach.runReachabilityAnalysis ? new Map() : undefined - const outcomes = await generateRecursiveManifests({ + const dynamicResult = await runDynamicSbomInference({ 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 + // until reach finishes below) instead of letting the call clean // its own ephemeral base up before reach ever reads those paths. - sbtTmpDir: reach.runReachabilityAnalysis ? manifestTmpDir : undefined, - sidecarAcc, - verbose: false, + sbtTmpDir: manifestTmpDir, 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')) { - 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!) scanTargets = Array.from( - new Set([...scanTargets, ...generatedFactsPaths]), + new Set([...scanTargets, ...dynamicResult.factsPaths]), ) - if (sidecarAcc && hasSidecarEntries(sidecarAcc)) { - resolvedPathsSidecar = serializeSidecar(sidecarAcc) - } + resolvedPathsSidecar = dynamicResult.resolvedPathsSidecar } const autoManifestResult = await generateAutoManifest({ diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index b1bf3399ac..65ca09355c 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -6,13 +6,16 @@ import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.m import { finalizeTier1Scan } from './finalize-tier1-scan.mts' import { outputScanReach } from './output-scan-reach.mts' import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' +import { runDynamicSbomInference } from './run-dynamic-sbom-inference.mts' import constants from '../../constants.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { findSocketYmlSync } from '../../utils/config.mts' +import { withTmpDir } from '../../utils/fs.mts' import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import type { ReachabilityOptions } from './perform-reachability-analysis.mts' import type { OutputKind } from '../../types.mts' +import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' export type HandleScanReachConfig = { cwd: string @@ -24,17 +27,42 @@ export type HandleScanReachConfig = { targets: string[] } -export async function handleScanReach({ - cwd, - interactive: _interactive, - orgSlug, - outputKind, - outputPath, - reachabilityOptions, - targets, -}: HandleScanReachConfig) { +async function runScanReach( + { + cwd, + interactive: _interactive, + orgSlug, + outputKind, + outputPath, + reachabilityOptions, + targets, + }: HandleScanReachConfig, + sbtTmpDir: string | undefined, +) { const { spinner } = constants + // Extra discovery targets beyond the user's own; the reachability target + // itself stays `targets[0]`. + let scanTargets = targets + // Sidecar forwarded to reachability; populated by dynamic SBOM inference. + let resolvedPathsSidecar: ResolvedPathsSidecar | undefined + + if (reachabilityOptions.dynamicSbomInference) { + logger.info( + 'Generating Socket facts for each Gradle, sbt, and Maven build root ...', + ) + const dynamicResult = await runDynamicSbomInference({ + cwd, + excludePaths: reachabilityOptions.excludePaths, + sbtTmpDir, + withFiles: true, + }) + scanTargets = Array.from( + new Set([...scanTargets, ...dynamicResult.factsPaths]), + ) + resolvedPathsSidecar = dynamicResult.resolvedPathsSidecar + } + // Get supported file names. const supportedFilesCResult = await fetchSupportedScanFileNames({ orgSlug, @@ -68,11 +96,15 @@ export async function handleScanReach({ target: targets[0]!, }) - const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { - additionalIgnores: additionalScaIgnores, - config: socketConfig, - cwd, - }) + const packagePaths = await getPackageFilesForScan( + scanTargets, + supportedFiles, + { + additionalIgnores: additionalScaIgnores, + config: socketConfig, + cwd, + }, + ) spinner.successAndStop( `Found ${packagePaths.length} ${pluralize('manifest file', packagePaths.length)} for reachability analysis.`, @@ -102,6 +134,7 @@ export async function handleScanReach({ outputPath, packagePaths, reachabilityOptions: mergedReachabilityOptions, + resolvedPathsSidecar, spinner, target: targets[0]!, uploadManifests: true, @@ -127,3 +160,16 @@ export async function handleScanReach({ await outputScanReach(result, { cwd, outputKind, outputPath }) } + +export async function handleScanReach( + config: HandleScanReachConfig, +): Promise { + // sbt provisions its Scala toolchain under the directory passed as its + // isolated global base; the sidecar's artifactPaths point into it, so it + // must stay on disk until the reachability analysis has consumed them. + return config.reachabilityOptions.dynamicSbomInference + ? await withTmpDir('socket-dynamic-sbom-inference-', tmpDir => + runScanReach(config, tmpDir), + ) + : await runScanReach(config, undefined) +} diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index 2c780e0b17..023f570e36 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -8,10 +8,12 @@ const { mockFinalizeTier1Scan, mockFindSocketYmlSync, mockGetPackageFilesForScan, + mockLoggerInfo, mockLoggerSuccess, mockLoggerWarn, mockOutputScanReach, mockPerformReachabilityAnalysis, + mockRunDynamicSbomInference, mockSentryInternalsSymbol, } = vi.hoisted(() => ({ mockCheckCommandInput: vi.fn(), @@ -19,10 +21,12 @@ const { mockFinalizeTier1Scan: vi.fn(), mockFindSocketYmlSync: vi.fn(), mockGetPackageFilesForScan: vi.fn(), + mockLoggerInfo: vi.fn(), mockLoggerSuccess: vi.fn(), mockLoggerWarn: vi.fn(), mockOutputScanReach: vi.fn(), mockPerformReachabilityAnalysis: vi.fn(), + mockRunDynamicSbomInference: vi.fn(), mockSentryInternalsSymbol: Symbol('kInternalsSymbol'), })) @@ -42,6 +46,10 @@ vi.mock('./perform-reachability-analysis.mts', () => ({ performReachabilityAnalysis: mockPerformReachabilityAnalysis, })) +vi.mock('./run-dynamic-sbom-inference.mts', () => ({ + runDynamicSbomInference: mockRunDynamicSbomInference, +})) + vi.mock('../../constants.mts', () => ({ default: { kInternalsSymbol: mockSentryInternalsSymbol, @@ -74,6 +82,7 @@ vi.mock('../../utils/path-resolve.mts', () => ({ vi.mock('@socketsecurity/registry/lib/logger', () => ({ logger: { + info: mockLoggerInfo, success: mockLoggerSuccess, warn: mockLoggerWarn, }, @@ -100,6 +109,10 @@ describe('handleScanReach', () => { tier1ReachabilityScanId: undefined, }, }) + mockRunDynamicSbomInference.mockResolvedValue({ + factsPaths: [], + resolvedPathsSidecar: undefined, + }) }) it('applies excludePaths to manifest discovery and reachability analysis', async () => { @@ -463,4 +476,123 @@ describe('handleScanReach', () => { { cwd: '/repo', outputKind: 'text', outputPath: '' }, ) }) + describe('dynamic SBOM inference', () => { + const baseReachabilityOptions = { + dynamicSbomInference: true, + excludePaths: ['vendor/**'], + reachAnalysisMemoryLimit: '8192', + reachAnalysisTimeout: '', + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: [], + reachLazyMode: false, + reachRetainFactsFile: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + } + + it('generates per-build-root facts, merges them into manifest discovery, and forwards the sidecar', async () => { + mockRunDynamicSbomInference.mockResolvedValueOnce({ + factsPaths: [ + '/repo/service-a/.socket.facts.json', + '/repo/service-b/.socket.facts.json', + ], + resolvedPathsSidecar: { + '/repo/service-a/.socket.facts.json': { + projects: [], + components: [], + }, + }, + }) + + await handleScanReach({ + cwd: '/repo', + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + outputPath: '', + reachabilityOptions: baseReachabilityOptions, + targets: ['/repo'], + }) + + expect(mockRunDynamicSbomInference).toHaveBeenCalledWith({ + cwd: '/repo', + excludePaths: ['vendor/**'], + // A caller-owned dir that outlives the analysis consuming its paths. + sbtTmpDir: expect.any(String), + withFiles: true, + }) + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + [ + '/repo', + '/repo/service-a/.socket.facts.json', + '/repo/service-b/.socket.facts.json', + ], + expect.anything(), + expect.objectContaining({ cwd: '/repo' }), + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + resolvedPathsSidecar: { + '/repo/service-a/.socket.facts.json': { + projects: [], + components: [], + }, + }, + // The reachability target stays the user's own, never a facts file. + target: '/repo', + }), + ) + }) + + it('propagates a build-root failure instead of analyzing a partial set', async () => { + mockRunDynamicSbomInference.mockRejectedValueOnce( + new Error('One or more independent build roots failed'), + ) + + await expect( + handleScanReach({ + cwd: '/repo', + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + outputPath: '', + reachabilityOptions: baseReachabilityOptions, + targets: ['/repo'], + }), + ).rejects.toThrow(/build roots failed/) + + expect(mockPerformReachabilityAnalysis).not.toHaveBeenCalled() + }) + + it('does not run when the flag is off', async () => { + await handleScanReach({ + cwd: '/repo', + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + outputPath: '', + reachabilityOptions: { + ...baseReachabilityOptions, + dynamicSbomInference: false, + }, + targets: ['/repo'], + }) + + expect(mockRunDynamicSbomInference).not.toHaveBeenCalled() + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ resolvedPathsSidecar: undefined }), + ) + }) + }) }) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index 14b9f15232..efb9d8e1c9 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -3,12 +3,17 @@ import { getReachabilityEcosystemChoices } from '../../utils/ecosystem.mts' import type { MeowFlags } from '../../flags.mts' +// Shared prefix so each command can append how it obtains the per-build-root +// SBOMs, which differs: `scan create` piggybacks on --auto-manifest, while +// `scan reach` runs the build tools itself. +export const DYNAMIC_SBOM_INFERENCE_DESCRIPTION = + '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.' + export const reachabilityFlags: MeowFlags = { dynamicSbomInference: { type: 'boolean', default: false, - description: - '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.', + description: `${DYNAMIC_SBOM_INFERENCE_DESCRIPTION} Reachability analysis only; implies --auto-manifest.`, }, reachVersion: { type: 'string', diff --git a/src/commands/scan/run-dynamic-sbom-inference.mts b/src/commands/scan/run-dynamic-sbom-inference.mts new file mode 100644 index 0000000000..9336c5dc58 --- /dev/null +++ b/src/commands/scan/run-dynamic-sbom-inference.mts @@ -0,0 +1,75 @@ +import { InputError } from '../../utils/errors.mts' +import { generateRecursiveManifests } from '../manifest/generate-recursive-manifests.mts' +import { + hasSidecarEntries, + serializeSidecar, +} from '../manifest/scripts/sidecar.mts' + +import type { + ResolvedPathsSidecar, + SidecarAccumulator, +} from '../manifest/scripts/sidecar.mts' + +export type DynamicSbomInferenceResult = { + factsPaths: string[] + resolvedPathsSidecar: ResolvedPathsSidecar | undefined +} + +// Recursively discovers and generates Socket facts for every independent +// gradle/sbt/maven build root under `cwd`, returning the generated facts +// paths plus the resolved-paths sidecar that reachability forwards to Coana. +export async function runDynamicSbomInference({ + cwd, + excludePaths, + sbtTmpDir, + withFiles, +}: { + cwd: string + excludePaths: string[] + // sbt provisions its Scala toolchain under this directory and withFiles' + // artifactPaths point into it, so it must outlive whoever consumes them. + // Only meaningful alongside `withFiles`. + sbtTmpDir: string | undefined + withFiles: boolean +}): Promise { + const sidecarAcc: SidecarAccumulator | undefined = withFiles + ? new Map() + : undefined + const outcomes = await generateRecursiveManifests({ + cwd, + excludePaths, + sbtTmpDir: withFiles ? sbtTmpDir : undefined, + sidecarAcc, + verbose: false, + withFiles, + }) + // 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 proceed with a partial multi-root result: + // 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).', + ) + } + return { + factsPaths: outcomes + .filter(o => o.status === 'generated') + .map(o => o.factsPath!), + resolvedPathsSidecar: + sidecarAcc && hasSidecarEntries(sidecarAcc) + ? serializeSidecar(sidecarAcc) + : undefined, + } +} diff --git a/src/commands/scan/run-dynamic-sbom-inference.test.mts b/src/commands/scan/run-dynamic-sbom-inference.test.mts new file mode 100644 index 0000000000..92ab9eac09 --- /dev/null +++ b/src/commands/scan/run-dynamic-sbom-inference.test.mts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { runDynamicSbomInference } from './run-dynamic-sbom-inference.mts' + +const { mockGenerateRecursiveManifests } = vi.hoisted(() => ({ + mockGenerateRecursiveManifests: vi.fn(), +})) + +vi.mock('../manifest/generate-recursive-manifests.mts', () => ({ + generateRecursiveManifests: mockGenerateRecursiveManifests, +})) + +describe('runDynamicSbomInference', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns only the generated facts paths, ignoring empty and skipped roots', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + { + dir: '/repo/a', + ecosystem: 'maven', + factsPath: '/repo/a/.socket.facts.json', + status: 'generated', + }, + { dir: '/repo/b', ecosystem: 'gradle', status: 'empty' }, + { dir: '/repo/c', ecosystem: 'sbt', status: 'skippedDisabled' }, + { dir: '/repo/d', ecosystem: 'maven', status: 'skippedCovered' }, + ]) + + const result = await runDynamicSbomInference({ + cwd: '/repo', + excludePaths: ['vendor/**'], + sbtTmpDir: '/tmp/sbt', + withFiles: false, + }) + + expect(result).toEqual({ + factsPaths: ['/repo/a/.socket.facts.json'], + resolvedPathsSidecar: undefined, + }) + expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith({ + cwd: '/repo', + excludePaths: ['vendor/**'], + // Only meaningful with withFiles; withheld otherwise so the callee + // allocates and cleans up its own ephemeral base. + sbtTmpDir: undefined, + sidecarAcc: undefined, + verbose: false, + withFiles: false, + }) + }) + + it('accumulates and serializes a sidecar when running with files', async () => { + mockGenerateRecursiveManifests.mockImplementationOnce( + async ({ sidecarAcc }) => { + sidecarAcc.set('/repo/a/.socket.facts.json', { + projects: [{ name: 'app' }], + components: [], + }) + return [ + { + dir: '/repo/a', + ecosystem: 'maven', + factsPath: '/repo/a/.socket.facts.json', + status: 'generated', + }, + ] + }, + ) + + const result = await runDynamicSbomInference({ + cwd: '/repo', + excludePaths: [], + sbtTmpDir: '/tmp/sbt', + withFiles: true, + }) + + expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( + expect.objectContaining({ sbtTmpDir: '/tmp/sbt', withFiles: true }), + ) + expect(result.resolvedPathsSidecar).toEqual({ + '/repo/a/.socket.facts.json': { + projects: [{ name: 'app' }], + components: [], + }, + }) + }) + + it('throws when no build root was discovered at all', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([]) + + await expect( + runDynamicSbomInference({ + cwd: '/repo', + excludePaths: [], + sbtTmpDir: undefined, + withFiles: true, + }), + ).rejects.toThrow(/No Gradle, sbt, or Maven build root was found/) + }) + + it('throws when a discovered build root failed to generate facts', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + { + dir: '/repo/a', + ecosystem: 'maven', + factsPath: '/repo/a/.socket.facts.json', + status: 'generated', + }, + { dir: '/repo/b', ecosystem: 'maven', status: 'failed' }, + ]) + + await expect( + runDynamicSbomInference({ + cwd: '/repo', + excludePaths: [], + sbtTmpDir: undefined, + withFiles: true, + }), + ).rejects.toThrow(/one or more independent build roots failed/i) + }) +})