From 574ce1d7045b954ae220ce1532866a21eae5b2f6 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Thu, 14 Aug 2025 11:21:43 +0200 Subject: [PATCH 1/4] update socket scan reach to include the same flags as socket scan create --reach and to use the same --manifests-tar-hash based approach for computing SBOMs --- src/commands/scan/cmd-scan-create.mts | 37 +--- src/commands/scan/cmd-scan-reach.mts | 142 ++++++++++-- src/commands/scan/cmd-scan-reach.test.mts | 206 +++++++++++++++++- src/commands/scan/handle-create-new-scan.mts | 189 +--------------- src/commands/scan/handle-scan-reach.mts | 79 +++++-- src/commands/scan/output-scan-reach.mts | 12 +- .../scan/perform-reachability-analysis.mts | 199 +++++++++++++++++ src/commands/scan/reachability-flags.mts | 37 ++++ 8 files changed, 644 insertions(+), 257 deletions(-) create mode 100644 src/commands/scan/perform-reachability-analysis.mts create mode 100644 src/commands/scan/reachability-flags.mts diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 97464f1c2..ee94c6dad 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -4,6 +4,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { handleCreateNewScan } from './handle-create-new-scan.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' +import { reachabilityFlags } from './reachability-flags.mts' import { suggestOrgSlug } from './suggest-org-slug.mts' import { suggestTarget } from './suggest_target.mts' import constants from '../../constants.mts' @@ -31,42 +32,6 @@ const { SOCKET_DEFAULT_REPOSITORY, } = constants -const reachabilityFlags: MeowFlags = { - reachDisableAnalytics: { - type: 'boolean', - description: - 'Disable reachability analytics sharing with Socket. Also disables caching-based optimizations.', - }, - reachAnalysisMemoryLimit: { - type: 'number', - description: - 'The maximum memory in MB to use for the reachability analysis. The default is 8192MB.', - default: 8192, - }, - reachAnalysisTimeout: { - type: 'number', - description: - 'Set timeout for the reachability analysis. Split analysis runs may cause the total scan time to exceed this timeout significantly.', - }, - reachEcosystems: { - type: 'string', - isMultiple: true, - description: - 'List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems.', - }, - reachContinueOnFailingProjects: { - type: 'boolean', - description: - 'Continue reachability analysis even when some projects/workspaces fail. Default is to crash the CLI at the first failing project/workspace.', - }, - reachExcludePaths: { - type: 'string', - isMultiple: true, - description: - 'List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags.', - }, -} - const config: CliCommandConfig = { commandName: 'create', description: 'Create a new Socket scan and report', diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index 983fa2ce8..ee6f4a06b 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -3,12 +3,21 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' import { handleScanReach } from './handle-scan-reach.mts' +import { reachabilityFlags } from './reachability-flags.mts' +import { suggestTarget } from './suggest_target.mts' import constants from '../../constants.mts' -import { commonFlags, outputFlags } from '../../flags.mts' +import { type MeowFlags, commonFlags, outputFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { + type EcosystemString, + getEcosystemChoicesForMeow, +} from '../../utils/ecosystem.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' @@ -21,18 +30,43 @@ const config: CliCommandConfig = { flags: { ...commonFlags, ...outputFlags, + cwd: { + type: 'string', + description: 'working directory, defaults to process.cwd()', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + ...reachabilityFlags, }, - help: (command, config) => ` + help: (command, config) => { + const allFlags = config.flags || {} + const generalFlags: MeowFlags = {} + + // Separate general flags from reachability flags + for (const [key, value] of Object.entries(allFlags)) { + if (!reachabilityFlags[key]) { + generalFlags[key] = value + } + } + + return ` Usage $ ${command} [options] [CWD=.] Options - ${getFlagListOutput(config.flags)} + ${getFlagListOutput(generalFlags)} + + Reachability Options + ${getFlagListOutput(reachabilityFlags)} Examples $ ${command} $ ${command} ./proj - `, + ` + }, } export const cmdScanReach = { @@ -53,30 +87,108 @@ async function run( parentName, }) - const { dryRun, json, markdown } = cli.flags + const { + cwd: cwdOverride, + dryRun = false, + interactive = true, + json, + markdown, + org: orgFlag, + reachAnalysisMemoryLimit, + reachAnalysisTimeout, + reachContinueOnFailingProjects, + reachDisableAnalytics, + } = cli.flags as { + cwd: string + dryRun: boolean + interactive: boolean + json: boolean + markdown: boolean + org: string + reachAnalysisTimeout?: number + reachAnalysisMemoryLimit?: number + reachContinueOnFailingProjects: boolean + reachDisableAnalytics: boolean + } + + // Process comma-separated values for isMultiple flags + const reachEcosystemsRaw = cmdFlagValueToArray(cli.flags['reachEcosystems']) + const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) + + // Validate ecosystem values + const validEcosystems = getEcosystemChoicesForMeow() + const reachEcosystems: EcosystemString[] = [] + for (const ecosystem of reachEcosystemsRaw) { + if (!validEcosystems.includes(ecosystem)) { + throw new Error( + `Invalid ecosystem: "${ecosystem}". Valid values are: ${validEcosystems.join(', ')}`, + ) + } + reachEcosystems.push(ecosystem as EcosystemString) + } const outputKind = getOutputKind(json, markdown) - const wasValidInput = checkCommandInput(outputKind) - if (!wasValidInput) { - return - } + const cwd = + cwdOverride && cwdOverride !== 'process.cwd()' + ? path.resolve(process.cwd(), String(cwdOverride)) + : process.cwd() + // Accept zero or more paths. Default to cwd() if none given. + let targets = cli.input || [cwd] + + // Check if we're in dry-run mode first if (dryRun) { logger.log(DRY_RUN_BAILING_NOW) return } - const { unknownFlags } = cli + // Use suggestTarget if no targets specified and in interactive mode + if (!targets.length && !dryRun && interactive) { + targets = await suggestTarget() + } + + // Determine org slug + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + interactive, + dryRun, + ) + + const hasApiToken = hasDefaultToken() - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + fail: 'missing', + }, + { + nook: true, + test: hasApiToken, + message: 'This command requires an API token for access', + fail: 'missing (try `socket login`)', + }, + ) + if (!wasValidInput) { + return + } await handleScanReach({ cwd, + orgSlug, outputKind, - unknownFlags, + targets, + interactive, + reachabilityOptions: { + reachContinueOnFailingProjects: Boolean(reachContinueOnFailingProjects), + reachDisableAnalytics: Boolean(reachDisableAnalytics), + reachAnalysisTimeout: Number(reachAnalysisTimeout), + reachAnalysisMemoryLimit: Number(reachAnalysisMemoryLimit), + reachEcosystems, + reachExcludePaths, + }, }) } diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index bb755d252..cf44d30ae 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -12,22 +12,30 @@ describe('socket scan reach', async () => { 'should support --help', async cmd => { const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot( - ` + expect(stdout).toMatchInlineSnapshot(` "Compute tier 1 reachability Usage $ socket scan reach [options] [CWD=.] Options + --cwd working directory, defaults to process.cwd() --json Output result as json --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + + Reachability Options + --reach-analysis-memory-limit The maximum memory in MB to use for the reachability analysis. The default is 8192MB. + --reach-analysis-timeout Set timeout for the reachability analysis. Split analysis runs may cause the total scan time to exceed this timeout significantly. + --reach-continue-on-failing-projects Continue reachability analysis even when some projects/workspaces fail. Default is to crash the CLI at the first failing project/workspace. + --reach-disable-analytics Disable reachability analytics sharing with Socket. Also disables caching-based optimizations. + --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems. + --reach-exclude-paths List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags. Examples $ socket scan reach $ socket scan reach ./proj" - `, - ) + `) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- @@ -60,4 +68,194 @@ describe('socket scan reach', async () => { expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) }, ) + + cmdit( + [ + 'scan', + 'reach', + '--dry-run', + '--reach-disable-analytics', + '--config', + '{}', + ], + 'should accept --reach-disable-analytics flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + '--dry-run', + '--reach-analysis-memory-limit', + '4096', + '--config', + '{}', + ], + 'should accept --reach-analysis-memory-limit flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + '--dry-run', + '--reach-analysis-timeout', + '3600', + '--config', + '{}', + ], + 'should accept --reach-analysis-timeout flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + '--dry-run', + '--reach-ecosystems', + 'npm,pypi', + '--config', + '{}', + ], + 'should accept --reach-ecosystems with comma-separated values', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + '--dry-run', + '--reach-ecosystems', + 'npm', + '--reach-ecosystems', + 'pypi', + '--config', + '{}', + ], + 'should accept multiple --reach-ecosystems flags', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + '--reach-ecosystems', + 'invalid-ecosystem', + '--config', + '{}', + ], + 'should fail with invalid ecosystem', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain('Invalid ecosystem: "invalid-ecosystem"') + expect(code, 'should exit with non-zero code').not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + '--dry-run', + '--reach-continue-on-failing-projects', + '--config', + '{}', + ], + 'should accept --reach-continue-on-failing-projects flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + '--dry-run', + '--reach-exclude-paths', + 'node_modules,dist', + '--config', + '{}', + ], + 'should accept --reach-exclude-paths with comma-separated values', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + '--dry-run', + '--reach-exclude-paths', + 'node_modules', + '--reach-exclude-paths', + 'dist', + '--config', + '{}', + ], + 'should accept multiple --reach-exclude-paths flags', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + '--dry-run', + '--reach-disable-analytics', + '--reach-analysis-memory-limit', + '4096', + '--reach-analysis-timeout', + '3600', + '--reach-ecosystems', + 'npm,pypi', + '--reach-continue-on-failing-projects', + '--reach-exclude-paths', + 'node_modules,dist', + '--config', + '{}', + ], + 'should accept all reachability flags together', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) }) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 8af4687c3..4453f7ecb 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -7,25 +7,18 @@ import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.m import { finalizeTier1Scan } from './finalize-tier1-scan.mts' import { handleScanReport } from './handle-scan-report.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' +import { + type ReachabilityOptions, + performReachabilityAnalysis, +} from './perform-reachability-analysis.mts' import constants from '../../constants.mts' -import { handleApiCall } from '../../utils/api.mts' import { checkCommandInput } from '../../utils/check-input.mts' -import { - extractTier1ReachabilityScanId, - spawnCoana, -} from '../../utils/coana.mts' -import { - type EcosystemString, - convertToCoanaEcosystems, -} from '../../utils/ecosystem.mts' import { getPackageFilesForScan } from '../../utils/path-resolve.mts' -import { setupSdk } from '../../utils/sdk.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { detectManifestActions } from '../manifest/detect-manifest-actions.mts' import { generateAutoManifest } from '../manifest/generate_auto_manifest.mts' -import type { CResult, OutputKind } from '../../types.mts' -import type { Spinner } from '@socketsecurity/registry/lib/spinner' +import type { OutputKind } from '../../types.mts' export async function handleCreateNewScan({ autoManifest, @@ -61,13 +54,7 @@ export async function handleCreateNewScan({ outputKind: OutputKind reach: { runReachabilityAnalysis: boolean - reachContinueOnFailingProjects: boolean - reachDisableAnalytics: boolean - reachAnalysisTimeout: number - reachAnalysisMemoryLimit: number - reachEcosystems: EcosystemString[] - reachExcludePaths: string[] - } + } & ReachabilityOptions readOnly: boolean repoName: string report: boolean @@ -148,6 +135,7 @@ export async function handleCreateNewScan({ repoName, branchName, reachabilityOptions: reach, + uploadManifests: true, }, { spinner }, ) @@ -226,166 +214,3 @@ export async function handleCreateNewScan({ await outputCreateNewScan(fullScanCResult, { interactive, outputKind }) } } - -type ReachabilityAnalysisConfig = { - branchName: string - cwd: string - orgSlug: string - packagePaths: string[] - reachabilityOptions: { - reachContinueOnFailingProjects: boolean - reachDisableAnalytics: boolean - reachAnalysisTimeout: number - reachAnalysisMemoryLimit: number - reachEcosystems: EcosystemString[] - reachExcludePaths: string[] - } - repoName: string -} - -type ReachabilityAnalysisOptions = { - spinner?: Spinner | undefined -} - -type ReachabilityAnalysisResult = { - scanPaths: string[] - tier1ReachabilityScanId: string | undefined -} - -async function performReachabilityAnalysis( - { - branchName, - cwd, - orgSlug, - packagePaths, - reachabilityOptions, - repoName, - }: ReachabilityAnalysisConfig, - options?: ReachabilityAnalysisOptions | undefined, -): Promise> { - const { spinner } = { - __proto__: null, - ...options, - } as ReachabilityAnalysisOptions - - // Setup SDK for uploading manifests - const sockSdkCResult = await setupSdk() - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - - const sockSdk = sockSdkCResult.data - - const wasSpinning = !!spinner?.isSpinning - - // Upload manifests to get tar hash - spinner?.start('Uploading manifests for reachability analysis...') - - // Exclude DOT_SOCKET_DOT_FACTS_JSON from previous runs. - const filteredPackagePaths = packagePaths.filter( - p => !p.endsWith(constants.DOT_SOCKET_DOT_FACTS_JSON), - ) - const uploadCResult = await handleApiCall( - sockSdk.uploadManifestFiles(orgSlug, filteredPackagePaths), - { - desc: 'upload manifests', - spinner, - }, - ) - - spinner?.stop() - - if (!uploadCResult.ok) { - if (wasSpinning) { - spinner.start() - } - return uploadCResult - } - - const tarHash = (uploadCResult.data as { tarHash?: string })?.tarHash - if (!tarHash) { - if (wasSpinning) { - spinner.start() - } - return { - ok: false, - message: 'Failed to get manifest tar hash', - cause: 'Server did not return a tar hash for the uploaded manifests', - } - } - - spinner?.start() - spinner?.success(`Manifests uploaded successfully. Tar hash: ${tarHash}`) - spinner?.infoAndStop('Running reachability analysis with Coana...') - - // Run Coana with the manifests tar hash. - const coanaResult = await spawnCoana( - [ - 'run', - cwd, - '--output-dir', - cwd, - '--socket-mode', - constants.DOT_SOCKET_DOT_FACTS_JSON, - '--disable-report-submission', - ...(reachabilityOptions.reachAnalysisTimeout - ? [ - '--analysis-timeout', - reachabilityOptions.reachAnalysisTimeout.toString(), - ] - : []), - ...(reachabilityOptions.reachAnalysisMemoryLimit - ? [ - '--memory-limit', - reachabilityOptions.reachAnalysisMemoryLimit.toString(), - ] - : []), - ...(reachabilityOptions.reachDisableAnalytics - ? ['--disable-analytics-sharing'] - : []), - ...(reachabilityOptions.reachContinueOnFailingProjects - ? ['--ignore-failing-workspaces'] - : []), - // empty reachEcosystems implies scan all ecosystems - ...(reachabilityOptions.reachEcosystems.length - ? [ - '--ecosystems', - convertToCoanaEcosystems(reachabilityOptions.reachEcosystems).join( - ' ', - ), - ] - : []), - ...(reachabilityOptions.reachExcludePaths.length - ? ['--exclude-dirs', reachabilityOptions.reachExcludePaths.join(' ')] - : []), - '--manifests-tar-hash', - tarHash, - ], - { - cwd, - env: { - ...process.env, - SOCKET_REPO_NAME: repoName, - SOCKET_BRANCH_NAME: branchName, - }, - spinner, - stdio: 'inherit', - }, - ) - - if (wasSpinning) { - spinner.start() - } - return coanaResult.ok - ? { - ok: true, - data: { - // Use the DOT_SOCKET_DOT_FACTS_JSON file for the scan. - scanPaths: [constants.DOT_SOCKET_DOT_FACTS_JSON], - tier1ReachabilityScanId: extractTier1ReachabilityScanId( - constants.DOT_SOCKET_DOT_FACTS_JSON, - ), - }, - } - : coanaResult -} diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index dd9330c8a..508ae97bd 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -1,42 +1,85 @@ +import { logger } from '@socketsecurity/registry/lib/logger' +import { pluralize } from '@socketsecurity/registry/lib/words' + +import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' import { outputScanReach } from './output-scan-reach.mts' +import { + type ReachabilityOptions, + performReachabilityAnalysis, +} from './perform-reachability-analysis.mts' import constants from '../../constants.mts' -import { spawnCoana } from '../../utils/coana.mts' - -const { DOT_SOCKET_DOT_FACTS_JSON } = constants +import { checkCommandInput } from '../../utils/check-input.mts' +import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import type { OutputKind } from '../../types.mts' export type HandleScanReachConfig = { cwd: string + interactive: boolean + orgSlug: string outputKind: OutputKind - unknownFlags: string[] + reachabilityOptions: ReachabilityOptions + targets: string[] } export async function handleScanReach({ cwd, + interactive: _interactive, + orgSlug, outputKind, - unknownFlags, + reachabilityOptions, + targets, }: HandleScanReachConfig) { + // Get supported file names + const supportedFilesCResult = await fetchSupportedScanFileNames() + if (!supportedFilesCResult.ok) { + await outputScanReach(supportedFilesCResult, { cwd, outputKind }) + return + } + // Lazily access constants.spinner. const { spinner } = constants - spinner.start('Running reachability scan...') + spinner.start( + 'Searching for local manifest files to include in reachability analysis...', + ) - const result = await spawnCoana( - [ - 'run', - cwd, - '--output-dir', + const supportedFiles = supportedFilesCResult.data + const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { + cwd, + }) + + spinner.stop() + + const wasValidInput = checkCommandInput(outputKind, { + nook: true, + test: packagePaths.length > 0, + fail: 'found no eligible files to analyze', + message: + 'TARGET (file/dir) must contain matching / supported file types for reachability analysis', + }) + if (!wasValidInput) { + return + } + + logger.success( + `Found ${packagePaths.length} local ${pluralize('file', packagePaths.length)}`, + ) + + spinner.start('Running reachability analysis...') + + const result = await performReachabilityAnalysis( + { cwd, - '--socket-mode', - DOT_SOCKET_DOT_FACTS_JSON, - '--disable-report-submission', - ...unknownFlags, - ], - { cwd, spinner }, + orgSlug, + packagePaths, + reachabilityOptions, + uploadManifests: true, + }, + { spinner }, ) spinner.stop() - await outputScanReach(result, outputKind) + await outputScanReach(result, { cwd, outputKind }) } diff --git a/src/commands/scan/output-scan-reach.mts b/src/commands/scan/output-scan-reach.mts index 806494154..97aee4184 100644 --- a/src/commands/scan/output-scan-reach.mts +++ b/src/commands/scan/output-scan-reach.mts @@ -1,13 +1,18 @@ +import path from 'node:path' + import { logger } from '@socketsecurity/registry/lib/logger' +import constants from '../../constants.mts' import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' import { serializeResultJson } from '../../utils/serialize-result-json.mts' import type { CResult, OutputKind } from '../../types.mts' +const { DOT_SOCKET_DOT_FACTS_JSON } = constants + export async function outputScanReach( result: CResult, - outputKind: OutputKind, + { cwd, outputKind }: { cwd: string; outputKind: OutputKind }, ): Promise { if (!result.ok) { process.exitCode = result.code ?? 1 @@ -23,5 +28,8 @@ export async function outputScanReach( } logger.log('') - logger.success('Finished!') + logger.success('Reachability analysis completed successfully!') + logger.info( + `Reachability report has been written to: ${path.join(cwd, DOT_SOCKET_DOT_FACTS_JSON)}`, + ) } diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts new file mode 100644 index 000000000..40f72d19d --- /dev/null +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -0,0 +1,199 @@ +import constants from '../../constants.mts' +import { handleApiCall } from '../../utils/api.mts' +import { + extractTier1ReachabilityScanId, + spawnCoana, +} from '../../utils/coana.mts' +import { + type EcosystemString, + convertToCoanaEcosystems, +} from '../../utils/ecosystem.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { Spinner } from '@socketsecurity/registry/lib/spinner' + +export type ReachabilityOptions = { + reachContinueOnFailingProjects: boolean + reachDisableAnalytics: boolean + reachAnalysisTimeout: number + reachAnalysisMemoryLimit: number + reachEcosystems: EcosystemString[] + reachExcludePaths: string[] +} + +export type ReachabilityAnalysisConfig = { + branchName?: string + cwd: string + orgSlug?: string + packagePaths?: string[] + reachabilityOptions: ReachabilityOptions + repoName?: string + uploadManifests?: boolean +} + +export type ReachabilityAnalysisOptions = { + spinner?: Spinner | undefined +} + +export type ReachabilityAnalysisResult = { + scanPaths: string[] + tier1ReachabilityScanId: string | undefined +} + +export async function performReachabilityAnalysis( + { + branchName, + cwd, + orgSlug, + packagePaths, + reachabilityOptions, + repoName, + uploadManifests = true, + }: ReachabilityAnalysisConfig, + options?: ReachabilityAnalysisOptions | undefined, +): Promise> { + const { spinner } = { + __proto__: null, + ...options, + } as ReachabilityAnalysisOptions + + let tarHash: string | undefined + + if (uploadManifests && orgSlug && packagePaths) { + // Setup SDK for uploading manifests + const sockSdkCResult = await setupSdk() + if (!sockSdkCResult.ok) { + return sockSdkCResult + } + + const sockSdk = sockSdkCResult.data + + const wasSpinning = !!spinner?.isSpinning + + // Upload manifests to get tar hash + spinner?.start('Uploading manifests for reachability analysis...') + + // Exclude DOT_SOCKET_DOT_FACTS_JSON if it was created in previous runs. + const filteredPackagePaths = packagePaths.filter( + p => !p.endsWith(constants.DOT_SOCKET_DOT_FACTS_JSON), + ) + const uploadCResult = await handleApiCall( + sockSdk.uploadManifestFiles(orgSlug, filteredPackagePaths), + { + desc: 'upload manifests', + spinner, + }, + ) + + spinner?.stop() + + if (!uploadCResult.ok) { + if (wasSpinning) { + spinner.start() + } + return uploadCResult + } + + tarHash = (uploadCResult.data as { tarHash?: string })?.tarHash + if (!tarHash) { + if (wasSpinning) { + spinner.start() + } + return { + ok: false, + message: 'Failed to get manifest tar hash', + cause: 'Server did not return a tar hash for the uploaded manifests', + } + } + + spinner?.start() + spinner?.success(`Manifests uploaded successfully. Tar hash: ${tarHash}`) + spinner?.infoAndStop('Running reachability analysis with Coana...') + } else { + const wasSpinning = !!spinner?.isSpinning + spinner?.start('Running reachability analysis with Coana...') + if (!wasSpinning) { + spinner?.stop() + } + } + + // Build Coana arguments + const coanaArgs = [ + 'run', + cwd, + '--output-dir', + cwd, + '--socket-mode', + constants.DOT_SOCKET_DOT_FACTS_JSON, + '--disable-report-submission', + ...(reachabilityOptions.reachAnalysisTimeout + ? [ + '--analysis-timeout', + reachabilityOptions.reachAnalysisTimeout.toString(), + ] + : []), + ...(reachabilityOptions.reachAnalysisMemoryLimit + ? [ + '--memory-limit', + reachabilityOptions.reachAnalysisMemoryLimit.toString(), + ] + : []), + ...(reachabilityOptions.reachDisableAnalytics + ? ['--disable-analytics-sharing'] + : []), + ...(reachabilityOptions.reachContinueOnFailingProjects + ? ['--ignore-failing-workspaces'] + : []), + // empty reachEcosystems implies scan all ecosystems + ...(reachabilityOptions.reachEcosystems.length + ? [ + '--ecosystems', + ...convertToCoanaEcosystems(reachabilityOptions.reachEcosystems), + ] + : []), + ...(reachabilityOptions.reachExcludePaths.length + ? ['--exclude-dirs', reachabilityOptions.reachExcludePaths.join(' ')] + : []), + ...(tarHash + ? ['--manifests-tar-hash', tarHash, '--run-without-docker'] + : []), + ] + + // Build environment variables + const env: NodeJS.ProcessEnv = { + ...process.env, + } + if (repoName) { + env['SOCKET_REPO_NAME'] = repoName + } + if (branchName) { + env['SOCKET_BRANCH_NAME'] = branchName + } + + // Run Coana with the manifests tar hash. + const coanaResult = await spawnCoana(coanaArgs, { + cwd, + env, + spinner, + stdio: 'inherit', + }) + + const wasSpinning = !!spinner?.isSpinning + if (wasSpinning) { + spinner.start() + } + + return coanaResult.ok + ? { + ok: true, + data: { + // Use the DOT_SOCKET_DOT_FACTS_JSON file for the scan. + scanPaths: [constants.DOT_SOCKET_DOT_FACTS_JSON], + tier1ReachabilityScanId: extractTier1ReachabilityScanId( + constants.DOT_SOCKET_DOT_FACTS_JSON, + ), + }, + } + : coanaResult +} diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts new file mode 100644 index 000000000..45de0f1a8 --- /dev/null +++ b/src/commands/scan/reachability-flags.mts @@ -0,0 +1,37 @@ +import type { MeowFlags } from '../../flags.mts' + +export const reachabilityFlags: MeowFlags = { + reachDisableAnalytics: { + type: 'boolean', + description: + 'Disable reachability analytics sharing with Socket. Also disables caching-based optimizations.', + }, + reachAnalysisMemoryLimit: { + type: 'number', + description: + 'The maximum memory in MB to use for the reachability analysis. The default is 8192MB.', + default: 8192, + }, + reachAnalysisTimeout: { + type: 'number', + description: + 'Set timeout for the reachability analysis. Split analysis runs may cause the total scan time to exceed this timeout significantly.', + }, + reachEcosystems: { + type: 'string', + isMultiple: true, + description: + 'List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems.', + }, + reachContinueOnFailingProjects: { + type: 'boolean', + description: + 'Continue reachability analysis even when some projects/workspaces fail. Default is to crash the CLI at the first failing project/workspace.', + }, + reachExcludePaths: { + type: 'string', + isMultiple: true, + description: + 'List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags.', + }, +} From 719deba0f2895645cf98bc4fd6f782fedcef5580 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Thu, 14 Aug 2025 11:31:33 +0200 Subject: [PATCH 2/4] update socket scan reach description --- src/commands/scan/cmd-scan-reach.mts | 8 ++++++++ src/commands/scan/cmd-scan-reach.test.mts | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index ee6f4a06b..239b71cd8 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -62,9 +62,17 @@ const config: CliCommandConfig = { Reachability Options ${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. + + Note: Manifest files are uploaded to Socket's backend services because the + reachability analysis requires creating a Software Bill of Materials (SBOM) + from these files before the analysis can run. + Examples $ ${command} $ ${command} ./proj + $ ${command} ./proj --reach-ecosystems npm,pypi ` }, } diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index cf44d30ae..4b6f8f887 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -32,9 +32,17 @@ describe('socket scan reach', async () => { --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems. --reach-exclude-paths List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags. + Runs the Socket reachability analysis without creating a scan in Socket. + The output is written to .socket.facts.json in the current working directory. + + Note: Manifest files are uploaded to Socket's backend services because the + reachability analysis requires creating a Software Bill of Materials (SBOM) + from these files before the analysis can run. + Examples $ socket scan reach - $ socket scan reach ./proj" + $ socket scan reach ./proj + $ socket scan reach ./proj --reach-ecosystems npm,pypi" `) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " From 9553f823363a1b1054a596975d7014f4ba66bdc1 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Thu, 14 Aug 2025 13:08:11 +0200 Subject: [PATCH 3/4] fix bug where reachability analyses were not included in the rollup external dependencies --- .config/rollup.dist.config.mjs | 2 +- src/constants.mts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index 10136e5d1..43d081543 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -121,7 +121,7 @@ async function copyExternalPackages() { [ [blessedPath, ['lib/**/*.js', 'usr/**/**', 'vendor/**/*.js', 'LICENSE*']], [blessedContribPath, ['lib/**/*.js', 'index.js', 'LICENSE*']], - [coanaPath, ['**/*.mjs']], + [coanaPath, ['**/*.mjs', 'coana-repos/**/*']], [ socketRegistryPath, [ diff --git a/src/constants.mts b/src/constants.mts index 6637f3830..4ab8ca46a 100644 --- a/src/constants.mts +++ b/src/constants.mts @@ -451,7 +451,7 @@ const lazyBlessedPath = () => const lazyCoanaBinPath = () => // Lazily access constants.coanaPath. - path.join(constants.coanaPath, 'cli.mjs') + path.join(constants.coanaPath, 'cli-wrapper.mjs') const lazyCoanaPath = () => // Lazily access constants.externalPath. From 27e728f1c8e992a02215f4ed54be739c403fddd9 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Thu, 14 Aug 2025 13:55:17 +0200 Subject: [PATCH 4/4] move dryrun bail to end cmd-scan-reach --- src/commands/scan/cmd-scan-reach.mts | 11 +++-- src/commands/scan/cmd-scan-reach.test.mts | 52 +++++++++++++---------- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index 239b71cd8..6c96c5b69 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -145,12 +145,6 @@ async function run( // Accept zero or more paths. Default to cwd() if none given. let targets = cli.input || [cwd] - // Check if we're in dry-run mode first - if (dryRun) { - logger.log(DRY_RUN_BAILING_NOW) - return - } - // Use suggestTarget if no targets specified and in interactive mode if (!targets.length && !dryRun && interactive) { targets = await suggestTarget() @@ -184,6 +178,11 @@ async function run( return } + if (dryRun) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + await handleScanReach({ cwd, orgSlug, diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 4b6f8f887..a771530eb 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -60,7 +60,15 @@ describe('socket scan reach', async () => { ) cmdit( - ['scan', 'reach', '--dry-run', '--config', '{}'], + [ + 'scan', + 'reach', + '--dry-run', + '--org', + 'fakeorg', + '--config', + '{"apiToken": "abc"}', + ], 'should require args with just dry-run', async cmd => { const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) @@ -69,7 +77,7 @@ describe('socket scan reach', async () => { " _____ _ _ /--------------- | __|___ ___| |_ ___| |_ | Socket.dev CLI ver - |__ | * | _| '_| -_| _| | Node: , API token: , org: + |__ | * | _| '_| -_| _| | Node: , API token: , --org: fakeorg |_____|___|___|_,_|___|_|.dev | Command: \`socket scan reach\`, cwd: " `) @@ -82,9 +90,9 @@ describe('socket scan reach', async () => { 'scan', 'reach', '--dry-run', + '--org', + 'fakeorg', '--reach-disable-analytics', - '--config', - '{}', ], 'should accept --reach-disable-analytics flag', async cmd => { @@ -101,8 +109,8 @@ describe('socket scan reach', async () => { '--dry-run', '--reach-analysis-memory-limit', '4096', - '--config', - '{}', + '--org', + 'fakeorg', ], 'should accept --reach-analysis-memory-limit flag', async cmd => { @@ -119,8 +127,8 @@ describe('socket scan reach', async () => { '--dry-run', '--reach-analysis-timeout', '3600', - '--config', - '{}', + '--org', + 'fakeorg', ], 'should accept --reach-analysis-timeout flag', async cmd => { @@ -137,8 +145,8 @@ describe('socket scan reach', async () => { '--dry-run', '--reach-ecosystems', 'npm,pypi', - '--config', - '{}', + '--org', + 'fakeorg', ], 'should accept --reach-ecosystems with comma-separated values', async cmd => { @@ -157,8 +165,8 @@ describe('socket scan reach', async () => { 'npm', '--reach-ecosystems', 'pypi', - '--config', - '{}', + '--org', + 'fakeorg', ], 'should accept multiple --reach-ecosystems flags', async cmd => { @@ -174,8 +182,8 @@ describe('socket scan reach', async () => { 'reach', '--reach-ecosystems', 'invalid-ecosystem', - '--config', - '{}', + '--org', + 'fakeorg', ], 'should fail with invalid ecosystem', async cmd => { @@ -192,8 +200,8 @@ describe('socket scan reach', async () => { 'reach', '--dry-run', '--reach-continue-on-failing-projects', - '--config', - '{}', + '--org', + 'fakeorg', ], 'should accept --reach-continue-on-failing-projects flag', async cmd => { @@ -210,8 +218,8 @@ describe('socket scan reach', async () => { '--dry-run', '--reach-exclude-paths', 'node_modules,dist', - '--config', - '{}', + '--org', + 'fakeorg', ], 'should accept --reach-exclude-paths with comma-separated values', async cmd => { @@ -230,8 +238,8 @@ describe('socket scan reach', async () => { 'node_modules', '--reach-exclude-paths', 'dist', - '--config', - '{}', + '--org', + 'fakeorg', ], 'should accept multiple --reach-exclude-paths flags', async cmd => { @@ -256,8 +264,8 @@ describe('socket scan reach', async () => { '--reach-continue-on-failing-projects', '--reach-exclude-paths', 'node_modules,dist', - '--config', - '{}', + '--org', + 'fakeorg', ], 'should accept all reachability flags together', async cmd => {