diff --git a/src/commands/scan/finalize-tier1-scan.mts b/src/commands/scan/finalize-tier1-scan.mts new file mode 100644 index 000000000..5cfbe5ef9 --- /dev/null +++ b/src/commands/scan/finalize-tier1-scan.mts @@ -0,0 +1,28 @@ +import { sendApiRequest } from '../../utils/api.mts' + +import type { CResult } from '../../types.mts' + +export type FinalizeTier1ScanOptions = { + tier1_reachability_scan_id: string + report_run_id: string +} + +/** + * Finalize a tier1 reachability scan. + * - Associates the tier1 reachability scan metadata with the full scan. + * - Sets the tier1 reachability scan to "finalized" state. + */ +export async function finalizeTier1Scan( + tier1_reachability_scan_id: string, + report_run_id: string, +): Promise> { + // we do not use the SDK here because the tier1-reachability-scan/finalize is a hidden + // endpoint that is not part of the OpenAPI specification. + return await sendApiRequest('tier1-reachability-scan/finalize', { + method: 'POST', + body: { + tier1_reachability_scan_id, + report_run_id, + }, + }) +} diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 579b7fff4..e0ec7ef90 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -4,12 +4,16 @@ import { pluralize } from '@socketsecurity/registry/lib/words' import { fetchCreateOrgFullScan } from './fetch-create-org-full-scan.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' +import { finalizeTier1Scan } from './finalize-tier1-scan.mts' import { handleScanReport } from './handle-scan-report.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' import constants from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' import { checkCommandInput } from '../../utils/check-input.mts' -import { spawnCoana } from '../../utils/coana.mts' +import { + extractTier1ReachabilityScanId, + spawnCoana, +} from '../../utils/coana.mts' import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import { setupSdk } from '../../utils/sdk.mts' import { readOrDefaultSocketJson } from '../../utils/socketjson.mts' @@ -112,6 +116,7 @@ export async function handleCreateNewScan({ } let scanPaths: string[] = packagePaths + let tier1ReachabilityScanId: string | undefined // If reachability is enabled, perform reachability analysis if (reach) { @@ -131,6 +136,7 @@ export async function handleCreateNewScan({ } scanPaths = reachResult.data?.scanPaths || [] + tier1ReachabilityScanId = reachResult.data?.tier1ReachabilityScanId } const fullScanCResult = await fetchCreateOrgFullScan( @@ -152,6 +158,15 @@ export async function handleCreateNewScan({ }, ) + if ( + fullScanCResult.ok && + reach && + tier1ReachabilityScanId && + fullScanCResult.data?.id + ) { + await finalizeTier1Scan(tier1ReachabilityScanId, fullScanCResult.data?.id) + } + if (fullScanCResult.ok && report) { if (fullScanCResult.data?.id) { await handleScanReport({ @@ -195,7 +210,9 @@ async function performReachabilityAnalysis({ branchName: string outputKind: OutputKind interactive: boolean -}): Promise> { +}): Promise< + CResult<{ scanPaths?: string[]; tier1ReachabilityScanId: string | undefined }> +> { logger.info('Starting reachability analysis...') packagePaths = packagePaths.filter( @@ -275,6 +292,9 @@ async function performReachabilityAnalysis({ ok: true, data: { scanPaths: [constants.DOT_SOCKET_DOT_FACTS_JSON], + tier1ReachabilityScanId: extractTier1ReachabilityScanId( + constants.DOT_SOCKET_DOT_FACTS_JSON, + ), }, } } diff --git a/src/utils/api.mts b/src/utils/api.mts index 9d77cbe3e..ba522a3bf 100644 --- a/src/utils/api.mts +++ b/src/utils/api.mts @@ -285,3 +285,103 @@ export async function queryApiSafeJson( } } } + +export async function sendApiRequest( + path: string, + options: { + method: 'POST' | 'PUT' + body?: unknown + fetchSpinnerDesc?: string + }, +): Promise> { + const apiToken = getDefaultToken() + if (!apiToken) { + return { + ok: false, + message: 'Authentication Error', + cause: + 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your Socket API token.', + } + } + + const baseUrl = getDefaultApiBaseUrl() || '' + if (!baseUrl) { + logger.warn( + 'API endpoint is not set and default was empty. Request is likely to fail.', + ) + } + + // Lazily access constants.spinner. + const { spinner } = constants + + if (options.fetchSpinnerDesc) { + spinner.start(`Requesting ${options.fetchSpinnerDesc} from API...`) + } + + let result + try { + const fetchOptions = { + method: options.method, + headers: { + Authorization: `Basic ${btoa(`${apiToken}:`)}`, + 'Content-Type': 'application/json', + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + } + + result = await fetch( + `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`, + fetchOptions, + ) + if (options.fetchSpinnerDesc) { + spinner.successAndStop( + `Received Socket API response (after requesting ${options.fetchSpinnerDesc}).`, + ) + } + } catch (e) { + if (options.fetchSpinnerDesc) { + spinner.failAndStop( + `An error was thrown while requesting ${options.fetchSpinnerDesc}.`, + ) + } + + const cause = (e as undefined | { message: string })?.message + + debugFn('error', `caught: await fetch() ${options.method} error`) + debugDir('inspect', { error: e }) + + return { + ok: false, + message: 'API Request failed to complete', + ...(cause ? { cause } : {}), + } + } + + if (!result.ok) { + const cause = await getErrorMessageForHttpStatusCode(result.status) + return { + ok: false, + message: 'Socket API returned an error', + cause: `${result.statusText}${cause ? ` (cause: ${cause})` : ''}`, + data: { + code: result.status, + }, + } + } + + try { + const data = await result.json() + return { + ok: true, + data: data as T, + } + } catch (e) { + debugFn('error', 'caught: await result.json() error') + debugDir('inspect', { error: e }) + return { + ok: false, + message: 'API Request failed to complete', + cause: 'There was an unexpected error trying to parse the response JSON', + } + } +} diff --git a/src/utils/coana.mts b/src/utils/coana.mts index 9fe83fbff..59528f32d 100644 --- a/src/utils/coana.mts +++ b/src/utils/coana.mts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' + import { spawn } from '@socketsecurity/registry/lib/spawn' import { getDefaultOrgSlug } from '../commands/ci/fetch-default-org-slug.mts' @@ -14,7 +16,7 @@ export async function spawnCoana( args: string[] | readonly string[], options?: SpawnOptions | undefined, extra?: SpawnExtra | undefined, -): Promise> { +): Promise> { const { env: spawnEnv } = { __proto__: null, ...options } as SpawnOptions const mixinsEnv: Record = { // Lazily access constants.ENV.INLINED_SOCKET_CLI_VERSION. @@ -59,3 +61,15 @@ export async function spawnCoana( return { ok: false, data: e, message } } } + +export function extractTier1ReachabilityScanId( + socketFactsFile: string, +): string | undefined { + try { + const content = readFileSync(socketFactsFile, 'utf8') + const json = JSON.parse(content) + return json.tier1ReachabilityScanId + } catch { + return undefined + } +}