From 733b293019b4577f0989f0ad5f5fe51288dc09b9 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Thu, 18 Jun 2026 13:36:40 +0200 Subject: [PATCH] fix(scan): isolate --json/--markdown output during reachability analysis Reachability spawned the Coana CLI with stdio: 'inherit', so Coana's progress/log output went to the parent's stdout and corrupted the machine-readable payload. Since 2>/dev/null only drops stderr, `socket scan create --reach --json` (and `socket scan reach --json`) could not be isolated to just the JSON the way a non-reach scan can. In json/markdown output modes, route the Coana child's stdout to the parent's stderr (fd 2) via the stdio array ['inherit', 2, 'inherit'] so the final JSON/markdown stays alone on stdout while progress stays visible on stderr. Text mode keeps inheriting stdout unchanged. --- src/commands/scan/handle-create-new-scan.mts | 1 + src/commands/scan/handle-scan-reach.mts | 1 + .../scan/perform-reachability-analysis.mts | 15 +++- .../perform-reachability-analysis.test.mts | 74 +++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 68facffa4..10bec0231 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -238,6 +238,7 @@ export async function handleCreateNewScan({ branchName, cwd, orgSlug, + outputKind, packagePaths, reachabilityOptions: mergedReachabilityOptions, repoName, diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 22537041b..6542e06e0 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -95,6 +95,7 @@ export async function handleScanReach({ const result = await performReachabilityAnalysis({ cwd, orgSlug, + outputKind, outputPath, packagePaths, reachabilityOptions: mergedReachabilityOptions, diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 623826ac0..69a445bce 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -15,10 +15,11 @@ import { setupSdk } from '../../utils/sdk.mts' import { socketDevLink } from '../../utils/terminal-link.mts' import { fetchOrganization } from '../organization/fetch-organization-list.mts' -import type { CResult } from '../../types.mts' +import type { CResult, OutputKind } from '../../types.mts' import type { AutoManifestConfig } from '../../utils/auto-manifest-config.mts' import type { PURL_Type } from '../../utils/ecosystem.mts' import type { Spinner } from '@socketsecurity/registry/lib/spinner' +import type { StdioOptions } from 'node:child_process' export type ReachabilityOptions = { autoManifestConfig?: AutoManifestConfig | undefined @@ -47,6 +48,7 @@ export type ReachabilityAnalysisOptions = { branchName?: string | undefined cwd?: string | undefined orgSlug?: string | undefined + outputKind?: OutputKind | undefined outputPath?: string | undefined packagePaths?: string[] | undefined reachabilityOptions: ReachabilityOptions @@ -68,6 +70,7 @@ export async function performReachabilityAnalysis( branchName, cwd = process.cwd(), orgSlug, + outputKind = 'text', outputPath, packagePaths, reachabilityOptions, @@ -270,6 +273,14 @@ export async function performReachabilityAnalysis( coanaEnv['SOCKET_BRANCH_NAME'] = branchName } + // In machine-readable modes (--json/--markdown) the final payload is written + // to stdout by the output layer. Coana streams progress/logs over stdout + // under `inherit`, which would corrupt that payload, so redirect the child's + // stdout to our stderr (fd 2). Progress stays visible for humans and + // `2>/dev/null` isolates the JSON/markdown. stdin and stderr stay inherited. + const coanaStdio: StdioOptions = + outputKind === 'text' ? 'inherit' : ['inherit', 2, 'inherit'] + try { // Run Coana with the manifests tar hash. const coanaResult = await spawnCoanaDlx(coanaArgs, orgSlug, { @@ -277,7 +288,7 @@ export async function performReachabilityAnalysis( cwd, env: coanaEnv, spinner, - stdio: 'inherit', + stdio: coanaStdio, }) if (wasSpinning) { diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index d144c9fdd..054478a38 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -162,3 +162,77 @@ describe('performReachabilityAnalysis facts-file resolution', () => { expect(result.ok && result.data.tier1ReachabilityScanId).toBeUndefined() }) }) + +describe('performReachabilityAnalysis stdio routing by output kind', () => { + let scanCwd: string + + beforeEach(() => { + vi.clearAllMocks() + mockFetchOrganization.mockResolvedValue({ + ok: true, + data: { organizations: {} }, + }) + mockHasEnterpriseOrgPlan.mockReturnValue(true) + mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: '' }) + scanCwd = mkdtempSync(path.join(tmpdir(), 'socket-rea-stdio-')) + writeFileSync( + path.join(scanCwd, '.socket.facts.json'), + JSON.stringify({ components: [] }), + ) + }) + + afterEach(() => { + rmSync(scanCwd, { force: true, recursive: true }) + }) + + it('inherits stdio in text output mode', async () => { + await performReachabilityAnalysis({ + cwd: scanCwd, + outputKind: 'text', + reachabilityOptions: makeReachabilityOptions(), + target: scanCwd, + }) + + expect(mockSpawnCoanaDlx.mock.calls[0]![2]).toMatchObject({ + stdio: 'inherit', + }) + }) + + it('defaults to inheriting stdio when no output kind is given', async () => { + await performReachabilityAnalysis({ + cwd: scanCwd, + reachabilityOptions: makeReachabilityOptions(), + target: scanCwd, + }) + + expect(mockSpawnCoanaDlx.mock.calls[0]![2]).toMatchObject({ + stdio: 'inherit', + }) + }) + + it('redirects Coana stdout to stderr (fd 2) in json output mode', async () => { + await performReachabilityAnalysis({ + cwd: scanCwd, + outputKind: 'json', + reachabilityOptions: makeReachabilityOptions(), + target: scanCwd, + }) + + expect(mockSpawnCoanaDlx.mock.calls[0]![2]).toMatchObject({ + stdio: ['inherit', 2, 'inherit'], + }) + }) + + it('redirects Coana stdout to stderr (fd 2) in markdown output mode', async () => { + await performReachabilityAnalysis({ + cwd: scanCwd, + outputKind: 'markdown', + reachabilityOptions: makeReachabilityOptions(), + target: scanCwd, + }) + + expect(mockSpawnCoanaDlx.mock.calls[0]![2]).toMatchObject({ + stdio: ['inherit', 2, 'inherit'], + }) + }) +})