diff --git a/CHANGELOG.md b/CHANGELOG.md index aad3fbaba..6ed3737a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,17 @@ 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/). -## [1.1.41](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.40) - 2025-12-02 +## [1.1.42](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.42) - 2025-12-04 + +### Added +- Added `--ecosystems` flag to `socket fix`. + +### Changed +- Updated the Coana CLI to v `14.12.113`. +- Rename `--limit` flag to `--pr-limit` for `socket fix`, but keep old flag as an alias. Note: `--pr-limit` has no effect in local mode, use `--id` options instead. +- Process all vulnerabilities with `socket fix` when no `--id` options are provided. + +## [1.1.41](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.41) - 2025-12-02 ### Added - Added `--reach-version` flag to `socket scan create` and `socket scan reach` to override the @coana-tech/cli version used for reachability analysis. diff --git a/package.json b/package.json index 676e86191..66c4b478e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socket", - "version": "1.1.41", + "version": "1.1.42", "description": "CLI for Socket.dev", "homepage": "https://github.com/SocketDev/socket-cli", "license": "MIT AND OFL-1.1", @@ -94,7 +94,7 @@ "@babel/preset-typescript": "7.27.1", "@babel/runtime": "7.28.4", "@biomejs/biome": "2.2.4", - "@coana-tech/cli": "14.12.110", + "@coana-tech/cli": "14.12.113", "@cyclonedx/cdxgen": "11.11.0", "@dotenvx/dotenvx": "1.49.0", "@eslint/compat": "1.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e0c130b1..5ae05f332 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,8 +124,8 @@ importers: specifier: 2.2.4 version: 2.2.4 '@coana-tech/cli': - specifier: 14.12.110 - version: 14.12.110 + specifier: 14.12.113 + version: 14.12.113 '@cyclonedx/cdxgen': specifier: 11.11.0 version: 11.11.0 @@ -677,8 +677,8 @@ packages: '@bufbuild/protobuf@2.6.3': resolution: {integrity: sha512-w/gJKME9mYN7ZoUAmSMAWXk4hkVpxRKvEJCb3dV5g9wwWdxTJJ0ayOJAVcNxtdqaxDyFuC0uz4RSGVacJ030PQ==} - '@coana-tech/cli@14.12.110': - resolution: {integrity: sha512-I+SAm9VKoZpLsI0yz4qTIKJIGkN5qbTtp/fxhWaOgjC01riCcAdm0cIqsJZJZgp6xYlR401G86efeauIsSlQsA==} + '@coana-tech/cli@14.12.113': + resolution: {integrity: sha512-YWxLoHcCKjjbXhlf8qxhVdvd4BbUIjQ934SJdMgBBDbnNrlc26nsRTCM9sHdFc8r4MESBIkXIY3rfENLmFJEtg==} hasBin: true '@colors/colors@1.5.0': @@ -5315,7 +5315,7 @@ snapshots: '@bufbuild/protobuf@2.6.3': optional: true - '@coana-tech/cli@14.12.110': {} + '@coana-tech/cli@14.12.113': {} '@colors/colors@1.5.0': optional: true diff --git a/src/commands/fix/cmd-fix.integration.test.mts b/src/commands/fix/cmd-fix.integration.test.mts index 9144163ca..ee1a6d22c 100644 --- a/src/commands/fix/cmd-fix.integration.test.mts +++ b/src/commands/fix/cmd-fix.integration.test.mts @@ -166,6 +166,7 @@ describe('socket fix', async () => { Options --autopilot Enable auto-merge for pull requests that Socket opens. 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. + --ecosystems Limit fix analysis to specific ecosystems. Can be provided as comma separated values or as multiple flags. Defaults to all ecosystems. --exclude Exclude workspaces matching these glob patterns. Can be provided as comma separated values or as multiple flags --fix-version Override the version of @coana-tech/cli used for fix analysis. Default: . --id Provide a list of vulnerability identifiers to compute fixes for: @@ -175,12 +176,12 @@ describe('socket fix', async () => { Can be provided as comma separated values or as multiple flags --include Include workspaces matching these glob patterns. Can be provided as comma separated values or as multiple flags --json Output as JSON - --limit The number of fixes to attempt at a time (default 10) --markdown Output as Markdown --minimum-release-age Set a minimum age requirement for suggested upgrade versions (e.g., 1h, 2d, 3w). A higher age requirement reduces the risk of upgrading to malicious versions. For example, setting the value to 1 week (1w) gives ecosystem maintainers one week to remove potentially malicious versions. --no-apply-fixes Compute fixes only, do not apply them. Logs what upgrades would be applied. If combined with --output-file, the output file will contain the upgrades that would be applied. --no-major-updates Do not suggest or apply fixes that require major version updates of direct or transitive dependencies --output-file Path to store upgrades as a JSON file at this path. + --pr-limit Maximum number of pull requests to create in CI mode (default 10). Has no effect in local mode. --range-style Define how dependency version ranges are updated in package.json (default 'preserve'). Available styles: * pin - Use the exact version (e.g. 1.2.3) @@ -921,17 +922,17 @@ describe('socket fix', async () => { ) }) - describe('--limit flag behavior', () => { + describe('--pr-limit flag behavior', () => { cmdit( [ 'fix', FLAG_DRY_RUN, - '--limit', + '--pr-limit', '0', FLAG_CONFIG, '{"apiToken":"fakeToken"}', ], - 'should accept --limit with value 0', + 'should accept --pr-limit with value 0', async cmd => { const { code, stdout } = await spawnSocketCli(binCliPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) @@ -943,12 +944,12 @@ describe('socket fix', async () => { [ 'fix', FLAG_DRY_RUN, - '--limit', + '--pr-limit', '1', FLAG_CONFIG, '{"apiToken":"fakeToken"}', ], - 'should accept --limit with value 1', + 'should accept --pr-limit with value 1', async cmd => { const { code, stdout } = await spawnSocketCli(binCliPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) @@ -960,12 +961,12 @@ describe('socket fix', async () => { [ 'fix', FLAG_DRY_RUN, - '--limit', + '--pr-limit', '100', FLAG_CONFIG, '{"apiToken":"fakeToken"}', ], - 'should accept --limit with large value', + 'should accept --pr-limit with large value', async cmd => { const { code, stdout } = await spawnSocketCli(binCliPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) @@ -975,7 +976,7 @@ describe('socket fix', async () => { cmdit( ['fix', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should use default limit of 10 when --limit is not specified', + 'should use default pr-limit of 10 when --pr-limit is not specified', async cmd => { const { code, stdout } = await spawnSocketCli(binCliPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) @@ -984,8 +985,8 @@ describe('socket fix', async () => { ) cmdit( - ['fix', '--limit', '0', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should handle --limit 0 in non-dry-run mode', + ['fix', '--pr-limit', '0', FLAG_CONFIG, '{"apiToken":"fake-token"}'], + 'should handle --pr-limit 0 in non-dry-run mode', async cmd => { const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) const output = stdout + stderr @@ -995,6 +996,96 @@ describe('socket fix', async () => { expect(code, 'should exit with non-zero code').not.toBe(0) }, ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--limit', + '5', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --limit as hidden alias for --pr-limit', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + }) + + describe('--ecosystems flag behavior', () => { + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--ecosystems', + 'npm', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --ecosystems with single ecosystem', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--ecosystems', + 'npm,pypi', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --ecosystems with comma-separated values', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--ecosystems', + 'npm', + '--ecosystems', + 'pypi', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept multiple --ecosystems flags', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--ecosystems', + 'invalid-ecosystem', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should fail with invalid ecosystem value', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain('Invalid ecosystem') + expect(code, 'should exit with non-zero code').not.toBe(0) + }, + ) }) describe('--id flag behavior', () => { @@ -1086,19 +1177,19 @@ describe('socket fix', async () => { ) }) - describe('--limit and --id combination', () => { + describe('--pr-limit and --id combination', () => { cmdit( [ 'fix', FLAG_DRY_RUN, - '--limit', + '--pr-limit', '1', FLAG_ID, 'GHSA-1234-5678-9abc', FLAG_CONFIG, '{"apiToken":"fakeToken"}', ], - 'should accept both --limit and --id flags together', + 'should accept both --pr-limit and --id flags together', async cmd => { const { code, stdout } = await spawnSocketCli(binCliPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) @@ -1110,14 +1201,14 @@ describe('socket fix', async () => { [ 'fix', FLAG_DRY_RUN, - '--limit', + '--pr-limit', '5', FLAG_ID, 'GHSA-1234-5678-9abc,CVE-2021-12345,pkg:npm/lodash@4.17.20', FLAG_CONFIG, '{"apiToken":"fakeToken"}', ], - 'should accept --limit with multiple vulnerability IDs', + 'should accept --pr-limit with multiple vulnerability IDs', async cmd => { const { code, stdout } = await spawnSocketCli(binCliPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) @@ -1129,7 +1220,7 @@ describe('socket fix', async () => { [ 'fix', FLAG_DRY_RUN, - '--limit', + '--pr-limit', '1', FLAG_ID, 'GHSA-1234-5678-9abc', @@ -1137,7 +1228,7 @@ describe('socket fix', async () => { FLAG_CONFIG, '{"apiToken":"fakeToken"}', ], - 'should accept --limit, --id, and --autopilot together', + 'should accept --pr-limit, --id, and --autopilot together', async cmd => { const { code, stdout } = await spawnSocketCli(binCliPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) @@ -1148,14 +1239,14 @@ describe('socket fix', async () => { cmdit( [ 'fix', - '--limit', + '--pr-limit', '2', FLAG_ID, 'GHSA-1234-5678-9abc,GHSA-abcd-efgh-ijkl', FLAG_CONFIG, '{"apiToken":"fake-token"}', ], - 'should handle --limit and --id in non-dry-run mode', + 'should handle --pr-limit and --id in non-dry-run mode', async cmd => { const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) const output = stdout + stderr @@ -1170,7 +1261,7 @@ describe('socket fix', async () => { [ 'fix', FLAG_DRY_RUN, - '--limit', + '--pr-limit', '3', FLAG_ID, 'GHSA-1234-5678-9abc', @@ -1178,7 +1269,7 @@ describe('socket fix', async () => { FLAG_CONFIG, '{"apiToken":"fakeToken"}', ], - 'should accept --limit, --id, and --json output format together', + 'should accept --pr-limit, --id, and --json output format together', async cmd => { const { code, stdout } = await spawnSocketCli(binCliPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) @@ -1190,7 +1281,7 @@ describe('socket fix', async () => { [ 'fix', FLAG_DRY_RUN, - '--limit', + '--pr-limit', '10', FLAG_ID, 'CVE-2021-12345', @@ -1198,7 +1289,7 @@ describe('socket fix', async () => { FLAG_CONFIG, '{"apiToken":"fakeToken"}', ], - 'should accept --limit, --id, and --markdown output format together', + 'should accept --pr-limit, --id, and --markdown output format together', async cmd => { const { code, stdout } = await spawnSocketCli(binCliPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) diff --git a/src/commands/fix/cmd-fix.mts b/src/commands/fix/cmd-fix.mts index eb15bd7de..b2c945d5c 100644 --- a/src/commands/fix/cmd-fix.mts +++ b/src/commands/fix/cmd-fix.mts @@ -2,7 +2,11 @@ import path from 'node:path' import terminalLink from 'terminal-link' -import { arrayUnique, joinOr } from '@socketsecurity/registry/lib/arrays' +import { + arrayUnique, + joinAnd, + joinOr, +} from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' import { handleFix } from './handle-fix.mts' @@ -13,6 +17,7 @@ import constants, { import { commonFlags, outputFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { getEcosystemChoicesForMeow } from '../../utils/ecosystem.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { @@ -23,6 +28,7 @@ import { RangeStyles } from '../../utils/semver.mts' import { getDefaultOrgSlug } from '../ci/fetch-default-org-slug.mts' import type { MeowFlag, MeowFlags } from '../../flags.mts' +import type { PURL_Type } from '../../utils/ecosystem.mts' import type { CliCommandConfig, CliCommandContext, @@ -108,10 +114,10 @@ const generalFlags: MeowFlags = { Can be provided as comma separated values or as multiple flags`, isMultiple: true, }, - limit: { + prLimit: { type: 'number', default: DEFAULT_LIMIT, - description: `The number of fixes to attempt at a time (default ${DEFAULT_LIMIT})`, + description: `Maximum number of pull requests to create in CI mode (default ${DEFAULT_LIMIT}). Has no effect in local mode.`, }, rangeStyle: { type: 'string', @@ -134,6 +140,13 @@ Available styles: description: 'Set a minimum age requirement for suggested upgrade versions (e.g., 1h, 2d, 3w). A higher age requirement reduces the risk of upgrading to malicious versions. For example, setting the value to 1 week (1w) gives ecosystem maintainers one week to remove potentially malicious versions.', }, + ecosystems: { + type: 'string', + default: [], + description: + 'Limit fix analysis to specific ecosystems. Can be provided as comma separated values or as multiple flags. Defaults to all ecosystems.', + isMultiple: true, + }, showAffectedDirectDependencies: { type: 'boolean', default: false, @@ -151,7 +164,10 @@ const hiddenFlags: MeowFlags = { ...generalFlags['id'], hidden: true, } as MeowFlag, - + limit: { + ...generalFlags['prLimit'], + hidden: true, + } as MeowFlag, maxSatisfying: { type: 'boolean', default: true, @@ -261,17 +277,18 @@ async function run( const { applyFixes, autopilot, + ecosystems, exclude, fixVersion, include, json, - limit, majorUpdates, markdown, maxSatisfying, minimumReleaseAge, outputFile, prCheck, + prLimit, rangeStyle, showAffectedDirectDependencies, // We patched in this feature with `npx custompatch meow` at @@ -280,11 +297,11 @@ async function run( } = cli.flags as { applyFixes: boolean autopilot: boolean + ecosystems: string[] exclude: string[] fixVersion: string | undefined include: string[] json: boolean - limit: number majorUpdates: boolean markdown: boolean maxSatisfying: boolean @@ -292,6 +309,7 @@ async function run( minimumReleaseAge: string outputFile: string prCheck: boolean + prLimit: number rangeStyle: RangeStyle showAffectedDirectDependencies: boolean unknownFlags?: string[] @@ -306,6 +324,23 @@ async function run( const outputKind = getOutputKind(json, markdown) + // Process comma-separated values for ecosystems flag. + const ecosystemsRaw = cmdFlagValueToArray(ecosystems) + + // Validate ecosystem values early, before dry-run check. + const validatedEcosystems: PURL_Type[] = [] + const validEcosystemChoices = getEcosystemChoicesForMeow() + for (const ecosystem of ecosystemsRaw) { + if (!validEcosystemChoices.includes(ecosystem)) { + logger.fail( + `Invalid ecosystem: "${ecosystem}". Valid values are: ${joinAnd(validEcosystemChoices)}`, + ) + process.exitCode = 1 + return + } + validatedEcosystems.push(ecosystem as PURL_Type) + } + const wasValidInput = checkCommandInput( outputKind, { @@ -362,16 +397,17 @@ async function run( coanaVersion: fixVersion, cwd, disableMajorUpdates, + ecosystems: validatedEcosystems, exclude: excludePatterns, ghsas, include: includePatterns, - limit, minimumReleaseAge, minSatisfying, orgSlug, outputFile, outputKind, prCheck, + prLimit, rangeStyle, showAffectedDirectDependencies, spinner, diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index f505f1c76..2ce508de7 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -50,13 +50,14 @@ import { fetchSupportedScanFileNames } from '../scan/fetch-supported-scan-file-n import type { FixConfig } from './types.mts' import type { CResult } from '../../types.mts' +import type { PURL_Type } from '../../utils/ecosystem.mts' import type { Spinner } from '@socketsecurity/registry/lib/spinner' type DiscoverGhsaIdsOptions = { + coanaVersion?: string | undefined cwd?: string | undefined - limit?: number | undefined + ecosystems?: PURL_Type[] | undefined spinner?: Spinner | undefined - coanaVersion?: string | undefined } /** @@ -70,7 +71,7 @@ async function discoverGhsaIds( ): Promise { const { cwd = process.cwd(), - limit, + ecosystems, spinner, } = { __proto__: null, @@ -78,22 +79,26 @@ async function discoverGhsaIds( } as DiscoverGhsaIdsOptions const foundCResult = await spawnCoanaDlx( - ['find-vulnerabilities', cwd, '--manifests-tar-hash', tarHash], + [ + 'find-vulnerabilities', + cwd, + '--manifests-tar-hash', + tarHash, + ...(ecosystems?.length ? ['--purl-types', ...ecosystems] : []), + ], orgSlug, - { cwd, spinner, coanaVersion: options?.coanaVersion }, + { cwd, spinner, coanaVersion: options?.coanaVersion }, { stdio: 'pipe' }, ) if (foundCResult.ok) { - // Coana prints ghsaIds as json-formatted string on the final line of the output - const foundIds: string[] = [] try { + // Coana prints ghsaIds as json-formatted string on the final line of the output. const ghsaIdsRaw = foundCResult.data.trim().split('\n').pop() if (ghsaIdsRaw) { - foundIds.push(...JSON.parse(ghsaIdsRaw)) + return JSON.parse(ghsaIdsRaw) } } catch {} - return limit !== undefined ? foundIds.slice(0, limit) : foundIds } return [] } @@ -107,13 +112,14 @@ export async function coanaFix( coanaVersion, cwd, disableMajorUpdates, + ecosystems, exclude, ghsas, include, - limit, minimumReleaseAge, orgSlug, outputFile, + prLimit, showAffectedDirectDependencies, spinner, } = fixConfig @@ -167,9 +173,7 @@ export async function coanaFix( } } - const isAll = - !ghsas.length || - (ghsas.length === 1 && (ghsas[0] === 'all' || ghsas[0] === 'auto')) + const shouldDiscoverGhsaIds = !ghsas.length const shouldOpenPrs = fixEnv.isCi && fixEnv.repoInfo @@ -194,21 +198,17 @@ export async function coanaFix( } } - let ids: string[] - if (isAll && limit > 0) { - ids = await discoverGhsaIds(orgSlug, tarHash, { - cwd, - limit, - spinner, - coanaVersion, - }) - } else if (limit > 0) { - ids = ghsas.slice(0, limit) - } else { - ids = [] - } + // In local mode, process all discovered/provided IDs (no limit). + const ids: string[] = shouldDiscoverGhsaIds + ? await discoverGhsaIds(orgSlug, tarHash, { + coanaVersion, + cwd, + ecosystems, + spinner, + }) + : ghsas - if (limit < 1 || ids.length === 0) { + if (ids.length === 0) { spinner?.stop() return { ok: true, data: { fixed: false } } } @@ -234,6 +234,7 @@ export async function coanaFix( : []), ...(include.length ? ['--include', ...include] : []), ...(exclude.length ? ['--exclude', ...exclude] : []), + ...(ecosystems.length ? ['--purl-types', ...ecosystems] : []), ...(!applyFixes ? [FLAG_DRY_RUN] : []), '--output-file', tmpFile, @@ -274,8 +275,8 @@ export async function coanaFix( } } - // Adjust limit based on open Socket Fix PRs. - let adjustedLimit = limit + // Adjust PR limit based on open Socket Fix PRs. + let adjustedPrLimit = prLimit if (shouldOpenPrs && fixEnv.repoInfo) { try { const openPrs = await getSocketFixPrs( @@ -285,11 +286,11 @@ export async function coanaFix( ) const openPrCount = openPrs.length // Reduce limit by number of open PRs to avoid creating too many. - adjustedLimit = Math.max(0, limit - openPrCount) + adjustedPrLimit = Math.max(0, prLimit - openPrCount) if (openPrCount > 0) { debugFn( 'notice', - `limit: adjusted from ${limit} to ${adjustedLimit} (${openPrCount} open Socket Fix ${pluralize('PR', openPrCount)}`, + `prLimit: adjusted from ${prLimit} to ${adjustedPrLimit} (${openPrCount} open Socket Fix ${pluralize('PR', openPrCount)}`, ) } } catch (e) { @@ -298,19 +299,21 @@ export async function coanaFix( } } - const shouldSpawnCoana = adjustedLimit > 0 + const shouldSpawnCoana = adjustedPrLimit > 0 let ids: string[] | undefined - if (shouldSpawnCoana && isAll) { - ids = await discoverGhsaIds(orgSlug, tarHash, { - cwd, - limit: adjustedLimit, - spinner, - coanaVersion, - }) - } else if (shouldSpawnCoana) { - ids = ghsas.slice(0, adjustedLimit) + if (shouldSpawnCoana) { + ids = ( + shouldDiscoverGhsaIds + ? await discoverGhsaIds(orgSlug, tarHash, { + coanaVersion, + cwd, + ecosystems, + spinner, + }) + : ghsas + ).slice(0, adjustedPrLimit) } if (!ids?.length) { @@ -359,6 +362,7 @@ export async function coanaFix( : []), ...(include.length ? ['--include', ...include] : []), ...(exclude.length ? ['--exclude', ...exclude] : []), + ...(ecosystems.length ? ['--purl-types', ...ecosystems] : []), ...(disableMajorUpdates ? ['--disable-major-updates'] : []), ...(showAffectedDirectDependencies ? ['--show-affected-direct-dependencies'] @@ -573,9 +577,9 @@ export async function coanaFix( count += 1 debugFn( 'notice', - `increment: count ${count}/${Math.min(adjustedLimit, ids.length)}`, + `increment: count ${count}/${Math.min(adjustedPrLimit, ids.length)}`, ) - if (count >= adjustedLimit) { + if (count >= adjustedPrLimit) { break ghsaLoop } } diff --git a/src/commands/fix/handle-fix-limit.test.mts b/src/commands/fix/handle-fix-limit.test.mts index 0d8183071..0d7f732e9 100644 --- a/src/commands/fix/handle-fix-limit.test.mts +++ b/src/commands/fix/handle-fix-limit.test.mts @@ -70,21 +70,23 @@ vi.mock('./branch-cleanup.mts', () => ({ cleanupSuccessfulPrLocalBranch: vi.fn(), })) -describe('socket fix --limit behavior verification', () => { +describe('socket fix --pr-limit behavior verification', () => { const baseConfig: FixConfig = { applyFixes: true, autopilot: false, + coanaVersion: undefined, cwd: '/test/cwd', disableMajorUpdates: false, + ecosystems: [], exclude: [], ghsas: [], include: [], - limit: 10, minSatisfying: false, minimumReleaseAge: '', orgSlug: 'test-org', outputFile: '', prCheck: true, + prLimit: 10, rangeStyle: 'preserve', showAffectedDirectDependencies: false, spinner: undefined, @@ -132,7 +134,7 @@ describe('socket fix --limit behavior verification', () => { }) describe('local mode (no PRs)', () => { - it('should process only N GHSAs when --limit N is specified', async () => { + it('should process all GHSAs in local mode (no limit)', async () => { const ghsas = [ 'GHSA-1111-1111-1111', 'GHSA-2222-2222-2222', @@ -150,12 +152,12 @@ describe('socket fix --limit behavior verification', () => { const result = await coanaFix({ ...baseConfig, ghsas, - limit: 3, + prLimit: 3, // prLimit should have no effect in local mode. }) expect(result.ok).toBe(true) - // Verify spawnCoanaDlx was called once with only the first 3 GHSAs. + // Verify spawnCoanaDlx was called once with all GHSAs (local mode has no limit). expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] expect(callArgs).toContain('--apply-fixes-to') @@ -166,14 +168,17 @@ describe('socket fix --limit behavior verification', () => { .slice(applyFixesIndex + 1) .filter(arg => arg.startsWith('GHSA-')) + // All 5 GHSAs should be processed in local mode. expect(ghsaArgs).toEqual([ 'GHSA-1111-1111-1111', 'GHSA-2222-2222-2222', 'GHSA-3333-3333-3333', + 'GHSA-4444-4444-4444', + 'GHSA-5555-5555-5555', ]) }) - it('should process all GHSAs when limit exceeds GHSA count', async () => { + it('should process all provided GHSAs in local mode', async () => { const ghsas = ['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222'] mockSpawnCoanaDlx.mockResolvedValue({ @@ -184,7 +189,6 @@ describe('socket fix --limit behavior verification', () => { const result = await coanaFix({ ...baseConfig, ghsas, - limit: 10, }) expect(result.ok).toBe(true) @@ -199,24 +203,23 @@ describe('socket fix --limit behavior verification', () => { expect(ghsaArgs).toEqual(['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222']) }) - it('should process no GHSAs when --limit 0 is specified', async () => { - const ghsas = [ - 'GHSA-1111-1111-1111', - 'GHSA-2222-2222-2222', - 'GHSA-3333-3333-3333', - ] + it('should return early when no GHSAs are provided and none are discovered', async () => { + // Discovery returns empty array. + mockSpawnCoanaDlx.mockResolvedValueOnce({ + ok: true, + data: JSON.stringify([]), + }) const result = await coanaFix({ ...baseConfig, - ghsas, - limit: 0, + ghsas: [], }) expect(result.ok).toBe(true) expect(result.data?.fixed).toBe(false) - // spawnCoanaDlx should not be called at all with limit 0. - expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() + // Only discovery call, no fix call since no GHSAs found. + expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) }) it('should discover vulnerabilities when no GHSAs are provided', async () => { @@ -235,7 +238,6 @@ describe('socket fix --limit behavior verification', () => { const result = await coanaFix({ ...baseConfig, ghsas: [], - limit: 10, }) expect(result.ok).toBe(true) @@ -258,9 +260,10 @@ describe('socket fix --limit behavior verification', () => { beforeEach(() => { // Enable PR mode. mockGetFixEnv.mockResolvedValue({ + baseBranch: 'main', githubToken: 'test-token', - gitUserEmail: 'test@example.com', - gitUserName: 'test-user', + gitEmail: 'test@example.com', + gitUser: 'test-user', isCi: true, repoInfo: { defaultBranch: 'main', @@ -273,7 +276,7 @@ describe('socket fix --limit behavior verification', () => { mockFetchGhsaDetails.mockResolvedValue(new Map()) }) - it('should process only N GHSAs when --limit N is specified in PR mode', async () => { + it('should process only N GHSAs when --pr-limit N is specified in PR mode', async () => { const ghsas = [ 'GHSA-aaaa-aaaa-aaaa', 'GHSA-bbbb-bbbb-bbbb', @@ -281,7 +284,7 @@ describe('socket fix --limit behavior verification', () => { 'GHSA-dddd-dddd-dddd', ] - // First call returns the IDs to process. + // First call discovers vulnerabilities. mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: JSON.stringify(ghsas), @@ -300,8 +303,8 @@ describe('socket fix --limit behavior verification', () => { const result = await coanaFix({ ...baseConfig, - ghsas: ['all'], - limit: 2, + ghsas: [], // Empty to trigger discovery. + prLimit: 2, }) expect(result.ok).toBe(true) @@ -310,7 +313,7 @@ describe('socket fix --limit behavior verification', () => { expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(3) }) - it('should adjust limit based on existing open PRs', async () => { + it('should adjust prLimit based on existing open PRs', async () => { const ghsas = [ 'GHSA-aaaa-aaaa-aaaa', 'GHSA-bbbb-bbbb-bbbb', @@ -342,18 +345,18 @@ describe('socket fix --limit behavior verification', () => { const result = await coanaFix({ ...baseConfig, - ghsas: ['all'], - limit: 3, + ghsas: [], // Empty to trigger discovery. + prLimit: 3, }) expect(result.ok).toBe(true) - // With limit 3 and 1 existing PR, adjusted limit is 2. + // With prLimit 3 and 1 existing PR, adjusted limit is 2. // So: 1 discovery call + 2 fix calls = 3 total. expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(3) }) - it('should process no GHSAs when existing open PRs exceed limit', async () => { + it('should process no GHSAs when existing open PRs exceed prLimit', async () => { // Mock 5 existing open PRs. mockGetSocketFixPrs.mockResolvedValue([ { number: 1, state: 'OPEN' }, @@ -365,20 +368,20 @@ describe('socket fix --limit behavior verification', () => { const result = await coanaFix({ ...baseConfig, - ghsas: ['all'], - limit: 3, + ghsas: [], // Empty to trigger discovery. + prLimit: 3, }) expect(result.ok).toBe(true) expect(result.data?.fixed).toBe(false) - // With 5 open PRs and limit 3, adjusted limit is 0, so no processing. + // With 5 open PRs and prLimit 3, adjusted limit is 0, so no processing. expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() }) }) - describe('--id filtering with --limit', () => { - it('should apply limit to filtered GHSA IDs', async () => { + describe('--id filtering in local mode', () => { + it('should process all provided GHSA IDs in local mode (prLimit ignored)', async () => { const ghsas = [ 'GHSA-1111-1111-1111', 'GHSA-2222-2222-2222', @@ -395,12 +398,12 @@ describe('socket fix --limit behavior verification', () => { const result = await coanaFix({ ...baseConfig, ghsas, - limit: 2, + prLimit: 2, // Should be ignored in local mode. }) expect(result.ok).toBe(true) - // Should only process first 2 GHSAs. + // Should process all 5 GHSAs in local mode (prLimit is ignored). expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') @@ -408,11 +411,17 @@ describe('socket fix --limit behavior verification', () => { .slice(applyFixesIndex + 1) .filter(arg => arg.startsWith('GHSA-')) - expect(ghsaArgs).toHaveLength(2) - expect(ghsaArgs).toEqual(['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222']) + expect(ghsaArgs).toHaveLength(5) + expect(ghsaArgs).toEqual([ + 'GHSA-1111-1111-1111', + 'GHSA-2222-2222-2222', + 'GHSA-3333-3333-3333', + 'GHSA-4444-4444-4444', + 'GHSA-5555-5555-5555', + ]) }) - it('should handle limit 1 with single GHSA ID', async () => { + it('should handle single GHSA ID in local mode', async () => { const ghsas = ['GHSA-1111-1111-1111'] mockSpawnCoanaDlx.mockResolvedValue({ @@ -423,7 +432,6 @@ describe('socket fix --limit behavior verification', () => { const result = await coanaFix({ ...baseConfig, ghsas, - limit: 1, }) expect(result.ok).toBe(true) diff --git a/src/commands/fix/handle-fix.mts b/src/commands/fix/handle-fix.mts index a10145b2c..3bb78d5f0 100644 --- a/src/commands/fix/handle-fix.mts +++ b/src/commands/fix/handle-fix.mts @@ -102,16 +102,17 @@ export async function handleFix({ coanaVersion, cwd, disableMajorUpdates, + ecosystems, exclude, ghsas, include, - limit, minSatisfying, minimumReleaseAge, orgSlug, outputFile, outputKind, prCheck, + prLimit, rangeStyle, showAffectedDirectDependencies, spinner, @@ -124,15 +125,16 @@ export async function handleFix({ coanaVersion, cwd, disableMajorUpdates, + ecosystems, exclude, ghsas, include, - limit, minSatisfying, minimumReleaseAge, outputFile, outputKind, prCheck, + prLimit, rangeStyle, showAffectedDirectDependencies, unknownFlags, @@ -145,16 +147,17 @@ export async function handleFix({ coanaVersion, cwd, disableMajorUpdates, + ecosystems, exclude, // Convert mixed CVE/GHSA/PURL inputs to GHSA IDs only. ghsas: await convertIdsToGhsas(ghsas), include, - limit, minimumReleaseAge, minSatisfying, orgSlug, outputFile, prCheck, + prLimit, rangeStyle, showAffectedDirectDependencies, spinner, diff --git a/src/commands/fix/types.mts b/src/commands/fix/types.mts index 90eb20374..e1edb2430 100644 --- a/src/commands/fix/types.mts +++ b/src/commands/fix/types.mts @@ -1,3 +1,4 @@ +import type { PURL_Type } from '../../utils/ecosystem.mts' import type { RangeStyle } from '../../utils/semver.mts' import type { Spinner } from '@socketsecurity/registry/lib/spinner' @@ -7,15 +8,16 @@ export type FixConfig = { coanaVersion: string | undefined cwd: string disableMajorUpdates: boolean + ecosystems: PURL_Type[] exclude: string[] ghsas: string[] include: string[] - limit: number minimumReleaseAge: string minSatisfying: boolean orgSlug: string outputFile: string prCheck: boolean + prLimit: number rangeStyle: RangeStyle showAffectedDirectDependencies: boolean spinner: Spinner | undefined