From 7594c03dfa68fba5fb4703c6bc62c3f9b3035a21 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 11:51:43 +0200 Subject: [PATCH 1/2] feat(scan): recognise any *.socket.facts.json and keep reachability reports out of input Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ src/commands/fix/coana-fix.mts | 20 ++----- src/commands/scan/cmd-scan-create.mts | 17 ++++-- src/commands/scan/cmd-scan-create.test.mts | 2 +- src/commands/scan/cmd-scan-reach.test.mts | 2 +- src/commands/scan/handle-create-new-scan.mts | 34 +++++------ .../scan/handle-create-new-scan.test.mts | 60 +++++++++++++++++++ .../scan/perform-reachability-analysis.mts | 27 +++++---- .../perform-reachability-analysis.test.mts | 30 ++++++++++ src/commands/scan/reachability-flags.mts | 2 +- src/utils/coana.mts | 28 +++++++++ src/utils/coana.test.mts | 55 +++++++++++++++++ 12 files changed, 228 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa85e021..0ce645096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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 +- Reachability scans no longer feed a leftover `.socket.facts.json` report from an earlier run back into the analysis. + ## [1.6.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.1) - 2026-10-08 ### Changed diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 7d1ec971e..e27284cc2 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -22,12 +22,9 @@ import { import { generateSocketFactsForFix } from './generated-socket-facts.mts' import { getSocketFixBranchName, getSocketFixCommitMessage } from './git.mts' import { getSocketFixPrs, openSocketFixPr } from './pull-request.mts' -import { - DOT_SOCKET_DOT_FACTS_JSON, - FLAG_DRY_RUN, - GQL_PR_STATE_OPEN, -} from '../../constants.mts' +import { FLAG_DRY_RUN, GQL_PR_STATE_OPEN } from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' +import { isSocketFactsFile } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { getErrorCause } from '../../utils/errors.mts' @@ -194,10 +191,6 @@ async function discoverGhsaIds( } } -function isFactsFile(filepath: string): boolean { - return path.basename(filepath).toLowerCase() === DOT_SOCKET_DOT_FACTS_JSON -} - type GitWorkingTreeChanges = { modified: string[] untracked: string[] @@ -379,16 +372,15 @@ async function coanaFixWithFacts( cwd, }) const scanFilepaths = await findScanFilepaths() - // Fail if any .socket.facts.json files are present in the scan folder. - // These are analysis artifacts and must be removed before re-running fix. - const factsFiles = scanFilepaths.filter(isFactsFile) + // Facts files are analysis artifacts and must be removed before re-running fix. + const factsFiles = scanFilepaths.filter(isSocketFactsFile) if (factsFiles.length) { if (!silence) { spinner?.stop() } return { ok: false, - message: `Found ${DOT_SOCKET_DOT_FACTS_JSON} in manifest files`, + message: 'Found Socket facts files in manifest files', cause: `Delete the following ${pluralize('file', factsFiles.length)} before running socket fix again:\n` + factsFiles.map(p => ` - ${p}`).join('\n'), @@ -409,7 +401,7 @@ async function coanaFixWithFacts( }) } catch (e) { // A failed build root aborts inference after others wrote their facts. - const partial = (await findScanFilepaths()).filter(isFactsFile) + const partial = (await findScanFilepaths()).filter(isSocketFactsFile) await Promise.all(partial.map(p => fs.rm(p, { force: true }))) throw e } diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 7768cfd1d..5634e54ca 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { readdirSync } from 'node:fs' import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' @@ -26,6 +26,7 @@ import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags, outputFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { isSocketFactsFile } from '../../utils/coana.mts' import { determineOrgSlug } from '../../utils/determine-org-slug.mts' import { parseReachEcosystems } from '../../utils/ecosystem.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' @@ -184,6 +185,14 @@ const generalFlags: MeowFlags = { }, } +function hasSocketFactsFileIn(dir: string): boolean { + try { + return readdirSync(dir).some(isSocketFactsFile) + } catch { + return false + } +} + export const cmdScanCreate = { description, hidden, @@ -472,14 +481,12 @@ async function run( } const detected = await detectManifestActions(sockJson, cwd) - // Suppress the --auto-manifest suggestion when a `.socket.facts.json` is + // Suppress the --auto-manifest suggestion when a Socket facts file is // already present at cwd. That file is the output of `socket manifest auto` // (and `--facts` mode of the per-ecosystem manifest commands), so suggesting // to regenerate it would be misleading; the manifest data is already there // and will be picked up by the scan. - const hasFactsFile = existsSync( - path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON), - ) + const hasFactsFile = hasSocketFactsFileIn(cwd) if ( detected.count > 0 && !autoManifest && diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 8d22ebf8e..98d7bc032 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -136,7 +136,7 @@ describe('socket scan create', async () => { --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supported: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. --reach-fallback-to-regular-scan If reachability analysis fails, continue with a regular SCA scan (without reachability results) instead of halting. By default, the CLI halts on reachability errors. - --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale \`.socket.facts.json\` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable. + --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 80c677b1a..4917a96ce 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -52,7 +52,7 @@ describe('socket scan reach', async () => { --reach-disable-external-tool-checks Disable external tool checks during reachability analysis. --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supported: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. - --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale \`.socket.facts.json\` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable. + --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 3a19b6a22..98ecd3152 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -19,6 +19,8 @@ import constants from '../../constants.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { compressSocketFactsForUpload, + isReachabilityReportPath, + isSocketFactsFile, snapshotSocketFacts, } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' @@ -197,7 +199,7 @@ async function createNewScan( if (reach.dynamicSbomInference) { // Already generated recursively above; resolving cwd's own build - // root a second time would race on the same .socket.facts.json. + // root a second time would race on the same facts file. detected.gradle = false detected.sbt = false detected.maven = false @@ -358,16 +360,17 @@ async function createNewScan( reachabilityReport = reachResult.data?.reachabilityReport - // When using only pre-generated SBOMs, build the scan from those inputs — - // CycloneDX, SPDX, and Socket facts (`.socket.facts.json`) — matching - // Coana's `--use-only-pregenerated-sboms` selection. Otherwise drop any - // stray `.socket.facts.json`; coana's fresh reachability report (appended - // below) is the authoritative facts file for the scan. + // Mirror the SBOM inputs Coana analyzed; otherwise its fresh report + // (appended below) supersedes every facts file. const pathsForScan = reach.reachUseOnlyPregeneratedSboms - ? filterToPregeneratedSboms(packagePaths, supportedFiles) - : packagePaths.filter( - p => path.basename(p) !== constants.DOT_SOCKET_DOT_FACTS_JSON, + ? filterToPregeneratedSboms(packagePaths, supportedFiles).filter( + p => + !isReachabilityReportPath(p, { + cwd, + outputPath: constants.DOT_SOCKET_DOT_FACTS_JSON, + }), ) + : packagePaths.filter(p => !isSocketFactsFile(p)) // Append coana's reachability report, but not twice: a pre-generated facts // input can resolve to the same path coana wrote its report to. @@ -439,17 +442,8 @@ async function createNewScan( ) } - // On a successful scan, clean up the `.socket.facts.json` coana wrote at - // the path we instructed it to write to (via `--socket-mode`). Failed - // scans leave the file in place for debugging. Producer-written files - // (e.g. from `socket manifest gradle --facts`) are NOT touched here — - // those are user-owned input that the user can clean up themselves; in - // the --reach path coana overwrites that file with its enriched output - // anyway, so it's the same path that gets removed. `--reach-retain-facts-file` - // opts out of this cleanup so the report can be inspected; the user is then - // responsible for deleting it before the next full application reachability - // scan (a stale file is picked up as pre-generated input and would make those - // results unreliable). + // A scan without --reach would upload a leftover report as an SBOM with + // stale reachability results; a failed scan keeps it for debugging. if ( fullScanCResult.ok && scanId && diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 7a750749a..6a86ff950 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -263,6 +263,66 @@ describe('handleCreateNewScan excludePaths', () => { expect(cleanup).toHaveBeenCalledOnce() }) + it('replaces every facts file input with the reachability report', async () => { + const cleanup = vi.fn() + const files = [ + '/repo/pom.xml.socket.facts.json', + '/repo/gradle.socket.facts.json', + '/repo/service/.socket.facts.json', + '/repo/package-lock.json', + ] + const config = createConfig({ + generateScanFiles: async () => ({ cleanup, files }), + }) + config.reach.runReachabilityAnalysis = true + mockPerformReachabilityAnalysis.mockResolvedValueOnce({ + data: { + reachabilityReport: '.socket.facts.json', + tier1ReachabilityScanId: 'tier1-id', + }, + ok: true, + }) + await handleCreateNewScan(config) + expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( + ['/repo/package-lock.json', '.socket.facts.json'], + 'fakeOrg', + expect.anything(), + expect.anything(), + ) + }) + + it('keeps build facts but not earlier reports when using only pre-generated SBOMs', async () => { + const cleanup = vi.fn() + const files = [ + '/repo/pom.xml.socket.facts.json', + '/repo/service/.socket.facts.json', + '/repo/package-lock.json', + ] + const config = createConfig({ + generateScanFiles: async () => ({ cleanup, files }), + }) + config.reach.runReachabilityAnalysis = true + config.reach.reachUseOnlyPregeneratedSboms = true + mockFetchSupportedScanFileNames.mockResolvedValueOnce({ + data: { socket: { facts: { pattern: '*.socket.facts.json' } } }, + ok: true, + }) + mockPerformReachabilityAnalysis.mockResolvedValueOnce({ + data: { + reachabilityReport: '.socket.facts.json', + tier1ReachabilityScanId: 'tier1-id', + }, + ok: true, + }) + await handleCreateNewScan(config) + expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( + ['/repo/pom.xml.socket.facts.json', '.socket.facts.json'], + 'fakeOrg', + expect.anything(), + expect.anything(), + ) + }) + it('includes generated auto-manifest files in SCA discovery targets', async () => { mockGenerateAutoManifest.mockResolvedValueOnce({ generatedFiles: ['/repo/.socket-auto-manifest/maven_install.json'], diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 3314e1bc9..95d8c3b38 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -8,7 +8,10 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { isOmittedReachValue } from './reachability-units.mts' import constants from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' -import { extractTier1ReachabilityScanId } from '../../utils/coana.mts' +import { + extractTier1ReachabilityScanId, + isReachabilityReportPath, +} from '../../utils/coana.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { hasEnterpriseOrgPlan } from '../../utils/organization.mts' import { setupSdk } from '../../utils/sdk.mts' @@ -134,17 +137,19 @@ export async function performReachabilityAnalysis( spinner?.start('Uploading manifests for reachability analysis...') + const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON + // Ensure uploaded manifest files are relative to analysis target as coana resolves SBOM manifest files relative to this path - // NOTE: previously stripped any `.socket.facts.json` from packagePaths - // here to avoid uploading leftover post-reachability output. With the - // producer flow (`socket manifest gradle --facts`) those files are - // legitimate INPUT to compute-artifacts, so we now upload them. Stale - // facts files are cleaned up downstream — see the post-success - // deletion in handle-create-new-scan.mts. const uploadCResult = await handleApiCall( - sockSdk.uploadManifestFiles(orgSlug, packagePaths, { - pathsRelativeTo: path.resolve(cwd, analysisTarget), - }), + sockSdk.uploadManifestFiles( + orgSlug, + packagePaths.filter( + p => !isReachabilityReportPath(p, { cwd, outputPath: outputFilePath }), + ), + { + pathsRelativeTo: path.resolve(cwd, analysisTarget), + }, + ), { description: 'upload manifests', spinner, @@ -179,8 +184,6 @@ export async function performReachabilityAnalysis( spinner?.start() spinner?.infoAndStop('Running reachability analysis with Coana...') - const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON - // Temp file for --compute-artifacts-sidecar, removed in the finally below. // Written even when empty under dynamicSbomInference, since the // --maven-use-only-socket-facts flag below requires one to be present. diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index bf24a6dbc..d8dfd220d 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -220,6 +220,36 @@ describe('performReachabilityAnalysis manifests tar hash', () => { expect(args[args.indexOf('--manifests-tar-hash') + 1]).toBe(TEST_TAR_HASH) }) + it('uploads build facts but not earlier reachability reports', async () => { + const uploadManifestFiles = vi.fn() + mockSetupSdk.mockResolvedValueOnce({ + ok: true, + data: { uploadManifestFiles }, + }) + + await performReachabilityAnalysis({ + cwd: scanCwd, + orgSlug: TEST_ORG_SLUG, + outputPath: 'out/report.json', + packagePaths: [ + 'package.json', + 'pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + '.socket.facts.json', + 'nested/.socket.facts.json', + path.join(scanCwd, 'out/report.json'), + ], + reachabilityOptions: makeReachabilityOptions(), + target: scanCwd, + }) + + expect(uploadManifestFiles.mock.calls[0]![1]).toEqual([ + 'package.json', + 'pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + ]) + }) + it('fails without spawning Coana when the upload returns no tar hash', async () => { mockHandleApiCall.mockResolvedValueOnce({ ok: true, data: {} } as never) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index a2c4c2c65..ffe3ca2a0 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -121,7 +121,7 @@ export const reachabilityFlags: MeowFlags = { type: 'boolean', default: false, description: - 'Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale `.socket.facts.json` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable.', + 'Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results.', }, reachSkipCache: { type: 'boolean', diff --git a/src/utils/coana.mts b/src/utils/coana.mts index 8b130e097..add373b84 100644 --- a/src/utils/coana.mts +++ b/src/utils/coana.mts @@ -86,6 +86,8 @@ export async function compressSocketFactsForUpload( // remove a `.br` only to have it re-created after we returned. const results = await Promise.allSettled( scanPaths.map(async p => { + // depscan decodes only the bare `.socket.facts.json.br`; a named facts + // file is uploaded as plain JSON. if (path.basename(p) !== DOT_SOCKET_DOT_FACTS_JSON) { return p } @@ -119,6 +121,32 @@ export async function compressSocketFactsForUpload( return { paths, cleanup } } +// `.socket.facts.json` or a named `.socket.facts.json`, matching +// depscan's case-insensitive `*.socket.facts.json`. +export function isSocketFactsFile(filepath: string): boolean { + return path + .basename(filepath) + .toLowerCase() + .endsWith(DOT_SOCKET_DOT_FACTS_JSON) +} + +// A Coana reachability report, never input to a new analysis: the bare +// `.socket.facts.json` (Coana's default name; producers name theirs after the +// build) or the path this run tells Coana to write to. +export function isReachabilityReportPath( + filepath: string, + options: { cwd: string; outputPath: string }, +): boolean { + const { cwd, outputPath } = { __proto__: null, ...options } as { + cwd: string + outputPath: string + } + return ( + path.basename(filepath).toLowerCase() === DOT_SOCKET_DOT_FACTS_JSON || + path.resolve(cwd, filepath) === path.resolve(cwd, outputPath) + ) +} + export type ReachabilityError = { componentName: string componentVersion: string diff --git a/src/utils/coana.test.mts b/src/utils/coana.test.mts index 9e2126fae..d4fd3a2d0 100644 --- a/src/utils/coana.test.mts +++ b/src/utils/coana.test.mts @@ -33,6 +33,8 @@ import { extractReachabilityErrors, extractTier1ReachabilityScanId, getFullWorkspacePath, + isReachabilityReportPath, + isSocketFactsFile, snapshotSocketFacts, } from './coana.mts' @@ -53,6 +55,44 @@ describe('coana facts-file utils', () => { return filePath } + describe('isSocketFactsFile', () => { + it.each([ + '.socket.facts.json', + 'a/pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + 'Foo.sln.SOCKET.FACTS.JSON', + ])('matches %s', p => { + expect(isSocketFactsFile(p)).toBe(true) + }) + it.each([ + 'socket.facts.json', + '.socket.facts.json.br', + 'pom.xml', + 'a/.socket.facts.json/pom.xml', + ])('rejects %s', p => { + expect(isSocketFactsFile(p)).toBe(false) + }) + }) + + describe('isReachabilityReportPath', () => { + const options = { cwd: '/repo', outputPath: 'out/report.json' } + it.each([ + '.socket.facts.json', + 'a/.SOCKET.FACTS.JSON', + '/repo/out/report.json', + 'out/report.json', + ])('matches %s', p => { + expect(isReachabilityReportPath(p, options)).toBe(true) + }) + it.each([ + 'pom.xml.socket.facts.json', + 'gradle.socket.facts.json', + 'report.json', + ])('rejects %s', p => { + expect(isReachabilityReportPath(p, options)).toBe(false) + }) + }) + describe('compressSocketFactsForUpload', () => { it('writes brotli .br as a sibling of the source file', async () => { const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) @@ -99,6 +139,21 @@ describe('coana facts-file utils', () => { } }) + it('uploads a named facts file uncompressed', async () => { + const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) + const facts = path.join(wrapDir, 'pom.xml.socket.facts.json') + writeFileSync(facts, '{}') + + const result = await compressSocketFactsForUpload([facts]) + try { + expect(result.paths).toEqual([facts]) + expect(existsSync(`${facts}.br`)).toBe(false) + } finally { + await result.cleanup() + rmSync(wrapDir, { recursive: true, force: true }) + } + }) + it('leaves a missing .socket.facts.json path unchanged', async () => { const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) const missingFacts = path.join(wrapDir, '.socket.facts.json') From ef9ed6fce0316dcffefa715436c3c82f2a3f578f Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 12:56:44 +0200 Subject: [PATCH 2/2] fix(scan): keep bare .socket.facts.json in reachability input Producers still write that name, so leaving it out of the reachability upload dropped their dependency graphs. Excluding earlier reports moves to the change that renames producer output. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 --- src/commands/scan/cmd-scan-create.test.mts | 2 +- src/commands/scan/cmd-scan-reach.test.mts | 2 +- src/commands/scan/handle-create-new-scan.mts | 29 ++++++++++------- .../scan/handle-create-new-scan.test.mts | 32 ------------------- .../scan/perform-reachability-analysis.mts | 27 +++++++--------- .../perform-reachability-analysis.test.mts | 30 ----------------- src/commands/scan/reachability-flags.mts | 2 +- src/utils/coana.mts | 17 ---------- src/utils/coana.test.mts | 20 ------------ 10 files changed, 32 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce645096..daa85e021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,6 @@ 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 -- Reachability scans no longer feed a leftover `.socket.facts.json` report from an earlier run back into the analysis. - ## [1.6.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.1) - 2026-10-08 ### Changed diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 98d7bc032..8d22ebf8e 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -136,7 +136,7 @@ describe('socket scan create', async () => { --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supported: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. --reach-fallback-to-regular-scan If reachability analysis fails, continue with a regular SCA scan (without reachability results) instead of halting. By default, the CLI halts on reachability errors. - --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results. + --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale \`.socket.facts.json\` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 4917a96ce..80c677b1a 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -52,7 +52,7 @@ describe('socket scan reach', async () => { --reach-disable-external-tool-checks Disable external tool checks during reachability analysis. --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supported: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. - --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results. + --reach-retain-facts-file Keep the \`.socket.facts.json\` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale \`.socket.facts.json\` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 98ecd3152..29fe7f614 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -19,7 +19,6 @@ import constants from '../../constants.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { compressSocketFactsForUpload, - isReachabilityReportPath, isSocketFactsFile, snapshotSocketFacts, } from '../../utils/coana.mts' @@ -360,16 +359,13 @@ async function createNewScan( reachabilityReport = reachResult.data?.reachabilityReport - // Mirror the SBOM inputs Coana analyzed; otherwise its fresh report - // (appended below) supersedes every facts file. + // When using only pre-generated SBOMs, build the scan from those inputs — + // CycloneDX, SPDX, and Socket facts — matching Coana's + // `--use-only-pregenerated-sboms` selection. Otherwise drop every facts + // file; coana's fresh reachability report (appended below) is the + // authoritative facts file for the scan. const pathsForScan = reach.reachUseOnlyPregeneratedSboms - ? filterToPregeneratedSboms(packagePaths, supportedFiles).filter( - p => - !isReachabilityReportPath(p, { - cwd, - outputPath: constants.DOT_SOCKET_DOT_FACTS_JSON, - }), - ) + ? filterToPregeneratedSboms(packagePaths, supportedFiles) : packagePaths.filter(p => !isSocketFactsFile(p)) // Append coana's reachability report, but not twice: a pre-generated facts @@ -442,8 +438,17 @@ async function createNewScan( ) } - // A scan without --reach would upload a leftover report as an SBOM with - // stale reachability results; a failed scan keeps it for debugging. + // On a successful scan, clean up the `.socket.facts.json` coana wrote at + // the path we instructed it to write to (via `--socket-mode`). Failed + // scans leave the file in place for debugging. Producer-written files + // (e.g. from `socket manifest gradle --facts`) are NOT touched here — + // those are user-owned input that the user can clean up themselves; in + // the --reach path coana overwrites that file with its enriched output + // anyway, so it's the same path that gets removed. `--reach-retain-facts-file` + // opts out of this cleanup so the report can be inspected; the user is then + // responsible for deleting it before the next full application reachability + // scan (a stale file is picked up as pre-generated input and would make those + // results unreliable). if ( fullScanCResult.ok && scanId && diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 6a86ff950..aeb688327 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -291,38 +291,6 @@ describe('handleCreateNewScan excludePaths', () => { ) }) - it('keeps build facts but not earlier reports when using only pre-generated SBOMs', async () => { - const cleanup = vi.fn() - const files = [ - '/repo/pom.xml.socket.facts.json', - '/repo/service/.socket.facts.json', - '/repo/package-lock.json', - ] - const config = createConfig({ - generateScanFiles: async () => ({ cleanup, files }), - }) - config.reach.runReachabilityAnalysis = true - config.reach.reachUseOnlyPregeneratedSboms = true - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - data: { socket: { facts: { pattern: '*.socket.facts.json' } } }, - ok: true, - }) - mockPerformReachabilityAnalysis.mockResolvedValueOnce({ - data: { - reachabilityReport: '.socket.facts.json', - tier1ReachabilityScanId: 'tier1-id', - }, - ok: true, - }) - await handleCreateNewScan(config) - expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( - ['/repo/pom.xml.socket.facts.json', '.socket.facts.json'], - 'fakeOrg', - expect.anything(), - expect.anything(), - ) - }) - it('includes generated auto-manifest files in SCA discovery targets', async () => { mockGenerateAutoManifest.mockResolvedValueOnce({ generatedFiles: ['/repo/.socket-auto-manifest/maven_install.json'], diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 95d8c3b38..3314e1bc9 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -8,10 +8,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { isOmittedReachValue } from './reachability-units.mts' import constants from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' -import { - extractTier1ReachabilityScanId, - isReachabilityReportPath, -} from '../../utils/coana.mts' +import { extractTier1ReachabilityScanId } from '../../utils/coana.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { hasEnterpriseOrgPlan } from '../../utils/organization.mts' import { setupSdk } from '../../utils/sdk.mts' @@ -137,19 +134,17 @@ export async function performReachabilityAnalysis( spinner?.start('Uploading manifests for reachability analysis...') - const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON - // Ensure uploaded manifest files are relative to analysis target as coana resolves SBOM manifest files relative to this path + // NOTE: previously stripped any `.socket.facts.json` from packagePaths + // here to avoid uploading leftover post-reachability output. With the + // producer flow (`socket manifest gradle --facts`) those files are + // legitimate INPUT to compute-artifacts, so we now upload them. Stale + // facts files are cleaned up downstream — see the post-success + // deletion in handle-create-new-scan.mts. const uploadCResult = await handleApiCall( - sockSdk.uploadManifestFiles( - orgSlug, - packagePaths.filter( - p => !isReachabilityReportPath(p, { cwd, outputPath: outputFilePath }), - ), - { - pathsRelativeTo: path.resolve(cwd, analysisTarget), - }, - ), + sockSdk.uploadManifestFiles(orgSlug, packagePaths, { + pathsRelativeTo: path.resolve(cwd, analysisTarget), + }), { description: 'upload manifests', spinner, @@ -184,6 +179,8 @@ export async function performReachabilityAnalysis( spinner?.start() spinner?.infoAndStop('Running reachability analysis with Coana...') + const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON + // Temp file for --compute-artifacts-sidecar, removed in the finally below. // Written even when empty under dynamicSbomInference, since the // --maven-use-only-socket-facts flag below requires one to be present. diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index d8dfd220d..bf24a6dbc 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -220,36 +220,6 @@ describe('performReachabilityAnalysis manifests tar hash', () => { expect(args[args.indexOf('--manifests-tar-hash') + 1]).toBe(TEST_TAR_HASH) }) - it('uploads build facts but not earlier reachability reports', async () => { - const uploadManifestFiles = vi.fn() - mockSetupSdk.mockResolvedValueOnce({ - ok: true, - data: { uploadManifestFiles }, - }) - - await performReachabilityAnalysis({ - cwd: scanCwd, - orgSlug: TEST_ORG_SLUG, - outputPath: 'out/report.json', - packagePaths: [ - 'package.json', - 'pom.xml.socket.facts.json', - 'gradle.socket.facts.json', - '.socket.facts.json', - 'nested/.socket.facts.json', - path.join(scanCwd, 'out/report.json'), - ], - reachabilityOptions: makeReachabilityOptions(), - target: scanCwd, - }) - - expect(uploadManifestFiles.mock.calls[0]![1]).toEqual([ - 'package.json', - 'pom.xml.socket.facts.json', - 'gradle.socket.facts.json', - ]) - }) - it('fails without spawning Coana when the upload returns no tar hash', async () => { mockHandleApiCall.mockResolvedValueOnce({ ok: true, data: {} } as never) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index ffe3ca2a0..a2c4c2c65 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -121,7 +121,7 @@ export const reachabilityFlags: MeowFlags = { type: 'boolean', default: false, description: - 'Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as an SBOM carrying stale reachability results.', + 'Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of deleting it after a successful scan. IMPORTANT: you must delete this file before running a fresh full application reachability scan. A stale `.socket.facts.json` left in place is picked up as a pre-generated input and silently overrides fresh analysis, so the new scan results will not be reliable.', }, reachSkipCache: { type: 'boolean', diff --git a/src/utils/coana.mts b/src/utils/coana.mts index add373b84..f414ea76a 100644 --- a/src/utils/coana.mts +++ b/src/utils/coana.mts @@ -130,23 +130,6 @@ export function isSocketFactsFile(filepath: string): boolean { .endsWith(DOT_SOCKET_DOT_FACTS_JSON) } -// A Coana reachability report, never input to a new analysis: the bare -// `.socket.facts.json` (Coana's default name; producers name theirs after the -// build) or the path this run tells Coana to write to. -export function isReachabilityReportPath( - filepath: string, - options: { cwd: string; outputPath: string }, -): boolean { - const { cwd, outputPath } = { __proto__: null, ...options } as { - cwd: string - outputPath: string - } - return ( - path.basename(filepath).toLowerCase() === DOT_SOCKET_DOT_FACTS_JSON || - path.resolve(cwd, filepath) === path.resolve(cwd, outputPath) - ) -} - export type ReachabilityError = { componentName: string componentVersion: string diff --git a/src/utils/coana.test.mts b/src/utils/coana.test.mts index d4fd3a2d0..aeea3441d 100644 --- a/src/utils/coana.test.mts +++ b/src/utils/coana.test.mts @@ -33,7 +33,6 @@ import { extractReachabilityErrors, extractTier1ReachabilityScanId, getFullWorkspacePath, - isReachabilityReportPath, isSocketFactsFile, snapshotSocketFacts, } from './coana.mts' @@ -74,25 +73,6 @@ describe('coana facts-file utils', () => { }) }) - describe('isReachabilityReportPath', () => { - const options = { cwd: '/repo', outputPath: 'out/report.json' } - it.each([ - '.socket.facts.json', - 'a/.SOCKET.FACTS.JSON', - '/repo/out/report.json', - 'out/report.json', - ])('matches %s', p => { - expect(isReachabilityReportPath(p, options)).toBe(true) - }) - it.each([ - 'pom.xml.socket.facts.json', - 'gradle.socket.facts.json', - 'report.json', - ])('rejects %s', p => { - expect(isReachabilityReportPath(p, options)).toBe(false) - }) - }) - describe('compressSocketFactsForUpload', () => { it('writes brotli .br as a sibling of the source file', async () => { const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-'))