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
13 changes: 13 additions & 0 deletions src/commands/scan/handle-create-new-scan.mts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,19 @@ export async function handleCreateNewScan({

if (reach && scanId && tier1ReachabilityScanId) {
await finalizeTier1Scan(tier1ReachabilityScanId, scanId)
} else if (
reach.runReachabilityAnalysis &&
scanId &&
!tier1ReachabilityScanId
) {
// Reachability analysis ran and a scan was created, but no tier 1
// reachability scan id was extracted from the facts file. Surface this
// instead of silently skipping finalize — otherwise the tier 1 row stays
// stuck (e.g. at COANA_DONE) and the full scan is never linked to its
// reachability report.
logger.warn(
'Reachability analysis ran but no tier 1 reachability scan ID was found; skipping tier 1 finalize. The scan was created but its reachability report was not linked.',
)
}

// On a successful scan, clean up the `.socket.facts.json` coana wrote at
Expand Down
67 changes: 66 additions & 1 deletion src/commands/scan/handle-create-new-scan.test.mts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { logger } from '@socketsecurity/registry/lib/logger'

import { finalizeTier1Scan } from './finalize-tier1-scan.mts'
import { handleCreateNewScan } from './handle-create-new-scan.mts'

import type { HandleCreateNewScanConfig } from './handle-create-new-scan.mts'
Expand Down Expand Up @@ -480,3 +483,65 @@ describe('handleCreateNewScan excludePaths', () => {
)
})
})

describe('handleCreateNewScan tier1 finalize', () => {
let warnSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
vi.clearAllMocks()
warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger)
mockFetchSupportedScanFileNames.mockResolvedValue({
data: { size: 1 },
ok: true,
})
mockFindSocketYmlSync.mockReturnValue({ ok: false })
mockGetPackageFilesForScan.mockResolvedValue(['package.json'])
mockFetchCreateOrgFullScan.mockResolvedValue({
data: { id: 'scan-id' },
ok: true,
})
})

afterEach(() => {
warnSpy.mockRestore()
})

it('finalizes the tier 1 scan when a scan id and tier 1 id are present', async () => {
mockPerformReachabilityAnalysis.mockResolvedValue({
data: {
reachabilityReport: '.socket.facts.json',
tier1ReachabilityScanId: 'tier1-id',
},
ok: true,
})

const config = createConfig()
config.reach.runReachabilityAnalysis = true

await handleCreateNewScan(config)

expect(finalizeTier1Scan).toHaveBeenCalledWith('tier1-id', 'scan-id')
expect(warnSpy).not.toHaveBeenCalled()
})

it('warns instead of silently skipping finalize when no tier 1 id was extracted', async () => {
mockPerformReachabilityAnalysis.mockResolvedValue({
data: {
reachabilityReport: '.socket.facts.json',
tier1ReachabilityScanId: undefined,
},
ok: true,
})

const config = createConfig()
config.reach.runReachabilityAnalysis = true

await handleCreateNewScan(config)

expect(finalizeTier1Scan).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(String(warnSpy.mock.calls[0]![0])).toMatch(
/tier 1 finalize|reachability report was not linked/i,
)
})
})
3 changes: 2 additions & 1 deletion src/commands/scan/handle-scan-reach.mts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export async function handleScanReach({
const supportedFilesCResult = await fetchSupportedScanFileNames({ spinner })
if (!supportedFilesCResult.ok) {
await outputScanReach(supportedFilesCResult, {
cwd,
outputKind,
outputPath,
})
Expand Down Expand Up @@ -103,5 +104,5 @@ export async function handleScanReach({

spinner.stop()

await outputScanReach(result, { outputKind, outputPath })
await outputScanReach(result, { cwd, outputKind, outputPath })
}
16 changes: 14 additions & 2 deletions src/commands/scan/output-scan-reach.mts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import path from 'node:path'

import { logger } from '@socketsecurity/registry/lib/logger'
import { pluralize } from '@socketsecurity/registry/lib/words'

Expand All @@ -11,7 +13,11 @@ import type { CResult, OutputKind } from '../../types.mts'

export async function outputScanReach(
result: CResult<ReachabilityAnalysisResult>,
{ outputKind, outputPath }: { outputKind: OutputKind; outputPath: string },
{
cwd,
outputKind,
outputPath,
}: { cwd: string; outputKind: OutputKind; outputPath: string },
): Promise<void> {
if (!result.ok) {
process.exitCode = result.code ?? 1
Expand All @@ -33,7 +39,13 @@ export async function outputScanReach(
logger.info(`Reachability report has been written to: ${actualOutputPath}`)

// Warn about individual vulnerabilities where reachability analysis errored.
const errors = extractReachabilityErrors(result.data.reachabilityReport)
// Resolve the report path against the scan `cwd` (not `process.cwd()`):
// Coana writes the facts file relative to `cwd` and `reachabilityReport`
// is a `cwd`-relative path, so reading the bare relative path would miss
// the file whenever `cwd !== process.cwd()` (e.g. `--cwd <dir>`).
const errors = extractReachabilityErrors(
path.resolve(cwd, result.data.reachabilityReport),
)
if (errors.length) {
logger.log('')
logger.warn(
Expand Down
95 changes: 95 additions & 0 deletions src/commands/scan/output-scan-reach.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Regression tests for outputScanReach facts-file resolution.
*
* Test Coverage:
* - Per-vulnerability reachability errors must be read from the facts
* file Coana actually wrote at `<cwd>/.socket.facts.json`, not from a
* relative path resolved against `process.cwd()`, so `--cwd <dir>` runs
* surface their errors instead of silently reporting none.
*
* Related Files:
* - output-scan-reach.mts (implementation)
* - utils/coana.mts (extractReachabilityErrors — exercised for real)
*/

import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { logger } from '@socketsecurity/registry/lib/logger'

import { outputScanReach } from './output-scan-reach.mts'

import type { ReachabilityAnalysisResult } from './perform-reachability-analysis.mts'
import type { CResult } from '../../types.mts'

vi.mock('../../constants.mts', () => ({
default: {
DOT_SOCKET_DOT_FACTS_JSON: '.socket.facts.json',
},
}))

const errorComponentsBody = {
components: [
{
name: 'lodash',
version: '4.17.21',
reachability: [
{
ghsa_id: 'GHSA-aaaa-bbbb-cccc',
reachability: [{ type: 'error', subprojectPath: 'packages/web' }],
},
],
},
],
}

describe('outputScanReach facts-file resolution', () => {
let scanCwd: string
let warnSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
scanCwd = mkdtempSync(path.join(tmpdir(), 'socket-rea495-out-'))
// Silence the rest of the text output; only assert on warnings.
vi.spyOn(logger, 'log').mockImplementation(() => logger)
vi.spyOn(logger, 'info').mockImplementation(() => logger)
vi.spyOn(logger, 'success').mockImplementation(() => logger)
warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger)
})

afterEach(() => {
rmSync(scanCwd, { force: true, recursive: true })
vi.restoreAllMocks()
})

it('reads reachability errors from the facts file under the scan cwd, not process.cwd()', async () => {
writeFileSync(
path.join(scanCwd, '.socket.facts.json'),
JSON.stringify(errorComponentsBody),
)

expect(scanCwd).not.toBe(process.cwd())

const result: CResult<ReachabilityAnalysisResult> = {
ok: true,
data: {
reachabilityReport: '.socket.facts.json',
tier1ReachabilityScanId: undefined,
},
}

await outputScanReach(result, {
cwd: scanCwd,
outputKind: 'text',
outputPath: '',
})

// The summary warning plus one per-vulnerability warning are emitted.
expect(warnSpy).toHaveBeenCalled()
const warned = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
expect(warned).toContain('GHSA-aaaa-bbbb-cccc')
expect(warned).toContain('lodash@4.17.21')
})
})
14 changes: 12 additions & 2 deletions src/commands/scan/perform-reachability-analysis.mts
Original file line number Diff line number Diff line change
Expand Up @@ -267,12 +267,22 @@ export async function performReachabilityAnalysis(
return coanaResult
}

// Coana writes the facts file relative to the scan `cwd` (it is spawned
// with `cwd` above), so resolve the read path against `cwd` too. Reading
// the bare relative path would resolve against `process.cwd()` and miss
// the file whenever `cwd !== process.cwd()` (e.g. `--cwd <dir>`), silently
// dropping the tier 1 scan id and skipping finalize downstream.
const resolvedReportPath = path.resolve(cwd, outputFilePath)

return {
ok: true,
data: {
// Use the actual output filename for the scan.
// Use the actual output filename for the scan. Keep this `cwd`-relative
// so the upload (which relativizes against `cwd`) and the post-success
// unlink (`path.resolve(cwd, reachabilityReport)`) keep working.
reachabilityReport: outputFilePath,
tier1ReachabilityScanId: extractTier1ReachabilityScanId(outputFilePath),
tier1ReachabilityScanId:
extractTier1ReachabilityScanId(resolvedReportPath),
},
}
}
Loading
Loading