From af3e55872bb88a2a6b021557e6779a13aa5948e6 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 4 Nov 2026 11:14:40 +0100 Subject: [PATCH 1/3] fix(scan): make every discovered ecosystem facts-only under --dynamic-sbom-inference Coana was told NuGet artifacts come only from facts files when some .NET build root happened to produce facts, and Maven always. Which raw manifests reachability and fix trusted therefore depended on what other build roots produced. Every ecosystem a build root was discovered for is now facts-only, generated or not; Maven alone keeps the older flag. When Coana consumes the facts, a .NET build root without a usable SDK now fails before any build runs instead of silently losing its dependencies, reachability fails clearly when no build root produced facts (Coana refuses such a run), and roots skipped in socket.json get a warning that the analysis leaves them out. Callers now name the facts consumer (fix or reach) instead of passing withFiles/sidecar flags. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + src/commands/fix/generated-socket-facts.mts | 16 +- .../fix/generated-socket-facts.test.mts | 12 +- .../manifest/generate-recursive-manifests.mts | 24 ++- .../generate-recursive-manifests.test.mts | 24 +++ src/commands/manifest/scripts/run.mts | 40 +++-- src/commands/manifest/scripts/sidecar.mts | 15 +- .../manifest/scripts/sidecar.test.mts | 28 ++-- .../.gradle/9.8.1/checksums/checksums.lock | Bin 0 -> 17 bytes .../.gradle/9.8.1/fileChanges/last-build.bin | Bin 0 -> 1 bytes .../.gradle/9.8.1/fileHashes/fileHashes.bin | Bin 0 -> 18547 bytes .../.gradle/9.8.1/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .../app/.gradle/9.8.1/gc.properties | 0 .../buildOutputCleanup.lock | Bin 0 -> 17 bytes .../buildOutputCleanup/cache.properties | 2 + .../app/.gradle/vcs-1/gc.properties | 0 .../.gradle/9.8.1/checksums/checksums.lock | Bin 0 -> 17 bytes .../.gradle/9.8.1/fileChanges/last-build.bin | Bin 0 -> 1 bytes .../.gradle/9.8.1/fileHashes/fileHashes.bin | Bin 0 -> 18547 bytes .../.gradle/9.8.1/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .../shared/.gradle/9.8.1/gc.properties | 0 .../buildOutputCleanup.lock | Bin 0 -> 17 bytes .../buildOutputCleanup/cache.properties | 2 + .../shared/.gradle/vcs-1/gc.properties | 0 src/commands/scan/handle-create-new-scan.mts | 6 +- .../scan/handle-create-new-scan.test.mts | 2 + src/commands/scan/handle-scan-reach.mts | 6 +- src/commands/scan/handle-scan-reach.test.mts | 4 +- .../scan/perform-reachability-analysis.mts | 11 +- .../perform-reachability-analysis.test.mts | 35 +++- .../scan/run-dynamic-sbom-inference.mts | 75 +++++++-- .../scan/run-dynamic-sbom-inference.test.mts | 158 ++++++++++++++---- 32 files changed, 354 insertions(+), 112 deletions(-) create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/checksums/checksums.lock create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileChanges/last-build.bin create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.bin create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.lock create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/gc.properties create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/buildOutputCleanup.lock create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/cache.properties create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/vcs-1/gc.properties create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/checksums/checksums.lock create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileChanges/last-build.bin create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.bin create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.lock create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/gc.properties create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/buildOutputCleanup.lock create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/cache.properties create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/vcs-1/gc.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index c14893bed..fb5a5f300 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. +- With `--dynamic-sbom-inference`, reachability analysis and `socket fix` now stop with clear next steps when a .NET build cannot be read because no .NET SDK is installed, and warn that builds skipped in `socket.json` are left out. Reachability analysis also 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..b937fa4c1 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -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..452ff7595 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, skipWithoutDotnetSdk } 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, @@ -152,6 +153,7 @@ async function runEcosystemCandidates({ ignoreUnresolved: ignoreUnresolvedOverride, optionFlags, realCwd, + requireDotnetSdk, rootSockJson, sbtTmpDir, sidecarAcc, @@ -163,6 +165,7 @@ async function runEcosystemCandidates({ ignoreUnresolved: boolean | undefined optionFlags: ReadonlySet realCwd: string + requireDotnetSdk: boolean | undefined rootSockJson: SocketJson // sbt only: a shared global base reused across every sbt root in this run, // so sbt's own Scala-toolchain cache under /boot survives between @@ -212,7 +215,20 @@ async function runEcosystemCandidates({ outcomes.push({ ...root, ecosystem, status: 'skippedDisabled' }) continue } - if ( + if (ecosystem === 'dotnet' && requireDotnetSdk) { + // 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 it has no Socket facts, and the analysis takes .NET dependencies only from them.`, + '', + '- Install the .NET SDK, or point defaults.manifest.dotnet.bin in socket.json at one.', + '- Or leave it out with --exclude-paths, or with defaults.manifest.dotnet.disabled in socket.json.', + ].join('\n'), + ) + } + } else if ( ecosystem === 'dotnet' && // eslint-disable-next-line no-await-in-loop (await skipWithoutDotnetSdk(dirConfig.bin, dir)) @@ -355,6 +371,7 @@ export async function generateRecursiveManifests({ excludePaths, ignoreUnresolved, optionFlags = new Set(), + requireDotnetSdk, sbtTmpDir, sidecarAcc, verbose, @@ -367,6 +384,8 @@ 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 + // Fail instead of skipping a .NET build root no .NET SDK can build. + requireDotnetSdk?: boolean | 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, @@ -405,6 +424,7 @@ export async function generateRecursiveManifests({ ignoreUnresolved, optionFlags, realCwd, + requireDotnetSdk, rootSockJson, sbtTmpDir: resolvedSbtTmpDir, sidecarAcc, diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index f472e5703..211f9d91b 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -18,6 +18,7 @@ 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), skipWithoutDotnetSdk: vi.fn(async () => false), })) @@ -26,6 +27,7 @@ import { generateRecursiveManifests } from './generate-recursive-manifests.mts' import { runManifestFacts } from './run-manifest-facts.mts' import { listDotnetEntryProjects, + noDotnetSdkReason, skipWithoutDotnetSdk, } from './scripts/run.mts' import { testPath } from '../../../test/utils.mts' @@ -527,6 +529,28 @@ describe('generateRecursiveManifests', () => { }) }) + it('fails before building anything when a .NET build root needs an SDK that does not run', 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, + requireDotnetSdk: true, + verbose: false, + }), + ).rejects.toThrow(/No .NET SDK runs for .* \(dotnet not found\)/) + expect(skipWithoutDotnetSdk).not.toHaveBeenCalled() + expect(runManifestFacts).not.toHaveBeenCalled() + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + it('skips .NET builds, without failing, where no .NET SDK runs', async () => { const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-no-sdk-')) try { 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..6db5e3a78 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -158,19 +158,12 @@ 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. +// Facts-only whether or not any facts were generated. Maven alone keeps the +// older flag, which Coana releases before 15.14.1 also know. export function useOnlySocketFactsArtifactsFlags( - sidecar: ResolvedPathsSidecar | undefined, + types: readonly string[], ): 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 + return types.length === 1 && types[0] === 'maven' ? ['--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..31ef708b1 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -423,27 +423,21 @@ describe('compute-artifacts sidecar', () => { }) }) -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([ + it('keeps the Maven-only flag every Coana release knows for Maven alone', () => { + expect(useOnlySocketFactsArtifactsFlags(['maven'])).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']) + it('names every other selection, sorted', () => { + expect(useOnlySocketFactsArtifactsFlags(['nuget', 'maven'])).toEqual([ + '--use-only-socket-facts-artifacts', + 'maven,nuget', + ]) + expect(useOnlySocketFactsArtifactsFlags(['nuget'])).toEqual([ + '--use-only-socket-facts-artifacts', + 'nuget', + ]) }) }) diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/checksums/checksums.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..4a6e3c136da130d369f0f45762dd5c08d825f9f6 GIT binary patch literal 17 TcmZQRXC35UyWqi91}FdkHW37X literal 0 HcmV?d00001 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileChanges/last-build.bin b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileChanges/last-build.bin new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.bin b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.bin new file mode 100644 index 0000000000000000000000000000000000000000..8a6b626d9ccd64d5433a4723c0ba1f6ea6167736 GIT binary patch literal 18547 zcmeI(u?oRK7y$5NlwwgPWmJ+f8I{2zr96O?(O@xIK=_!grLusf+*t0t5&UAV7cs0RjXF5FkK+009C72oU%efl+KE zmo`(bXOpC`2%(veN#=GPpZkaVFg@;dKKBQo824-GnUw$m0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5cmawRxBcU>|af0ak8$Q9{c&S YI)APP?aMKK7T9ce+i60NJ1rPWwMgRZ+ literal 0 HcmV?d00001 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.lock new file mode 100644 index 0000000000000000000000000000000000000000..750503458c9af3081e9d51c6111f7d7d3c223e7a GIT binary patch literal 17 UcmZQ({gqs=b>+to1_)pS063Wh*#H0l literal 0 HcmV?d00001 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/gc.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/gc.properties new file mode 100644 index 000000000..e69de29bb diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000000000000000000000000000000000000..f20bbf759d550652e93cfcdeaa9be43a7c3cd994 GIT binary patch literal 17 UcmZQRknzjRuyOf+1_)pT06H54wEzGB literal 0 HcmV?d00001 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/cache.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 000000000..18b5b43cb --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Tue Nov 03 21:49:16 CET 2026 +gradle.version=9.8.1 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/vcs-1/gc.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/vcs-1/gc.properties new file mode 100644 index 000000000..e69de29bb diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/checksums/checksums.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..8c1adfa8ffdc9d512e9bbc436dd00d78d0b30367 GIT binary patch literal 17 ScmZRU5S?LqsXeHJ0SW*mp#w1h literal 0 HcmV?d00001 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileChanges/last-build.bin b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileChanges/last-build.bin new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.bin b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.bin new file mode 100644 index 0000000000000000000000000000000000000000..8a6b626d9ccd64d5433a4723c0ba1f6ea6167736 GIT binary patch literal 18547 zcmeI(u?oRK7y$5NlwwgPWmJ+f8I{2zr96O?(O@xIK=_!grLusf+*t0t5&UAV7cs0RjXF5FkK+009C72oU%efl+KE zmo`(bXOpC`2%(veN#=GPpZkaVFg@;dKKBQo824-GnUw$m0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5cmawRxBcU>|af0ak8$Q9{c&S YI)APP?aMKK7T9ce+i60NJ1rPWwMgRZ+ literal 0 HcmV?d00001 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.lock new file mode 100644 index 0000000000000000000000000000000000000000..b4a437f52819a1d0a752d1e8075b082581409dfa GIT binary patch literal 17 UcmZR+{Fd)ql-=B|3=qHu06YK$xBvhE literal 0 HcmV?d00001 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/gc.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/gc.properties new file mode 100644 index 000000000..e69de29bb diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000000000000000000000000000000000000..05e5989c8fa4d3d2a57bae6e97f882b94ca7d83c GIT binary patch literal 17 UcmZSH*?R2Q_HKJi1_)pT06T^Ri2wiq literal 0 HcmV?d00001 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/cache.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 000000000..2d5c65cc6 --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Tue Nov 03 21:49:18 CET 2026 +gradle.version=9.8.1 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/vcs-1/gc.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/vcs-1/gc.properties new file mode 100644 index 000000000..e69de29bb diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index dd15c54ed..5a08e8a7c 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,6 +159,7 @@ 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 @@ -175,6 +177,7 @@ 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' : undefined, cwd, excludePaths: reach.excludePaths, // sbt's Scala toolchain lives under its shared global base; @@ -183,12 +186,12 @@ async function createNewScan( // 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-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 3db5fcb0c..e83d7513b 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -391,6 +391,7 @@ describe('handleCreateNewScan excludePaths', () => { expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo', + requireDotnetSdk: false, sbtTmpDir: undefined, withFiles: false, }), @@ -504,6 +505,7 @@ describe('handleCreateNewScan excludePaths', () => { expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( expect.objectContaining({ + requireDotnetSdk: true, sbtTmpDir: expect.any(String), withFiles: true, }), diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 8d4082dee..506a456c3 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -15,6 +15,7 @@ import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import type { ReachabilityOptions } from './perform-reachability-analysis.mts' import type { OutputKind } from '../../types.mts' +import type { PURL_Type } from '../../utils/ecosystem.mts' import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' export type HandleScanReachConfig = { @@ -46,21 +47,23 @@ async function runScanReach( let scanTargets = targets // Sidecar forwarded to reachability; populated by dynamic SBOM inference. let resolvedPathsSidecar: ResolvedPathsSidecar | undefined + let factsOnlyTypes: PURL_Type[] | undefined if (reachabilityOptions.dynamicSbomInference) { logger.info( 'Generating Socket facts for each Gradle, sbt, Maven and .NET build root ...', ) const dynamicResult = await runDynamicSbomInference({ + consumer: 'reach', cwd, excludePaths: reachabilityOptions.excludePaths, sbtTmpDir, - withFiles: true, }) scanTargets = Array.from( new Set([...scanTargets, ...dynamicResult.factsPaths]), ) resolvedPathsSidecar = dynamicResult.resolvedPathsSidecar + factsOnlyTypes = dynamicResult.factsOnlyTypes } // Get supported file names. @@ -129,6 +132,7 @@ async function runScanReach( const result = await performReachabilityAnalysis({ cwd, + factsOnlyTypes, orgSlug, outputKind, outputPath, 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..2441ab0ad 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -57,6 +57,8 @@ export type ReachabilityOptions = { export type ReachabilityAnalysisOptions = { branchName?: string | undefined cwd?: string | undefined + // Purl types whose artifacts Coana takes only from facts files. + factsOnlyTypes?: PURL_Type[] | undefined // Required: the manifest upload they drive produces the tar hash Coana needs // to run without Docker. orgSlug: string @@ -83,6 +85,7 @@ export async function performReachabilityAnalysis( const { branchName, cwd = process.cwd(), + factsOnlyTypes, orgSlug, outputKind = 'text', outputPath, @@ -269,10 +272,10 @@ 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) + // Only dynamicSbomInference sets these, and 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..bd45bab8e 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -319,7 +319,7 @@ describe('performReachabilityAnalysis flag forwarding', () => { }) }) -describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () => { +describe('performReachabilityAnalysis --use-only-socket-facts-artifacts gating', () => { let scanCwd: string beforeEach(() => { @@ -353,17 +353,20 @@ describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () ...makeReachabilityOptions(), dynamicSbomInference: true, }, + factsOnlyTypes: ['maven', 'nuget'], resolvedPathsSidecar: undefined, target: scanCwd, }) const args = mockSpawnCoana.mock.calls[0]![0] as string[] - expect(args).toContain('--maven-use-only-socket-facts') + expect(args[args.indexOf('--use-only-socket-facts-artifacts') + 1]).toBe( + 'maven,nuget', + ) 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 () => { + it('passes --use-only-socket-facts-artifacts alongside --compute-artifacts-sidecar when dynamicSbomInference is on and a sidecar was generated', async () => { await performReachabilityAnalysis({ cwd: scanCwd, orgSlug: TEST_ORG_SLUG, @@ -372,6 +375,7 @@ describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () ...makeReachabilityOptions(), dynamicSbomInference: true, }, + factsOnlyTypes: ['maven', 'nuget'], resolvedPathsSidecar: { '/repo/reactor/.socket.facts.json': { components: [], @@ -382,11 +386,31 @@ describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () }) const args = mockSpawnCoana.mock.calls[0]![0] as string[] - expect(args).toContain('--maven-use-only-socket-facts') + expect(args[args.indexOf('--use-only-socket-facts-artifacts') + 1]).toBe( + 'maven,nuget', + ) 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 () => { + it('keeps the Maven-only flag older Coana releases know when only JVM builds were found', async () => { + await performReachabilityAnalysis({ + cwd: scanCwd, + factsOnlyTypes: ['maven'], + orgSlug: TEST_ORG_SLUG, + packagePaths: TEST_PACKAGE_PATHS, + reachabilityOptions: { + ...makeReachabilityOptions(), + dynamicSbomInference: true, + }, + target: scanCwd, + }) + + const args = mockSpawnCoana.mock.calls[0]![0] as string[] + expect(args).toContain('--maven-use-only-socket-facts') + expect(args).not.toContain('--use-only-socket-facts-artifacts') + }) + + it('never passes --use-only-socket-facts-artifacts when dynamicSbomInference is off, even if a sidecar happens to be present', async () => { await performReachabilityAnalysis({ cwd: scanCwd, orgSlug: TEST_ORG_SLUG, @@ -405,6 +429,7 @@ describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () }) const args = mockSpawnCoana.mock.calls[0]![0] as string[] + expect(args).not.toContain('--use-only-socket-facts-artifacts') expect(args).not.toContain('--maven-use-only-socket-facts') expect(args).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..cf0c2d409 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.mts @@ -1,46 +1,84 @@ +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 { 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 runs Coana on the facts: `reach` also resolves artifact paths. +export type FactsConsumer = 'fix' | 'reach' + 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: FactsConsumer, +): 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' && !outcomes.some(o => o.status === 'generated')) { + 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.'] + : []), + '- 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 | undefined 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 sidecarAcc: SidecarAccumulator | undefined = consumer + ? new Map() + : undefined const outcomes = await generateRecursiveManifests({ cwd, excludePaths, + requireDotnetSdk: !!consumer, sbtTmpDir: withFiles ? sbtTmpDir : undefined, sidecarAcc, verbose: false, @@ -66,10 +104,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 (consumer) { + checkFactsForCoana(outcomes, consumer) + } 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..9fd3d0a13 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.test.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.test.mts @@ -2,14 +2,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { runDynamicSbomInference } from './run-dynamic-sbom-inference.mts' -const { mockGenerateRecursiveManifests } = vi.hoisted(() => ({ +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', +} + describe('runDynamicSbomInference', () => { beforeEach(() => { vi.clearAllMocks() @@ -17,32 +29,29 @@ describe('runDynamicSbomInference', () => { 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({ + consumer: undefined, cwd: '/repo', excludePaths: ['vendor/**'], sbtTmpDir: '/tmp/sbt', - withFiles: false, }) expect(result).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 + requireDotnetSdk: false, + // Only meaningful for reach; withheld otherwise so the callee // allocates and cleans up its own ephemeral base. sbtTmpDir: undefined, sidecarAcc: undefined, @@ -51,33 +60,30 @@ describe('runDynamicSbomInference', () => { }) }) - it('accumulates and serializes a sidecar when running with files', async () => { + it('accumulates and serializes a sidecar for reachability', 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({ + consumer: 'reach', cwd: '/repo', excludePaths: [], sbtTmpDir: '/tmp/sbt', - withFiles: true, }) expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( - expect.objectContaining({ sbtTmpDir: '/tmp/sbt', withFiles: true }), + expect.objectContaining({ + requireDotnetSdk: true, + sbtTmpDir: '/tmp/sbt', + withFiles: true, + }), ) expect(result.resolvedPathsSidecar).toEqual({ '/repo/a/.socket.facts.json': { @@ -87,37 +93,133 @@ describe('runDynamicSbomInference', () => { }) }) + it('collects a sidecar for fix without resolving artifact paths', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([generated]) + + await runDynamicSbomInference({ + consumer: 'fix', + cwd: '/repo', + excludePaths: [], + sbtTmpDir: undefined, + }) + + expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( + expect.objectContaining({ + requireDotnetSdk: true, + sidecarAcc: expect.any(Map), + withFiles: false, + }), + ) + }) + it('throws when no build root was discovered at all', async () => { mockGenerateRecursiveManifests.mockResolvedValueOnce([]) await expect( runDynamicSbomInference({ + consumer: 'reach', cwd: '/repo', excludePaths: [], sbtTmpDir: undefined, - withFiles: true, }), ).rejects.toThrow(/No Gradle, sbt, Maven or .NET build root was found/) }) it('throws when a discovered build root failed to generate facts', async () => { mockGenerateRecursiveManifests.mockResolvedValueOnce([ - { - dir: '/repo/a', - ecosystem: 'maven', - factsPath: '/repo/a/.socket.facts.json', - status: 'generated', - }, + generated, { dir: '/repo/b', ecosystem: 'maven', status: 'failed' }, ]) await expect( runDynamicSbomInference({ + consumer: 'reach', cwd: '/repo', excludePaths: [], sbtTmpDir: undefined, - withFiles: true, }), ).rejects.toThrow(/one or more independent build roots failed/i) }) + + 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' }, + ]) + + const result = await runDynamicSbomInference({ + consumer: 'reach', + cwd: '/repo', + excludePaths: [], + sbtTmpDir: undefined, + }) + + expect(result.factsOnlyTypes.sort()).toEqual(['maven', 'nuget']) + }) + + it('throws for reachability when no build root produced facts', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + { dir: '/repo/a', ecosystem: 'maven', status: 'empty' }, + { dir: '/repo/b', ecosystem: 'gradle', status: 'skippedDisabled' }, + ]) + + await expect( + runDynamicSbomInference({ + consumer: 'reach', + cwd: '/repo', + excludePaths: [], + sbtTmpDir: undefined, + }), + ).rejects.toThrow(/Re-enable the builds/) + }) + + it('lets fix proceed when no build root produced facts', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + { dir: '/repo/a', ecosystem: 'maven', status: 'empty' }, + ]) + + const result = await runDynamicSbomInference({ + consumer: 'fix', + cwd: '/repo', + excludePaths: [], + sbtTmpDir: undefined, + }) + + expect(result.factsPaths).toEqual([]) + }) + + it('warns that build roots socket.json skips are left out', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + generated, + { dir: '/repo/b', ecosystem: 'maven', status: 'skippedDisabled' }, + ]) + + await runDynamicSbomInference({ + consumer: 'reach', + cwd: '/repo', + excludePaths: [], + sbtTmpDir: undefined, + }) + + expect(mockWarn).toHaveBeenCalledWith( + expect.stringMatching(/leaves out the build roots socket.json skips/), + ) + }) + + it('neither throws nor warns for roots without facts when Coana does not consume them', async () => { + mockGenerateRecursiveManifests.mockResolvedValueOnce([ + { dir: '/repo/a', ecosystem: 'maven', status: 'skippedDisabled' }, + ]) + + const result = await runDynamicSbomInference({ + consumer: undefined, + cwd: '/repo', + excludePaths: [], + sbtTmpDir: undefined, + }) + + expect(result.factsPaths).toEqual([]) + expect(mockWarn).not.toHaveBeenCalled() + }) }) From c81b1ac824a34418facbebd49b473a97a30eeac5 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 4 Nov 2026 16:35:56 +0100 Subject: [PATCH 2/3] chore: untrack Gradle compat-test caches Ignore every gradle-compat fixture's .gradle/ dir, not only project/'s, so local compat runs can't be committed again. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 +- .../app/.gradle/9.8.1/checksums/checksums.lock | Bin 17 -> 0 bytes .../.gradle/9.8.1/fileChanges/last-build.bin | Bin 1 -> 0 bytes .../app/.gradle/9.8.1/fileHashes/fileHashes.bin | Bin 18547 -> 0 bytes .../.gradle/9.8.1/fileHashes/fileHashes.lock | Bin 17 -> 0 bytes .../app/.gradle/9.8.1/gc.properties | 0 .../buildOutputCleanup/buildOutputCleanup.lock | Bin 17 -> 0 bytes .../.gradle/buildOutputCleanup/cache.properties | 2 -- .../app/.gradle/vcs-1/gc.properties | 0 .../.gradle/9.8.1/checksums/checksums.lock | Bin 17 -> 0 bytes .../.gradle/9.8.1/fileChanges/last-build.bin | Bin 1 -> 0 bytes .../.gradle/9.8.1/fileHashes/fileHashes.bin | Bin 18547 -> 0 bytes .../.gradle/9.8.1/fileHashes/fileHashes.lock | Bin 17 -> 0 bytes .../shared/.gradle/9.8.1/gc.properties | 0 .../buildOutputCleanup/buildOutputCleanup.lock | Bin 17 -> 0 bytes .../.gradle/buildOutputCleanup/cache.properties | 2 -- .../shared/.gradle/vcs-1/gc.properties | 0 17 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/checksums/checksums.lock delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileChanges/last-build.bin delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.bin delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.lock delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/gc.properties delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/buildOutputCleanup.lock delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/cache.properties delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/vcs-1/gc.properties delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/checksums/checksums.lock delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileChanges/last-build.bin delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.bin delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.lock delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/gc.properties delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/buildOutputCleanup.lock delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/cache.properties delete mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/vcs-1/gc.properties 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/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/checksums/checksums.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/checksums/checksums.lock deleted file mode 100644 index 4a6e3c136da130d369f0f45762dd5c08d825f9f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 TcmZQRXC35UyWqi91}FdkHW37X diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileChanges/last-build.bin b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileChanges/last-build.bin deleted file mode 100644 index f76dd238ade08917e6712764a16a22005a50573d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1 IcmZPo000310RR91 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.bin b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.bin deleted file mode 100644 index 8a6b626d9ccd64d5433a4723c0ba1f6ea6167736..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18547 zcmeI(u?oRK7y$5NlwwgPWmJ+f8I{2zr96O?(O@xIK=_!grLusf+*t0t5&UAV7cs0RjXF5FkK+009C72oU%efl+KE zmo`(bXOpC`2%(veN#=GPpZkaVFg@;dKKBQo824-GnUw$m0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5cmawRxBcU>|af0ak8$Q9{c&S YI)APP?aMKK7T9ce+i60NJ1rPWwMgRZ+ diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/fileHashes/fileHashes.lock deleted file mode 100644 index 750503458c9af3081e9d51c6111f7d7d3c223e7a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 UcmZQ({gqs=b>+to1_)pS063Wh*#H0l diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/gc.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/9.8.1/gc.properties deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/buildOutputCleanup.lock deleted file mode 100644 index f20bbf759d550652e93cfcdeaa9be43a7c3cd994..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 UcmZQRknzjRuyOf+1_)pT06H54wEzGB diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/cache.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/cache.properties deleted file mode 100644 index 18b5b43cb..000000000 --- a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/buildOutputCleanup/cache.properties +++ /dev/null @@ -1,2 +0,0 @@ -#Tue Nov 03 21:49:16 CET 2026 -gradle.version=9.8.1 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/vcs-1/gc.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/.gradle/vcs-1/gc.properties deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/checksums/checksums.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/checksums/checksums.lock deleted file mode 100644 index 8c1adfa8ffdc9d512e9bbc436dd00d78d0b30367..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 ScmZRU5S?LqsXeHJ0SW*mp#w1h diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileChanges/last-build.bin b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileChanges/last-build.bin deleted file mode 100644 index f76dd238ade08917e6712764a16a22005a50573d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1 IcmZPo000310RR91 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.bin b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.bin deleted file mode 100644 index 8a6b626d9ccd64d5433a4723c0ba1f6ea6167736..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18547 zcmeI(u?oRK7y$5NlwwgPWmJ+f8I{2zr96O?(O@xIK=_!grLusf+*t0t5&UAV7cs0RjXF5FkK+009C72oU%efl+KE zmo`(bXOpC`2%(veN#=GPpZkaVFg@;dKKBQo824-GnUw$m0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5cmawRxBcU>|af0ak8$Q9{c&S YI)APP?aMKK7T9ce+i60NJ1rPWwMgRZ+ diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/fileHashes/fileHashes.lock deleted file mode 100644 index b4a437f52819a1d0a752d1e8075b082581409dfa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 UcmZR+{Fd)ql-=B|3=qHu06YK$xBvhE diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/gc.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/9.8.1/gc.properties deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/buildOutputCleanup.lock deleted file mode 100644 index 05e5989c8fa4d3d2a57bae6e97f882b94ca7d83c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 UcmZSH*?R2Q_HKJi1_)pT06T^Ri2wiq diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/cache.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/cache.properties deleted file mode 100644 index 2d5c65cc6..000000000 --- a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/buildOutputCleanup/cache.properties +++ /dev/null @@ -1,2 +0,0 @@ -#Tue Nov 03 21:49:18 CET 2026 -gradle.version=9.8.1 diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/vcs-1/gc.properties b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/.gradle/vcs-1/gc.properties deleted file mode 100644 index e69de29bb..000000000 From d283907326665287c53b66fc516570e306d9951c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 5 Nov 2026 10:12:27 +0100 Subject: [PATCH 3/3] refactor(scan): fail closed without a .NET SDK, simplify facts-only wiring - --dynamic-sbom-inference and its setup wizard fail closed when no .NET SDK runs for a .NET build root, for every consumer, since no facts can be generated without it. --auto-manifest still warns and skips. - Name socket.json's path and the setup command in the next steps. - Write the reachability sidecar whenever facts-only types are set, so the flag and the sidecar `coana run` needs come from the same input. - Name the upload-only inference mode instead of an undefined consumer. - Move useOnlySocketFactsArtifactsFlags next to the other Coana helpers. - Table-driven tests with shared helpers. --- CHANGELOG.md | 2 +- src/commands/fix/generated-socket-facts.mts | 2 +- .../manifest/generate-recursive-manifests.mts | 27 +- .../generate-recursive-manifests.test.mts | 38 +-- src/commands/manifest/scripts/sidecar.mts | 10 - .../manifest/scripts/sidecar.test.mts | 20 -- .../setup-recursive-manifest-config.mts | 14 +- .../setup-recursive-manifest-config.test.mts | 24 ++ src/commands/scan/handle-create-new-scan.mts | 6 +- .../scan/handle-create-new-scan.test.mts | 2 - src/commands/scan/handle-scan-reach.mts | 15 +- .../scan/perform-reachability-analysis.mts | 17 +- .../perform-reachability-analysis.test.mts | 202 +++++++-------- .../scan/run-dynamic-sbom-inference.mts | 25 +- .../scan/run-dynamic-sbom-inference.test.mts | 244 ++++++++---------- src/utils/coana.mts | 12 + src/utils/coana.test.mts | 19 ++ 17 files changed, 302 insertions(+), 377 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5a5f300..852e20695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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. -- With `--dynamic-sbom-inference`, reachability analysis and `socket fix` now stop with clear next steps when a .NET build cannot be read because no .NET SDK is installed, and warn that builds skipped in `socket.json` are left out. Reachability analysis also stops with next steps when no build produced Socket 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 diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index b937fa4c1..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 = { diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 452ff7595..0655c33b7 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -18,7 +18,7 @@ import { DSI_SETUP_COMMAND, REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG, } from './scripts/resolution-report-render.mts' -import { noDotnetSdkReason, 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' @@ -45,7 +45,6 @@ export type RecursiveManifestOutcomeStatus = | 'generated' | 'skippedCovered' | 'skippedDisabled' - | 'skippedNoSdk' export type RecursiveManifestOutcome = BuildRoot & { ecosystem: BuildTool @@ -153,7 +152,6 @@ async function runEcosystemCandidates({ ignoreUnresolved: ignoreUnresolvedOverride, optionFlags, realCwd, - requireDotnetSdk, rootSockJson, sbtTmpDir, sidecarAcc, @@ -165,7 +163,6 @@ async function runEcosystemCandidates({ ignoreUnresolved: boolean | undefined optionFlags: ReadonlySet realCwd: string - requireDotnetSdk: boolean | undefined rootSockJson: SocketJson // sbt only: a shared global base reused across every sbt root in this run, // so sbt's own Scala-toolchain cache under /boot survives between @@ -215,26 +212,20 @@ async function runEcosystemCandidates({ outcomes.push({ ...root, ecosystem, status: 'skippedDisabled' }) continue } - if (ecosystem === 'dotnet' && requireDotnetSdk) { + 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 it has no Socket facts, and the analysis takes .NET dependencies only from them.`, + `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 socket.json at one.', - '- Or leave it out with --exclude-paths, or with defaults.manifest.dotnet.disabled in socket.json.', + `- 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'), ) } - } else if ( - ecosystem === 'dotnet' && - // eslint-disable-next-line no-await-in-loop - (await skipWithoutDotnetSdk(dirConfig.bin, dir)) - ) { - outcomes.push({ ...root, ecosystem, status: 'skippedNoSdk' }) - continue } // eslint-disable-next-line no-await-in-loop const result = await buildUncoveredRoot({ @@ -256,7 +247,7 @@ async function runEcosystemCandidates({ : undefined, flags: optionFlags, setupCommand: DSI_SETUP_COMMAND, - socketJson: displayPath(path.join(dir, SOCKET_JSON)), + socketJson, }, root, sidecarAcc, @@ -371,7 +362,6 @@ export async function generateRecursiveManifests({ excludePaths, ignoreUnresolved, optionFlags = new Set(), - requireDotnetSdk, sbtTmpDir, sidecarAcc, verbose, @@ -384,8 +374,6 @@ 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 - // Fail instead of skipping a .NET build root no .NET SDK can build. - requireDotnetSdk?: boolean | 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, @@ -424,7 +412,6 @@ export async function generateRecursiveManifests({ ignoreUnresolved, optionFlags, realCwd, - requireDotnetSdk, rootSockJson, sbtTmpDir: resolvedSbtTmpDir, sidecarAcc, diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 211f9d91b..d7996a03a 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -19,17 +19,12 @@ vi.mock('./scripts/run.mts', async importOriginal => ({ ...(await importOriginal()), listDotnetEntryProjects: vi.fn(async () => undefined), noDotnetSdkReason: vi.fn(async () => undefined), - skipWithoutDotnetSdk: vi.fn(async () => false), })) import { buildRootPath } from './discover-manifest-roots.mts' import { generateRecursiveManifests } from './generate-recursive-manifests.mts' import { runManifestFacts } from './run-manifest-facts.mts' -import { - listDotnetEntryProjects, - noDotnetSdkReason, - skipWithoutDotnetSdk, -} from './scripts/run.mts' +import { listDotnetEntryProjects, noDotnetSdkReason } from './scripts/run.mts' import { testPath } from '../../../test/utils.mts' import { readOrDefaultSocketJson, @@ -529,7 +524,7 @@ describe('generateRecursiveManifests', () => { }) }) - it('fails before building anything when a .NET build root needs an SDK that does not run', async () => { + 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'), '') @@ -540,44 +535,15 @@ describe('generateRecursiveManifests', () => { await expect( generateRecursiveManifests({ cwd: dir, - requireDotnetSdk: true, verbose: false, }), ).rejects.toThrow(/No .NET SDK runs for .* \(dotnet not found\)/) - expect(skipWithoutDotnetSdk).not.toHaveBeenCalled() expect(runManifestFacts).not.toHaveBeenCalled() } finally { await fs.rm(dir, { recursive: true, force: true }) } }) - it('skips .NET builds, without failing, where no .NET SDK runs', async () => { - const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-no-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, - }) - - 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']) - } 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/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index 6db5e3a78..bce3714e8 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -157,13 +157,3 @@ export function serializeSidecar( } return result } - -// Facts-only whether or not any facts were generated. Maven alone keeps the -// older flag, which Coana releases before 15.14.1 also know. -export function useOnlySocketFactsArtifactsFlags( - types: readonly string[], -): 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/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index 31ef708b1..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,22 +421,3 @@ describe('compute-artifacts sidecar', () => { expect(hasResolvedPathsSidecarEntries({})).toBe(false) }) }) - -describe('useOnlySocketFactsArtifactsFlags', () => { - it('keeps the Maven-only flag every Coana release knows for Maven alone', () => { - expect(useOnlySocketFactsArtifactsFlags(['maven'])).toEqual([ - '--maven-use-only-socket-facts', - ]) - }) - - it('names every other selection, sorted', () => { - expect(useOnlySocketFactsArtifactsFlags(['nuget', 'maven'])).toEqual([ - '--use-only-socket-facts-artifacts', - 'maven,nuget', - ]) - expect(useOnlySocketFactsArtifactsFlags(['nuget'])).toEqual([ - '--use-only-socket-facts-artifacts', - '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 5a08e8a7c..50ce4a07d 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -162,7 +162,7 @@ async function createNewScan( 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. @@ -177,11 +177,11 @@ 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' : undefined, + 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. diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index e83d7513b..3db5fcb0c 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -391,7 +391,6 @@ describe('handleCreateNewScan excludePaths', () => { expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo', - requireDotnetSdk: false, sbtTmpDir: undefined, withFiles: false, }), @@ -505,7 +504,6 @@ describe('handleCreateNewScan excludePaths', () => { expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( expect.objectContaining({ - requireDotnetSdk: true, sbtTmpDir: expect.any(String), withFiles: true, }), diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 506a456c3..27a3a41ff 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -14,9 +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 { PURL_Type } from '../../utils/ecosystem.mts' -import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' export type HandleScanReachConfig = { cwd: string @@ -45,15 +44,13 @@ 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 factsOnlyTypes: PURL_Type[] | 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, @@ -62,8 +59,6 @@ async function runScanReach( scanTargets = Array.from( new Set([...scanTargets, ...dynamicResult.factsPaths]), ) - resolvedPathsSidecar = dynamicResult.resolvedPathsSidecar - factsOnlyTypes = dynamicResult.factsOnlyTypes } // Get supported file names. @@ -132,13 +127,13 @@ async function runScanReach( const result = await performReachabilityAnalysis({ cwd, - factsOnlyTypes, + factsOnlyTypes: dynamicResult?.factsOnlyTypes, orgSlug, outputKind, outputPath, packagePaths, reachabilityOptions: mergedReachabilityOptions, - resolvedPathsSidecar, + resolvedPathsSidecar: dynamicResult?.resolvedPathsSidecar, spinner, target: targets[0]!, }) diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 2441ab0ad..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' @@ -58,7 +56,7 @@ export type ReachabilityAnalysisOptions = { branchName?: string | undefined cwd?: string | undefined // Purl types whose artifacts Coana takes only from facts files. - factsOnlyTypes?: PURL_Type[] | undefined + factsOnlyTypes?: readonly PURL_Type[] | undefined // Required: the manifest upload they drive produces the tar hash Coana needs // to run without Docker. orgSlug: string @@ -194,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)) ) { @@ -272,8 +270,7 @@ export async function performReachabilityAnalysis( ...(reachabilityOptions.reachExcludePaths.length ? ['--exclude-dirs', ...reachabilityOptions.reachExcludePaths] : []), - // Only dynamicSbomInference sets these, and sidecarPath is always set above - // then; Coana rejects this flag without one. + // sidecarPath is always set above then; Coana rejects this flag without one. ...(factsOnlyTypes?.length ? useOnlySocketFactsArtifactsFlags(factsOnlyTypes) : []), diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index bd45bab8e..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,7 +333,7 @@ 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') }) }) @@ -337,101 +356,62 @@ describe('performReachabilityAnalysis --use-only-socket-facts-artifacts 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: undefined, - target: scanCwd, - }) - - const args = mockSpawnCoana.mock.calls[0]![0] as string[] - expect(args[args.indexOf('--use-only-socket-facts-artifacts') + 1]).toBe( - 'maven,nuget', - ) - expect(args).toContain('--compute-artifacts-sidecar') - expect(sidecarContentAtSpawnTime).toEqual({}) - }) - - it('passes --use-only-socket-facts-artifacts 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, - }, - factsOnlyTypes: ['maven', 'nuget'], - resolvedPathsSidecar: { - '/repo/reactor/.socket.facts.json': { - components: [], - projects: [], - }, - }, - target: scanCwd, - }) - - const args = mockSpawnCoana.mock.calls[0]![0] as string[] - expect(args[args.indexOf('--use-only-socket-facts-artifacts') + 1]).toBe( - 'maven,nuget', - ) - expect(args).toContain('--compute-artifacts-sidecar') - }) - - it('keeps the Maven-only flag older Coana releases know when only JVM builds were found', async () => { - await performReachabilityAnalysis({ - cwd: scanCwd, + 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'], - orgSlug: TEST_ORG_SLUG, - packagePaths: TEST_PACKAGE_PATHS, - reachabilityOptions: { - ...makeReachabilityOptions(), - dynamicSbomInference: true, - }, - target: scanCwd, - }) - - const args = mockSpawnCoana.mock.calls[0]![0] as string[] - expect(args).toContain('--maven-use-only-socket-facts') - expect(args).not.toContain('--use-only-socket-facts-artifacts') - }) - - it('never passes --use-only-socket-facts-artifacts 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('--use-only-socket-facts-artifacts') - expect(args).not.toContain('--maven-use-only-socket-facts') - expect(args).toContain('--compute-artifacts-sidecar') + resolvedPathsSidecar: undefined, + 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 cf0c2d409..c98bf8230 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.mts @@ -3,6 +3,7 @@ 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, @@ -15,8 +16,9 @@ import type { SidecarAccumulator, } from '../manifest/scripts/sidecar.mts' -// Who runs Coana on the facts: `reach` also resolves artifact paths. -export type FactsConsumer = 'fix' | 'reach' +// 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[] @@ -30,17 +32,20 @@ export type DynamicSbomInferenceResult = { // build root without them is one it leaves out. function checkFactsForCoana( outcomes: RecursiveManifestOutcome[], - consumer: FactsConsumer, + 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' && !outcomes.some(o => o.status === 'generated')) { + 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.'] + ? [ + `- 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'), @@ -63,7 +68,7 @@ export async function runDynamicSbomInference({ excludePaths, sbtTmpDir, }: { - consumer: FactsConsumer | undefined + consumer: FactsConsumer cwd: string excludePaths: string[] // sbt provisions its Scala toolchain under this directory and reach's @@ -72,13 +77,13 @@ export async function runDynamicSbomInference({ sbtTmpDir: string | undefined }): Promise { const withFiles = consumer === 'reach' - const sidecarAcc: SidecarAccumulator | undefined = consumer + const coanaReadsFacts = consumer === 'fix' || consumer === 'reach' + const sidecarAcc: SidecarAccumulator | undefined = coanaReadsFacts ? new Map() : undefined const outcomes = await generateRecursiveManifests({ cwd, excludePaths, - requireDotnetSdk: !!consumer, sbtTmpDir: withFiles ? sbtTmpDir : undefined, sidecarAcc, verbose: false, @@ -107,8 +112,8 @@ export async function runDynamicSbomInference({ const factsPaths = outcomes .filter(o => o.status === 'generated') .map(o => o.factsPath!) - if (consumer) { - checkFactsForCoana(outcomes, consumer) + if (coanaReadsFacts) { + checkFactsForCoana(outcomes, consumer, !!factsPaths.length) } return { factsPaths, diff --git a/src/commands/scan/run-dynamic-sbom-inference.test.mts b/src/commands/scan/run-dynamic-sbom-inference.test.mts index 9fd3d0a13..490dc922d 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.test.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.test.mts @@ -2,6 +2,8 @@ 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' + const { mockGenerateRecursiveManifests, mockWarn } = vi.hoisted(() => ({ mockGenerateRecursiveManifests: vi.fn(), mockWarn: vi.fn(), @@ -22,11 +24,59 @@ const generated = { 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([ generated, @@ -35,32 +85,14 @@ describe('runDynamicSbomInference', () => { { dir: '/repo/d', ecosystem: 'maven', status: 'skippedCovered' }, ]) - const result = await runDynamicSbomInference({ - consumer: undefined, - cwd: '/repo', - excludePaths: ['vendor/**'], - sbtTmpDir: '/tmp/sbt', - }) - - 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/**'], - requireDotnetSdk: false, - // Only meaningful for reach; 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 for reachability', async () => { + it('serializes the accumulated sidecar', async () => { mockGenerateRecursiveManifests.mockImplementationOnce( async ({ sidecarAcc }) => { sidecarAcc.set('/repo/a/.socket.facts.json', { @@ -71,21 +103,7 @@ describe('runDynamicSbomInference', () => { }, ) - const result = await runDynamicSbomInference({ - consumer: 'reach', - cwd: '/repo', - excludePaths: [], - sbtTmpDir: '/tmp/sbt', - }) - - expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( - expect.objectContaining({ - requireDotnetSdk: true, - sbtTmpDir: '/tmp/sbt', - withFiles: true, - }), - ) - expect(result.resolvedPathsSidecar).toEqual({ + expect((await run('reach')).resolvedPathsSidecar).toEqual({ '/repo/a/.socket.facts.json': { projects: [{ name: 'app' }], components: [], @@ -93,54 +111,6 @@ describe('runDynamicSbomInference', () => { }) }) - it('collects a sidecar for fix without resolving artifact paths', async () => { - mockGenerateRecursiveManifests.mockResolvedValueOnce([generated]) - - await runDynamicSbomInference({ - consumer: 'fix', - cwd: '/repo', - excludePaths: [], - sbtTmpDir: undefined, - }) - - expect(mockGenerateRecursiveManifests).toHaveBeenCalledWith( - expect.objectContaining({ - requireDotnetSdk: true, - sidecarAcc: expect.any(Map), - withFiles: false, - }), - ) - }) - - it('throws when no build root was discovered at all', async () => { - mockGenerateRecursiveManifests.mockResolvedValueOnce([]) - - await expect( - runDynamicSbomInference({ - consumer: 'reach', - cwd: '/repo', - excludePaths: [], - sbtTmpDir: undefined, - }), - ).rejects.toThrow(/No Gradle, sbt, Maven or .NET build root was found/) - }) - - it('throws when a discovered build root failed to generate facts', async () => { - mockGenerateRecursiveManifests.mockResolvedValueOnce([ - generated, - { dir: '/repo/b', ecosystem: 'maven', status: 'failed' }, - ]) - - await expect( - runDynamicSbomInference({ - consumer: 'reach', - cwd: '/repo', - excludePaths: [], - sbtTmpDir: undefined, - }), - ).rejects.toThrow(/one or more independent build roots failed/i) - }) - it('makes every discovered ecosystem facts-only, generated or not', async () => { mockGenerateRecursiveManifests.mockResolvedValueOnce([ generated, @@ -148,46 +118,61 @@ describe('runDynamicSbomInference', () => { { dir: '/repo/c', ecosystem: 'sbt', status: 'empty' }, ]) - const result = await runDynamicSbomInference({ - consumer: 'reach', - cwd: '/repo', - excludePaths: [], - sbtTmpDir: undefined, - }) - - expect(result.factsOnlyTypes.sort()).toEqual(['maven', 'nuget']) - }) - - it('throws for reachability when no build root produced facts', async () => { - mockGenerateRecursiveManifests.mockResolvedValueOnce([ - { dir: '/repo/a', ecosystem: 'maven', status: 'empty' }, - { dir: '/repo/b', ecosystem: 'gradle', status: 'skippedDisabled' }, + expect((await run('reach')).factsOnlyTypes.sort()).toEqual([ + 'maven', + 'nuget', ]) - - await expect( - runDynamicSbomInference({ - consumer: 'reach', - cwd: '/repo', - excludePaths: [], - sbtTmpDir: undefined, - }), - ).rejects.toThrow(/Re-enable the builds/) }) - it('lets fix proceed when no build root produced facts', async () => { - mockGenerateRecursiveManifests.mockResolvedValueOnce([ - { dir: '/repo/a', ecosystem: 'maven', status: 'empty' }, - ]) + 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) + }) - const result = await runDynamicSbomInference({ + it.for([ + { consumer: 'fix', - cwd: '/repo', - excludePaths: [], - sbtTmpDir: undefined, - }) - - expect(result.factsPaths).toEqual([]) - }) + 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([ @@ -195,31 +180,10 @@ describe('runDynamicSbomInference', () => { { dir: '/repo/b', ecosystem: 'maven', status: 'skippedDisabled' }, ]) - await runDynamicSbomInference({ - consumer: 'reach', - cwd: '/repo', - excludePaths: [], - sbtTmpDir: undefined, - }) + await run('reach') expect(mockWarn).toHaveBeenCalledWith( expect.stringMatching(/leaves out the build roots socket.json skips/), ) }) - - it('neither throws nor warns for roots without facts when Coana does not consume them', async () => { - mockGenerateRecursiveManifests.mockResolvedValueOnce([ - { dir: '/repo/a', ecosystem: 'maven', status: 'skippedDisabled' }, - ]) - - const result = await runDynamicSbomInference({ - consumer: undefined, - cwd: '/repo', - excludePaths: [], - sbtTmpDir: undefined, - }) - - expect(result.factsPaths).toEqual([]) - expect(mockWarn).not.toHaveBeenCalled() - }) }) 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) + }) +})