Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Changed
- Socket facts for Maven, Gradle and sbt builds now record each subproject's own dependency tree, so a package is attributed only to the subprojects that actually pull it in.
- `socket fix` now accepts existing Socket facts files instead of asking you to delete them first.
- Updated the Coana CLI to v `15.12.5`.

### Fixed
- Socket facts for sbt builds now match sbt's own classpaths: dependencies pulled in by a version-conflict winner are no longer dropped, and sibling subprojects appear only in the configurations that actually depend on them.

## [1.7.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.7.0) - 2026-10-09

### Changed
Expand Down
49 changes: 39 additions & 10 deletions src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ vi.mock('./branch-cleanup.mts', () => ({
cleanupSuccessfulPrLocalBranch: vi.fn(),
}))

const FACTS = '/test/cwd/app/.socket.facts.json'
const FACTS = '/test/cwd/app/gradle.socket.facts.json'

function coanaCalls(command: string): string[][] {
return mockSpawnCoanaDlx.mock.calls
Expand Down Expand Up @@ -153,6 +153,7 @@ describe('socket fix --dynamic-sbom-inference', () => {
)
const args = coanaCalls('compute-fixes-and-upgrade-purls')[0]!
expect(args).toContain('--maven-use-only-socket-facts')
expect(args).toContain('--compute-artifacts-sidecar')
expect(args[args.indexOf('--compute-artifacts-sidecar') + 1]).toBe(
generated.sidecarFile,
)
Expand All @@ -172,20 +173,48 @@ describe('socket fix --dynamic-sbom-inference', () => {

await coanaFix({ ...baseConfig, all: true, ghsas: [] })

expect(coanaCalls('find-vulnerabilities')[0]).toContain(
'--maven-use-only-socket-facts',
)
const args = coanaCalls('find-vulnerabilities')[0]
expect(args).toContain('--maven-use-only-socket-facts')
expect(args).not.toContain('--compute-artifacts-sidecar')
})

it('still refuses facts files that were already present', async () => {
it('regenerates facts files that were already present, uploading each once', async () => {
mockGetPackageFilesForScan.mockResolvedValue([FACTS])

const result = await coanaFix(baseConfig)

expect(result.ok).toBe(true)
expect(mockGenerateSocketFactsForFix).toHaveBeenCalledTimes(1)
expect(uploadManifestFiles).toHaveBeenCalledWith('test-org', [FACTS], {
pathsRelativeTo: '/test/cwd',
})
})

it('uploads facts files that were already present without the flag', async () => {
mockGetPackageFilesForScan.mockResolvedValue([FACTS])

const result = await coanaFix({
...baseConfig,
dynamicSbomInference: false,
})

expect(result.ok).toBe(true)
expect(uploadManifestFiles).toHaveBeenCalledWith('test-org', [FACTS], {
pathsRelativeTo: '/test/cwd',
})
})

it('does not upload a leftover reachability report', async () => {
mockGetPackageFilesForScan.mockResolvedValue([
'/test/cwd/app/.socket.facts.json',
'/test/cwd/.socket.facts.json',
FACTS,
])

const result = await coanaFix(baseConfig)
await coanaFix({ ...baseConfig, dynamicSbomInference: false })

expect(result.ok).toBe(false)
expect(mockGenerateSocketFactsForFix).not.toHaveBeenCalled()
expect(uploadManifestFiles).toHaveBeenCalledWith('test-org', [FACTS], {
pathsRelativeTo: '/test/cwd',
})
})

it('does not pass the facts restriction without the flag', async () => {
Expand Down Expand Up @@ -268,7 +297,7 @@ describe('socket fix --dynamic-sbom-inference', () => {
ok: true,
data: [
'project/SocketDependencyOverrides.scala',
'app/.socket.facts.json',
'app/gradle.socket.facts.json',
],
})

Expand Down
72 changes: 35 additions & 37 deletions src/commands/fix/coana-fix.mts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,16 @@ import {
import { generateSocketFactsForFix } from './generated-socket-facts.mts'
import { getSocketFixBranchName, getSocketFixCommitMessage } from './git.mts'
import { getSocketFixPrs, openSocketFixPr } from './pull-request.mts'
import { FLAG_DRY_RUN, GQL_PR_STATE_OPEN } from '../../constants.mts'
import {
DOT_SOCKET_DOT_FACTS_JSON,
FLAG_DRY_RUN,
GQL_PR_STATE_OPEN,
} from '../../constants.mts'
import { handleApiCall } from '../../utils/api.mts'
import { isSocketFactsFile } from '../../utils/coana.mts'
import {
isReachabilityReportPath,
isSocketFactsFile,
} from '../../utils/coana.mts'
import { findSocketYmlSync } from '../../utils/config.mts'
import { spawnCoanaDlx } from '../../utils/dlx.mts'
import { getErrorCause } from '../../utils/errors.mts'
Expand Down Expand Up @@ -365,54 +372,45 @@ async function coanaFixWithFacts(
// 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 findScanFilepaths = () =>
getPackageFilesForScan(['.'], supportedFiles, {
additionalIgnores,
config: socketConfig,
cwd,
})
const findScanFilepaths = async () =>
(
await getPackageFilesForScan(['.'], supportedFiles, {
additionalIgnores,
config: socketConfig,
cwd,
})
).filter(
p =>
!isReachabilityReportPath(p, {
cwd,
outputPath: DOT_SOCKET_DOT_FACTS_JSON,
}),
)
const scanFilepaths = await findScanFilepaths()
// 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 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'),
}
}
if (factsSlot) {
if (!silence) {
spinner?.stop()
logger.info(
'Generating Socket facts for Gradle, sbt and Maven builds ...',
)
}
try {
factsSlot.generated = await generateSocketFactsForFix({
cwd,
excludePaths,
Comment thread
jfblaa marked this conversation as resolved.
tmpDir: factsSlot.tmpDir,
})
} catch (e) {
// A failed build root aborts inference after others wrote their facts.
const partial = (await findScanFilepaths()).filter(isSocketFactsFile)
await Promise.all(partial.map(p => fs.rm(p, { force: true })))
throw e
}
scanFilepaths.push(...factsSlot.generated.paths)
factsSlot.generated = await generateSocketFactsForFix({
cwd,
excludePaths,
findFactsPaths: async () =>
(await findScanFilepaths()).filter(isSocketFactsFile),
tmpDir: factsSlot.tmpDir,
})
const known = new Set(scanFilepaths)
scanFilepaths.push(...factsSlot.generated.paths.filter(p => !known.has(p)))
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.
// Discovery only needs which artifacts the facts files resolve. Applying
// fixes reads each project's tree; a Coana predating facts trees can only
// tell from the sidecar's classpaths which projects resolve an artifact.
const discoveryFlags = factsSlot ? ['--maven-use-only-socket-facts'] : []
const factsFlags = [
...discoveryFlags,
Expand Down
89 changes: 59 additions & 30 deletions src/commands/fix/generated-socket-facts.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { copyFile, rm, writeFile } from 'node:fs/promises'
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'

import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts'
Expand All @@ -11,47 +11,76 @@ export type GeneratedSocketFacts = {
restore: () => Promise<void>
}

// Backed up so each fix attempt sees the pre-fix build after `git clean`.
type Backup = { backup: string; source: string }

async function backUp(sources: string[], dir: string): Promise<Backup[]> {
await mkdir(dir, { recursive: true })
return await Promise.all(
sources.map(async (source, index) => {
const backup = path.join(dir, `${index}.json`)
await copyFile(source, backup)
return { backup, source }
}),
)
}

async function restoreAll(backups: Backup[]): Promise<void> {
await Promise.all(
backups.map(({ backup, source }) => copyFile(backup, source)),
)
}

// Backed up so each fix attempt sees the pre-fix build after `git clean`, and
// facts files already present are left as they were found.
export async function generateSocketFactsForFix({
cwd,
excludePaths,
findFactsPaths,
tmpDir,
}: {
cwd: string
excludePaths: string[]
findFactsPaths: () => Promise<string[]>
tmpDir: string
}): Promise<GeneratedSocketFacts> {
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 existingPaths = await findFactsPaths()
const originals = await backUp(existingPaths, path.join(tmpDir, 'existing'))
const existing = new Set(existingPaths)
const reset = async (written: string[]) => {
await Promise.all(
written.filter(p => !existing.has(p)).map(p => rm(p, { force: true })),
)
await restoreAll(originals)
}
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 }
}),
)
let paths: string[]
let sidecarFile: string | undefined
try {
const { factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({
cwd,
excludePaths,
sbtTmpDir: undefined,
sidecar: true,
withFiles: false,
})
paths = factsPaths.map(p => path.resolve(cwd, p))
if (resolvedPathsSidecar) {
sidecarFile = path.join(tmpDir, 'sidecar.json')
await writeFile(sidecarFile, JSON.stringify(resolvedPathsSidecar))
}
} catch (e) {
// A failed build root aborts inference after others wrote their facts.
await reset(await findFactsPaths())
throw e
}
const generated = new Set(paths)
const backups = [
...originals.filter(({ source }) => !generated.has(source)),
...(await backUp(paths, path.join(tmpDir, 'generated'))),
]
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)),
)
},
remove: () => reset(paths),
restore: () => restoreAll(backups),
}
}
Loading
Loading