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/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 3a19b6a22..29fe7f614 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -19,6 +19,7 @@ import constants from '../../constants.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { compressSocketFactsForUpload, + isSocketFactsFile, snapshotSocketFacts, } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' @@ -197,7 +198,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 @@ -359,15 +360,13 @@ 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. + // 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) - : packagePaths.filter( - p => path.basename(p) !== 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. diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 7a750749a..aeb688327 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -263,6 +263,34 @@ 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('includes generated auto-manifest files in SCA discovery targets', async () => { mockGenerateAutoManifest.mockResolvedValueOnce({ generatedFiles: ['/repo/.socket-auto-manifest/maven_install.json'], diff --git a/src/utils/coana.mts b/src/utils/coana.mts index 8b130e097..f414ea76a 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,15 @@ 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) +} + export type ReachabilityError = { componentName: string componentVersion: string diff --git a/src/utils/coana.test.mts b/src/utils/coana.test.mts index 9e2126fae..aeea3441d 100644 --- a/src/utils/coana.test.mts +++ b/src/utils/coana.test.mts @@ -33,6 +33,7 @@ import { extractReachabilityErrors, extractTier1ReachabilityScanId, getFullWorkspacePath, + isSocketFactsFile, snapshotSocketFacts, } from './coana.mts' @@ -53,6 +54,25 @@ 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('compressSocketFactsForUpload', () => { it('writes brotli .br as a sibling of the source file', async () => { const wrapDir = mkdtempSync(path.join(tmpdir(), 'socket-coana-wrap-')) @@ -99,6 +119,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')