diff --git a/CHANGELOG.md b/CHANGELOG.md index 852e20695..ec1c53954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- Choose which ecosystems `--dynamic-sbom-inference` covers with `--dynamic-sbom-inference-ecosystems` (`maven` for Gradle, sbt and Maven; `nuget` for .NET) on `socket scan create`, `socket scan reach` and `socket fix`. Ecosystems left out keep their manifest files as they are. + ### Changed - With `--dynamic-sbom-inference`, reachability analysis and `socket fix` now take the dependencies of every Gradle, sbt, Maven and .NET build they find from Socket facts only, so results no longer depend on which other builds produced facts. - `--dynamic-sbom-inference` and its setup now stop with clear next steps when a .NET build cannot be read because no .NET SDK is installed, instead of skipping it. With it, reachability analysis and `socket fix` warn that builds skipped in `socket.json` are left out, and reachability analysis stops with next steps when no build produced Socket facts. diff --git a/src/commands/ci/handle-ci.mts b/src/commands/ci/handle-ci.mts index 62487d0b9..406827841 100644 --- a/src/commands/ci/handle-ci.mts +++ b/src/commands/ci/handle-ci.mts @@ -10,6 +10,7 @@ import { gitBranch, } from '../../utils/git.mts' import { serializeResultJson } from '../../utils/serialize-result-json.mts' +import { FACTS_PURL_TYPES } from '../manifest/scripts/build-tool.mts' import { handleCreateNewScan } from '../scan/handle-create-new-scan.mts' /** @@ -66,6 +67,7 @@ export async function handleCi(autoManifest: boolean): Promise { pullRequest: detectCiPullRequestNumber(), reach: { dynamicSbomInference: false, + dynamicSbomInferenceEcosystems: FACTS_PURL_TYPES, excludePaths: [], reachAdvisories: [], reachAnalysisMemoryLimit: '', diff --git a/src/commands/fix/cmd-fix.integration.test.mts b/src/commands/fix/cmd-fix.integration.test.mts index 9f9ac3b60..9cb5bdf55 100644 --- a/src/commands/fix/cmd-fix.integration.test.mts +++ b/src/commands/fix/cmd-fix.integration.test.mts @@ -170,6 +170,7 @@ describe('socket fix', async () => { --debug Enable debug logging in the Coana-based Socket Fix CLI invocation. --disable-external-tool-checks Disable external tool checks during fix analysis. --dynamic-sbom-inference For Gradle, sbt, Maven and .NET: generate a Socket facts SBOM (produced directly by each package manager) per independent build root, instead of one synthetic root. Fixes are then attributed to the projects/modules that actually resolve each vulnerable dependency. The generated files are removed afterwards. + --dynamic-sbom-inference-ecosystems With --dynamic-sbom-inference: generate Socket facts only for these ecosystems, as either a comma separated value or as multiple flags; the others keep their manifest files as they are. Supported: maven (Gradle, sbt and Maven) and nuget (.NET). Defaults to both. --ecosystems Limit fix analysis to specific ecosystems. Accepts space- or comma-separated values and is case-insensitive. Defaults to all ecosystems. --exclude-paths Skip matching paths from the scan entirely: manifests under these paths are not uploaded, and fixes are not applied to workspaces under them. Patterns are anchored micromatch globs matched relative to the target directory (CWD); \`data/postgres/pgdata\` matches that exact path, \`**/pgdata\` matches at any depth. Use this to skip directories the current user cannot read so they do not abort manifest collection. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --fix-version Override the version of @coana-tech/cli used for fix analysis. Default: . diff --git a/src/commands/fix/cmd-fix.mts b/src/commands/fix/cmd-fix.mts index 2bfd99e36..1a2eebbe2 100644 --- a/src/commands/fix/cmd-fix.mts +++ b/src/commands/fix/cmd-fix.mts @@ -33,7 +33,11 @@ import { import { RangeStyles } from '../../utils/semver.mts' import { getDefaultOrgSlug } from '../ci/fetch-default-org-slug.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { DYNAMIC_SBOM_INFERENCE_DESCRIPTION } from '../scan/reachability-flags.mts' +import { + DYNAMIC_SBOM_INFERENCE_DESCRIPTION, + dynamicSbomInferenceEcosystemsFlags, + parseDynamicSbomInferenceEcosystems, +} from '../scan/reachability-flags.mts' import type { MeowFlag, MeowFlags } from '../../flags.mts' import type { PURL_Type } from '../../utils/ecosystem.mts' @@ -190,6 +194,7 @@ Available styles: default: false, description: `${DYNAMIC_SBOM_INFERENCE_DESCRIPTION} Fixes are then attributed to the projects/modules that actually resolve each vulnerable dependency. The generated files are removed afterwards.`, }, + ...dynamicSbomInferenceEcosystemsFlags, ecosystems: { type: 'string', default: [], @@ -398,6 +403,13 @@ async function run( const outputKind = getOutputKind(json, markdown) + const dynamicSbomInferenceEcosystemsRaw = cmdFlagValueToArray( + cli.flags['dynamicSbomInferenceEcosystems'], + ) + const dynamicSbomInferenceEcosystems = parseDynamicSbomInferenceEcosystems( + dynamicSbomInferenceEcosystemsRaw, + ) + // Process comma-separated values for ecosystems flag. // ALL_ECOSYSTEMS is lowercase, so normalize input for a case-insensitive // match (mirrors --package-managers behavior). @@ -446,6 +458,13 @@ async function run( const wasValidInput = checkCommandInput( outputKind, + { + nook: true, + test: dynamicSbomInference || !dynamicSbomInferenceEcosystemsRaw.length, + message: + '--dynamic-sbom-inference-ecosystems requires --dynamic-sbom-inference', + fail: 'add --dynamic-sbom-inference', + }, { test: RangeStyles.includes(rangeStyle), message: `Expecting range style of ${joinOr(RangeStyles)}`, @@ -538,6 +557,7 @@ async function run( disableExternalToolChecks, disableMajorUpdates, dynamicSbomInference, + dynamicSbomInferenceEcosystems, ecosystems: validatedEcosystems, exclude: excludePatterns, excludePaths: excludePathsPatterns, diff --git a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts index 7173f6d03..2685ef329 100644 --- a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -103,6 +103,7 @@ describe('socket fix --dynamic-sbom-inference', () => { disableExternalToolChecks: false, disableMajorUpdates: false, dynamicSbomInference: true, + dynamicSbomInferenceEcosystems: ['maven'], ecosystems: [], exclude: [], excludePaths: [], @@ -147,6 +148,9 @@ describe('socket fix --dynamic-sbom-inference', () => { const result = await coanaFix(baseConfig) expect(result.ok).toBe(true) + expect(mockGenerateSocketFactsForFix).toHaveBeenCalledWith( + expect.objectContaining({ purlTypes: ['maven'] }), + ) expect(uploadManifestFiles).toHaveBeenCalledWith( 'test-org', ['/test/cwd/app/build.gradle', FACTS], diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 368d118f8..a9c867573 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -391,13 +391,12 @@ async function coanaFixWithFacts( if (factsSlot) { if (!silence) { spinner?.stop() - logger.info( - 'Generating Socket facts for .NET, Gradle, sbt and Maven builds ...', - ) + logger.info('Generating Socket facts for each build ...') } factsSlot.generated = await generateSocketFactsForFix({ cwd, excludePaths, + purlTypes: fixConfig.dynamicSbomInferenceEcosystems, findFactsPaths: async () => (await findScanFilepaths()).filter(isSocketFactsFile), tmpDir: factsSlot.tmpDir, diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index 120543c67..21658ec94 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -4,6 +4,8 @@ import path from 'node:path' import { useOnlySocketFactsArtifactsFlags } from '../../utils/coana.mts' import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts' +import type { PURL_Type } from '../../utils/ecosystem.mts' + export type GeneratedSocketFacts = { // Coana's flags selecting what the facts files resolve. artifactsFlags: string[] @@ -39,11 +41,13 @@ export async function generateSocketFactsForFix({ cwd, excludePaths, findFactsPaths, + purlTypes, tmpDir, }: { cwd: string excludePaths: string[] findFactsPaths: () => Promise + purlTypes: readonly PURL_Type[] tmpDir: string }): Promise { const existingPaths = await findFactsPaths() @@ -64,6 +68,7 @@ export async function generateSocketFactsForFix({ consumer: 'fix', cwd, excludePaths, + purlTypes, sbtTmpDir: undefined, }) artifactsFlags = useOnlySocketFactsArtifactsFlags(factsOnlyTypes) diff --git a/src/commands/fix/generated-socket-facts.test.mts b/src/commands/fix/generated-socket-facts.test.mts index 745fdc640..8f7423085 100644 --- a/src/commands/fix/generated-socket-facts.test.mts +++ b/src/commands/fix/generated-socket-facts.test.mts @@ -37,6 +37,7 @@ describe('generateSocketFactsForFix', () => { cwd, excludePaths: [], findFactsPaths: async () => [existing, fresh].filter(p => existsSync(p)), + purlTypes: ['maven'], tmpDir, }) } @@ -75,7 +76,7 @@ describe('generateSocketFactsForFix', () => { const { artifactsFlags, sidecarFile } = await generate() expect(mockRunDynamicSbomInference).toHaveBeenCalledWith( - expect.objectContaining({ consumer: 'fix' }), + expect.objectContaining({ consumer: 'fix', purlTypes: ['maven'] }), ) expect(artifactsFlags).toEqual([ '--use-only-socket-facts-artifacts', diff --git a/src/commands/fix/handle-fix.mts b/src/commands/fix/handle-fix.mts index a21437e7e..7dc3135ed 100644 --- a/src/commands/fix/handle-fix.mts +++ b/src/commands/fix/handle-fix.mts @@ -69,6 +69,7 @@ export async function handleFix({ disableExternalToolChecks, disableMajorUpdates, dynamicSbomInference, + dynamicSbomInferenceEcosystems, ecosystems, exclude, excludePaths, @@ -100,6 +101,7 @@ export async function handleFix({ disableExternalToolChecks, disableMajorUpdates, dynamicSbomInference, + dynamicSbomInferenceEcosystems, ecosystems, exclude, excludePaths, @@ -130,6 +132,7 @@ export async function handleFix({ disableExternalToolChecks, disableMajorUpdates, dynamicSbomInference, + dynamicSbomInferenceEcosystems, ecosystems, exclude, excludePaths, diff --git a/src/commands/fix/types.mts b/src/commands/fix/types.mts index e3a70d79e..24a38da13 100644 --- a/src/commands/fix/types.mts +++ b/src/commands/fix/types.mts @@ -13,6 +13,7 @@ export type FixConfig = { disableExternalToolChecks: boolean disableMajorUpdates: boolean dynamicSbomInference: boolean + dynamicSbomInferenceEcosystems: readonly PURL_Type[] ecosystems: PURL_Type[] exclude: string[] excludePaths: string[] diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts index 1665bc708..607c33e42 100644 --- a/src/commands/manifest/discover-manifest-roots.mts +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -5,7 +5,13 @@ import micromatch from 'micromatch' import { logger } from '@socketsecurity/registry/lib/logger' -import { BUILD_FILES, isDotnetSolution } from './scripts/build-tool.mts' +import { + BUILD_FILES, + BUILD_TOOLS, + FACTS_PURL_TYPES, + buildToolsFor, + isDotnetSolution, +} from './scripts/build-tool.mts' import { listDotnetEntryProjects } from './scripts/run.mts' import { realpathOrResolved } from '../../utils/fs.mts' import { globWithGitIgnore } from '../../utils/glob.mts' @@ -14,11 +20,10 @@ import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { SocketFactsSbomProject } from './scripts/facts.mts' import type { ManifestScriptOptions } from './scripts/run.mts' +import type { PURL_Type } from '../../utils/ecosystem.mts' import type { SocketJson } from '../../utils/socket-json.mts' import type { SocketYml } from '@socketsecurity/config' -const BUILD_TOOLS: BuildTool[] = ['dotnet', 'gradle', 'maven', 'sbt'] - const BUILD_FILE_MATCHERS = new Map( BUILD_TOOLS.map(tool => { const files = BUILD_FILES[tool] @@ -222,17 +227,19 @@ export function sortByDepthThenPath( export async function findBuildToolCandidates({ cwd, excludePaths, + purlTypes = FACTS_PURL_TYPES, sockJson, socketConfig, }: { cwd: string excludePaths?: string[] | undefined + purlTypes?: readonly PURL_Type[] | undefined // The socket.yml a scan applies to its upload, so a build file a scan would // not upload is not built either. socketConfig: SocketYml | undefined sockJson: SocketJson }): Promise> { - const enabledTools = BUILD_TOOLS.filter( + const enabledTools = buildToolsFor(purlTypes).filter( tool => !sockJson.defaults?.manifest?.[tool]?.disabled, ) const result = new Map() diff --git a/src/commands/manifest/discover-manifest-roots.test.mts b/src/commands/manifest/discover-manifest-roots.test.mts index 4579512aa..95675a9bb 100644 --- a/src/commands/manifest/discover-manifest-roots.test.mts +++ b/src/commands/manifest/discover-manifest-roots.test.mts @@ -152,6 +152,26 @@ describe('findBuildToolCandidates', () => { expect(candidates.get('gradle')?.length).toBeGreaterThan(0) }) + it('drops the build tools of unselected purl types', async () => { + const maven = await findBuildToolCandidates({ + cwd: monorepo, + purlTypes: ['maven'], + socketConfig: undefined, + sockJson: {} as SocketJson, + }) + expect(maven.get('maven')?.length).toBeGreaterThan(0) + expect(maven.get('gradle')?.length).toBeGreaterThan(0) + expect(maven.has('dotnet')).toBe(false) + + const nuget = await findBuildToolCandidates({ + cwd: monorepo, + purlTypes: ['nuget'], + socketConfig: undefined, + sockJson: {} as SocketJson, + }) + expect([...nuget.keys()]).toEqual(['dotnet']) + }) + it('respects --exclude-paths', async () => { const candidates = await findBuildToolCandidates({ cwd: monorepo, diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 0655c33b7..d6a7cb43e 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -36,6 +36,7 @@ import type { BuildOptionHints, } from './scripts/resolution-report-render.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' +import type { PURL_Type } from '../../utils/ecosystem.mts' import type { SocketJson } from '../../utils/socket-json.mts' export type RecursiveManifestOutcomeStatus = @@ -362,6 +363,7 @@ export async function generateRecursiveManifests({ excludePaths, ignoreUnresolved, optionFlags = new Set(), + purlTypes, sbtTmpDir, sidecarAcc, verbose, @@ -374,6 +376,7 @@ export async function generateRecursiveManifests({ // Options the invoking command takes as flags; hints send the rest to each // build root's socket.json. optionFlags?: ReadonlySet | undefined + purlTypes?: readonly PURL_Type[] | 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, @@ -401,6 +404,7 @@ export async function generateRecursiveManifests({ const candidatesByTool = await findBuildToolCandidates({ cwd, excludePaths, + purlTypes, socketConfig: loadSocketConfig(cwd), sockJson: withoutDisabledFlags(rootSockJson), }) diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index b6c31497e..d33e21f25 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -7,6 +7,13 @@ import type { PURL_Type } from '../../../utils/ecosystem.mts' export type BuildTool = 'dotnet' | 'gradle' | 'maven' | 'sbt' +export const BUILD_TOOLS: readonly BuildTool[] = [ + 'dotnet', + 'gradle', + 'maven', + 'sbt', +] + // The purl type of the packages each tool resolves; exhaustive, so a new tool // cannot silently produce another ecosystem's purls. export const PURL_TYPE_BY_TOOL: Record = { @@ -17,6 +24,14 @@ export const PURL_TYPE_BY_TOOL: Record = { sbt: 'maven', } as unknown as Record +export const FACTS_PURL_TYPES: readonly PURL_Type[] = [ + ...new Set(Object.values(PURL_TYPE_BY_TOOL)), +].sort() + +export function buildToolsFor(purlTypes: readonly PURL_Type[]): BuildTool[] { + return BUILD_TOOLS.filter(tool => purlTypes.includes(PURL_TYPE_BY_TOOL[tool])) +} + // Purl types whose packages have no namespace (NuGet ids are flat). export const NAMESPACELESS_PURL_TYPES: ReadonlySet = new Set(['nuget']) diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 37a8cdc50..9d85ecb38 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -13,7 +13,9 @@ import { outputCreateNewScan } from './output-create-new-scan.mts' import { resolveReachAdvisories } from './reach-advisories.mts' import { DYNAMIC_SBOM_INFERENCE_DESCRIPTION, + dynamicSbomInferenceEcosystemsFlags, excludePathsFlag, + parseDynamicSbomInferenceEcosystems, reachabilityFlags, } from './reachability-flags.mts' import { @@ -115,6 +117,7 @@ const generalFlags: MeowFlags = { default: false, description: `${DYNAMIC_SBOM_INFERENCE_DESCRIPTION} Combine with --reach to split the reachability analysis per project/module.`, }, + ...dynamicSbomInferenceEcosystemsFlags, interactive: { type: 'boolean', default: true, @@ -355,6 +358,13 @@ async function run( cmdFlagValueToArray(cli.flags['reachEcosystems']), ) + const dynamicSbomInferenceEcosystemsRaw = cmdFlagValueToArray( + cli.flags['dynamicSbomInferenceEcosystems'], + ) + const dynamicSbomInferenceEcosystems = parseDynamicSbomInferenceEcosystems( + dynamicSbomInferenceEcosystemsRaw, + ) + warnSocketJsonOnlyFlags(cli.flags, `${parentName} ${CMD_NAME}`) const dryRun = !!cli.flags['dryRun'] @@ -587,6 +597,13 @@ async function run( const wasValidInput = checkCommandInput( outputKind, + { + nook: true, + test: dynamicSbomInference || !dynamicSbomInferenceEcosystemsRaw.length, + message: + '--dynamic-sbom-inference-ecosystems requires --dynamic-sbom-inference', + fail: 'add --dynamic-sbom-inference', + }, { nook: true, test: !!orgSlug, @@ -694,6 +711,7 @@ async function run( pullRequest: Number(pullRequest), reach: { dynamicSbomInference: Boolean(dynamicSbomInference), + dynamicSbomInferenceEcosystems, excludePaths, reachAdvisories, reachAnalysisMemoryLimit, diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 65fa85f5f..b35522fcc 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -105,6 +105,7 @@ describe('socket scan create', async () => { --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, Maven and .NET: 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. + --dynamic-sbom-inference-ecosystems With --dynamic-sbom-inference: generate Socket facts only for these ecosystems, as either a comma separated value or as multiple flags; the others keep their manifest files as they are. Supported: maven (Gradle, sbt and Maven) and nuget (.NET). Defaults to both. --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. An excluded JVM build module or .NET project is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module or project outside the scan root cannot be excluded. --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 @@ -406,6 +407,87 @@ describe('socket scan create', async () => { }, ) + cmdit( + [ + 'scan', + 'create', + FLAG_ORG, + 'fakeOrg', + 'target', + FLAG_DRY_RUN, + '--repo', + 'xyz', + '--branch', + 'abc', + '--dynamic-sbom-inference', + '--dynamic-sbom-inference-ecosystems', + 'Maven,NuGet', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should succeed when --dynamic-sbom-inference-ecosystems selects supported ecosystems without --reach', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code).toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + FLAG_ORG, + 'fakeOrg', + 'target', + FLAG_DRY_RUN, + '--repo', + 'xyz', + '--branch', + 'abc', + '--dynamic-sbom-inference-ecosystems', + 'maven', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should fail when --dynamic-sbom-inference-ecosystems is used without --dynamic-sbom-inference', + async cmd => { + const { code, stderr } = await spawnSocketCli(binCliPath, cmd) + expect(stderr).toContain( + '--dynamic-sbom-inference-ecosystems requires --dynamic-sbom-inference', + ) + expect(code).not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + FLAG_ORG, + 'fakeOrg', + 'target', + FLAG_DRY_RUN, + '--repo', + 'xyz', + '--branch', + 'abc', + '--dynamic-sbom-inference', + '--dynamic-sbom-inference-ecosystems', + 'maven,npm', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should fail when --dynamic-sbom-inference-ecosystems names an ecosystem without a facts generator', + async cmd => { + const { code, stderr } = await spawnSocketCli(binCliPath, cmd) + expect(stderr).toContain( + 'Invalid ecosystem: "npm". Valid values are: maven and nuget', + ) + expect(code).not.toBe(0) + }, + ) + cmdit( [ 'scan', diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index d3fd778af..4272cdd29 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -7,7 +7,9 @@ import { handleScanReach } from './handle-scan-reach.mts' import { resolveReachAdvisories } from './reach-advisories.mts' import { DYNAMIC_SBOM_INFERENCE_DESCRIPTION, + dynamicSbomInferenceEcosystemsFlags, excludePathsFlag, + parseDynamicSbomInferenceEcosystems, reachabilityFlags, } from './reachability-flags.mts' import { suggestTarget } from './suggest_target.mts' @@ -49,6 +51,7 @@ const reachabilityFlagsForReach: MeowFlags = { default: false, description: `${DYNAMIC_SBOM_INFERENCE_DESCRIPTION} The reachability analysis is split per project/module accordingly.`, }, + ...dynamicSbomInferenceEcosystemsFlags, // Fallback targets scan creation, which this command never performs. reachFallbackToRegularScan: { type: 'boolean', @@ -210,6 +213,12 @@ async function run( // Validate ecosystem values against the reachability-supported set. const reachEcosystems = parseReachEcosystems(reachEcosystemsRaw) + const dynamicSbomInferenceEcosystemsRaw = cmdFlagValueToArray( + cli.flags['dynamicSbomInferenceEcosystems'], + ) + const dynamicSbomInferenceEcosystems = parseDynamicSbomInferenceEcosystems( + dynamicSbomInferenceEcosystemsRaw, + ) const processCwd = process.cwd() const cwd = @@ -236,6 +245,13 @@ async function run( const wasValidInput = checkCommandInput( outputKind, + { + nook: true, + test: dynamicSbomInference || !dynamicSbomInferenceEcosystemsRaw.length, + message: + '--dynamic-sbom-inference-ecosystems requires --dynamic-sbom-inference', + fail: 'add --dynamic-sbom-inference', + }, { nook: true, test: !!orgSlug, @@ -304,6 +320,7 @@ async function run( outputPath: outputPath || '', reachabilityOptions: { dynamicSbomInference: Boolean(dynamicSbomInference), + dynamicSbomInferenceEcosystems, excludePaths, reachAdvisories, reachAnalysisMemoryLimit, diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 467d32546..57b375d9a 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -38,6 +38,7 @@ describe('socket scan reach', async () => { Reachability Options --dynamic-sbom-inference For Gradle, sbt, Maven and .NET: 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. + --dynamic-sbom-inference-ecosystems With --dynamic-sbom-inference: generate Socket facts only for these ecosystems, as either a comma separated value or as multiple flags; the others keep their manifest files as they are. Supported: maven (Gradle, sbt and Maven) and nuget (.NET). Defaults to both. --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. An excluded JVM build module or .NET project is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module or project outside the scan root cannot be excluded. --reach-advisories List of advisories to run reachability analysis for, as GHSA or CVE IDs, either as a comma separated value or as multiple flags. CVE IDs are converted to GHSA IDs through the GitHub API. Vulnerabilities from other advisories get unknown reachability. Defaults to all advisories. --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. diff --git a/src/commands/scan/create-scan-from-github.mts b/src/commands/scan/create-scan-from-github.mts index 9251d5990..3ff57ccf8 100644 --- a/src/commands/scan/create-scan-from-github.mts +++ b/src/commands/scan/create-scan-from-github.mts @@ -25,6 +25,7 @@ import { isGitHubBlockingError, } from '../../utils/github-errors.mts' import { isReportSupportedFile } from '../../utils/glob.mts' +import { FACTS_PURL_TYPES } from '../manifest/scripts/build-tool.mts' import { fetchListAllRepos } from '../repository/fetch-list-all-repos.mts' import type { CResult, OutputKind } from '../../types.mts' @@ -324,6 +325,7 @@ async function scanOneRepo( pullRequest: 0, reach: { dynamicSbomInference: false, + dynamicSbomInferenceEcosystems: FACTS_PURL_TYPES, excludePaths: [], reachAdvisories: [], reachAnalysisMemoryLimit: '', diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 50ce4a07d..25359010d 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -29,6 +29,7 @@ import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { socketDocsLink } from '../../utils/terminal-link.mts' import { detectManifestActions } from '../manifest/detect-manifest-actions.mts' import { generateAutoManifest } from '../manifest/generate_auto_manifest.mts' +import { buildToolsFor } from '../manifest/scripts/build-tool.mts' import { mergeResolvedPathsSidecars } from '../manifest/scripts/sidecar.mts' import type { ReachabilityFallback } from './output-create-new-scan.mts' @@ -180,6 +181,7 @@ async function createNewScan( consumer: reach.runReachabilityAnalysis ? 'reach' : 'upload', cwd, excludePaths: reach.excludePaths, + purlTypes: reach.dynamicSbomInferenceEcosystems, // sbt's Scala toolchain lives under its shared global base; // reach's artifactPaths point into it, so when reachability // will consume them afterward, reuse manifestTmpDir (kept alive @@ -202,10 +204,11 @@ async function createNewScan( if (reach.dynamicSbomInference) { // Already generated recursively above; resolving cwd's own build // root a second time would race on the same facts file. - detected.dotnet = false - detected.gradle = false - detected.sbt = false - detected.maven = false + for (const tool of buildToolsFor( + reach.dynamicSbomInferenceEcosystems, + )) { + detected[tool] = false + } } const autoManifestResult = await generateAutoManifest({ diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 3db5fcb0c..e97c0c11b 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -419,6 +419,30 @@ describe('handleCreateNewScan excludePaths', () => { ) }) + it('leaves auto-manifest the build tools --dynamic-sbom-inference-ecosystems does not select', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + { + dir: '/repo/app', + ecosystem: 'dotnet', + factsPath: '/repo/app/App.csproj.socket.facts.json', + status: 'generated', + }, + ]) + + const config = createConfig({ autoManifest: true, targets: ['/repo'] }) + config.reach.dynamicSbomInference = true + config.reach.dynamicSbomInferenceEcosystems = ['nuget'] + + await handleCreateNewScan(config) + + expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( + expect.objectContaining({ purlTypes: ['nuget'] }), + ) + expect(mockGenerateAutoManifest).toHaveBeenCalledWith( + expect.objectContaining({ detected: { count: 0, dotnet: false } }), + ) + }) + it('aborts instead of silently uploading a partial scan when a recursive build root fails', async () => { mockGenerateRecursiveManifests.mockResolvedValueOnce([ { diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 27a3a41ff..edbb16a32 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -47,13 +47,12 @@ async function runScanReach( let dynamicResult: DynamicSbomInferenceResult | undefined if (reachabilityOptions.dynamicSbomInference) { - logger.info( - 'Generating Socket facts for each Gradle, sbt, Maven and .NET build root ...', - ) + logger.info('Generating Socket facts for each build root ...') dynamicResult = await runDynamicSbomInference({ consumer: 'reach', cwd, excludePaths: reachabilityOptions.excludePaths, + purlTypes: reachabilityOptions.dynamicSbomInferenceEcosystems, sbtTmpDir, }) scanTargets = Array.from( diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index 9534840dd..da6e771e9 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -380,6 +380,7 @@ describe('handleScanReach', () => { consumer: 'reach', cwd: '/repo', excludePaths: ['vendor/**'], + purlTypes: ['maven', 'nuget'], // A caller-owned dir that outlives the analysis consuming its paths. sbtTmpDir: expect.any(String), }) diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 362180852..4d3355339 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -28,6 +28,7 @@ import type { StdioOptions } from 'node:child_process' export type ReachabilityOptions = { dynamicSbomInference: boolean + dynamicSbomInferenceEcosystems: readonly PURL_Type[] excludePaths: string[] // GHSA IDs. Empty means all advisories. reachAdvisories: string[] diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index be7b9e890..34c6356ab 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -1,7 +1,12 @@ import { COANA_CLI_VERSION } from '../../constants.mts' -import { getReachabilityEcosystemChoices } from '../../utils/ecosystem.mts' +import { + getReachabilityEcosystemChoices, + parseEcosystemChoices, +} from '../../utils/ecosystem.mts' +import { FACTS_PURL_TYPES } from '../manifest/scripts/build-tool.mts' import type { MeowFlags } from '../../flags.mts' +import type { PURL_Type } from '../../utils/ecosystem.mts' // 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 @@ -9,6 +14,31 @@ import type { MeowFlags } from '../../flags.mts' export const DYNAMIC_SBOM_INFERENCE_DESCRIPTION = 'For Gradle, sbt, Maven and .NET: generate a Socket facts SBOM (produced directly by each package manager) per independent build root, instead of one synthetic root.' +// Not in reachabilityFlags: it applies to a `scan create` without --reach too. +export const dynamicSbomInferenceEcosystemsFlags: MeowFlags = { + dynamicSbomInferenceEcosystems: { + type: 'string', + isMultiple: true, + description: + 'With --dynamic-sbom-inference: generate Socket facts only for these ecosystems, as either a comma separated value or as multiple flags; the others keep their manifest files as they are. Supported: maven (Gradle, sbt and Maven) and nuget (.NET). Defaults to both.', + }, +} + +export function parseDynamicSbomInferenceEcosystems( + raw: readonly string[], +): readonly PURL_Type[] { + return raw.length + ? [ + ...new Set( + parseEcosystemChoices( + raw.map(e => e.toLowerCase()), + FACTS_PURL_TYPES, + ), + ), + ] + : FACTS_PURL_TYPES +} + export const reachabilityFlags: MeowFlags = { reachVersion: { type: 'string', diff --git a/src/commands/scan/run-dynamic-sbom-inference.mts b/src/commands/scan/run-dynamic-sbom-inference.mts index c98bf8230..a91f77578 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.mts @@ -1,8 +1,12 @@ +import { joinOr } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' import { InputError } from '../../utils/errors.mts' import { generateRecursiveManifests } from '../manifest/generate-recursive-manifests.mts' -import { PURL_TYPE_BY_TOOL } from '../manifest/scripts/build-tool.mts' +import { + FACTS_PURL_TYPES, + PURL_TYPE_BY_TOOL, +} from '../manifest/scripts/build-tool.mts' import { DSI_SETUP_COMMAND } from '../manifest/scripts/resolution-report-render.mts' import { hasSidecarEntries, @@ -30,11 +34,17 @@ export type DynamicSbomInferenceResult = { // Coana takes the discovered ecosystems' artifacts only from facts files, so a // build root without them is one it leaves out. -function checkFactsForCoana( - outcomes: RecursiveManifestOutcome[], - consumer: Exclude, - hasFacts: boolean, -): void { +function checkFactsForCoana({ + consumer, + hasFacts, + narrowed, + outcomes, +}: { + consumer: Exclude + hasFacts: boolean + narrowed: boolean + outcomes: RecursiveManifestOutcome[] +}): void { const hasDisabled = outcomes.some(o => o.status === 'skippedDisabled') // Coana (15.14.1) refuses a facts-only reachability run without any facts. if (consumer === 'reach' && !hasFacts) { @@ -47,6 +57,11 @@ function checkFactsForCoana( `- Re-enable the builds that socket.json skips (\`${DSI_SETUP_COMMAND}\` walks you through it).`, ] : []), + ...(narrowed + ? [ + '- Or select other ecosystems with --dynamic-sbom-inference-ecosystems.', + ] + : []), '- Or remove --dynamic-sbom-inference to analyze the manifest files as they are.', ].join('\n'), ) @@ -59,18 +74,20 @@ function checkFactsForCoana( } // Recursively discovers and generates Socket facts for every independent -// dotnet/gradle/sbt/maven build root under `cwd`, returning the generated +// build root of the selected ecosystems under `cwd`, returning the generated // facts paths plus the resolved-paths sidecar that reachability forwards to // Coana. export async function runDynamicSbomInference({ consumer, cwd, excludePaths, + purlTypes, sbtTmpDir, }: { consumer: FactsConsumer cwd: string excludePaths: string[] + purlTypes: readonly PURL_Type[] // sbt provisions its Scala toolchain under this directory and reach's // artifactPaths point into it, so it must outlive whoever consumes them. // Only meaningful for `reach`. @@ -84,20 +101,30 @@ export async function runDynamicSbomInference({ const outcomes = await generateRecursiveManifests({ cwd, excludePaths, + purlTypes, sbtTmpDir: withFiles ? sbtTmpDir : undefined, sidecarAcc, verbose: false, withFiles, }) + const narrowed = purlTypes.length < FACTS_PURL_TYPES.length // 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, Maven or .NET build root was found.', - '', - '- Remove --dynamic-sbom-inference; it only applies to these ecosystems.', + ...(narrowed + ? [ + `No build root for ${joinOr(purlTypes)} was found.`, + '', + '- Select other ecosystems with --dynamic-sbom-inference-ecosystems, or remove --dynamic-sbom-inference.', + ] + : [ + 'No Gradle, sbt, Maven or .NET 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'), ) @@ -113,7 +140,12 @@ export async function runDynamicSbomInference({ .filter(o => o.status === 'generated') .map(o => o.factsPath!) if (coanaReadsFacts) { - checkFactsForCoana(outcomes, consumer, !!factsPaths.length) + checkFactsForCoana({ + consumer, + hasFacts: !!factsPaths.length, + narrowed, + outcomes, + }) } return { factsPaths, diff --git a/src/commands/scan/run-dynamic-sbom-inference.test.mts b/src/commands/scan/run-dynamic-sbom-inference.test.mts index 490dc922d..64ab48c95 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.test.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.test.mts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { runDynamicSbomInference } from './run-dynamic-sbom-inference.mts' import type { FactsConsumer } from './run-dynamic-sbom-inference.mts' +import type { PURL_Type } from '../../utils/ecosystem.mts' const { mockGenerateRecursiveManifests, mockWarn } = vi.hoisted(() => ({ mockGenerateRecursiveManifests: vi.fn(), @@ -24,11 +25,15 @@ const generated = { status: 'generated', } -function run(consumer: FactsConsumer) { +function run( + consumer: FactsConsumer, + purlTypes: readonly PURL_Type[] = ['maven', 'nuget'], +) { return runDynamicSbomInference({ consumer, cwd: '/repo', excludePaths: ['vendor/**'], + purlTypes, sbtTmpDir: '/tmp/sbt', }) } @@ -69,6 +74,7 @@ describe('runDynamicSbomInference', () => { expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith({ cwd: '/repo', excludePaths: ['vendor/**'], + purlTypes: ['maven', 'nuget'], sbtTmpDir, sidecarAcc: sidecar ? expect.any(Map) : undefined, verbose: false, @@ -152,6 +158,26 @@ describe('runDynamicSbomInference', () => { await expect(run('reach')).rejects.toThrow(error) }) + it.for([ + { + name: 'no build root of the selected ecosystems was found', + outcomes: [], + error: /No build root for nuget was found/, + }, + { + name: 'the selected ecosystems produced no facts', + outcomes: [{ dir: '/repo/a', ecosystem: 'dotnet', status: 'empty' }], + error: /select other ecosystems with --dynamic-sbom-inference-ecosystems/, + }, + ])('names the selection when $name', async ({ error, outcomes }) => { + mockGenerateRecursiveManifests.mockResolvedValueOnce(outcomes) + + await expect(run('reach', ['nuget'])).rejects.toThrow(error) + expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( + expect.objectContaining({ purlTypes: ['nuget'] }), + ) + }) + it.for([ { consumer: 'fix', diff --git a/src/utils/ecosystem.mts b/src/utils/ecosystem.mts index 597457b57..6dfa2d2aa 100644 --- a/src/utils/ecosystem.mts +++ b/src/utils/ecosystem.mts @@ -133,16 +133,20 @@ export function parseEcosystems( return values.filter(isValidEcosystem) } -export function parseReachEcosystems(raw: readonly string[]): PURL_Type[] { - const choices = getReachabilityEcosystemChoices() - const result: PURL_Type[] = [] +export function parseEcosystemChoices( + raw: readonly string[], + choices: readonly string[], +): PURL_Type[] { for (const ecosystem of raw) { if (!choices.includes(ecosystem)) { throw new InputError( `Invalid ecosystem: "${ecosystem}". Valid values are: ${joinAnd(choices)}`, ) } - result.push(ecosystem as PURL_Type) } - return result + return [...raw] as PURL_Type[] +} + +export function parseReachEcosystems(raw: readonly string[]): PURL_Type[] { + return parseEcosystemChoices(raw, getReachabilityEcosystemChoices()) } diff --git a/test/reachability-options.mts b/test/reachability-options.mts index 080f62902..8a6981df8 100644 --- a/test/reachability-options.mts +++ b/test/reachability-options.mts @@ -1,3 +1,5 @@ +import { FACTS_PURL_TYPES } from '../src/commands/manifest/scripts/build-tool.mts' + import type { ReachabilityOptions } from '../src/commands/scan/perform-reachability-analysis.mts' // Mirrors the CLI flag defaults, so a test spreads this and sets only what it @@ -5,6 +7,7 @@ import type { ReachabilityOptions } from '../src/commands/scan/perform-reachabil export function makeReachabilityOptions(): ReachabilityOptions { return { dynamicSbomInference: false, + dynamicSbomInferenceEcosystems: FACTS_PURL_TYPES, excludePaths: [], reachAdvisories: [], reachAnalysisMemoryLimit: '8192',