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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/commands/fix/cmd-fix.integration.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <coana-version>.
Expand Down
9 changes: 9 additions & 0 deletions src/commands/fix/cmd-fix.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -325,6 +331,7 @@ async function run(
autopilot,
debug,
disableExternalToolChecks,
dynamicSbomInference,
ecosystems,
exclude,
excludePaths,
Expand All @@ -351,6 +358,7 @@ async function run(
autopilot: boolean
debug: boolean
disableExternalToolChecks: boolean
dynamicSbomInference: boolean
ecosystems: string[]
exclude: string[]
excludePaths: string[]
Expand Down Expand Up @@ -519,6 +527,7 @@ async function run(
debug,
disableExternalToolChecks,
disableMajorUpdates,
dynamicSbomInference,
ecosystems: validatedEcosystems,
exclude: excludePatterns,
excludePaths: excludePathsPatterns,
Expand Down
283 changes: 283 additions & 0 deletions src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts
Original file line number Diff line number Diff line change
@@ -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(),
)
})
})
})
Loading
Loading