diff --git a/CHANGELOG.md b/CHANGELOG.md index c015ef7604..fd3cbb1167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- `socket fix --dynamic-sbom-inference` generates Socket facts for each Gradle, sbt and Maven build and fixes a vulnerable dependency only in the projects/modules that resolve it. The generated files are removed afterwards. + ### Changed - Updated the Coana CLI to v `15.11.0`. +### Fixed +- Fixes opened as pull requests now include edits to build files that are not uploaded manifests, such as `gradle.properties` or sbt `project/*.scala` files, and the files a fix creates. + ## [1.1.180](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.180) - 2026-09-25 ### Changed diff --git a/src/commands/fix/cmd-fix.integration.test.mts b/src/commands/fix/cmd-fix.integration.test.mts index 8b2908a4a0..0e364c3794 100644 --- a/src/commands/fix/cmd-fix.integration.test.mts +++ b/src/commands/fix/cmd-fix.integration.test.mts @@ -168,6 +168,7 @@ describe('socket fix', async () => { See GitHub documentation (https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-auto-merge-for-pull-requests-in-your-repository) for managing auto-merge for pull requests in your repository. --debug Enable debug logging in the Coana-based Socket Fix CLI invocation. --disable-external-tool-checks Disable external tool checks during fix analysis. + --dynamic-sbom-inference For Gradle, sbt, and Maven: generate a Socket facts SBOM (produced directly by each package manager) per independent build root, instead of one synthetic root. Fixes are then attributed to the projects/modules that actually resolve each vulnerable dependency. The generated files are removed afterwards. --ecosystems Limit fix analysis to specific ecosystems. Accepts space- or comma-separated values and is case-insensitive. Defaults to all ecosystems. --exclude-paths Skip matching paths from the scan entirely: manifests under these paths are not uploaded, and fixes are not applied to workspaces under them. Patterns are anchored micromatch globs matched relative to the target directory (CWD); \`data/postgres/pgdata\` matches that exact path, \`**/pgdata\` matches at any depth. Use this to skip directories the current user cannot read so they do not abort manifest collection. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --fix-version Override the version of @coana-tech/cli used for fix analysis. Default: . diff --git a/src/commands/fix/cmd-fix.mts b/src/commands/fix/cmd-fix.mts index 4ff010c046..684b8cde07 100644 --- a/src/commands/fix/cmd-fix.mts +++ b/src/commands/fix/cmd-fix.mts @@ -32,6 +32,7 @@ import { import { RangeStyles } from '../../utils/semver.mts' import { getDefaultOrgSlug } from '../ci/fetch-default-org-slug.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' +import { DYNAMIC_SBOM_INFERENCE_DESCRIPTION } from '../scan/reachability-flags.mts' import type { MeowFlag, MeowFlags } from '../../flags.mts' import type { PURL_Type } from '../../utils/ecosystem.mts' @@ -177,6 +178,11 @@ Available styles: default: false, description: 'Disable external tool checks during fix analysis.', }, + dynamicSbomInference: { + type: 'boolean', + default: false, + description: `${DYNAMIC_SBOM_INFERENCE_DESCRIPTION} Fixes are then attributed to the projects/modules that actually resolve each vulnerable dependency. The generated files are removed afterwards.`, + }, ecosystems: { type: 'string', default: [], @@ -325,6 +331,7 @@ async function run( autopilot, debug, disableExternalToolChecks, + dynamicSbomInference, ecosystems, exclude, excludePaths, @@ -351,6 +358,7 @@ async function run( autopilot: boolean debug: boolean disableExternalToolChecks: boolean + dynamicSbomInference: boolean ecosystems: string[] exclude: string[] excludePaths: string[] @@ -519,6 +527,7 @@ async function run( debug, disableExternalToolChecks, disableMajorUpdates, + dynamicSbomInference, ecosystems: validatedEcosystems, exclude: excludePatterns, excludePaths: excludePathsPatterns, diff --git a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts new file mode 100644 index 0000000000..09fe55e02e --- /dev/null +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -0,0 +1,283 @@ +import { promises as fs } from 'node:fs' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { coanaFix } from './coana-fix.mts' + +import type { FixConfig } from './types.mts' + +const mockSpawnCoanaDlx = vi.hoisted(() => vi.fn()) +const mockSetupSdk = vi.hoisted(() => vi.fn()) +const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) +const mockGetPackageFilesForScan = vi.hoisted(() => vi.fn()) +const mockHandleApiCall = vi.hoisted(() => vi.fn()) +const mockGetFixEnv = vi.hoisted(() => vi.fn()) +const mockGetSocketFixPrs = vi.hoisted(() => vi.fn()) +const mockFetchGhsaDetails = vi.hoisted(() => vi.fn()) +const mockGitUnstagedModifiedFiles = vi.hoisted(() => vi.fn()) +const mockGitUntrackedFiles = vi.hoisted(() => + vi.fn(async () => ({ ok: true, data: [] })), +) +const mockGitCommit = vi.hoisted(() => vi.fn()) +const mockGenerateSocketFactsForFix = vi.hoisted(() => vi.fn()) + +vi.mock('../../utils/dlx.mts', () => ({ + spawnCoanaDlx: mockSpawnCoanaDlx, +})) + +vi.mock('../../utils/sdk.mts', () => ({ + setupSdk: mockSetupSdk, +})) + +vi.mock('../scan/fetch-supported-scan-file-names.mts', () => ({ + fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, +})) + +vi.mock('../../utils/path-resolve.mts', () => ({ + getPackageFilesForScan: mockGetPackageFilesForScan, +})) + +vi.mock('../../utils/api.mts', () => ({ + handleApiCall: mockHandleApiCall, +})) + +vi.mock('./env-helpers.mts', () => ({ + checkCiEnvVars: vi.fn(() => ({ missing: [], present: [] })), + getCiEnvInstructions: vi.fn(() => 'Set CI env vars'), + getFixEnv: mockGetFixEnv, +})) + +vi.mock('./pull-request.mts', () => ({ + getSocketFixPrs: mockGetSocketFixPrs, + openSocketFixPr: vi.fn(), +})) + +vi.mock('../../utils/github.mts', () => ({ + enablePrAutoMerge: vi.fn(), + fetchGhsaDetails: mockFetchGhsaDetails, + setGitRemoteGithubRepoUrl: vi.fn(), +})) + +vi.mock('../../utils/git.mts', () => ({ + gitCheckoutBranch: vi.fn(() => Promise.resolve(true)), + gitCommit: mockGitCommit, + gitCreateBranch: vi.fn(() => Promise.resolve(true)), + gitDeleteBranch: vi.fn(() => Promise.resolve(true)), + gitPushBranch: vi.fn(() => Promise.resolve(true)), + gitRemoteBranchExists: vi.fn(() => Promise.resolve(false)), + gitResetAndClean: vi.fn(() => Promise.resolve(true)), + gitUnstagedModifiedFiles: mockGitUnstagedModifiedFiles, + gitUntrackedFiles: mockGitUntrackedFiles, +})) + +vi.mock('./generated-socket-facts.mts', () => ({ + generateSocketFactsForFix: mockGenerateSocketFactsForFix, +})) + +vi.mock('./branch-cleanup.mts', () => ({ + cleanupErrorBranches: vi.fn(), + cleanupFailedPrBranches: vi.fn(), + cleanupStaleBranch: vi.fn(() => Promise.resolve(true)), + cleanupSuccessfulPrLocalBranch: vi.fn(), +})) + +const FACTS = '/test/cwd/app/.socket.facts.json' + +function coanaCalls(command: string): string[][] { + return mockSpawnCoanaDlx.mock.calls + .map(call => call[0] as string[]) + .filter(args => args[0] === command) +} + +describe('socket fix --dynamic-sbom-inference', () => { + const baseConfig: FixConfig = { + all: false, + applyFixes: true, + autopilot: false, + coanaVersion: undefined, + cwd: '/test/cwd', + debug: false, + disableExternalToolChecks: false, + disableMajorUpdates: false, + dynamicSbomInference: true, + ecosystems: [], + exclude: [], + excludePaths: [], + ghsas: ['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222'], + include: [], + minSatisfying: false, + minimumReleaseAge: '', + orgSlug: 'test-org', + outputFile: '', + packageManagers: [], + prCheck: true, + prLimit: 10, + rangeStyle: 'preserve', + showAffectedDirectDependencies: false, + silence: true, + spinner: undefined, + unknownFlags: [], + } + const uploadManifestFiles = vi.fn() + const generated = { + paths: [FACTS], + sidecarFile: '/tmp/socket-fix-facts/sidecar.json', + remove: vi.fn(), + restore: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockSetupSdk.mockResolvedValue({ ok: true, data: { uploadManifestFiles } }) + mockFetchSupportedScanFileNames.mockResolvedValue({ ok: true, data: {} }) + mockGetPackageFilesForScan.mockResolvedValue(['/test/cwd/app/build.gradle']) + mockHandleApiCall.mockResolvedValue({ ok: true, data: { tarHash: 'hash' } }) + mockGenerateSocketFactsForFix.mockResolvedValue(generated) + mockGetFixEnv.mockResolvedValue({ isCi: false, repoInfo: null }) + mockGitUnstagedModifiedFiles.mockResolvedValue({ ok: true, data: [] }) + mockGitCommit.mockResolvedValue(true) + mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: '' }) + }) + + it('uploads the generated facts, restricts Maven artifacts to them and removes them', async () => { + const result = await coanaFix(baseConfig) + + expect(result.ok).toBe(true) + expect(uploadManifestFiles).toHaveBeenCalledWith( + 'test-org', + ['/test/cwd/app/build.gradle', FACTS], + { pathsRelativeTo: '/test/cwd' }, + ) + const args = coanaCalls('compute-fixes-and-upgrade-purls')[0]! + expect(args).toContain('--maven-use-only-socket-facts') + expect(args[args.indexOf('--compute-artifacts-sidecar') + 1]).toBe( + generated.sidecarFile, + ) + expect(generated.remove).toHaveBeenCalledTimes(1) + }) + + it('discovers vulnerabilities only through the generated facts', async () => { + mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + if (args[0] === 'find-vulnerabilities') { + await fs.writeFile( + args[args.indexOf('--output-file') + 1]!, + JSON.stringify({ ghsaIds: [], artifactCount: 1 }), + ) + } + return { ok: true, data: '' } + }) + + await coanaFix({ ...baseConfig, all: true, ghsas: [] }) + + expect(coanaCalls('find-vulnerabilities')[0]).toContain( + '--maven-use-only-socket-facts', + ) + }) + + it('still refuses facts files that were already present', async () => { + mockGetPackageFilesForScan.mockResolvedValue([ + '/test/cwd/app/.socket.facts.json', + ]) + + const result = await coanaFix(baseConfig) + + expect(result.ok).toBe(false) + expect(mockGenerateSocketFactsForFix).not.toHaveBeenCalled() + }) + + it('does not pass the facts restriction without the flag', async () => { + await coanaFix({ ...baseConfig, dynamicSbomInference: false }) + + expect(mockGenerateSocketFactsForFix).not.toHaveBeenCalled() + expect(coanaCalls('compute-fixes-and-upgrade-purls')[0]).not.toContain( + '--maven-use-only-socket-facts', + ) + }) + + describe('in PR mode', () => { + beforeEach(() => { + mockGetFixEnv.mockResolvedValue({ + baseBranch: 'main', + githubToken: 'test-token', + gitEmail: 'test@example.com', + gitUser: 'test-user', + isCi: true, + repoInfo: { defaultBranch: 'main', owner: 'o', repo: 'r' }, + }) + mockGetSocketFixPrs.mockResolvedValue([]) + mockFetchGhsaDetails.mockResolvedValue(new Map()) + }) + + it('restores the facts before every fix, since resetting cleans them away', async () => { + await coanaFix(baseConfig) + + expect(coanaCalls('compute-fixes-and-upgrade-purls')).toHaveLength(2) + expect(generated.restore).toHaveBeenCalledTimes(2) + expect(generated.remove).toHaveBeenCalledTimes(1) + }) + + it('commits the files the fix reports writing', async () => { + mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + await fs.writeFile( + args[args.indexOf('--output-file') + 1]!, + JSON.stringify({ + type: 'applied-fixes', + fixes: {}, + modifiedFiles: ['app/build.gradle', 'gradle/versions.gradle'], + }), + ) + return { ok: true, data: '' } + }) + mockGitUnstagedModifiedFiles.mockResolvedValue({ + ok: true, + data: ['app/build.gradle', 'gradle/versions.gradle', 'README.md'], + }) + + await coanaFix({ ...baseConfig, ghsas: ['GHSA-1111-1111-1111'] }) + + expect(mockGitCommit).toHaveBeenCalledWith( + expect.any(String), + ['app/build.gradle', 'gradle/versions.gradle'], + expect.anything(), + ) + }) + + it('commits files the fix creates', async () => { + mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + await fs.writeFile( + args[args.indexOf('--output-file') + 1]!, + JSON.stringify({ + type: 'applied-fixes', + fixes: {}, + modifiedFiles: [ + 'build.sbt', + 'project/SocketDependencyOverrides.scala', + ], + }), + ) + return { ok: true, data: '' } + }) + mockGitUnstagedModifiedFiles.mockResolvedValue({ + ok: true, + data: ['build.sbt'], + }) + mockGitUntrackedFiles.mockResolvedValue({ + ok: true, + data: [ + 'project/SocketDependencyOverrides.scala', + 'app/.socket.facts.json', + ], + }) + + await coanaFix({ ...baseConfig, ghsas: ['GHSA-1111-1111-1111'] }) + + expect(mockGitCommit).toHaveBeenCalledWith( + expect.any(String), + ['build.sbt', 'project/SocketDependencyOverrides.scala'], + expect.anything(), + ) + }) + }) +}) diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index c43b6baaa3..6a7d17c3cd 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -19,6 +19,7 @@ import { getCiEnvInstructions, getFixEnv, } from './env-helpers.mts' +import { generateSocketFactsForFix } from './generated-socket-facts.mts' import { getSocketFixBranchName, getSocketFixCommitMessage } from './git.mts' import { getSocketFixPrs, openSocketFixPr } from './pull-request.mts' import { @@ -30,6 +31,7 @@ import { handleApiCall } from '../../utils/api.mts' import { findSocketYmlSync } from '../../utils/config.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { getErrorCause } from '../../utils/errors.mts' +import { withTmpDir } from '../../utils/fs.mts' import { gitCheckoutBranch, gitCommit, @@ -39,6 +41,7 @@ import { gitRemoteBranchExists, gitResetAndClean, gitUnstagedModifiedFiles, + gitUntrackedFiles, } from '../../utils/git.mts' import { enablePrAutoMerge, @@ -50,6 +53,7 @@ import { setupSdk } from '../../utils/sdk.mts' import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' import { fetchSupportedScanFileNames } from '../scan/fetch-supported-scan-file-names.mts' +import type { GeneratedSocketFacts } from './generated-socket-facts.mts' import type { FixConfig } from './types.mts' import type { CResult } from '../../types.mts' import type { PURL_Type } from '../../utils/ecosystem.mts' @@ -59,6 +63,7 @@ type DiscoverGhsaIdsOptions = { coanaVersion?: string | undefined cwd?: string | undefined ecosystems?: PURL_Type[] | undefined + factsFlags?: string[] | undefined packageManagers?: string[] | undefined silence?: boolean | undefined spinner?: Spinner | undefined @@ -137,6 +142,7 @@ async function discoverGhsaIds( const { cwd = process.cwd(), ecosystems, + factsFlags = [], packageManagers, silence = false, spinner, @@ -165,6 +171,7 @@ async function discoverGhsaIds( ...(packageManagers?.length ? ['--package-managers', ...packageManagers] : []), + ...factsFlags, ], orgSlug, { @@ -187,9 +194,46 @@ async function discoverGhsaIds( } } -export async function coanaFix( +function isFactsFile(filepath: string): boolean { + return path.basename(filepath).toLowerCase() === DOT_SOCKET_DOT_FACTS_JSON +} + +function readWrittenFiles(outputFile: string): Set | undefined { + const result = readJsonSync(outputFile, { throws: false }) as + | { modifiedFiles?: unknown } + | null + | undefined + const files = result?.modifiedFiles + return Array.isArray(files) && files.every(f => typeof f === 'string') + ? new Set(files) + : undefined +} + +type CoanaFixResult = CResult<{ fixedAll: boolean; ghsaDetails: unknown[] }> + +type GeneratedSocketFactsSlot = { + generated?: GeneratedSocketFacts | undefined + tmpDir: string +} + +export async function coanaFix(fixConfig: FixConfig): Promise { + if (!fixConfig.dynamicSbomInference) { + return await coanaFixWithFacts(fixConfig, undefined) + } + return await withTmpDir('socket-fix-facts-', async tmpDir => { + const slot: GeneratedSocketFactsSlot = { tmpDir } + try { + return await coanaFixWithFacts(fixConfig, slot) + } finally { + await slot.generated?.remove() + } + }) +} + +async function coanaFixWithFacts( fixConfig: FixConfig, -): Promise> { + factsSlot: GeneratedSocketFactsSlot | undefined, +): Promise { const { all, applyFixes, @@ -258,16 +302,16 @@ export async function coanaFix( // sibling manifest's references). --exclude stays separate as a hidden // legacy escape hatch for the narrower "fix-application only" semantic. const coanaExcludePatterns = [...exclude, ...excludePaths] - const scanFilepaths = await getPackageFilesForScan(['.'], supportedFiles, { - additionalIgnores, - config: socketConfig, - cwd, - }) + const findScanFilepaths = () => + getPackageFilesForScan(['.'], supportedFiles, { + additionalIgnores, + config: socketConfig, + 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( - p => path.basename(p).toLowerCase() === DOT_SOCKET_DOT_FACTS_JSON, - ) + const factsFiles = scanFilepaths.filter(isFactsFile) if (factsFiles.length) { if (!silence) { spinner?.stop() @@ -280,6 +324,38 @@ export async function coanaFix( factsFiles.map(p => ` - ${p}`).join('\n'), } } + if (factsSlot) { + if (!silence) { + spinner?.stop() + logger.info( + 'Generating Socket facts for Gradle, sbt and Maven builds ...', + ) + } + try { + factsSlot.generated = await generateSocketFactsForFix({ + cwd, + excludePaths, + tmpDir: factsSlot.tmpDir, + }) + } catch (e) { + // A failed build root aborts inference after others wrote their facts. + const partial = (await findScanFilepaths()).filter(isFactsFile) + await Promise.all(partial.map(p => fs.rm(p, { force: true }))) + throw e + } + scanFilepaths.push(...factsSlot.generated.paths) + if (!silence) { + spinner?.start() + } + } + const sidecarFile = factsSlot?.generated?.sidecarFile + // Discovery only needs which artifacts the facts files resolve; applying + // fixes also needs each project's exact classpath from the sidecar. + const discoveryFlags = factsSlot ? ['--maven-use-only-socket-facts'] : [] + const factsFlags = [ + ...discoveryFlags, + ...(sidecarFile ? ['--compute-artifacts-sidecar', sidecarFile] : []), + ] const uploadCResult = await handleApiCall( sockSdk.uploadManifestFiles(orgSlug, scanFilepaths, { pathsRelativeTo: cwd, @@ -347,6 +423,7 @@ export async function coanaFix( coanaVersion, cwd, ecosystems, + factsFlags: discoveryFlags, packageManagers, silence, spinner, @@ -396,6 +473,7 @@ export async function coanaFix( ...(packageManagers.length ? ['--package-managers', ...packageManagers] : []), + ...factsFlags, ...(!applyFixes ? [FLAG_DRY_RUN] : []), '--output-file', tmpFile, @@ -492,6 +570,7 @@ export async function coanaFix( coanaVersion, cwd, ecosystems, + factsFlags: discoveryFlags, packageManagers, silence, spinner, @@ -539,6 +618,10 @@ export async function coanaFix( const ghsaId = ids[i]! debugFn('notice', `check: ${ghsaId}`) + // Resetting to the base branch cleans the untracked facts files away. + // eslint-disable-next-line no-await-in-loop + await factsSlot?.generated?.restore() + // Create a temporary file for Coana output. const tmpDir = os.tmpdir() const tmpFile = path.join(tmpDir, `socket-fix-${ghsaId}-${Date.now()}.json`) @@ -567,6 +650,7 @@ export async function coanaFix( ...(packageManagers.length ? ['--package-managers', ...packageManagers] : []), + ...factsFlags, ...(debug ? ['--debug'] : []), ...(disableExternalToolChecks ? ['--disable-external-tool-checks'] @@ -607,11 +691,21 @@ export async function coanaFix( // Check for modified files after applying the fix. // eslint-disable-next-line no-await-in-loop const unstagedCResult = await gitUnstagedModifiedFiles(cwd) - const modifiedFiles = unstagedCResult.ok - ? unstagedCResult.data.filter(relPath => - scanBaseNames.has(path.basename(relPath)), - ) - : [] + // Build scripts the fix edits need not be manifests the scan uploads, + // and files it creates are untracked. + const writtenFiles = readWrittenFiles(tmpFile) + // eslint-disable-next-line no-await-in-loop + const untrackedCResult = await gitUntrackedFiles(cwd) + const modifiedFiles = writtenFiles + ? [ + ...(unstagedCResult.ok ? unstagedCResult.data : []), + ...(untrackedCResult.ok ? untrackedCResult.data : []), + ].filter(relPath => writtenFiles.has(relPath)) + : unstagedCResult.ok + ? unstagedCResult.data.filter(relPath => + scanBaseNames.has(path.basename(relPath)), + ) + : [] if (!modifiedFiles.length) { debugFn('notice', `skip: no changes for ${ghsaId}`) diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts new file mode 100644 index 0000000000..93cff30351 --- /dev/null +++ b/src/commands/fix/generated-socket-facts.mts @@ -0,0 +1,57 @@ +import { copyFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts' + +export type GeneratedSocketFacts = { + paths: string[] + // The facts files' per-project classpaths, outside the repository. + sidecarFile: string | undefined + remove: () => Promise + restore: () => Promise +} + +// Backed up so each fix attempt sees the pre-fix build after `git clean`. +export async function generateSocketFactsForFix({ + cwd, + excludePaths, + tmpDir, +}: { + cwd: string + excludePaths: string[] + tmpDir: string +}): Promise { + const { factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({ + cwd, + excludePaths, + sbtTmpDir: undefined, + sidecar: true, + withFiles: false, + }) + const sidecarFile = resolvedPathsSidecar + ? path.join(tmpDir, 'sidecar.json') + : undefined + if (sidecarFile) { + await writeFile(sidecarFile, JSON.stringify(resolvedPathsSidecar)) + } + const paths = factsPaths.map(p => path.resolve(cwd, p)) + const backups = await Promise.all( + paths.map(async (source, index) => { + const backup = path.join(tmpDir, `${index}.json`) + await copyFile(source, backup) + return { backup, source } + }), + ) + return { + paths, + sidecarFile, + async remove() { + await Promise.all(paths.map(p => rm(p, { force: true }))) + }, + async restore() { + await Promise.all( + backups.map(({ backup, source }) => copyFile(backup, source)), + ) + }, + } +} diff --git a/src/commands/fix/handle-fix-limit.test.mts b/src/commands/fix/handle-fix-limit.test.mts index e6ad6af39a..bf83ecf213 100644 --- a/src/commands/fix/handle-fix-limit.test.mts +++ b/src/commands/fix/handle-fix-limit.test.mts @@ -18,6 +18,9 @@ const mockGetFixEnv = vi.hoisted(() => vi.fn()) const mockGetSocketFixPrs = vi.hoisted(() => vi.fn()) const mockFetchGhsaDetails = vi.hoisted(() => vi.fn()) const mockGitUnstagedModifiedFiles = vi.hoisted(() => vi.fn()) +const mockGitUntrackedFiles = vi.hoisted(() => + vi.fn(async () => ({ ok: true, data: [] })), +) vi.mock('../../utils/dlx.mts', () => ({ spawnCoanaDlx: mockSpawnCoanaDlx, @@ -65,6 +68,7 @@ vi.mock('../../utils/git.mts', () => ({ gitRemoteBranchExists: vi.fn(() => Promise.resolve(false)), gitResetAndClean: vi.fn(() => Promise.resolve(true)), gitUnstagedModifiedFiles: mockGitUnstagedModifiedFiles, + gitUntrackedFiles: mockGitUntrackedFiles, })) vi.mock('./branch-cleanup.mts', () => ({ diff --git a/src/commands/fix/handle-fix.mts b/src/commands/fix/handle-fix.mts index fb37fd5d98..dbf83ba9bb 100644 --- a/src/commands/fix/handle-fix.mts +++ b/src/commands/fix/handle-fix.mts @@ -122,6 +122,7 @@ export async function handleFix({ debug, disableExternalToolChecks, disableMajorUpdates, + dynamicSbomInference, ecosystems, exclude, excludePaths, @@ -151,6 +152,7 @@ export async function handleFix({ debug, disableExternalToolChecks, disableMajorUpdates, + dynamicSbomInference, ecosystems, exclude, excludePaths, @@ -179,6 +181,7 @@ export async function handleFix({ debug, disableExternalToolChecks, disableMajorUpdates, + dynamicSbomInference, ecosystems, exclude, excludePaths, diff --git a/src/commands/fix/types.mts b/src/commands/fix/types.mts index 3a436a71fd..a14500fc8c 100644 --- a/src/commands/fix/types.mts +++ b/src/commands/fix/types.mts @@ -11,6 +11,7 @@ export type FixConfig = { debug: boolean disableExternalToolChecks: boolean disableMajorUpdates: boolean + dynamicSbomInference: boolean ecosystems: PURL_Type[] exclude: string[] excludePaths: string[] diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 7127fa5350..cecf4f9d3b 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -236,7 +236,7 @@ export async function runManifestFacts({ } await fs.writeFile(factsPath, JSON.stringify(facts), 'utf8') - if (withFiles && sidecarAcc) { + if (sidecarAcc) { // Key by the symlink-resolved path so the sidecar's keys are comparable // regardless of which caller's cwd it was joined against (the recursive // discovery path already resolves symlinks before this point; the plain @@ -246,6 +246,7 @@ export async function runManifestFacts({ facts, artifactPaths, await realpathOrResolved(factsPath), + !!withFiles, ) } diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index 067f7ea3b0..f0fd5fddf8 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -105,11 +105,15 @@ export function accumulateSidecar( facts: SocketFactsSbom, artifactPaths: ResolvedArtifactPaths, factsFile: string, + // Off when artifact paths were not resolved; entries then omit `targets` and `sources`. + withPaths = true, ): void { + const paths = (entry: T) => + withPaths ? attachPaths(entry, artifactPaths) : { ...entry } acc.set(factsFile, { - components: facts.components.map(comp => attachPaths(comp, artifactPaths)), + components: facts.components.map(paths), projects: (facts.projects ?? []).map(proj => ({ - ...attachPaths(proj, artifactPaths), + ...paths(proj), classpath: [ ...(artifactPaths.classpathByProject.get(projectClasspathKey(proj)) ?? []), diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index f632be6624..e818972e7c 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -45,6 +45,48 @@ function mkComponentFixture(target: string): { } describe('compute-artifacts sidecar', () => { + it('carries only the classpaths when artifact paths were not resolved', () => { + const facts: SocketFactsSbom = { + projects: [ + { + type: 'maven', + namespace: 'g', + name: 'app', + subprojectDir: 'app', + dependencies: ['g:a:jar:1'], + }, + ], + components: [ + { + type: 'maven', + namespace: 'g', + name: 'a', + version: '1', + qualifiers: { ext: 'jar' }, + id: 'g:a:jar:1', + }, + ], + } + const artifactPaths = emptyArtifactPaths() + artifactPaths.classpathByProject.set('app g:app', ['g:a:jar:1']) + + const acc: SidecarAccumulator = new Map() + accumulateSidecar( + acc, + facts, + artifactPaths, + '/root/.socket.facts.json', + false, + ) + const entry = serializeSidecar(acc)['/root/.socket.facts.json']! + + expect(entry.projects[0]).toEqual({ + ...facts.projects![0], + classpath: ['g:a:jar:1'], + }) + expect(entry.components[0]).toEqual(facts.components[0]) + }) + it('carries a component through with resolved targets/sources attached, keyed by its own facts file', () => { const facts: SocketFactsSbom = { components: [ diff --git a/src/commands/scan/run-dynamic-sbom-inference.mts b/src/commands/scan/run-dynamic-sbom-inference.mts index 9336c5dc58..e51fb0b41f 100644 --- a/src/commands/scan/run-dynamic-sbom-inference.mts +++ b/src/commands/scan/run-dynamic-sbom-inference.mts @@ -22,19 +22,22 @@ export async function runDynamicSbomInference({ cwd, excludePaths, sbtTmpDir, + sidecar, withFiles, }: { 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' // artifactPaths point into it, so it must outlive whoever consumes them. // Only meaningful alongside `withFiles`. sbtTmpDir: string | undefined withFiles: boolean }): Promise { - const sidecarAcc: SidecarAccumulator | undefined = withFiles - ? new Map() - : undefined + const sidecarAcc: SidecarAccumulator | undefined = + (sidecar ?? withFiles) ? new Map() : undefined const outcomes = await generateRecursiveManifests({ cwd, excludePaths, diff --git a/src/utils/git.mts b/src/utils/git.mts index eda0efd265..4d804170dd 100644 --- a/src/utils/git.mts +++ b/src/utils/git.mts @@ -533,6 +533,33 @@ export async function gitUnstagedModifiedFiles( } } +export async function gitUntrackedFiles( + cwd = process.cwd(), +): Promise> { + try { + const result = await spawn( + 'git', + ['ls-files', '--others', '--exclude-standard'], + { cwd }, + ) + return { + ok: true, + data: result.stdout + .split('\n') + .filter(Boolean) + .map(p => normalizePath(p)), + } + } catch (e) { + debugFn('error', 'Failed to list untracked files') + debugDir('error', e) + return { + ok: false, + message: 'Git Error', + cause: 'Unexpected error while trying to list untracked files', + } + } +} + const parsedGitRemoteUrlCache = new Map() export function parseGitRemoteUrl(remoteUrl: string): RepoInfo | undefined {