diff --git a/CHANGELOG.md b/CHANGELOG.md index 852e20695..3879869c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,12 @@ 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. +- `socket manifest auto`, `--auto-manifest` and `--dynamic-sbom-inference` (with its setup) now fail a .NET build that has no usable .NET SDK, as they do a Maven build without Maven, instead of skipping it. With `--dynamic-sbom-inference`, 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. ## [1.13.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.13.1) - 2026-11-03 diff --git a/src/commands/ci/handle-ci.mts b/src/commands/ci/handle-ci.mts index 62487d0b9..62a4bb7db 100644 --- a/src/commands/ci/handle-ci.mts +++ b/src/commands/ci/handle-ci.mts @@ -65,7 +65,7 @@ export async function handleCi(autoManifest: boolean): Promise { pendingHead: true, pullRequest: detectCiPullRequestNumber(), reach: { - dynamicSbomInference: false, + dynamicSbomInference: undefined, 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..3f84f4df8 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, Maven, and sbt) and nuget (.NET). Defaults to all of them. --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..a6b8c384e 100644 --- a/src/commands/fix/cmd-fix.mts +++ b/src/commands/fix/cmd-fix.mts @@ -19,7 +19,10 @@ import constants, { import { commonFlags, outputFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' -import { getEcosystemChoicesForMeow } from '../../utils/ecosystem.mts' +import { + getEcosystemChoicesForMeow, + parseEcosystemChoices, +} from '../../utils/ecosystem.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { @@ -33,10 +36,13 @@ 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, + parseDynamicSbomInferenceFlags, +} from '../scan/reachability-flags.mts' import type { MeowFlag, MeowFlags } from '../../flags.mts' -import type { PURL_Type } from '../../utils/ecosystem.mts' import type { CliCommandConfig, CliCommandContext, @@ -190,6 +196,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: [], @@ -339,7 +346,6 @@ async function run( autopilot, debug, disableExternalToolChecks, - dynamicSbomInference, ecosystems, exclude, excludePaths, @@ -367,7 +373,6 @@ async function run( autopilot: boolean debug: boolean disableExternalToolChecks: boolean - dynamicSbomInference: boolean ecosystems: string[] exclude: string[] excludePaths: string[] @@ -398,26 +403,13 @@ async function run( const outputKind = getOutputKind(json, markdown) - // Process comma-separated values for ecosystems flag. - // ALL_ECOSYSTEMS is lowercase, so normalize input for a case-insensitive - // match (mirrors --package-managers behavior). - const ecosystemsRaw = cmdFlagValueToArray(ecosystems).map(s => - s.toLowerCase(), - ) + const dynamicSbomInference = parseDynamicSbomInferenceFlags(cli.flags) // Validate ecosystem values early, before dry-run check. - const validatedEcosystems: PURL_Type[] = [] - const validEcosystemChoices = getEcosystemChoicesForMeow() - for (const ecosystem of ecosystemsRaw) { - if (!validEcosystemChoices.includes(ecosystem)) { - logger.fail( - `Invalid ecosystem: "${ecosystem}". Valid values are: ${joinAnd(validEcosystemChoices)}`, - ) - process.exitCode = 1 - return - } - validatedEcosystems.push(ecosystem as PURL_Type) - } + const validatedEcosystems = parseEcosystemChoices( + cmdFlagValueToArray(ecosystems), + getEcosystemChoicesForMeow(), + ) // Process and validate package manager values early, before dry-run check. // Coana normalizes input to uppercase and rejects unknown values, so do the 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..b62add74b 100644 --- a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -102,7 +102,7 @@ describe('socket fix --dynamic-sbom-inference', () => { debug: false, disableExternalToolChecks: false, disableMajorUpdates: false, - dynamicSbomInference: true, + dynamicSbomInference: ['maven'], ecosystems: [], exclude: [], excludePaths: [], @@ -147,6 +147,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], @@ -196,7 +199,7 @@ describe('socket fix --dynamic-sbom-inference', () => { const result = await coanaFix({ ...baseConfig, - dynamicSbomInference: false, + dynamicSbomInference: undefined, }) expect(result.ok).toBe(true) @@ -211,7 +214,7 @@ describe('socket fix --dynamic-sbom-inference', () => { FACTS, ]) - await coanaFix({ ...baseConfig, dynamicSbomInference: false }) + await coanaFix({ ...baseConfig, dynamicSbomInference: undefined }) expect(uploadManifestFiles).toHaveBeenCalledWith('test-org', [FACTS], { pathsRelativeTo: '/test/cwd', @@ -219,7 +222,7 @@ describe('socket fix --dynamic-sbom-inference', () => { }) it('does not pass the facts restriction without the flag', async () => { - await coanaFix({ ...baseConfig, dynamicSbomInference: false }) + await coanaFix({ ...baseConfig, dynamicSbomInference: undefined }) expect(mockGenerateSocketFactsForFix).not.toHaveBeenCalled() expect(coanaCalls('compute-fixes-and-upgrade-purls')[0]).not.toContain( diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 368d118f8..570b63302 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -60,6 +60,7 @@ import { fetchSupportedScanFileNames } from '../scan/fetch-supported-scan-file-n import type { GeneratedSocketFacts } from './generated-socket-facts.mts' import type { FixConfig } from './types.mts' import type { CResult } from '../../types.mts' +import type { FactsOnlyTypes } from '../../utils/coana.mts' import type { PURL_Type } from '../../utils/ecosystem.mts' import type { Spinner } from '@socketsecurity/registry/lib/spinner' @@ -283,15 +284,17 @@ function formatNotAppliedFixes(notApplied: NotAppliedFix[]): string { type GeneratedSocketFactsSlot = { generated?: GeneratedSocketFacts | undefined + purlTypes: FactsOnlyTypes tmpDir: string } export async function coanaFix(fixConfig: FixConfig): Promise { - if (!fixConfig.dynamicSbomInference) { + const purlTypes = fixConfig.dynamicSbomInference + if (!purlTypes) { return await coanaFixWithFacts(fixConfig, undefined) } return await withTmpDir('socket-fix-facts-', async tmpDir => { - const slot: GeneratedSocketFactsSlot = { tmpDir } + const slot: GeneratedSocketFactsSlot = { purlTypes, tmpDir } try { return await coanaFixWithFacts(fixConfig, slot) } finally { @@ -391,13 +394,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: factsSlot.purlTypes, 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..80d98a858 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -4,12 +4,14 @@ 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[] paths: string[] // The facts files' per-project classpaths, written outside the repository. - sidecarFile: string | undefined + sidecarFile: string remove: () => Promise restore: () => Promise } @@ -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() @@ -57,21 +61,21 @@ export async function generateSocketFactsForFix({ } let artifactsFlags: string[] let paths: string[] - let sidecarFile: string | undefined + const sidecarFile = path.join(tmpDir, 'sidecar.json') try { const { factsOnlyTypes, factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({ consumer: 'fix', cwd, excludePaths, + purlTypes, sbtTmpDir: undefined, }) artifactsFlags = useOnlySocketFactsArtifactsFlags(factsOnlyTypes) paths = factsPaths.map(p => path.resolve(cwd, p)) - if (resolvedPathsSidecar) { - sidecarFile = path.join(tmpDir, 'sidecar.json') - await writeFile(sidecarFile, JSON.stringify(resolvedPathsSidecar)) - } + // Written even when empty, as for reach; applying fixes looks each facts + // file up in it, so an empty one is the same as none. + await writeFile(sidecarFile, JSON.stringify(resolvedPathsSidecar ?? {})) } catch (e) { // A failed build root aborts inference after others wrote their facts. await reset(await findFactsPaths()) diff --git a/src/commands/fix/generated-socket-facts.test.mts b/src/commands/fix/generated-socket-facts.test.mts index 745fdc640..02c4e7b3a 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,18 +76,30 @@ 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', 'maven,nuget', ]) - expect(path.dirname(sidecarFile!)).toBe(tmpDir) - expect(JSON.parse(await fs.readFile(sidecarFile!, 'utf8'))).toEqual({ + expect(path.dirname(sidecarFile)).toBe(tmpDir) + expect(JSON.parse(await fs.readFile(sidecarFile, 'utf8'))).toEqual({ [fresh]: { components: [], projects: [] }, }) }) + it('writes an empty sidecar when no build root resolved any paths', async () => { + mockRunDynamicSbomInference.mockResolvedValue({ + factsOnlyTypes: ['maven'], + factsPaths: [], + }) + + const { artifactsFlags, sidecarFile } = await generate() + + expect(artifactsFlags).toEqual(['--maven-use-only-socket-facts']) + expect(JSON.parse(await fs.readFile(sidecarFile, 'utf8'))).toEqual({}) + }) + it('restores an existing facts file that generation does not rewrite', async () => { mockRunDynamicSbomInference.mockImplementation(async () => { await fs.writeFile(fresh, 'generated') diff --git a/src/commands/fix/types.mts b/src/commands/fix/types.mts index e3a70d79e..42ccabe91 100644 --- a/src/commands/fix/types.mts +++ b/src/commands/fix/types.mts @@ -1,3 +1,4 @@ +import type { FactsOnlyTypes } from '../../utils/coana.mts' import type { PURL_Type } from '../../utils/ecosystem.mts' import type { RangeStyle } from '../../utils/semver.mts' import type { Spinner } from '@socketsecurity/registry/lib/spinner' @@ -12,7 +13,8 @@ export type FixConfig = { debug: boolean disableExternalToolChecks: boolean disableMajorUpdates: boolean - dynamicSbomInference: boolean + // The ecosystems to generate Socket facts for; undefined when off. + dynamicSbomInference: FactsOnlyTypes | undefined 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..81bc64af9 100644 --- a/src/commands/manifest/discover-manifest-roots.test.mts +++ b/src/commands/manifest/discover-manifest-roots.test.mts @@ -15,6 +15,8 @@ import { testPath } from '../../../test/utils.mts' import { loadSocketConfig } from '../../utils/config.mts' import type { BuildRoot } from './discover-manifest-roots.mts' +import type { BuildTool } from './scripts/build-tool.mts' +import type { PURL_Type } from '../../utils/ecosystem.mts' import type { SocketJson } from '../../utils/socket-json.mts' const monorepo = path.join( @@ -140,17 +142,41 @@ describe('findBuildToolCandidates', () => { expect(dirsOf(candidates.get('gradle'))).toContain(dualDir) }) - it('drops a disabled ecosystem entirely', async () => { - const candidates = await findBuildToolCandidates({ - cwd: monorepo, - socketConfig: undefined, + it.for<{ + name: string + purlTypes?: PURL_Type[] + sockJson?: SocketJson + tools: BuildTool[] + }>([ + { + name: 'a disabled ecosystem', sockJson: { defaults: { manifest: { maven: { disabled: true } } }, } as SocketJson, - }) - expect(candidates.has('maven')).toBe(false) - expect(candidates.get('gradle')?.length).toBeGreaterThan(0) - }) + tools: ['dotnet', 'gradle', 'sbt'], + }, + { + name: 'purl types other than maven', + purlTypes: ['maven'], + tools: ['gradle', 'maven', 'sbt'], + }, + { + name: 'purl types other than nuget', + purlTypes: ['nuget'], + tools: ['dotnet'], + }, + ])( + 'drops the build tools of $name', + async ({ purlTypes, sockJson = {} as SocketJson, tools }) => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + purlTypes, + socketConfig: undefined, + sockJson, + }) + expect([...candidates.keys()]).toEqual(tools) + }, + ) it('respects --exclude-paths', async () => { const candidates = await findBuildToolCandidates({ diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 0655c33b7..c9a815034 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -18,10 +18,8 @@ import { DSI_SETUP_COMMAND, REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG, } from './scripts/resolution-report-render.mts' -import { noDotnetSdkReason } from './scripts/run.mts' import { SOCKET_JSON } from '../../constants.mts' import { loadSocketConfig } from '../../utils/config.mts' -import { InputError } from '../../utils/errors.mts' import { withTmpDir } from '../../utils/fs.mts' import { readOrDefaultSocketJson, @@ -36,6 +34,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 = @@ -212,21 +211,6 @@ async function runEcosystemCandidates({ outcomes.push({ ...root, ecosystem, status: 'skippedDisabled' }) continue } - const socketJson = displayPath(path.join(dir, SOCKET_JSON)) - if (ecosystem === 'dotnet') { - // eslint-disable-next-line no-await-in-loop - const reason = await noDotnetSdkReason(dirConfig.bin, dir) - if (reason !== undefined) { - throw new InputError( - [ - `No .NET SDK runs for ${displayPath(buildRootPath(root))} (${reason}), so its Socket facts cannot be generated.`, - '', - `- Install the .NET SDK, or point defaults.manifest.dotnet.bin in ${socketJson} at one (\`${DSI_SETUP_COMMAND}\` walks you through it).`, - `- Or leave it out with --exclude-paths, or with defaults.manifest.dotnet.disabled in ${socketJson}.`, - ].join('\n'), - ) - } - } // eslint-disable-next-line no-await-in-loop const result = await buildUncoveredRoot({ config: { @@ -247,7 +231,7 @@ async function runEcosystemCandidates({ : undefined, flags: optionFlags, setupCommand: DSI_SETUP_COMMAND, - socketJson, + socketJson: displayPath(path.join(dir, SOCKET_JSON)), }, root, sidecarAcc, @@ -362,6 +346,7 @@ export async function generateRecursiveManifests({ excludePaths, ignoreUnresolved, optionFlags = new Set(), + purlTypes, sbtTmpDir, sidecarAcc, verbose, @@ -374,6 +359,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 +387,7 @@ export async function generateRecursiveManifests({ const candidatesByTool = await findBuildToolCandidates({ cwd, excludePaths, + purlTypes, socketConfig: loadSocketConfig(cwd), sockJson: withoutDisabledFlags(rootSockJson), }) diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index d7996a03a..f84dc555e 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -18,13 +18,12 @@ vi.mock('./run-manifest-facts.mts', () => ({ vi.mock('./scripts/run.mts', async importOriginal => ({ ...(await importOriginal()), listDotnetEntryProjects: vi.fn(async () => undefined), - noDotnetSdkReason: vi.fn(async () => undefined), })) import { buildRootPath } from './discover-manifest-roots.mts' import { generateRecursiveManifests } from './generate-recursive-manifests.mts' import { runManifestFacts } from './run-manifest-facts.mts' -import { listDotnetEntryProjects, noDotnetSdkReason } from './scripts/run.mts' +import { listDotnetEntryProjects } from './scripts/run.mts' import { testPath } from '../../../test/utils.mts' import { readOrDefaultSocketJson, @@ -524,26 +523,6 @@ describe('generateRecursiveManifests', () => { }) }) - it('fails before building anything when no .NET SDK runs for a .NET build root', async () => { - const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-require-sdk-')) - try { - await fs.writeFile(path.join(dir, 'pom.xml'), '') - await fs.writeFile(path.join(dir, 'App.csproj'), '') - vi.mocked(noDotnetSdkReason).mockResolvedValueOnce('dotnet not found') - vi.mocked(runManifestFacts).mockClear() - - await expect( - generateRecursiveManifests({ - cwd: dir, - verbose: false, - }), - ).rejects.toThrow(/No .NET SDK runs for .* \(dotnet not found\)/) - expect(runManifestFacts).not.toHaveBeenCalled() - } finally { - await fs.rm(dir, { recursive: true, force: true }) - } - }) - it("does not list a solution's members for coverage another tool's builds recorded", async () => { const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-per-tool-')) try { diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index de975f7b1..baa329481 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -14,7 +14,6 @@ import { handleManifestConda } from './handle-manifest-conda.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' import { REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG } from './scripts/resolution-report-render.mts' -import { skipWithoutDotnetSdk } from './scripts/run.mts' import { hasSidecarEntries, serializeSidecar } from './scripts/sidecar.mts' import { ENVIRONMENT_YML, @@ -225,21 +224,18 @@ export async function generateAutoManifest({ } if (!sockJson?.defaults?.manifest?.dotnet?.disabled && detected.dotnet) { - const config = resolveEcosystemConfig('dotnet', cwd, sockJson) - if (!(await skipWithoutDotnetSdk(config.bin, cwd))) { - logger.log( - 'Detected a .NET solution or project, generating Socket facts...', - ) - const outcome = await convertDotnetToFacts({ - config, - cwd, - excludePaths, - hints, - sidecarAcc, - withFiles: computeArtifactsSidecar, - }) - abortFactsRunIfFailed('dotnet', outcome) - } + logger.log( + 'Detected a .NET solution or project, generating Socket facts...', + ) + const outcome = await convertDotnetToFacts({ + config: resolveEcosystemConfig('dotnet', cwd, sockJson), + cwd, + excludePaths, + hints, + sidecarAcc, + withFiles: computeArtifactsSidecar, + }) + abortFactsRunIfFailed('dotnet', outcome) } if (!sockJson?.defaults?.manifest?.conda?.disabled && detected.conda) { diff --git a/src/commands/manifest/generate_auto_manifest.test.mts b/src/commands/manifest/generate_auto_manifest.test.mts index 78de7def7..e505e6cba 100644 --- a/src/commands/manifest/generate_auto_manifest.test.mts +++ b/src/commands/manifest/generate_auto_manifest.test.mts @@ -28,10 +28,6 @@ vi.mock('./convert-sbt-to-facts.mts', () => ({ vi.mock('./handle-manifest-conda.mts', () => ({ handleManifestConda: vi.fn(async () => undefined), })) -vi.mock('./scripts/run.mts', async importOriginal => ({ - ...(await importOriginal()), - skipWithoutDotnetSdk: vi.fn(async () => false), -})) vi.mock('../../utils/socket-json.mts', () => ({ readOrDefaultSocketJson: vi.fn(() => ({})), })) @@ -44,7 +40,6 @@ import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' import { generateAutoManifest } from './generate_auto_manifest.mts' import { handleManifestConda } from './handle-manifest-conda.mts' -import { skipWithoutDotnetSdk } from './scripts/run.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import type { SocketJson } from '../../utils/socket-json.mts' @@ -424,7 +419,6 @@ function generateForDotnet() { describe('generateAutoManifest — dotnet branch', () => { beforeEach(() => { vi.mocked(convertDotnetToFacts).mockClear() - vi.mocked(skipWithoutDotnetSdk).mockReset() vi.mocked(readOrDefaultSocketJson).mockReturnValue({} as SocketJson) }) @@ -442,10 +436,6 @@ describe('generateAutoManifest — dotnet branch', () => { }, } as SocketJson) await generateForDotnet() - expect(skipWithoutDotnetSdk).toHaveBeenCalledWith( - '/opt/dotnet/dotnet', - '/tmp/repo', - ) expect(convertDotnetToFacts).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ @@ -459,18 +449,11 @@ describe('generateAutoManifest — dotnet branch', () => { ) }) - it('skips the build where no .NET SDK runs', async () => { - vi.mocked(skipWithoutDotnetSdk).mockResolvedValue(true) - await generateForDotnet() - expect(convertDotnetToFacts).not.toHaveBeenCalled() - }) - it('does not run when defaults.manifest.dotnet.disabled is true', async () => { vi.mocked(readOrDefaultSocketJson).mockReturnValue({ defaults: { manifest: { dotnet: { disabled: true } } }, } as SocketJson) await generateForDotnet() - expect(skipWithoutDotnetSdk).not.toHaveBeenCalled() expect(convertDotnetToFacts).not.toHaveBeenCalled() }) diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index b6c31497e..69c2c7cbc 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -3,6 +3,7 @@ import { basename, resolve } from 'node:path' import constants from '../../../constants.mts' +import type { FactsOnlyTypes } from '../../../utils/coana.mts' import type { PURL_Type } from '../../../utils/ecosystem.mts' export type BuildTool = 'dotnet' | 'gradle' | 'maven' | 'sbt' @@ -17,6 +18,28 @@ export const PURL_TYPE_BY_TOOL: Record = { sbt: 'maven', } as unknown as Record +export const BUILD_TOOLS = Object.keys(PURL_TYPE_BY_TOOL) as BuildTool[] + +export const ECOSYSTEM_LABELS: Record = { + __proto__: null, + dotnet: '.NET', + gradle: 'Gradle', + maven: 'Maven', + sbt: 'sbt', +} as unknown as Record + +export const FACTS_PURL_TYPES = [ + ...new Set(Object.values(PURL_TYPE_BY_TOOL)), +].sort() as unknown as FactsOnlyTypes + +export function buildToolsFor(purlTypes: readonly PURL_Type[]): BuildTool[] { + return BUILD_TOOLS.filter(tool => purlTypes.includes(PURL_TYPE_BY_TOOL[tool])) +} + +export function buildToolLabelsFor(purlTypes: readonly PURL_Type[]): string[] { + return buildToolsFor(purlTypes).map(tool => ECOSYSTEM_LABELS[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/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index 012152506..ea09c83a4 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -1,7 +1,6 @@ import { existsSync, promises as fs } from 'node:fs' import path from 'node:path' -import { logger } from '@socketsecurity/registry/lib/logger' import { spawn } from '@socketsecurity/registry/lib/spawn' import { assembleFacts } from './assemble.mts' @@ -9,7 +8,6 @@ import { resolveBuildToolBin, socketFactsFileName } from './build-tool.mts' import { serializeExcludePathPatterns } from './exclude-paths-glob.mts' import { parseRecords } from './records.mts' import constants from '../../../constants.mts' -import { getErrorMessageOr } from '../../../utils/errors.mts' import { withTmpDir } from '../../../utils/fs.mts' import { getOrInsert } from '../../../utils/get-or-insert.mts' import { camelToKebab } from '../../../utils/strings.mts' @@ -526,41 +524,6 @@ async function readDotnetSdkVersion( return out.stdout.trim() } -const noSdkWarnings = new Set() - -// Why no .NET SDK runs in `dir` (none installed, or none matching its -// global.json), or undefined when one does. -export async function noDotnetSdkReason( - bin: string, - dir: string, -): Promise { - try { - await dotnetSdkVersion(bin, { projectDir: dir }) - return undefined - } catch (e) { - return getErrorMessageOr(e, `\`${bin} --version\` failed`).split('\n')[0]! - } -} - -// True, after a warning, when no .NET SDK runs in `dir`, so its .NET builds -// are skipped. -export async function skipWithoutDotnetSdk( - bin: string, - dir: string, -): Promise { - const reason = await noDotnetSdkReason(bin, dir) - if (reason === undefined) { - return false - } - if (!noSdkWarnings.has(reason)) { - noSdkWarnings.add(reason) - logger.warn( - `Skipping .NET builds where no .NET SDK runs (${reason}); their project files are scanned as they are.`, - ) - } - return true -} - // Runs the bundled tool against the build's own dotnet, in `mode` (none: a // full facts run), and returns its exit and records. async function invokeDotnetTool( diff --git a/src/commands/manifest/scripts/run.test.mts b/src/commands/manifest/scripts/run.test.mts index 683c86fb0..663e5ca4d 100644 --- a/src/commands/manifest/scripts/run.test.mts +++ b/src/commands/manifest/scripts/run.test.mts @@ -4,14 +4,12 @@ import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { logger } from '@socketsecurity/registry/lib/logger' import { spawn } from '@socketsecurity/registry/lib/spawn' import { enumerateWorkspaces, listDotnetEntryProjects, runManifestScript, - skipWithoutDotnetSdk, } from './run.mts' vi.mock('@socketsecurity/registry/lib/spawn', () => ({ @@ -258,49 +256,6 @@ describe('dotnet tool invocation', () => { }) }) -describe('skipWithoutDotnetSdk', () => { - it('skips, warning once per reason, where no SDK runs', async () => { - // Warnings are deduplicated for the whole run, so the reason is unique - // to this case. - const reason = `No .NET SDK matches ${dotnetDir()}.` - const warn = vi.spyOn(logger, 'warn').mockImplementation(() => logger) - vi.mocked(spawn).mockImplementation((async () => { - throw Object.assign(new Error('exit 145'), { - code: 145, - stderr: `${reason}\nRequested SDK version: 99.0.100`, - stdout: '', - }) - }) as any) - try { - expect(await skipWithoutDotnetSdk('dotnet', dotnetDir())).toBe(true) - expect(await skipWithoutDotnetSdk('dotnet', dotnetDir())).toBe(true) - expect(warn).toHaveBeenCalledOnce() - expect(warn.mock.calls[0]?.[0]).toContain(reason) - } finally { - warn.mockRestore() - } - }) - - it('runs the build where an SDK answers', async () => { - mockDotnet('8.0.414') - expect(await skipWithoutDotnetSdk('dotnet', dotnetDir())).toBe(false) - }) - - it('probes the SDK once per global.json', async () => { - const pinned = dotnetDir() - mockDotnet('8.0.414') - for (const dir of ['a', path.join('b', 'c')]) { - // eslint-disable-next-line no-await-in-loop - expect(await skipWithoutDotnetSdk('dotnet', path.join(pinned, dir))).toBe( - false, - ) - } - expect( - vi.mocked(spawn).mock.calls.filter(c => c[1]?.[0] === '--version'), - ).toHaveLength(1) - }) -}) - describe('listDotnetEntryProjects', () => { it('resolves the members against the build directory', async () => { const projectDir = dotnetDir() diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 1d4f90a41..dee44c18f 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -18,7 +18,7 @@ import { } from './discover-manifest-roots.mts' import { enumerateWorkspaces } from './enumerate-workspaces.mts' import { resolveEcosystemConfig } from './generate-recursive-manifests.mts' -import { noDotnetSdkReason } from './scripts/run.mts' +import { ECOSYSTEM_LABELS } from './scripts/build-tool.mts' import { setupDotnet, setupGradle, @@ -47,14 +47,6 @@ type Candidate = { dir: string; ecosystem: BuildTool; roots: BuildRoot[] } const ROOT_ECOSYSTEMS: BuildTool[] = ['maven', 'gradle', 'sbt', 'dotnet'] -const ECOSYSTEM_LABELS: Record = { - __proto__: null, - dotnet: '.NET', - gradle: 'Gradle', - maven: 'Maven', - sbt: 'sbt', -} as unknown as Record - // The shallowest directory matching `--exclude-paths`, not necessarily a // project dir itself - one write here covers every project beneath it. type ExclusionRoot = { dir: string; ecosystems: BuildTool[] } @@ -241,17 +233,6 @@ export async function markWorkspaceCoverage({ if (skipReason) { return { ok: true, data: undefined } } - if (ecosystem === 'dotnet') { - const reason = await noDotnetSdkReason(bin, dir) - if (reason !== undefined) { - return { - ok: false, - message: `No .NET SDK runs in ${path.relative(cwd, dir) || '.'} (${reason})`, - cause: - 'Install the .NET SDK, or point defaults.manifest.dotnet.bin at one; or leave the directory out with --exclude-paths.', - } - } - } for (const root of roots) { if ( diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index d9b6dec09..34ca9e0fb 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -25,10 +25,6 @@ vi.mock('./discover-manifest-roots.mts', async importOriginal => { vi.mock('./enumerate-workspaces.mts', () => ({ enumerateWorkspaces: vi.fn(), })) -vi.mock('./scripts/run.mts', async importOriginal => ({ - ...(await importOriginal()), - noDotnetSdkReason: vi.fn(async () => undefined), -})) vi.mock('@socketsecurity/registry/lib/prompts', () => ({ select: vi.fn(), })) @@ -52,7 +48,6 @@ import { findBuildToolCandidates, } from './discover-manifest-roots.mts' import { enumerateWorkspaces } from './enumerate-workspaces.mts' -import { noDotnetSdkReason } from './scripts/run.mts' import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' import { configureCandidate, @@ -460,25 +455,6 @@ describe('markWorkspaceCoverage', () => { expect(covered.size).toBe(0) }) - it('fails closed when no .NET SDK runs for a .NET root', async () => { - vi.mocked(noDotnetSdkReason).mockResolvedValueOnce('dotnet not found') - - const result = await markWorkspaceCoverage({ - covered: new Set(), - cwd, - dir: reactor, - ecosystem: 'dotnet', - rootSockJson: emptySockJson(), - roots: [{ dir: reactor, entryFile: 'App.csproj' }], - }) - - expect(result).toMatchObject({ - ok: false, - message: 'No .NET SDK runs in reactor (dotnet not found)', - }) - expect(enumerateWorkspaces).not.toHaveBeenCalled() - }) - it('resolves settings once per directory and skips a POM an earlier one covers', async () => { vi.mocked(enumerateWorkspaces).mockResolvedValue({ buildRoot: reactor, diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 37a8cdc50..5a2390b11 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, + parseDynamicSbomInferenceFlags, 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, @@ -279,7 +282,6 @@ async function run( committers, cwd: cwdOverride, defaultBranch, - dynamicSbomInference, interactive = true, json, markdown, @@ -316,7 +318,6 @@ async function run( committers: string cwd: string defaultBranch: boolean - dynamicSbomInference: boolean interactive: boolean json: boolean markdown: boolean @@ -355,6 +356,8 @@ async function run( cmdFlagValueToArray(cli.flags['reachEcosystems']), ) + const dynamicSbomInference = parseDynamicSbomInferenceFlags(cli.flags) + warnSocketJsonOnlyFlags(cli.flags, `${parentName} ${CMD_NAME}`) const dryRun = !!cli.flags['dryRun'] @@ -693,7 +696,7 @@ async function run( pendingHead: Boolean(pendingHead), pullRequest: Number(pullRequest), reach: { - dynamicSbomInference: Boolean(dynamicSbomInference), + dynamicSbomInference, 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..940d9e962 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, Maven, and sbt) and nuget (.NET). Defaults to all of them. --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,32 @@ 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', diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index d3fd778af..70d0c4f75 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, + parseDynamicSbomInferenceFlags, 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', @@ -145,7 +148,6 @@ async function run( const { cwd: cwdOverride, - dynamicSbomInference, interactive = true, json, markdown, @@ -172,7 +174,6 @@ async function run( reachVersion, } = cli.flags as { cwd: string - dynamicSbomInference: boolean interactive: boolean json: boolean markdown: boolean @@ -210,6 +211,7 @@ async function run( // Validate ecosystem values against the reachability-supported set. const reachEcosystems = parseReachEcosystems(reachEcosystemsRaw) + const dynamicSbomInference = parseDynamicSbomInferenceFlags(cli.flags) const processCwd = process.cwd() const cwd = @@ -303,7 +305,7 @@ async function run( outputKind, outputPath: outputPath || '', reachabilityOptions: { - dynamicSbomInference: Boolean(dynamicSbomInference), + dynamicSbomInference, 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..4d807d0b7 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, Maven, and sbt) and nuget (.NET). Defaults to all of them. --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..796ae2f5a 100644 --- a/src/commands/scan/create-scan-from-github.mts +++ b/src/commands/scan/create-scan-from-github.mts @@ -323,7 +323,7 @@ async function scanOneRepo( pendingHead: true, pullRequest: 0, reach: { - dynamicSbomInference: false, + dynamicSbomInference: undefined, 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..a60548033 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -29,13 +29,14 @@ 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' import type { ReachabilityOptions } from './perform-reachability-analysis.mts' import type { REPORT_LEVEL } from './types.mts' import type { OutputKind } from '../../types.mts' -import type { PURL_Type } from '../../utils/ecosystem.mts' +import type { FactsOnlyTypes } from '../../utils/coana.mts' import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' import type { Remap } from '@socketsecurity/registry/lib/objects' import type { SocketSdkSuccessResult } from '@socketsecurity/sdk' @@ -159,7 +160,7 @@ async function createNewScan( // Sidecar forwarded to reachability; populated only when reach runs. let resolvedPathsSidecar: ResolvedPathsSidecar | undefined - let factsOnlyTypes: PURL_Type[] | undefined + let factsOnlyTypes: FactsOnlyTypes | undefined // sbt provisions its Scala toolchain under the directory passed as its // isolated global base; reach's artifactPaths point into it, so it must @@ -180,6 +181,7 @@ async function createNewScan( consumer: reach.runReachabilityAnalysis ? 'reach' : 'upload', cwd, excludePaths: reach.excludePaths, + purlTypes: reach.dynamicSbomInference, // 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,9 @@ 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.dynamicSbomInference)) { + 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..628711d00 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -345,7 +345,7 @@ describe('handleCreateNewScan excludePaths', () => { ]) const config = createConfig({ autoManifest: false, targets: ['/repo'] }) - config.reach.dynamicSbomInference = true + config.reach.dynamicSbomInference = ['maven', 'nuget'] await handleCreateNewScan(config) @@ -384,7 +384,7 @@ describe('handleCreateNewScan excludePaths', () => { ]) const config = createConfig({ autoManifest: true, targets: ['/repo'] }) - config.reach.dynamicSbomInference = true + config.reach.dynamicSbomInference = ['maven', 'nuget'] await handleCreateNewScan(config) @@ -419,6 +419,29 @@ 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 = ['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([ { @@ -431,7 +454,7 @@ describe('handleCreateNewScan excludePaths', () => { ]) const config = createConfig({ autoManifest: false, targets: ['/repo'] }) - config.reach.dynamicSbomInference = true + config.reach.dynamicSbomInference = ['maven', 'nuget'] await expect(handleCreateNewScan(config)).rejects.toThrow( /one or more independent build roots failed/i, @@ -444,10 +467,10 @@ describe('handleCreateNewScan excludePaths', () => { mockGenerateRecursiveManifests.mockResolvedValueOnce([]) const config = createConfig({ autoManifest: false, targets: ['/repo'] }) - config.reach.dynamicSbomInference = true + config.reach.dynamicSbomInference = ['maven', 'nuget'] await expect(handleCreateNewScan(config)).rejects.toThrow( - /No Gradle, sbt, Maven or .NET build root was found/, + /No .NET, Gradle, Maven, or sbt build root was found/, ) expect(mockGetPackageFilesForScan).not.toHaveBeenCalled() expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() @@ -460,7 +483,7 @@ describe('handleCreateNewScan excludePaths', () => { ]) const config = createConfig({ autoManifest: false, targets: ['/repo'] }) - config.reach.dynamicSbomInference = true + config.reach.dynamicSbomInference = ['maven', 'nuget'] await handleCreateNewScan(config) @@ -497,7 +520,7 @@ describe('handleCreateNewScan excludePaths', () => { ) const config = createConfig({ autoManifest: false, targets: ['/repo'] }) - config.reach.dynamicSbomInference = true + config.reach.dynamicSbomInference = ['maven', 'nuget'] config.reach.runReachabilityAnalysis = true await handleCreateNewScan(config) diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 27a3a41ff..264ca5e50 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.dynamicSbomInference, 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..c35ce6a34 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -347,7 +347,7 @@ describe('handleScanReach', () => { describe('dynamic SBOM inference', () => { const baseReachabilityOptions = { ...makeReachabilityOptions(), - dynamicSbomInference: true, + dynamicSbomInference: ['maven', 'nuget'], excludePaths: ['vendor/**'], } @@ -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), }) @@ -436,7 +437,7 @@ describe('handleScanReach', () => { outputPath: '', reachabilityOptions: { ...baseReachabilityOptions, - dynamicSbomInference: false, + dynamicSbomInference: undefined, }, targets: ['/repo'], }) diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 362180852..9e57bc1a0 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -21,13 +21,15 @@ import { hasResolvedPathsSidecarEntries } from '../manifest/scripts/sidecar.mts' import { fetchOrganization } from '../organization/fetch-organization-list.mts' import type { CResult, OutputKind } from '../../types.mts' +import type { FactsOnlyTypes } from '../../utils/coana.mts' import type { PURL_Type } from '../../utils/ecosystem.mts' import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' import type { Spinner } from '@socketsecurity/registry/lib/spinner' import type { StdioOptions } from 'node:child_process' export type ReachabilityOptions = { - dynamicSbomInference: boolean + // The ecosystems to generate Socket facts for; undefined when off. + dynamicSbomInference: FactsOnlyTypes | undefined excludePaths: string[] // GHSA IDs. Empty means all advisories. reachAdvisories: string[] @@ -56,7 +58,7 @@ export type ReachabilityAnalysisOptions = { branchName?: string | undefined cwd?: string | undefined // Purl types whose artifacts Coana takes only from facts files. - factsOnlyTypes?: readonly PURL_Type[] | undefined + factsOnlyTypes?: FactsOnlyTypes | undefined // Required: the manifest upload they drive produces the tar hash Coana needs // to run without Docker. orgSlug: string @@ -192,11 +194,11 @@ export async function performReachabilityAnalysis( spinner?.infoAndStop('Running reachability analysis with Coana...') // Temp file for --compute-artifacts-sidecar, removed in the finally below. - // Written even when empty with factsOnlyTypes, since the facts-only flag - // below requires one to be present. + // Written even when empty with factsOnlyTypes: `coana run` rejects the + // facts-only flag without one. let sidecarPath: string | undefined if ( - factsOnlyTypes?.length || + factsOnlyTypes || (resolvedPathsSidecar && hasResolvedPathsSidecarEntries(resolvedPathsSidecar)) ) { @@ -204,11 +206,7 @@ export async function performReachabilityAnalysis( tmpdir(), `socket-compute-artifacts-sidecar-${randomUUID()}.json`, ) - await fs.writeFile( - sidecarPath, - JSON.stringify(resolvedPathsSidecar ?? {}), - 'utf8', - ) + await fs.writeFile(sidecarPath, JSON.stringify(resolvedPathsSidecar ?? {})) } // Build Coana arguments. @@ -270,10 +268,7 @@ export async function performReachabilityAnalysis( ...(reachabilityOptions.reachExcludePaths.length ? ['--exclude-dirs', ...reachabilityOptions.reachExcludePaths] : []), - // sidecarPath is always set above then; Coana rejects this flag without one. - ...(factsOnlyTypes?.length - ? useOnlySocketFactsArtifactsFlags(factsOnlyTypes) - : []), + ...(factsOnlyTypes ? useOnlySocketFactsArtifactsFlags(factsOnlyTypes) : []), ...(reachabilityOptions.reachSkipCache ? ['--skip-cache-usage'] : []), ...(reachabilityOptions.reachUseOnlyPregeneratedSboms ? ['--use-only-pregenerated-sboms'] diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index 33c80fea1..82add0f18 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -410,7 +410,9 @@ describe('performReachabilityAnalysis --use-only-socket-facts-artifacts gating', ) it('writes no sidecar without facts-only types or sidecar entries', async () => { - const args = await coanaArgsFor(scanCwd, { dynamicSbomInference: true }) + const args = await coanaArgsFor(scanCwd, { + dynamicSbomInference: ['maven', 'nuget'], + }) expect(args).not.toContain('--compute-artifacts-sidecar') }) }) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index be7b9e890..79261f967 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -1,7 +1,20 @@ +import { joinAnd } from '@socketsecurity/registry/lib/arrays' + import { COANA_CLI_VERSION } from '../../constants.mts' -import { getReachabilityEcosystemChoices } from '../../utils/ecosystem.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { isFactsOnlyTypes } from '../../utils/coana.mts' +import { + getReachabilityEcosystemChoices, + parseEcosystemChoices, +} from '../../utils/ecosystem.mts' +import { InputError } from '../../utils/errors.mts' +import { + FACTS_PURL_TYPES, + buildToolLabelsFor, +} from '../manifest/scripts/build-tool.mts' import type { MeowFlags } from '../../flags.mts' +import type { FactsOnlyTypes } from '../../utils/coana.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 +22,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: ${joinAnd(FACTS_PURL_TYPES.map(t => `${t} (${joinAnd(buildToolLabelsFor([t]))})`))}. Defaults to all of them.`, + }, +} + +export function parseDynamicSbomInferenceFlags(flags: { + [flag: string]: unknown +}): FactsOnlyTypes | undefined { + const raw = cmdFlagValueToArray(flags['dynamicSbomInferenceEcosystems']) + if (!flags['dynamicSbomInference']) { + if (raw.length) { + throw new InputError( + '--dynamic-sbom-inference-ecosystems requires --dynamic-sbom-inference', + ) + } + return undefined + } + const selected = parseEcosystemChoices(raw, FACTS_PURL_TYPES) + return isFactsOnlyTypes(selected) ? selected : FACTS_PURL_TYPES +} + export const reachabilityFlags: MeowFlags = { reachVersion: { type: 'string', diff --git a/src/commands/scan/reachability-flags.test.mts b/src/commands/scan/reachability-flags.test.mts new file mode 100644 index 000000000..5b0f9ef36 --- /dev/null +++ b/src/commands/scan/reachability-flags.test.mts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { parseDynamicSbomInferenceFlags } from './reachability-flags.mts' + +describe('parseDynamicSbomInferenceFlags', () => { + it.for([ + { flags: {}, expected: undefined }, + { flags: { dynamicSbomInference: true }, expected: ['maven', 'nuget'] }, + { + flags: { + dynamicSbomInference: true, + dynamicSbomInferenceEcosystems: ['NuGet,nuget'], + }, + expected: ['nuget'], + }, + { + flags: { + dynamicSbomInference: true, + dynamicSbomInferenceEcosystems: ['maven', 'Maven,NUGET'], + }, + expected: ['maven', 'nuget'], + }, + ])('reads $flags as $expected', ({ expected, flags }) => { + expect(parseDynamicSbomInferenceFlags(flags)).toEqual(expected) + }) + + it.for([ + { + flags: { dynamicSbomInferenceEcosystems: ['maven'] }, + error: + '--dynamic-sbom-inference-ecosystems requires --dynamic-sbom-inference', + }, + { + flags: { + dynamicSbomInference: true, + dynamicSbomInferenceEcosystems: ['maven,npm'], + }, + error: 'Invalid ecosystem: "npm". Valid values are: maven and nuget', + }, + ])('rejects $flags', ({ error, flags }) => { + expect(() => parseDynamicSbomInferenceFlags(flags)).toThrow(error) + }) +}) diff --git a/src/commands/scan/run-dynamic-sbom-inference.mts b/src/commands/scan/run-dynamic-sbom-inference.mts index c98bf8230..25010c533 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.mts @@ -1,14 +1,20 @@ +import { joinOr } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' +import { isFactsOnlyTypes } from '../../utils/coana.mts' 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 { + PURL_TYPE_BY_TOOL, + buildToolLabelsFor, +} from '../manifest/scripts/build-tool.mts' import { DSI_SETUP_COMMAND } from '../manifest/scripts/resolution-report-render.mts' import { hasSidecarEntries, serializeSidecar, } from '../manifest/scripts/sidecar.mts' +import type { FactsOnlyTypes } from '../../utils/coana.mts' import type { PURL_Type } from '../../utils/ecosystem.mts' import type { RecursiveManifestOutcome } from '../manifest/generate-recursive-manifests.mts' import type { @@ -21,10 +27,10 @@ import type { export type FactsConsumer = 'fix' | 'reach' | 'upload' export type DynamicSbomInferenceResult = { - factsPaths: string[] // Each discovered build root's purl type, generated or not: Coana takes // these types' artifacts only from facts files. - factsOnlyTypes: PURL_Type[] + factsOnlyTypes: FactsOnlyTypes + factsPaths: string[] resolvedPathsSidecar: ResolvedPathsSidecar | undefined } @@ -47,6 +53,7 @@ function checkFactsForCoana( `- Re-enable the builds that socket.json skips (\`${DSI_SETUP_COMMAND}\` walks you through it).`, ] : []), + '- 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 +66,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 +93,24 @@ export async function runDynamicSbomInference({ const outcomes = await generateRecursiveManifests({ cwd, excludePaths, + purlTypes, sbtTmpDir: withFiles ? sbtTmpDir : undefined, sidecarAcc, verbose: false, withFiles, }) + const factsOnlyTypes = [ + ...new Set(outcomes.map(o => PURL_TYPE_BY_TOOL[o.ecosystem])), + ] // 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) { + if (!isFactsOnlyTypes(factsOnlyTypes)) { throw new InputError( [ - 'No Gradle, sbt, Maven or .NET build root was found.', + `No ${joinOr(buildToolLabelsFor(purlTypes))} build root was found.`, '', - '- Remove --dynamic-sbom-inference; it only applies to these ecosystems.', + '- Select other ecosystems with --dynamic-sbom-inference-ecosystems, or remove --dynamic-sbom-inference.', '- Make sure to run it from the correct dir (use --cwd to target another dir).', ].join('\n'), ) @@ -116,10 +129,8 @@ export async function runDynamicSbomInference({ checkFactsForCoana(outcomes, consumer, !!factsPaths.length) } return { + factsOnlyTypes, factsPaths, - factsOnlyTypes: [ - ...new Set(outcomes.map(o => PURL_TYPE_BY_TOOL[o.ecosystem])), - ], resolvedPathsSidecar: sidecarAcc && hasSidecarEntries(sidecarAcc) ? serializeSidecar(sidecarAcc) diff --git a/src/commands/scan/run-dynamic-sbom-inference.test.mts b/src/commands/scan/run-dynamic-sbom-inference.test.mts index 490dc922d..656fa02ee 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, @@ -124,11 +130,22 @@ describe('runDynamicSbomInference', () => { ]) }) - it.for([ + it.for<{ + error: RegExp + name: string + outcomes: object[] + purlTypes?: PURL_Type[] + }>([ { name: 'no build root was discovered', outcomes: [], - error: /No Gradle, sbt, Maven or .NET build root was found/, + error: /No .NET, Gradle, Maven, or sbt build root was found/, + }, + { + name: 'no build root of the selected ecosystems was discovered', + outcomes: [], + purlTypes: ['nuget'], + error: /No .NET build root was found/, }, { name: 'a discovered build root failed to generate facts', @@ -146,10 +163,16 @@ describe('runDynamicSbomInference', () => { ], error: /Re-enable the builds that socket.json skips/, }, - ])('throws when $name', async ({ error, outcomes }) => { + { + name: 'the selected ecosystems produced no facts for reachability', + outcomes: [{ dir: '/repo/a', ecosystem: 'dotnet', status: 'empty' }], + purlTypes: ['nuget'], + error: /select other ecosystems with --dynamic-sbom-inference-ecosystems/, + }, + ])('throws when $name', async ({ error, outcomes, purlTypes }) => { mockGenerateRecursiveManifests.mockResolvedValueOnce(outcomes) - await expect(run('reach')).rejects.toThrow(error) + await expect(run('reach', purlTypes)).rejects.toThrow(error) }) it.for([ diff --git a/src/utils/coana.mts b/src/utils/coana.mts index a3b4a2a84..c27f4cae3 100644 --- a/src/utils/coana.mts +++ b/src/utils/coana.mts @@ -32,6 +32,8 @@ import constants from '../constants.mts' import type { PURL_Type } from './ecosystem.mts' +export type FactsOnlyTypes = readonly [PURL_Type, ...PURL_Type[]] + const { DOT_SOCKET_DOT_FACTS_JSON } = constants export type CompressedScanPaths = { @@ -303,10 +305,16 @@ export async function snapshotSocketFacts( } } +export function isFactsOnlyTypes( + types: readonly PURL_Type[], +): types is FactsOnlyTypes { + return !!types.length +} + // Maven alone keeps the older flag, which Coana releases before 15.14.1 also // know. export function useOnlySocketFactsArtifactsFlags( - types: readonly PURL_Type[], + types: FactsOnlyTypes, ): string[] { return types.length === 1 && types[0] === 'maven' ? ['--maven-use-only-socket-facts'] diff --git a/src/utils/ecosystem.mts b/src/utils/ecosystem.mts index 597457b57..ccef4d477 100644 --- a/src/utils/ecosystem.mts +++ b/src/utils/ecosystem.mts @@ -22,7 +22,7 @@ * - Ensures type safety for ecosystem operations */ -import { joinAnd } from '@socketsecurity/registry/lib/arrays' +import { arrayUnique, joinAnd } from '@socketsecurity/registry/lib/arrays' import { NPM } from '../constants.mts' import { InputError } from './errors.mts' @@ -133,16 +133,21 @@ export function parseEcosystems( return values.filter(isValidEcosystem) } -export function parseReachEcosystems(raw: readonly string[]): PURL_Type[] { - const choices = getReachabilityEcosystemChoices() - const result: PURL_Type[] = [] - for (const ecosystem of raw) { +export function parseEcosystemChoices( + raw: readonly string[], + choices: readonly string[], +): PURL_Type[] { + const ecosystems = arrayUnique(raw.map(e => e.toLowerCase())) + for (const ecosystem of ecosystems) { if (!choices.includes(ecosystem)) { throw new InputError( `Invalid ecosystem: "${ecosystem}". Valid values are: ${joinAnd(choices)}`, ) } - result.push(ecosystem as PURL_Type) } - return result + return ecosystems 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..adfc6a398 100644 --- a/test/reachability-options.mts +++ b/test/reachability-options.mts @@ -4,7 +4,7 @@ import type { ReachabilityOptions } from '../src/commands/scan/perform-reachabil // exercises. export function makeReachabilityOptions(): ReachabilityOptions { return { - dynamicSbomInference: false, + dynamicSbomInference: undefined, excludePaths: [], reachAdvisories: [], reachAnalysisMemoryLimit: '8192',