diff --git a/.gitignore b/.gitignore index 3dd4c3ebf..b75a474ee 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,7 @@ test/fixtures/commands/fix/e2e-test-py-temp-* # The stub repos and caches are rebuilt on every run; smoke-test-workspaces.sh # still keeps its Gradle user home inside the fixture directory. /src/commands/manifest/scripts/test/gradle-compat/.gradle-home/ -/src/commands/manifest/scripts/test/gradle-compat/project/.gradle/ +/src/commands/manifest/scripts/test/gradle-compat/**/.gradle/ /src/commands/manifest/scripts/test/gradle-compat/project/build/ /src/commands/manifest/scripts/test/gradle-compat/project/localrepo/ /src/commands/manifest/scripts/test/gradle-compat/project/records.tsv diff --git a/CHANGELOG.md b/CHANGELOG.md index c14893bed..852e20695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### 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. + ## [1.13.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.13.1) - 2026-11-03 ### Fixed diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index fc49bae70..120543c67 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -1,7 +1,7 @@ import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises' import path from 'node:path' -import { useOnlySocketFactsArtifactsFlags } from '../manifest/scripts/sidecar.mts' +import { useOnlySocketFactsArtifactsFlags } from '../../utils/coana.mts' import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts' export type GeneratedSocketFacts = { @@ -59,14 +59,14 @@ export async function generateSocketFactsForFix({ let paths: string[] let sidecarFile: string | undefined try { - const { factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({ - cwd, - excludePaths, - sbtTmpDir: undefined, - sidecar: true, - withFiles: false, - }) - artifactsFlags = useOnlySocketFactsArtifactsFlags(resolvedPathsSidecar) + const { factsOnlyTypes, factsPaths, resolvedPathsSidecar } = + await runDynamicSbomInference({ + consumer: 'fix', + cwd, + excludePaths, + sbtTmpDir: undefined, + }) + artifactsFlags = useOnlySocketFactsArtifactsFlags(factsOnlyTypes) paths = factsPaths.map(p => path.resolve(cwd, p)) if (resolvedPathsSidecar) { sidecarFile = path.join(tmpDir, 'sidecar.json') diff --git a/src/commands/fix/generated-socket-facts.test.mts b/src/commands/fix/generated-socket-facts.test.mts index 0a95c663a..745fdc640 100644 --- a/src/commands/fix/generated-socket-facts.test.mts +++ b/src/commands/fix/generated-socket-facts.test.mts @@ -45,7 +45,7 @@ describe('generateSocketFactsForFix', () => { mockRunDynamicSbomInference.mockImplementation(async () => { await fs.writeFile(existing, 'regenerated') await fs.writeFile(fresh, 'generated') - return { factsPaths: [existing, fresh] } + return { factsOnlyTypes: ['maven'], factsPaths: [existing, fresh] } }) const generated = await generate() @@ -66,6 +66,7 @@ describe('generateSocketFactsForFix', () => { mockRunDynamicSbomInference.mockImplementation(async () => { await fs.writeFile(fresh, 'generated') return { + factsOnlyTypes: ['maven', 'nuget'], factsPaths: [fresh], resolvedPathsSidecar: { [fresh]: { components: [], projects: [] } }, } @@ -74,9 +75,12 @@ describe('generateSocketFactsForFix', () => { const { artifactsFlags, sidecarFile } = await generate() expect(mockRunDynamicSbomInference).toHaveBeenCalledWith( - expect.objectContaining({ sidecar: true }), + expect.objectContaining({ consumer: 'fix' }), ) - expect(artifactsFlags).toEqual(['--maven-use-only-socket-facts']) + 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({ [fresh]: { components: [], projects: [] }, @@ -86,7 +90,7 @@ describe('generateSocketFactsForFix', () => { it('restores an existing facts file that generation does not rewrite', async () => { mockRunDynamicSbomInference.mockImplementation(async () => { await fs.writeFile(fresh, 'generated') - return { factsPaths: [fresh] } + return { factsOnlyTypes: ['maven'], factsPaths: [fresh] } }) const generated = await generate() diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index c73b2282a..0655c33b7 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -18,9 +18,10 @@ import { DSI_SETUP_COMMAND, REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG, } from './scripts/resolution-report-render.mts' -import { skipWithoutDotnetSdk } from './scripts/run.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, @@ -44,7 +45,6 @@ export type RecursiveManifestOutcomeStatus = | 'generated' | 'skippedCovered' | 'skippedDisabled' - | 'skippedNoSdk' export type RecursiveManifestOutcome = BuildRoot & { ecosystem: BuildTool @@ -212,13 +212,20 @@ async function runEcosystemCandidates({ outcomes.push({ ...root, ecosystem, status: 'skippedDisabled' }) continue } - if ( - ecosystem === 'dotnet' && + const socketJson = displayPath(path.join(dir, SOCKET_JSON)) + if (ecosystem === 'dotnet') { // eslint-disable-next-line no-await-in-loop - (await skipWithoutDotnetSdk(dirConfig.bin, dir)) - ) { - outcomes.push({ ...root, ecosystem, status: 'skippedNoSdk' }) - continue + 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({ @@ -240,7 +247,7 @@ async function runEcosystemCandidates({ : undefined, flags: optionFlags, setupCommand: DSI_SETUP_COMMAND, - socketJson: displayPath(path.join(dir, SOCKET_JSON)), + socketJson, }, root, sidecarAcc, diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index f472e5703..d7996a03a 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -18,16 +18,13 @@ vi.mock('./run-manifest-facts.mts', () => ({ vi.mock('./scripts/run.mts', async importOriginal => ({ ...(await importOriginal()), listDotnetEntryProjects: vi.fn(async () => undefined), - skipWithoutDotnetSdk: vi.fn(async () => false), + 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, - skipWithoutDotnetSdk, -} from './scripts/run.mts' +import { listDotnetEntryProjects, noDotnetSdkReason } from './scripts/run.mts' import { testPath } from '../../../test/utils.mts' import { readOrDefaultSocketJson, @@ -527,28 +524,21 @@ describe('generateRecursiveManifests', () => { }) }) - it('skips .NET builds, without failing, where no .NET SDK runs', async () => { - const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-no-sdk-')) + 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.mkdir(path.join(dir, 'samples')) - await fs.writeFile(path.join(dir, 'samples', 'Stray.csproj'), '') - vi.mocked(skipWithoutDotnetSdk).mockResolvedValueOnce(true) - vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ - factsPath: path.join(cwd, 'pom.xml.socket.facts.json'), - projects: [], - })) - const outcomes = await generateRecursiveManifests({ - cwd: dir, - verbose: false, - }) + await fs.writeFile(path.join(dir, 'App.csproj'), '') + vi.mocked(noDotnetSdkReason).mockResolvedValueOnce('dotnet not found') + vi.mocked(runManifestFacts).mockClear() - expect( - outcomes.map(o => `${o.ecosystem}:${o.entryFile}:${o.status}`).sort(), - ).toEqual(['dotnet:Stray.csproj:skippedNoSdk', 'maven:pom.xml:generated']) - expect( - vi.mocked(runManifestFacts).mock.calls.map(([o]) => o.ecosystem), - ).toEqual(['maven']) + 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 }) } diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index c7a751dce..012152506 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -528,27 +528,37 @@ async function readDotnetSdkVersion( const noSdkWarnings = new Set() -// True, after a warning, when no .NET SDK runs in `dir` (none installed, or -// none matching its global.json), so its .NET builds are skipped. -export async function skipWithoutDotnetSdk( +// 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 { +): Promise { try { await dotnetSdkVersion(bin, { projectDir: dir }) - return false + return undefined } catch (e) { - const reason = getErrorMessageOr(e, `\`${bin} --version\` failed`).split( - '\n', - )[0]! - 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 + 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 diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index caeb609d0..bce3714e8 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -157,20 +157,3 @@ export function serializeSidecar( } return result } - -// Tells Coana which ecosystems' artifacts only these facts files attribute. -// Maven's always are (the dynamic-SBOM-inference contract), under its own flag -// every Coana release knows unless another ecosystem's facts are present too. -export function useOnlySocketFactsArtifactsFlags( - sidecar: ResolvedPathsSidecar | undefined, -): string[] { - const types = new Set(['maven']) - for (const entry of Object.values(sidecar ?? {})) { - for (const item of [...entry.projects, ...entry.components]) { - types.add(item.type) - } - } - return types.size === 1 - ? ['--maven-use-only-socket-facts'] - : ['--use-only-socket-facts-artifacts', [...types].sort().join(',')] -} diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index 6eae56a04..412819d19 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -6,7 +6,6 @@ import { hasSidecarEntries, mergeResolvedPathsSidecars, serializeSidecar, - useOnlySocketFactsArtifactsFlags, } from './sidecar.mts' import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts' @@ -422,28 +421,3 @@ describe('compute-artifacts sidecar', () => { expect(hasResolvedPathsSidecarEntries({})).toBe(false) }) }) - -function entryOfType(type: string) { - return { projects: [], components: [{ type, name: 'x', id: 'x' }] } -} - -describe('useOnlySocketFactsArtifactsFlags', () => { - it('keeps the Maven-only flag every Coana release knows for JVM facts', () => { - expect(useOnlySocketFactsArtifactsFlags(undefined)).toEqual([ - '--maven-use-only-socket-facts', - ]) - expect( - useOnlySocketFactsArtifactsFlags({ - '/a/pom.xml.socket.facts.json': entryOfType('maven'), - }), - ).toEqual(['--maven-use-only-socket-facts']) - }) - - it('names every ecosystem once another one has facts', () => { - expect( - useOnlySocketFactsArtifactsFlags({ - '/a/App.sln.socket.facts.json': entryOfType('nuget'), - }), - ).toEqual(['--use-only-socket-facts-artifacts', 'maven,nuget']) - }) -}) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 3cffc5bc2..1d4f90a41 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 { skipWithoutDotnetSdk } from './scripts/run.mts' +import { noDotnetSdkReason } from './scripts/run.mts' import { setupDotnet, setupGradle, @@ -241,8 +241,16 @@ export async function markWorkspaceCoverage({ if (skipReason) { return { ok: true, data: undefined } } - if (ecosystem === 'dotnet' && (await skipWithoutDotnetSdk(bin, dir))) { - 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) { diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index 34ca9e0fb..d9b6dec09 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -25,6 +25,10 @@ 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(), })) @@ -48,6 +52,7 @@ 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, @@ -455,6 +460,25 @@ 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/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index dd15c54ed..50ce4a07d 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -35,6 +35,7 @@ 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 { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' import type { Remap } from '@socketsecurity/registry/lib/objects' import type { SocketSdkSuccessResult } from '@socketsecurity/sdk' @@ -158,9 +159,10 @@ async function createNewScan( // Sidecar forwarded to reachability; populated only when reach runs. let resolvedPathsSidecar: ResolvedPathsSidecar | undefined + let factsOnlyTypes: PURL_Type[] | undefined // sbt provisions its Scala toolchain under the directory passed as its - // isolated global base; withFiles' artifactPaths point into it, so it must + // isolated global base; reach's artifactPaths point into it, so it must // stay on disk until the reachability analysis below has consumed those // paths. Always allocated (even when unused) to keep this uniform rather // than conditional on autoManifest/reach. @@ -175,20 +177,21 @@ async function createNewScan( // one at cwd. This runs on its own, so nothing outside those // ecosystems is generated unless --auto-manifest also asked for it. const dynamicResult = await runDynamicSbomInference({ + consumer: reach.runReachabilityAnalysis ? 'reach' : 'upload', cwd, excludePaths: reach.excludePaths, // sbt's Scala toolchain lives under its shared global base; - // withFiles' resolved paths point into it, so when reachability + // reach's artifactPaths point into it, so when reachability // will consume them afterward, reuse manifestTmpDir (kept alive // until reach finishes below) instead of letting the call clean // its own ephemeral base up before reach ever reads those paths. sbtTmpDir: manifestTmpDir, - withFiles: reach.runReachabilityAnalysis, }) scanTargets = Array.from( new Set([...scanTargets, ...dynamicResult.factsPaths]), ) resolvedPathsSidecar = dynamicResult.resolvedPathsSidecar + factsOnlyTypes = dynamicResult.factsOnlyTypes } if (autoManifest) { @@ -327,6 +330,7 @@ async function createNewScan( const reachResult = await performReachabilityAnalysis({ branchName, cwd, + factsOnlyTypes, orgSlug, outputKind, packagePaths, diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 8d4082dee..27a3a41ff 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -14,8 +14,8 @@ import { withTmpDir } from '../../utils/fs.mts' import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import type { ReachabilityOptions } from './perform-reachability-analysis.mts' +import type { DynamicSbomInferenceResult } from './run-dynamic-sbom-inference.mts' import type { OutputKind } from '../../types.mts' -import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' export type HandleScanReachConfig = { cwd: string @@ -44,23 +44,21 @@ async function runScanReach( // Extra discovery targets beyond the user's own; the reachability target // itself stays `targets[0]`. let scanTargets = targets - // Sidecar forwarded to reachability; populated by dynamic SBOM inference. - let resolvedPathsSidecar: ResolvedPathsSidecar | undefined + let dynamicResult: DynamicSbomInferenceResult | undefined if (reachabilityOptions.dynamicSbomInference) { logger.info( 'Generating Socket facts for each Gradle, sbt, Maven and .NET build root ...', ) - const dynamicResult = await runDynamicSbomInference({ + dynamicResult = await runDynamicSbomInference({ + consumer: 'reach', cwd, excludePaths: reachabilityOptions.excludePaths, sbtTmpDir, - withFiles: true, }) scanTargets = Array.from( new Set([...scanTargets, ...dynamicResult.factsPaths]), ) - resolvedPathsSidecar = dynamicResult.resolvedPathsSidecar } // Get supported file names. @@ -129,12 +127,13 @@ async function runScanReach( const result = await performReachabilityAnalysis({ cwd, + factsOnlyTypes: dynamicResult?.factsOnlyTypes, orgSlug, outputKind, outputPath, packagePaths, reachabilityOptions: mergedReachabilityOptions, - resolvedPathsSidecar, + resolvedPathsSidecar: dynamicResult?.resolvedPathsSidecar, spinner, target: targets[0]!, }) diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index 487a0d7a7..9534840dd 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -353,6 +353,7 @@ describe('handleScanReach', () => { it('generates per-build-root facts, merges them into manifest discovery, and forwards the sidecar', async () => { mockRunDynamicSbomInference.mockResolvedValueOnce({ + factsOnlyTypes: ['maven'], factsPaths: [ '/repo/service-a/.socket.facts.json', '/repo/service-b/.socket.facts.json', @@ -376,11 +377,11 @@ describe('handleScanReach', () => { }) expect(mockRunDynamicSbomInference).toHaveBeenCalledWith({ + consumer: 'reach', cwd: '/repo', excludePaths: ['vendor/**'], // A caller-owned dir that outlives the analysis consuming its paths. sbtTmpDir: expect.any(String), - withFiles: true, }) expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( [ @@ -393,6 +394,7 @@ describe('handleScanReach', () => { ) expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( expect.objectContaining({ + factsOnlyTypes: ['maven'], resolvedPathsSidecar: { '/repo/service-a/.socket.facts.json': { projects: [], diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index e2d495b17..362180852 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -12,14 +12,12 @@ import { spawnCoana } from '../../utils/coana-release.mts' import { extractTier1ReachabilityScanId, isReachabilityReportPath, + useOnlySocketFactsArtifactsFlags, } from '../../utils/coana.mts' import { hasEnterpriseOrgPlan } from '../../utils/organization.mts' import { setupSdk } from '../../utils/sdk.mts' import { socketDevLink } from '../../utils/terminal-link.mts' -import { - hasResolvedPathsSidecarEntries, - useOnlySocketFactsArtifactsFlags, -} from '../manifest/scripts/sidecar.mts' +import { hasResolvedPathsSidecarEntries } from '../manifest/scripts/sidecar.mts' import { fetchOrganization } from '../organization/fetch-organization-list.mts' import type { CResult, OutputKind } from '../../types.mts' @@ -57,6 +55,8 @@ export type ReachabilityOptions = { export type ReachabilityAnalysisOptions = { branchName?: string | undefined cwd?: string | undefined + // Purl types whose artifacts Coana takes only from facts files. + factsOnlyTypes?: readonly PURL_Type[] | undefined // Required: the manifest upload they drive produces the tar hash Coana needs // to run without Docker. orgSlug: string @@ -83,6 +83,7 @@ export async function performReachabilityAnalysis( const { branchName, cwd = process.cwd(), + factsOnlyTypes, orgSlug, outputKind = 'text', outputPath, @@ -191,11 +192,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 under dynamicSbomInference, since the - // facts-only flag below requires one to be present. + // Written even when empty with factsOnlyTypes, since the facts-only flag + // below requires one to be present. let sidecarPath: string | undefined if ( - reachabilityOptions.dynamicSbomInference || + factsOnlyTypes?.length || (resolvedPathsSidecar && hasResolvedPathsSidecarEntries(resolvedPathsSidecar)) ) { @@ -269,10 +270,9 @@ export async function performReachabilityAnalysis( ...(reachabilityOptions.reachExcludePaths.length ? ['--exclude-dirs', ...reachabilityOptions.reachExcludePaths] : []), - // sidecarPath is always set above when this is true - Coana rejects this - // flag without one. - ...(reachabilityOptions.dynamicSbomInference - ? useOnlySocketFactsArtifactsFlags(resolvedPathsSidecar) + // sidecarPath is always set above then; Coana rejects this flag without one. + ...(factsOnlyTypes?.length + ? useOnlySocketFactsArtifactsFlags(factsOnlyTypes) : []), ...(reachabilityOptions.reachSkipCache ? ['--skip-cache-usage'] : []), ...(reachabilityOptions.reachUseOnlyPregeneratedSboms diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index 60306e124..33c80fea1 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -23,7 +23,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' import { makeReachabilityOptions } from '../../../test/reachability-options.mts' -import type { ReachabilityOptions } from './perform-reachability-analysis.mts' +import type { + ReachabilityAnalysisOptions, + ReachabilityOptions, +} from './perform-reachability-analysis.mts' // The manifest upload is mandatory, so every call below runs through it and // gets this tar hash back. @@ -242,6 +245,35 @@ describe('performReachabilityAnalysis manifests tar hash', () => { }) }) +async function coanaArgsFor( + cwd: string, + reachabilityOptions: Partial, + options: Partial = {}, +): Promise { + await performReachabilityAnalysis({ + cwd, + orgSlug: TEST_ORG_SLUG, + packagePaths: TEST_PACKAGE_PATHS, + reachabilityOptions: { + ...makeReachabilityOptions(), + ...reachabilityOptions, + }, + target: cwd, + ...options, + }) + return mockSpawnCoana.mock.calls[0]![0] as string[] +} + +function factsOnlyArgs(args: string[]): string[] { + return args.flatMap((arg, i) => + arg === '--maven-use-only-socket-facts' + ? [arg] + : arg === '--use-only-socket-facts-artifacts' + ? [arg, args[i + 1]!] + : [], + ) +} + describe('performReachabilityAnalysis flag forwarding', () => { let scanCwd: string @@ -260,21 +292,8 @@ describe('performReachabilityAnalysis flag forwarding', () => { rmSync(scanCwd, { force: true, recursive: true }) }) - async function coanaArgsFor( - overrides: Partial, - ): Promise { - await performReachabilityAnalysis({ - cwd: scanCwd, - orgSlug: TEST_ORG_SLUG, - packagePaths: TEST_PACKAGE_PATHS, - reachabilityOptions: { ...makeReachabilityOptions(), ...overrides }, - target: scanCwd, - }) - return mockSpawnCoana.mock.calls[0]![0] as string[] - } - it('forwards unit-bearing values to Coana verbatim', async () => { - const args = await coanaArgsFor({ + const args = await coanaArgsFor(scanCwd, { reachAnalysisTimeout: '90s', reachAnalysisMemoryLimit: '8GB', }) @@ -285,7 +304,7 @@ describe('performReachabilityAnalysis flag forwarding', () => { }) it('omits both flags for empty values so Coana applies its defaults', async () => { - const args = await coanaArgsFor({ + const args = await coanaArgsFor(scanCwd, { reachAnalysisTimeout: '', reachAnalysisMemoryLimit: '', }) @@ -294,7 +313,7 @@ describe('performReachabilityAnalysis flag forwarding', () => { }) it('omits flags for zero-magnitude values (back-compat sentinel)', async () => { - const args = await coanaArgsFor({ + const args = await coanaArgsFor(scanCwd, { reachAnalysisTimeout: '0', reachAnalysisMemoryLimit: '0', }) @@ -303,7 +322,7 @@ describe('performReachabilityAnalysis flag forwarding', () => { }) it('passes --reach-advisories to Coana as --ghsas', async () => { - const args = await coanaArgsFor({ + const args = await coanaArgsFor(scanCwd, { reachAdvisories: ['GHSA-35jh-r3h4-6jhm', 'GHSA-r5fr-rjxr-66jc'], }) const ghsasIndex = args.indexOf('--ghsas') @@ -314,12 +333,12 @@ describe('performReachabilityAnalysis flag forwarding', () => { }) it('omits --ghsas when no advisories are given', async () => { - const args = await coanaArgsFor({ reachAdvisories: [] }) + const args = await coanaArgsFor(scanCwd, { reachAdvisories: [] }) expect(args).not.toContain('--ghsas') }) }) -describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () => { +describe('performReachabilityAnalysis --use-only-socket-facts-artifacts gating', () => { let scanCwd: string beforeEach(() => { @@ -337,76 +356,62 @@ describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () rmSync(scanCwd, { force: true, recursive: true }) }) - it('writes an empty sidecar and still passes both flags when dynamicSbomInference is on but no build root produced one (e.g. none found, or all empty/disabled)', async () => { - let sidecarContentAtSpawnTime: unknown - mockSpawnCoana.mockImplementationOnce(async (args: string[]) => { - const sidecarPath = args[args.indexOf('--compute-artifacts-sidecar') + 1]! - sidecarContentAtSpawnTime = JSON.parse(readFileSync(sidecarPath, 'utf8')) - return { ok: true, data: '' } - }) + const sidecar = { + '/repo/reactor/.socket.facts.json': { components: [], projects: [] }, + } - await performReachabilityAnalysis({ - cwd: scanCwd, - orgSlug: TEST_ORG_SLUG, - packagePaths: TEST_PACKAGE_PATHS, - reachabilityOptions: { - ...makeReachabilityOptions(), - dynamicSbomInference: true, - }, + it.for([ + { + name: 'names every facts-only type next to the sidecar', + factsOnlyTypes: ['maven', 'nuget'], + resolvedPathsSidecar: sidecar, + expected: ['--use-only-socket-facts-artifacts', 'maven,nuget'], + expectedSidecar: sidecar, + }, + { + name: 'keeps the Maven-only flag older Coana releases know, with an empty sidecar', + factsOnlyTypes: ['maven'], resolvedPathsSidecar: undefined, - target: scanCwd, - }) - - const args = mockSpawnCoana.mock.calls[0]![0] as string[] - expect(args).toContain('--maven-use-only-socket-facts') - expect(args).toContain('--compute-artifacts-sidecar') - expect(sidecarContentAtSpawnTime).toEqual({}) - }) - - it('passes --maven-use-only-socket-facts alongside --compute-artifacts-sidecar when dynamicSbomInference is on and a sidecar was generated', async () => { - await performReachabilityAnalysis({ - cwd: scanCwd, - orgSlug: TEST_ORG_SLUG, - packagePaths: TEST_PACKAGE_PATHS, - reachabilityOptions: { - ...makeReachabilityOptions(), - dynamicSbomInference: true, - }, - resolvedPathsSidecar: { - '/repo/reactor/.socket.facts.json': { - components: [], - projects: [], - }, - }, - target: scanCwd, - }) - - const args = mockSpawnCoana.mock.calls[0]![0] as string[] - expect(args).toContain('--maven-use-only-socket-facts') - expect(args).toContain('--compute-artifacts-sidecar') - }) - - it('never passes --maven-use-only-socket-facts when dynamicSbomInference is off, even if a sidecar happens to be present', async () => { - await performReachabilityAnalysis({ - cwd: scanCwd, - orgSlug: TEST_ORG_SLUG, - packagePaths: TEST_PACKAGE_PATHS, - reachabilityOptions: { - ...makeReachabilityOptions(), - dynamicSbomInference: false, - }, - resolvedPathsSidecar: { - '/repo/reactor/.socket.facts.json': { - components: [], - projects: [], - }, - }, - target: scanCwd, - }) - - const args = mockSpawnCoana.mock.calls[0]![0] as string[] - expect(args).not.toContain('--maven-use-only-socket-facts') - expect(args).toContain('--compute-artifacts-sidecar') + expected: ['--maven-use-only-socket-facts'], + expectedSidecar: {}, + }, + { + name: 'passes no facts-only flag without facts-only types, even with a sidecar', + factsOnlyTypes: undefined, + resolvedPathsSidecar: sidecar, + expected: [], + expectedSidecar: sidecar, + }, + ] as const)( + '$name', + async ({ + expected, + expectedSidecar, + factsOnlyTypes, + resolvedPathsSidecar, + }) => { + let sidecarAtSpawn: unknown + mockSpawnCoana.mockImplementationOnce(async (args: string[]) => { + const sidecarPath = + args[args.indexOf('--compute-artifacts-sidecar') + 1]! + sidecarAtSpawn = JSON.parse(readFileSync(sidecarPath, 'utf8')) + return { ok: true, data: '' } + }) + + const args = await coanaArgsFor( + scanCwd, + {}, + { factsOnlyTypes, resolvedPathsSidecar }, + ) + + expect(factsOnlyArgs(args)).toEqual(expected) + expect(sidecarAtSpawn).toEqual(expectedSidecar) + }, + ) + + it('writes no sidecar without facts-only types or sidecar entries', async () => { + const args = await coanaArgsFor(scanCwd, { dynamicSbomInference: true }) + expect(args).not.toContain('--compute-artifacts-sidecar') }) }) diff --git a/src/commands/scan/run-dynamic-sbom-inference.mts b/src/commands/scan/run-dynamic-sbom-inference.mts index 5c3b9621f..c98bf8230 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.mts @@ -1,43 +1,86 @@ +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 { DSI_SETUP_COMMAND } from '../manifest/scripts/resolution-report-render.mts' import { hasSidecarEntries, serializeSidecar, } from '../manifest/scripts/sidecar.mts' +import type { PURL_Type } from '../../utils/ecosystem.mts' +import type { RecursiveManifestOutcome } from '../manifest/generate-recursive-manifests.mts' import type { ResolvedPathsSidecar, SidecarAccumulator, } from '../manifest/scripts/sidecar.mts' +// Who consumes the facts: Coana for `fix` and `reach` (which also resolves +// artifact paths), or only the scan upload. +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[] resolvedPathsSidecar: ResolvedPathsSidecar | undefined } +// 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 { + 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) { + throw new InputError( + [ + 'No build root produced Socket facts, which reachability analysis with --dynamic-sbom-inference needs.', + '', + ...(hasDisabled + ? [ + `- Re-enable the builds that socket.json skips (\`${DSI_SETUP_COMMAND}\` walks you through it).`, + ] + : []), + '- Or remove --dynamic-sbom-inference to analyze the manifest files as they are.', + ].join('\n'), + ) + } + if (hasDisabled) { + logger.warn( + 'The analysis leaves out the build roots socket.json skips: with --dynamic-sbom-inference, it takes their dependencies only from Socket facts.', + ) + } +} + // Recursively discovers and generates Socket facts for every independent -// gradle/sbt/maven build root under `cwd`, returning the generated facts -// paths plus the resolved-paths sidecar that reachability forwards to Coana. +// dotnet/gradle/sbt/maven build root under `cwd`, returning the generated +// facts paths plus the resolved-paths sidecar that reachability forwards to +// Coana. export async function runDynamicSbomInference({ + consumer, cwd, excludePaths, sbtTmpDir, - sidecar, - withFiles, }: { + consumer: FactsConsumer cwd: string excludePaths: string[] - // Collects the sidecar's per-project classpaths even without resolving - // artifact paths. - sidecar?: boolean | undefined - // sbt provisions its Scala toolchain under this directory and withFiles' + // sbt provisions its Scala toolchain under this directory and reach's // artifactPaths point into it, so it must outlive whoever consumes them. - // Only meaningful alongside `withFiles`. + // Only meaningful for `reach`. sbtTmpDir: string | undefined - withFiles: boolean }): Promise { - const sidecarAcc: SidecarAccumulator | undefined = - (sidecar ?? withFiles) ? new Map() : undefined + const withFiles = consumer === 'reach' + const coanaReadsFacts = consumer === 'fix' || consumer === 'reach' + const sidecarAcc: SidecarAccumulator | undefined = coanaReadsFacts + ? new Map() + : undefined const outcomes = await generateRecursiveManifests({ cwd, excludePaths, @@ -66,10 +109,17 @@ export async function runDynamicSbomInference({ 'One or more independent build roots failed to generate Socket facts; aborting (see the errors above).', ) } + const factsPaths = outcomes + .filter(o => o.status === 'generated') + .map(o => o.factsPath!) + if (coanaReadsFacts) { + checkFactsForCoana(outcomes, consumer, !!factsPaths.length) + } return { - factsPaths: outcomes - .filter(o => o.status === 'generated') - .map(o => o.factsPath!), + 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 18d5b6c40..490dc922d 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.test.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.test.mts @@ -2,84 +2,108 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { runDynamicSbomInference } from './run-dynamic-sbom-inference.mts' -const { mockGenerateRecursiveManifests } = vi.hoisted(() => ({ +import type { FactsConsumer } from './run-dynamic-sbom-inference.mts' + +const { mockGenerateRecursiveManifests, mockWarn } = vi.hoisted(() => ({ mockGenerateRecursiveManifests: vi.fn(), + mockWarn: vi.fn(), +})) + +vi.mock('@socketsecurity/registry/lib/logger', () => ({ + logger: { warn: mockWarn }, })) vi.mock('../manifest/generate-recursive-manifests.mts', () => ({ generateRecursiveManifests: mockGenerateRecursiveManifests, })) +const generated = { + dir: '/repo/a', + ecosystem: 'maven', + factsPath: '/repo/a/.socket.facts.json', + status: 'generated', +} + +function run(consumer: FactsConsumer) { + return runDynamicSbomInference({ + consumer, + cwd: '/repo', + excludePaths: ['vendor/**'], + sbtTmpDir: '/tmp/sbt', + }) +} + describe('runDynamicSbomInference', () => { beforeEach(() => { vi.clearAllMocks() }) + it.for([ + // sbtTmpDir only matters for reach; withheld otherwise so the callee + // allocates and cleans up its own ephemeral base. + { + consumer: 'upload', + sbtTmpDir: undefined, + sidecar: false, + withFiles: false, + }, + { + consumer: 'fix', + sbtTmpDir: undefined, + sidecar: true, + withFiles: false, + }, + { + consumer: 'reach', + sbtTmpDir: '/tmp/sbt', + sidecar: true, + withFiles: true, + }, + ] as const)( + 'generates for $consumer', + async ({ consumer, sbtTmpDir, sidecar, withFiles }) => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([generated]) + + await run(consumer) + + expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith({ + cwd: '/repo', + excludePaths: ['vendor/**'], + sbtTmpDir, + sidecarAcc: sidecar ? expect.any(Map) : undefined, + verbose: false, + withFiles, + }) + }, + ) + it('returns only the generated facts paths, ignoring empty and skipped roots', async () => { mockGenerateRecursiveManifests.mockResolvedValueOnce([ - { - dir: '/repo/a', - ecosystem: 'maven', - factsPath: '/repo/a/.socket.facts.json', - status: 'generated', - }, + generated, { dir: '/repo/b', ecosystem: 'gradle', status: 'empty' }, { dir: '/repo/c', ecosystem: 'sbt', status: 'skippedDisabled' }, { dir: '/repo/d', ecosystem: 'maven', status: 'skippedCovered' }, ]) - const result = await runDynamicSbomInference({ - cwd: '/repo', - excludePaths: ['vendor/**'], - sbtTmpDir: '/tmp/sbt', - withFiles: false, - }) - - expect(result).toEqual({ + expect(await run('upload')).toEqual({ + factsOnlyTypes: ['maven'], factsPaths: ['/repo/a/.socket.facts.json'], resolvedPathsSidecar: undefined, }) - expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith({ - cwd: '/repo', - excludePaths: ['vendor/**'], - // Only meaningful with withFiles; withheld otherwise so the callee - // allocates and cleans up its own ephemeral base. - sbtTmpDir: undefined, - sidecarAcc: undefined, - verbose: false, - withFiles: false, - }) }) - it('accumulates and serializes a sidecar when running with files', async () => { + it('serializes the accumulated sidecar', async () => { mockGenerateRecursiveManifests.mockImplementationOnce( async ({ sidecarAcc }) => { sidecarAcc.set('/repo/a/.socket.facts.json', { projects: [{ name: 'app' }], components: [], }) - return [ - { - dir: '/repo/a', - ecosystem: 'maven', - factsPath: '/repo/a/.socket.facts.json', - status: 'generated', - }, - ] + return [generated] }, ) - const result = await runDynamicSbomInference({ - cwd: '/repo', - excludePaths: [], - sbtTmpDir: '/tmp/sbt', - withFiles: true, - }) - - expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( - expect.objectContaining({ sbtTmpDir: '/tmp/sbt', withFiles: true }), - ) - expect(result.resolvedPathsSidecar).toEqual({ + expect((await run('reach')).resolvedPathsSidecar).toEqual({ '/repo/a/.socket.facts.json': { projects: [{ name: 'app' }], components: [], @@ -87,37 +111,79 @@ describe('runDynamicSbomInference', () => { }) }) - it('throws when no build root was discovered at all', async () => { - mockGenerateRecursiveManifests.mockResolvedValueOnce([]) + it('makes every discovered ecosystem facts-only, generated or not', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + generated, + { dir: '/repo/b', ecosystem: 'dotnet', status: 'skippedDisabled' }, + { dir: '/repo/c', ecosystem: 'sbt', status: 'empty' }, + ]) - await expect( - runDynamicSbomInference({ - cwd: '/repo', - excludePaths: [], - sbtTmpDir: undefined, - withFiles: true, - }), - ).rejects.toThrow(/No Gradle, sbt, Maven or .NET build root was found/) + expect((await run('reach')).factsOnlyTypes.sort()).toEqual([ + 'maven', + 'nuget', + ]) }) - it('throws when a discovered build root failed to generate facts', async () => { + it.for([ + { + name: 'no build root was discovered', + outcomes: [], + error: /No Gradle, sbt, Maven or .NET build root was found/, + }, + { + name: 'a discovered build root failed to generate facts', + outcomes: [ + generated, + { dir: '/repo/b', ecosystem: 'maven', status: 'failed' }, + ], + error: /one or more independent build roots failed/i, + }, + { + name: 'no build root produced facts for reachability', + outcomes: [ + { dir: '/repo/a', ecosystem: 'maven', status: 'empty' }, + { dir: '/repo/b', ecosystem: 'gradle', status: 'skippedDisabled' }, + ], + error: /Re-enable the builds that socket.json skips/, + }, + ])('throws when $name', async ({ error, outcomes }) => { + mockGenerateRecursiveManifests.mockResolvedValueOnce(outcomes) + + await expect(run('reach')).rejects.toThrow(error) + }) + + it.for([ + { + consumer: 'fix', + outcomes: [{ dir: '/repo/a', ecosystem: 'maven', status: 'empty' }], + }, + // Without Coana, the roots socket.json skips are not left out of anything. + { + consumer: 'upload', + outcomes: [ + { dir: '/repo/a', ecosystem: 'maven', status: 'skippedDisabled' }, + ], + }, + ] as const)( + 'lets $consumer proceed quietly without facts', + async ({ consumer, outcomes }) => { + mockGenerateRecursiveManifests.mockResolvedValueOnce(outcomes) + + expect((await run(consumer)).factsPaths).toEqual([]) + expect(mockWarn).not.toHaveBeenCalled() + }, + ) + + it('warns that build roots socket.json skips are left out', async () => { mockGenerateRecursiveManifests.mockResolvedValueOnce([ - { - dir: '/repo/a', - ecosystem: 'maven', - factsPath: '/repo/a/.socket.facts.json', - status: 'generated', - }, - { dir: '/repo/b', ecosystem: 'maven', status: 'failed' }, + generated, + { dir: '/repo/b', ecosystem: 'maven', status: 'skippedDisabled' }, ]) - await expect( - runDynamicSbomInference({ - cwd: '/repo', - excludePaths: [], - sbtTmpDir: undefined, - withFiles: true, - }), - ).rejects.toThrow(/one or more independent build roots failed/i) + await run('reach') + + expect(mockWarn).toHaveBeenCalledWith( + expect.stringMatching(/leaves out the build roots socket.json skips/), + ) }) }) diff --git a/src/utils/coana.mts b/src/utils/coana.mts index add373b84..a3b4a2a84 100644 --- a/src/utils/coana.mts +++ b/src/utils/coana.mts @@ -30,6 +30,8 @@ import { readJsonSync } from '@socketsecurity/registry/lib/fs' import constants from '../constants.mts' +import type { PURL_Type } from './ecosystem.mts' + const { DOT_SOCKET_DOT_FACTS_JSON } = constants export type CompressedScanPaths = { @@ -300,3 +302,13 @@ export async function snapshotSocketFacts( }, } } + +// Maven alone keeps the older flag, which Coana releases before 15.14.1 also +// know. +export function useOnlySocketFactsArtifactsFlags( + types: readonly PURL_Type[], +): string[] { + return types.length === 1 && types[0] === 'maven' + ? ['--maven-use-only-socket-facts'] + : ['--use-only-socket-facts-artifacts', [...types].sort().join(',')] +} diff --git a/src/utils/coana.test.mts b/src/utils/coana.test.mts index d4fd3a2d0..0bb1bde68 100644 --- a/src/utils/coana.test.mts +++ b/src/utils/coana.test.mts @@ -10,6 +10,7 @@ * single-root and absent-path cases. * - snapshotSocketFacts: restores overwritten facts files and removes ones * the analysis created. + * - useOnlySocketFactsArtifactsFlags: the facts-only flag per purl type list. * * Related Files: * - utils/coana.mts (implementation) @@ -36,6 +37,7 @@ import { isReachabilityReportPath, isSocketFactsFile, snapshotSocketFacts, + useOnlySocketFactsArtifactsFlags, } from './coana.mts' describe('coana facts-file utils', () => { @@ -478,3 +480,20 @@ describe('coana facts-file utils', () => { }) }) }) + +describe('useOnlySocketFactsArtifactsFlags', () => { + it.for([ + // Maven alone keeps the flag Coana releases before 15.14.1 also know. + { types: ['maven'], expected: ['--maven-use-only-socket-facts'] }, + { + types: ['nuget', 'maven'], + expected: ['--use-only-socket-facts-artifacts', 'maven,nuget'], + }, + { + types: ['nuget'], + expected: ['--use-only-socket-facts-artifacts', 'nuget'], + }, + ] as const)('$types -> $expected', ({ expected, types }) => { + expect(useOnlySocketFactsArtifactsFlags(types)).toEqual(expected) + }) +})