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/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..6c96c5b69 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,51 @@ 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)} + + 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 + ` + }, } export const cmdScanReach = { @@ -53,11 +95,85 @@ 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) + 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] + + // 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() + + 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 } @@ -67,16 +183,19 @@ async function run( return } - const { unknownFlags } = cli - - 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) - 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..a771530eb 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -12,22 +12,38 @@ 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. + + 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(` " _____ _ _ /--------------- @@ -44,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) @@ -53,11 +77,201 @@ describe('socket scan reach', async () => { " _____ _ _ /--------------- | __|___ ___| |_ ___| |_ | Socket.dev CLI ver - |__ | * | _| '_| -_| _| | Node: , API token: , org: + |__ | * | _| '_| -_| _| | Node: , API token: , --org: fakeorg |_____|___|___|_,_|___|_|.dev | Command: \`socket scan reach\`, cwd: " `) expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) }, ) + + cmdit( + [ + 'scan', + 'reach', + '--dry-run', + '--org', + 'fakeorg', + '--reach-disable-analytics', + ], + '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', + '--org', + 'fakeorg', + ], + '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', + '--org', + 'fakeorg', + ], + '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', + '--org', + 'fakeorg', + ], + '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', + '--org', + 'fakeorg', + ], + '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', + '--org', + 'fakeorg', + ], + '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', + '--org', + 'fakeorg', + ], + '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', + '--org', + 'fakeorg', + ], + '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', + '--org', + 'fakeorg', + ], + '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', + '--org', + 'fakeorg', + ], + '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.', + }, +} 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.