diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a3ae52a2..6ff2d3cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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/). +## [Unreleased] + +### Changed +- Updated the Coana CLI to v `15.12.2`. + ## [1.6.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.0) - 2026-10-07 ### Added diff --git a/package.json b/package.json index caf7c6be4..6c706902c 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "@babel/preset-typescript": "7.27.1", "@babel/runtime": "7.28.4", "@biomejs/biome": "2.2.4", - "@coana-tech/cli": "15.12.1", + "@coana-tech/cli": "15.12.2", "@cyclonedx/cdxgen": "12.1.2", "@dotenvx/dotenvx": "1.49.0", "@eslint/compat": "1.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14f7a1f4e..f7f0a7975 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,8 +138,8 @@ importers: specifier: 2.2.4 version: 2.2.4 '@coana-tech/cli': - specifier: 15.12.1 - version: 15.12.1 + specifier: 15.12.2 + version: 15.12.2 '@cyclonedx/cdxgen': specifier: 12.1.2 version: 12.1.2 @@ -827,8 +827,8 @@ packages: resolution: {integrity: sha512-hAs5PPKPCQ3/Nha+1fo4A4/gL85fIfxZwHPehsjCJ+BhQH2/yw6/xReuaPA/RfNQr6iz1PcD7BZcE3ctyyl3EA==} cpu: [x64] - '@coana-tech/cli@15.12.1': - resolution: {integrity: sha512-91NnY5K+6FT65S5wqteiLOY19vUqdYLcnndt6sVkMTvHbU9h+7Aq7uBx/lGSoYkhF7QN+7SqXGLJVlM//IN4tQ==} + '@coana-tech/cli@15.12.2': + resolution: {integrity: sha512-z7PwQbg6xggw3oFIZWcgsSo37VV57WrWkHqMZ/zvj2Qgw40D4uDIXSsPrU9f2DXgmz+eDEKnLl/lxfaYcB/q+w==} hasBin: true '@colors/colors@1.5.0': @@ -5702,7 +5702,7 @@ snapshots: '@cdxgen/cdxgen-plugins-bin@2.0.2': optional: true - '@coana-tech/cli@15.12.1': {} + '@coana-tech/cli@15.12.2': {} '@colors/colors@1.5.0': optional: true diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 6b9191490..7d1ec971e 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -251,6 +251,35 @@ function readWrittenFiles(outputFile: string): Set | undefined { type CoanaFixResult = CResult<{ fixedAll: boolean; ghsaDetails: unknown[] }> +// Coana exits with this code after writing an --output-file that lists computed fixes it left unapplied. +const COANA_FIXES_NOT_APPLIED_EXIT_CODE = 5 + +type NotAppliedFix = { + ghsa: string + purl: string + fixedVersion: string + reason: string +} + +function isFixesNotAppliedExit(result: CResult): boolean { + return ( + !result.ok && + (result.data as any)?.code === COANA_FIXES_NOT_APPLIED_EXIT_CODE + ) +} + +function readNotAppliedFixes(fixesResultJson: unknown): NotAppliedFix[] { + const notApplied = (fixesResultJson as { notApplied?: unknown } | null) + ?.notApplied + return Array.isArray(notApplied) ? (notApplied as NotAppliedFix[]) : [] +} + +function formatNotAppliedFixes(notApplied: NotAppliedFix[]): string { + return notApplied + .map(f => ` ${f.ghsa}: ${f.purl} -> ${f.fixedVersion}: ${f.reason}`) + .join('\n') +} + type GeneratedSocketFactsSlot = { generated?: GeneratedSocketFacts | undefined tmpDir: string @@ -542,13 +571,13 @@ async function coanaFixWithFacts( spinner?.stop() } - if (!fixCResult.ok) { + if (!fixCResult.ok && !isFixesNotAppliedExit(fixCResult)) { return fixCResult } // Read the temporary file to get the actual fixes result. const fixesResultJson = readJsonSync(tmpFile, { throws: false }) as - | { fixes?: Record } + | { type?: string; fixes?: Record } | null | undefined @@ -561,12 +590,23 @@ async function coanaFixWithFacts( await fs.writeFile(outputFile, tmpContent, 'utf8') } + const ghsaDetails = fixesResultJson ? [fixesResultJson] : [] + const notApplied = readNotAppliedFixes(fixesResultJson) + if (!fixCResult.ok || notApplied.length) { + return { + ok: false, + message: + fixesResultJson?.type === 'no-fixes-applied' + ? 'No computed fixes were applied' + : 'Some computed fixes were not applied', + cause: formatNotAppliedFixes(notApplied), + data: { fixedAll: false, ghsaDetails }, + } + } + return { ok: true, - data: { - fixedAll: true, - ghsaDetails: fixesResultJson ? [fixesResultJson] : [], - }, + data: { fixedAll: true, ghsaDetails }, } } finally { // Clean up the temporary file. @@ -718,7 +758,13 @@ async function coanaFixWithFacts( }, ) - if (!fixCResult.ok) { + if (isFixesNotAppliedExit(fixCResult)) { + if (!silence) { + logger.warn( + `Not every fix for ${ghsaId} was applied:\n${formatNotAppliedFixes(readNotAppliedFixes(readJsonSync(tmpFile, { throws: false })))}`, + ) + } + } else if (!fixCResult.ok) { if (!silence) { logger.error( `Update failed for ${ghsaId}: ${getErrorCause(fixCResult)}`, diff --git a/src/commands/fix/handle-fix-limit.test.mts b/src/commands/fix/handle-fix-limit.test.mts index 19da2b943..3f3803051 100644 --- a/src/commands/fix/handle-fix-limit.test.mts +++ b/src/commands/fix/handle-fix-limit.test.mts @@ -1,4 +1,6 @@ import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -104,6 +106,25 @@ function mockDiscoveryEnvelope(envelope: { }) } +function mockFixOutput( + output: Record, + exitCode: number | undefined, +) { + mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + await fs.writeFile( + args[args.indexOf('--output-file') + 1]!, + JSON.stringify(output), + ) + return exitCode === undefined + ? { ok: true, data: '' } + : { + ok: false, + data: { code: exitCode }, + message: `Coana command failed (exit code ${exitCode})`, + } + }) +} + describe('socket fix --pr-limit behavior verification', () => { const baseConfig: FixConfig = { all: false, @@ -567,6 +588,108 @@ describe('socket fix --pr-limit behavior verification', () => { }) }) + describe('fixes Coana did not apply', () => { + const notApplied = [ + { + ghsa: 'GHSA-1111-1111-1111', + purl: 'pkg:npm/lodash@4.17.20', + fixedVersion: '4.17.21', + reason: + 'Skipping upgrade for this directory: no supported lockfile found (.)', + }, + ] + + it('fails with the not-applied fixes when Coana applied none', async () => { + mockFixOutput({ type: 'no-fixes-applied', notApplied }, 5) + + const result = await coanaFix({ + ...baseConfig, + ghsas: ['GHSA-1111-1111-1111'], + }) + + expect(result).toEqual({ + ok: false, + message: 'No computed fixes were applied', + cause: expect.stringContaining('no supported lockfile found'), + data: { + fixedAll: false, + ghsaDetails: [{ type: 'no-fixes-applied', notApplied }], + }, + }) + }) + + it('fails and still writes --output-file when only some fixes were applied', async () => { + const output = { + type: 'applied-fixes', + fixes: { + 'GHSA-2222-2222-2222': [ + { purl: 'pkg:npm/qs@6.5.2', fixedVersion: '6.5.3' }, + ], + }, + notApplied, + modifiedFiles: ['package-lock.json'], + } + mockFixOutput(output, 5) + const outputFile = path.join( + os.tmpdir(), + `socket-fix-not-applied-${Date.now()}.json`, + ) + + const result = await coanaFix({ + ...baseConfig, + ghsas: ['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222'], + outputFile, + }) + + expect(result.ok).toBe(false) + expect(result.message).toBe('Some computed fixes were not applied') + expect(result.data).toEqual({ fixedAll: false, ghsaDetails: [output] }) + expect(JSON.parse(await fs.readFile(outputFile, 'utf8'))).toEqual(output) + await fs.rm(outputFile, { force: true }) + }) + + it('reports fixedAll when every computed fix was applied', async () => { + const output = { + type: 'applied-fixes', + fixes: { + 'GHSA-1111-1111-1111': [ + { purl: 'pkg:npm/lodash@4.17.20', fixedVersion: '4.17.21' }, + ], + }, + modifiedFiles: ['package-lock.json'], + } + mockFixOutput(output, undefined) + + const result = await coanaFix({ + ...baseConfig, + ghsas: ['GHSA-1111-1111-1111'], + }) + + expect(result).toEqual({ + ok: true, + data: { fixedAll: true, ghsaDetails: [output] }, + }) + }) + + it('keeps other Coana failures as errors', async () => { + mockSpawnCoanaDlx.mockResolvedValue({ + ok: false, + data: { code: 2 }, + message: 'Coana command failed (exit code 2)', + }) + + const result = await coanaFix({ + ...baseConfig, + ghsas: ['GHSA-1111-1111-1111'], + }) + + expect(result).toMatchObject({ + ok: false, + message: 'Coana command failed (exit code 2)', + }) + }) + }) + describe('--id filtering in local mode', () => { it('should process all provided GHSA IDs in local mode (prLimit ignored)', async () => { const ghsas = [