diff --git a/CHANGELOG.md b/CHANGELOG.md index a65aa6c09..3c8bfe98f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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.39](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.39) - 2025-12-01 + +### Added +- Added the `--output ` flag to `socket scan reach`. + +### Changed +- Updated the Coana CLI to v `14.12.107`. + ## [1.1.38](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.38) - 2025-11-26 ### Changed diff --git a/package.json b/package.json index 24e039584..d5917a8d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socket", - "version": "1.1.38", + "version": "1.1.39", "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.101", + "@coana-tech/cli": "14.12.107", "@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 1b86d6d24..02285d706 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.101 - version: 14.12.101 + specifier: 14.12.107 + version: 14.12.107 '@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.101': - resolution: {integrity: sha512-rlqJaGNBzgYGd7/2NoabD1FpBzpX6csq3JRIOvBK+8MpRsC1I0VESN6s71AAvheMQb2/e/9HnbZR/koKqjTzog==} + '@coana-tech/cli@14.12.107': + resolution: {integrity: sha512-MGmd0xY2q5grsPfDgKjFXmaZsAwwLQhtS+sCpDAt3EUfVUGDBQdZ4dJVONUifzvo3YqPCKNwU1fw9WDmic0fGA==} hasBin: true '@colors/colors@1.5.0': @@ -5315,7 +5315,7 @@ snapshots: '@bufbuild/protobuf@2.6.3': optional: true - '@coana-tech/cli@14.12.101': {} + '@coana-tech/cli@14.12.107': {} '@colors/colors@1.5.0': optional: true diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index bca4b6ed4..b0aa3053e 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -48,6 +48,13 @@ const generalFlags: MeowFlags = { description: 'Force override the organization slug, overrides the default org from config', }, + output: { + type: 'string', + default: '', + description: + 'Path to write the reachability report to (must end with .json). Defaults to .socket.facts.json in the current working directory.', + shortFlag: 'o', + }, } export const cmdScanReach = { @@ -84,7 +91,8 @@ async function run( ${getFlagListOutput(reachabilityFlags)} Runs the Socket reachability analysis without creating a scan in Socket. - The output is written to .socket.facts.json in the current working directory. + The output is written to .socket.facts.json in the current working directory + unless the --output flag is specified. Note: Manifest files are uploaded to Socket's backend services because the reachability analysis requires creating a Software Bill of Materials (SBOM) @@ -94,6 +102,8 @@ async function run( $ ${command} $ ${command} ./proj $ ${command} ./proj --reach-ecosystems npm,pypi + $ ${command} --output custom-report.json + $ ${command} ./proj --output ./reports/analysis.json `, } @@ -110,6 +120,7 @@ async function run( json, markdown, org: orgFlag, + output: outputPath, reachAnalysisMemoryLimit, reachAnalysisTimeout, reachConcurrency, @@ -123,6 +134,7 @@ async function run( json: boolean markdown: boolean org: string + output: string reachAnalysisTimeout: number reachAnalysisMemoryLimit: number reachConcurrency: number @@ -193,6 +205,12 @@ async function run( message: 'The json and markdown flags cannot be both set, pick one', fail: 'omit one', }, + { + nook: true, + test: !outputPath || outputPath.endsWith('.json'), + message: 'The --output path must end with .json', + fail: 'use a path ending with .json', + }, { nook: true, test: targetValidation.isValid, @@ -229,10 +247,10 @@ async function run( await handleScanReach({ cwd, + interactive, orgSlug, outputKind, - targets, - interactive, + outputPath: outputPath || '', reachabilityOptions: { reachAnalysisTimeout: Number(reachAnalysisTimeout), reachAnalysisMemoryLimit: Number(reachAnalysisMemoryLimit), @@ -244,5 +262,6 @@ async function run( reachExcludePaths, reachSkipCache: Boolean(reachSkipCache), }, + targets, }) } diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 90165077b..62473b4fd 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -34,6 +34,7 @@ describe('socket scan reach', async () => { --json Output as JSON --markdown Output as Markdown --org Force override the organization slug, overrides the default org from config + --output Path to write the reachability report to (must end with .json). Defaults to .socket.facts.json in the current working directory. Reachability Options --reach-analysis-memory-limit The maximum memory in MB to use for the reachability analysis. The default is 8192MB. @@ -47,7 +48,8 @@ describe('socket scan reach', async () => { --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. Runs the Socket reachability analysis without creating a scan in Socket. - The output is written to .socket.facts.json in the current working directory. + The output is written to .socket.facts.json in the current working directory + unless the --output flag is specified. Note: Manifest files are uploaded to Socket's backend services because the reachability analysis requires creating a Software Bill of Materials (SBOM) @@ -56,7 +58,9 @@ describe('socket scan reach', async () => { Examples $ socket scan reach $ socket scan reach ./proj - $ socket scan reach ./proj --reach-ecosystems npm,pypi" + $ socket scan reach ./proj --reach-ecosystems npm,pypi + $ socket scan reach --output custom-report.json + $ socket scan reach ./proj --output ./reports/analysis.json" `) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -763,6 +767,131 @@ describe('socket scan reach', async () => { ) }) + describe('output path tests', () => { + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--output', + 'custom-report.json', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --output flag with .json extension', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '-o', + 'report.json', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept -o short flag with .json extension', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--output', + './reports/analysis.json', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --output flag with path', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--output', + 'report.txt', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should fail when --output does not end with .json', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain('The --output path must end with .json') + expect(code, 'should exit with non-zero code').not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--output', + 'report', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should fail when --output has no extension', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain('The --output path must end with .json') + expect(code, 'should exit with non-zero code').not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--output', + 'report.JSON', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should fail when --output ends with .JSON (uppercase)', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain('The --output path must end with .json') + expect(code, 'should exit with non-zero code').not.toBe(0) + }, + ) + }) + describe('error handling and usability tests', () => { cmdit( [ diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 3d13c76f2..a421b34d0 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -16,6 +16,7 @@ export type HandleScanReachConfig = { interactive: boolean orgSlug: string outputKind: OutputKind + outputPath: string reachabilityOptions: ReachabilityOptions targets: string[] } @@ -25,6 +26,7 @@ export async function handleScanReach({ interactive: _interactive, orgSlug, outputKind, + outputPath, reachabilityOptions, targets, }: HandleScanReachConfig) { @@ -33,7 +35,10 @@ export async function handleScanReach({ // Get supported file names const supportedFilesCResult = await fetchSupportedScanFileNames({ spinner }) if (!supportedFilesCResult.ok) { - await outputScanReach(supportedFilesCResult, { cwd, outputKind }) + await outputScanReach(supportedFilesCResult, { + outputKind, + outputPath, + }) return } @@ -70,6 +75,7 @@ export async function handleScanReach({ const result = await performReachabilityAnalysis({ cwd, orgSlug, + outputPath, packagePaths, reachabilityOptions, spinner, @@ -79,5 +85,5 @@ export async function handleScanReach({ spinner.stop() - await outputScanReach(result, { cwd, outputKind }) + await outputScanReach(result, { outputKind, outputPath }) } diff --git a/src/commands/scan/output-scan-reach.mts b/src/commands/scan/output-scan-reach.mts index c0d72a2d9..75f792f38 100644 --- a/src/commands/scan/output-scan-reach.mts +++ b/src/commands/scan/output-scan-reach.mts @@ -1,5 +1,3 @@ -import path from 'node:path' - import { logger } from '@socketsecurity/registry/lib/logger' import constants from '../../constants.mts' @@ -11,7 +9,10 @@ import type { CResult, OutputKind } from '../../types.mts' export async function outputScanReach( result: CResult, - { cwd, outputKind }: { cwd: string; outputKind: OutputKind }, + { + outputKind, + outputPath, + }: { outputKind: OutputKind; outputPath: string }, ): Promise { if (!result.ok) { process.exitCode = result.code ?? 1 @@ -26,9 +27,9 @@ export async function outputScanReach( return } + const actualOutputPath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON + logger.log('') logger.success('Reachability analysis completed successfully!') - logger.info( - `Reachability report has been written to: ${path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON)}`, - ) + logger.info(`Reachability report has been written to: ${actualOutputPath}`) } diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 48c745aa9..3eb372c67 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -29,6 +29,7 @@ export type ReachabilityAnalysisOptions = { branchName?: string | undefined cwd?: string | undefined orgSlug?: string | undefined + outputPath?: string | undefined packagePaths?: string[] | undefined reachabilityOptions: ReachabilityOptions repoName?: string | undefined @@ -49,6 +50,7 @@ export async function performReachabilityAnalysis( branchName, cwd = process.cwd(), orgSlug, + outputPath, packagePaths, reachabilityOptions, repoName, @@ -147,14 +149,15 @@ export async function performReachabilityAnalysis( spinner?.start() spinner?.infoAndStop('Running reachability analysis with Coana...') + const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON // Build Coana arguments. const coanaArgs = [ 'run', analysisTarget, '--output-dir', - cwd, + path.dirname(outputFilePath), '--socket-mode', - constants.DOT_SOCKET_DOT_FACTS_JSON, + outputFilePath, '--disable-report-submission', ...(reachabilityOptions.reachAnalysisTimeout ? ['--analysis-timeout', `${reachabilityOptions.reachAnalysisTimeout}`] @@ -212,11 +215,10 @@ export async function performReachabilityAnalysis( ? { ok: true, data: { - // Use the DOT_SOCKET_DOT_FACTS_JSON file for the scan. - reachabilityReport: constants.DOT_SOCKET_DOT_FACTS_JSON, - tier1ReachabilityScanId: extractTier1ReachabilityScanId( - constants.DOT_SOCKET_DOT_FACTS_JSON, - ), + // Use the actual output filename for the scan. + reachabilityReport: outputFilePath, + tier1ReachabilityScanId: + extractTier1ReachabilityScanId(outputFilePath), }, } : coanaResult