diff --git a/.gitignore b/.gitignore index 42fd3e332..d6bb54053 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,10 @@ test/fixtures/commands/fix/e2e-test-py-temp-* /src/commands/manifest/scripts/test/sbt-compat/project/localrepo/ /src/commands/manifest/scripts/test/sbt-compat/project/records.tsv /src/commands/manifest/scripts/test/sbt-compat/project/target/ + +# Generated when the dynamic SBOM inference tests walk their fixtures +# (test/fixtures/commands/manifest/dynamic-sbom-inference). Those fixtures are +# build-tool marker files only, so any build output under them is debris. +/test/fixtures/commands/manifest/dynamic-sbom-inference/**/.gradle/ +/test/fixtures/commands/manifest/dynamic-sbom-inference/**/build/ +/test/fixtures/commands/manifest/dynamic-sbom-inference/**/target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ed23524d..fabe2cd2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - Updated the Coana CLI to v `15.10.33`. +### Fixed +- `socket scan create --dynamic-sbom-inference` works without `--reach` again, so a single command produces a per-build-root Socket facts SBOM for Gradle, sbt, and Maven projects. It is a standalone flag now, no longer a reachability modifier. +- `--dynamic-sbom-inference` no longer generates Conda or Bazel manifests as a side effect. It touches Gradle, sbt, and Maven only; pass `--auto-manifest` alongside it if you want the other ecosystems too. + ## [1.1.165](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.165) - 2026-09-02 ### Changed diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 41564b50c..3ef29df8b 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -6,7 +6,11 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { assertValidExcludePaths } from './exclude-paths.mts' import { handleCreateNewScan } from './handle-create-new-scan.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' -import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts' +import { + DYNAMIC_SBOM_INFERENCE_DESCRIPTION, + excludePathsFlag, + reachabilityFlags, +} from './reachability-flags.mts' import { isOmittedReachValue, reachMemoryLimitToMb, @@ -55,7 +59,7 @@ const generalFlags: MeowFlags = { autoManifest: { type: 'boolean', description: - 'Run `socket manifest auto` before collecting manifest files. This is necessary for languages like Scala, Gradle, and Kotlin, See `socket manifest auto --help`.', + 'Run `socket manifest auto` before collecting manifest files, which generates them for ecosystems that need their build tool to resolve dependencies, such as Scala, Gradle, and Kotlin. See `socket manifest auto --help`. For Gradle, sbt, and Maven, --dynamic-sbom-inference generates a Socket facts SBOM per build root instead.', }, branch: { type: 'string', @@ -92,6 +96,11 @@ const generalFlags: MeowFlags = { description: 'Set the default branch of the repository to the branch of this full-scan. Should only need to be done once, for example for the "main" or "master" branch.', }, + dynamicSbomInference: { + type: 'boolean', + default: false, + description: `${DYNAMIC_SBOM_INFERENCE_DESCRIPTION} Combine with --reach to split the reachability analysis per project/module.`, + }, interactive: { type: 'boolean', default: true, @@ -355,11 +364,6 @@ async function run( autoManifest = false } } - // --dynamic-sbom-inference requires auto-manifest to generate the - // per-workspace facts it feeds to Coana. - if (dynamicSbomInference) { - autoManifest = true - } if (!branchName) { if (sockJson.defaults?.scan?.create?.branch) { branchName = sockJson.defaults.scan.create.branch @@ -455,7 +459,12 @@ async function run( const hasFactsFile = existsSync( path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON), ) - if (detected.count > 0 && !autoManifest && !hasFactsFile) { + if ( + detected.count > 0 && + !autoManifest && + !dynamicSbomInference && + !hasFactsFile + ) { logger.info( `Detected ${detected.count} manifest targets we could try to generate. Please set the --auto-manifest flag if you want to include languages covered by \`socket manifest auto\` in the Scan.`, ) diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index ddaaa47cd..8a80c62d2 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -33,13 +33,14 @@ describe('socket scan create', async () => { - Permissions: full-scans:create Options - --auto-manifest Run \`socket manifest auto\` before collecting manifest files. This is necessary for languages like Scala, Gradle, and Kotlin, See \`socket manifest auto --help\`. + --auto-manifest Run \`socket manifest auto\` before collecting manifest files, which generates them for ecosystems that need their build tool to resolve dependencies, such as Scala, Gradle, and Kotlin. See \`socket manifest auto --help\`. For Gradle, sbt, and Maven, --dynamic-sbom-inference generates a Socket facts SBOM per build root instead. --branch Branch name --commit-hash Commit hash --commit-message Commit message --committers Committers --cwd working directory, defaults to process.cwd() --default-branch Set the default branch of the repository to the branch of this full-scan. Should only need to be done once, for example for the "main" or "master" branch. + --dynamic-sbom-inference For Gradle, sbt, and Maven: generate a Socket facts SBOM (produced directly by each package manager) per independent build root, instead of one synthetic root. Combine with --reach to split the reachability analysis per project/module. --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. --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. --json Output as JSON @@ -56,7 +57,6 @@ describe('socket scan create', async () => { --workspace The workspace in the Socket Organization that the repository is in to associate with the full scan. Reachability Options (when --reach is used) - --dynamic-sbom-inference For Gradle, sbt, and Maven: splits reachability analysis per project/module using a Socket facts SBOM (generated directly by each package manager) per build root, instead of one synthetic root. Reachability analysis only; implies --auto-manifest. --reach-analysis-memory-limit The maximum memory for the reachability analysis as a whole number optionally followed by MB or GB (e.g. 512MB, 8GB). The default is 8GB. --reach-analysis-timeout Set the timeout for the reachability analysis as a whole number optionally followed by s, m or h (e.g. 90s, 10m, 1h). Defaults to 10m. Split analysis runs may cause the total scan time to exceed this timeout significantly. --reach-concurrency Set the maximum number of concurrent reachability analysis runs. It is recommended to choose a concurrency level that ensures each analysis run has at least the --reach-analysis-memory-limit amount of memory available. @@ -276,6 +276,36 @@ describe('socket scan create', async () => { }, ) + cmdit( + [ + 'scan', + 'create', + FLAG_ORG, + 'fakeOrg', + 'target', + FLAG_DRY_RUN, + '--repo', + 'xyz', + '--branch', + 'abc', + '--dynamic-sbom-inference', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should succeed when --dynamic-sbom-inference is used without --reach', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(stdout + stderr).not.toContain( + 'Reachability analysis flags require --reach to be enabled', + ) + expect( + code, + 'should exit with code 0 since it is not a reachability modifier', + ).toBe(0) + }, + ) + cmdit( [ 'scan', diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index 23ca86375..52d0936a9 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -37,15 +37,15 @@ const description = 'Compute full application reachability' const hidden = true -// `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. +// Not in reachabilityFlags: on `scan create` it is a general flag the --reach +// guard must not reject. Reachability is always on here, so the description +// drops that command's --reach caveat. const reachabilityFlagsForReach: MeowFlags = { ...reachabilityFlags, dynamicSbomInference: { type: 'boolean', default: false, - description: `${DYNAMIC_SBOM_INFERENCE_DESCRIPTION} Each discovered build root is built first to generate its SBOM.`, + description: `${DYNAMIC_SBOM_INFERENCE_DESCRIPTION} The reachability analysis is split per project/module accordingly.`, }, } diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index cc769f65b..80c677b1a 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -37,7 +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. + --dynamic-sbom-inference For Gradle, sbt, and Maven: generate a Socket facts SBOM (produced directly by each package manager) per independent build root, instead of one synthetic root. The reachability analysis is split per project/module accordingly. --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/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 587b05ce9..de87c2a81 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -149,21 +149,15 @@ export async function handleCreateNewScan({ // paths. Always allocated (even when unused) to keep this uniform rather // than conditional on autoManifest/reach. await withTmpDir('socket-auto-manifest-', async manifestTmpDir => { - if (autoManifest) { + if (autoManifest || reach.dynamicSbomInference) { logger.info('Auto-generating manifest files ...') - debugFn('notice', 'Auto-manifest mode enabled') - const sockJson = readOrDefaultSocketJson(cwd) - const detected = await detectManifestActions(sockJson, cwd) - debugDir('inspect', { detected }) + debugFn('notice', 'Manifest auto-generation enabled') 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 - + // cwd. This runs on its own, so nothing outside those three + // ecosystems is generated unless --auto-manifest also asked for it. const dynamicResult = await runDynamicSbomInference({ cwd, excludePaths: reach.excludePaths, @@ -181,27 +175,41 @@ export async function handleCreateNewScan({ resolvedPathsSidecar = dynamicResult.resolvedPathsSidecar } - const autoManifestResult = await generateAutoManifest({ - computeArtifactsSidecar: reach.runReachabilityAnalysis, - cwd, - detected, - excludePaths: reach.excludePaths, - outputKind, - tmpDir: manifestTmpDir, - verbose: false, - }) - if (autoManifestResult.resolvedPathsSidecar) { - resolvedPathsSidecar = resolvedPathsSidecar - ? mergeResolvedPathsSidecars( - resolvedPathsSidecar, - autoManifestResult.resolvedPathsSidecar, - ) - : autoManifestResult.resolvedPathsSidecar - } - if (autoManifestResult.generatedFiles.length) { - scanTargets = Array.from( - new Set([...scanTargets, ...autoManifestResult.generatedFiles]), - ) + if (autoManifest) { + const sockJson = readOrDefaultSocketJson(cwd) + const detected = await detectManifestActions(sockJson, cwd) + debugDir('inspect', { detected }) + + if (reach.dynamicSbomInference) { + // Already generated recursively above; resolving cwd's own build + // root a second time would race on the same .socket.facts.json. + detected.gradle = false + detected.sbt = false + detected.maven = false + } + + const autoManifestResult = await generateAutoManifest({ + computeArtifactsSidecar: reach.runReachabilityAnalysis, + cwd, + detected, + excludePaths: reach.excludePaths, + outputKind, + tmpDir: manifestTmpDir, + verbose: false, + }) + if (autoManifestResult.resolvedPathsSidecar) { + resolvedPathsSidecar = resolvedPathsSidecar + ? mergeResolvedPathsSidecars( + resolvedPathsSidecar, + autoManifestResult.resolvedPathsSidecar, + ) + : autoManifestResult.resolvedPathsSidecar + } + if (autoManifestResult.generatedFiles.length) { + scanTargets = Array.from( + 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 fdd5fe5f9..fb47a65be 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -178,7 +178,39 @@ describe('handleCreateNewScan excludePaths', () => { expect(mockFetchCreateOrgFullScan).toHaveBeenCalled() }) - it('drives JVM facts generation through generateRecursiveManifests under --dynamic-sbom-inference, merging generated facts into scan targets', async () => { + it('generates nothing beyond Gradle/sbt/Maven when --dynamic-sbom-inference is used without --auto-manifest', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + { + dir: '/repo/service-a', + ecosystem: 'gradle', + factsPath: '/repo/service-a/.socket.facts.json', + status: 'generated', + }, + ]) + + const config = createConfig({ autoManifest: false, targets: ['/repo'] }) + config.reach.dynamicSbomInference = true + + await handleCreateNewScan(config) + + expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/repo' }), + ) + // generateAutoManifest is what would pull in conda and bazel; the flag on + // its own must never reach it. + expect(mockGenerateAutoManifest).not.toHaveBeenCalled() + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['/repo', '/repo/service-a/.socket.facts.json'], + { size: 1 }, + { + additionalIgnores: [], + config: { projectIgnorePaths: ['fixtures/**'] }, + cwd: '/repo', + }, + ) + }) + + it('suppresses auto-manifest JVM branches and merges recursive facts into scan targets when --dynamic-sbom-inference is combined with --auto-manifest', async () => { mockGenerateRecursiveManifests.mockResolvedValueOnce([ { dir: '/repo/service-a', @@ -242,7 +274,7 @@ describe('handleCreateNewScan excludePaths', () => { { dir: '/repo/service-b', ecosystem: 'maven', status: 'failed' }, ]) - const config = createConfig({ autoManifest: true, targets: ['/repo'] }) + const config = createConfig({ autoManifest: false, targets: ['/repo'] }) config.reach.dynamicSbomInference = true await expect(handleCreateNewScan(config)).rejects.toThrow( @@ -255,7 +287,7 @@ describe('handleCreateNewScan excludePaths', () => { it('aborts when --dynamic-sbom-inference finds no Gradle/sbt/Maven build root', async () => { mockGenerateRecursiveManifests.mockResolvedValueOnce([]) - const config = createConfig({ autoManifest: true, targets: ['/repo'] }) + const config = createConfig({ autoManifest: false, targets: ['/repo'] }) config.reach.dynamicSbomInference = true await expect(handleCreateNewScan(config)).rejects.toThrow( @@ -271,7 +303,7 @@ describe('handleCreateNewScan excludePaths', () => { { dir: '/repo/service-b', ecosystem: 'maven', status: 'skippedDisabled' }, ]) - const config = createConfig({ autoManifest: true, targets: ['/repo'] }) + const config = createConfig({ autoManifest: false, targets: ['/repo'] }) config.reach.dynamicSbomInference = true await handleCreateNewScan(config) @@ -309,7 +341,7 @@ describe('handleCreateNewScan excludePaths', () => { }, ) - const config = createConfig({ autoManifest: true, targets: ['/repo'] }) + const config = createConfig({ autoManifest: false, targets: ['/repo'] }) config.reach.dynamicSbomInference = true config.reach.runReachabilityAnalysis = true diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index efb9d8e1c..74768588b 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -3,18 +3,13 @@ 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. +// Shared prefix so each command can append what the SBOMs are used for, which +// differs: reachability is optional on `scan create` and always on for +// `scan reach`. 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.' + 'For Gradle, sbt, and Maven: generate a Socket facts SBOM (produced directly by each package manager) per independent build root, instead of one synthetic root.' export const reachabilityFlags: MeowFlags = { - dynamicSbomInference: { - type: 'boolean', - default: false, - description: `${DYNAMIC_SBOM_INFERENCE_DESCRIPTION} Reachability analysis only; implies --auto-manifest.`, - }, reachVersion: { type: 'string', description: `Override the version of @coana-tech/cli used for reachability analysis. Default: ${constants.ENV.INLINED_SOCKET_CLI_COANA_TECH_CLI_VERSION}.`,