diff --git a/src/commands/ci/handle-ci.mts b/src/commands/ci/handle-ci.mts index 1581ce4d1..b0a684eec 100644 --- a/src/commands/ci/handle-ci.mts +++ b/src/commands/ci/handle-ci.mts @@ -37,7 +37,15 @@ export async function handleCi(autoManifest: boolean): Promise { // When 'pendingHead' is true, it requires 'branchName' set and 'tmp' false. pendingHead: true, pullRequest: 0, - reach: false, + reach: { + runReachabilityAnalysis: false, + reachContinueOnFailingProjects: false, + reachDisableAnalytics: false, + reachAnalysisTimeout: 0, + reachAnalysisMemoryLimit: 0, + reachEcosystems: [], + reachExcludePaths: [], + }, repoName, readOnly: false, report: true, diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index c6bd4304c..97464f1c2 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -7,9 +7,14 @@ import { outputCreateNewScan } from './output-create-new-scan.mts' import { suggestOrgSlug } from './suggest-org-slug.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 { getRepoName, gitBranch } from '../../utils/git.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' @@ -26,6 +31,42 @@ 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', @@ -87,19 +128,16 @@ const config: CliCommandConfig = { description: 'Force override the organization slug, overrides the default org from config', }, - readOnly: { + reach: { type: 'boolean', default: false, - description: - 'Similar to --dry-run except it can read from remote, stops before it would create an actual report', + description: 'Run tier 1 full application reachability analysis', }, - reach: { + readOnly: { type: 'boolean', default: false, - // TODO: Temporarily hide option until Coana side is ironed out. - hidden: true, description: - 'Run tier 1 full application reachability analysis during the scanning process', + 'Similar to --dry-run except it can read from remote, stops before it would create an actual report', }, repo: { type: 'string', @@ -125,9 +163,23 @@ const config: CliCommandConfig = { description: 'Set the visibility (true/false) of the scan in your dashboard.', }, + + // Reachability scan flags + ...reachabilityFlags, }, // TODO: Your project's "socket.yml" file's "projectIgnorePaths". - 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] [TARGET...] @@ -136,7 +188,10 @@ const config: CliCommandConfig = { - Permissions: full-scans:create Options - ${getFlagListOutput(config.flags)} + ${getFlagListOutput(generalFlags)} + + Reachability Options (when --reach is used) + ${getFlagListOutput(reachabilityFlags)} Uploads the specified dependency manifest files for Go, Gradle, JavaScript, Kotlin, Python, and Scala. Files like "package.json" and "requirements.txt". @@ -172,7 +227,8 @@ const config: CliCommandConfig = { $ ${command} $ ${command} ./proj --json $ ${command} --repo=test-repo --branch=main ./package.json - `, + ` + }, } export const cmdScanCreate = { @@ -206,6 +262,10 @@ async function run( org: orgFlag, pullRequest, reach, + reachAnalysisMemoryLimit, + reachAnalysisTimeout, + reachContinueOnFailingProjects, + reachDisableAnalytics, readOnly, setAsAlertsPage: pendingHeadFlag, tmp, @@ -221,11 +281,34 @@ async function run( markdown: boolean org: string pullRequest: number - reach: boolean readOnly: boolean setAsAlertsPage: boolean tmp: boolean + + // reachability flags + reach: boolean + 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) + } + let { autoManifest, branch: branchName, @@ -395,6 +478,52 @@ async function run( message: 'When --defaultBranch is set, --branch is mandatory', fail: 'missing branch name', }, + { + nook: true, + test: reach || !reachDisableAnalytics, + message: 'The --reachDisableAnalytics flag requires --reach to be set', + pass: 'ok', + fail: 'missing --reach flag', + }, + { + nook: true, + test: + reach || + reachAnalysisMemoryLimit === undefined || + reachAnalysisMemoryLimit === 8192, + message: 'The --reachAnalysisMemoryLimit flag requires --reach to be set', + pass: 'ok', + fail: 'missing --reach flag', + }, + { + nook: true, + test: reach || !reachAnalysisTimeout, + message: 'The --reachAnalysisTimeout flag requires --reach to be set', + pass: 'ok', + fail: 'missing --reach flag', + }, + { + nook: true, + test: reach || !reachEcosystems.length, + message: 'The --reachEcosystems flag requires --reach to be set', + pass: 'ok', + fail: 'missing --reach flag', + }, + { + nook: true, + test: reach || !reachContinueOnFailingProjects, + message: + 'The --reachContinueOnFailingProjects flag requires --reach to be set', + pass: 'ok', + fail: 'missing --reach flag', + }, + { + nook: true, + test: reach || !reachExcludePaths.length, + message: 'The --reachExcludePaths flag requires --reach to be set', + pass: 'ok', + fail: 'missing --reach flag', + }, ) if (!wasValidInput) { return @@ -419,7 +548,15 @@ async function run( outputKind, pendingHead: Boolean(pendingHead), pullRequest: Number(pullRequest), - reach: Boolean(reach), + reach: { + runReachabilityAnalysis: Boolean(reach), + reachContinueOnFailingProjects: Boolean(reachContinueOnFailingProjects), + reachDisableAnalytics: Boolean(reachDisableAnalytics), + reachAnalysisTimeout: Number(reachAnalysisTimeout), + reachAnalysisMemoryLimit: Number(reachAnalysisMemoryLimit), + reachEcosystems, + reachExcludePaths, + }, readOnly: Boolean(readOnly), repoName, report, diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index bc783fd64..217d3cf61 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -35,12 +35,21 @@ describe('socket scan create', async () => { --markdown Output result as markdown --org Force override the organization slug, overrides the default org from config --pull-request Commit hash + --reach Run tier 1 full application reachability analysis --read-only Similar to --dry-run except it can read from remote, stops before it would create an actual report --repo Repository name --report Wait for the scan creation to complete, then basically run \`socket scan report\` on it --set-as-alerts-page When true and if this is the "default branch" then this Scan will be the one reflected on your alerts page. See help for details. Defaults to true. --tmp Set the visibility (true/false) of the scan in your dashboard. + Reachability Options (when --reach is used) + --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. + Uploads the specified dependency manifest files for Go, Gradle, JavaScript, Kotlin, Python, and Scala. Files like "package.json" and "requirements.txt". If any folder is specified, the ones found in there recursively are uploaded. @@ -121,4 +130,474 @@ describe('socket scan create', async () => { expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) }, ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reachDisableAnalytics', + '--config', + '{"apiToken": "abc"}', + ], + 'should fail when --reachDisableAnalytics is used without --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'The --reachDisableAnalytics flag requires --reach to be set', + ) + expect(output).toContain('missing --reach flag') + expect( + code, + 'should exit with non-zero code when validation fails', + ).not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reachAnalysisMemoryLimit', + '8192', + '--config', + '{"apiToken": "abc"}', + ], + 'should succeed when --reachAnalysisMemoryLimit is used with default value without --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0 when using default value').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reachAnalysisMemoryLimit', + '4096', + '--config', + '{"apiToken": "abc"}', + ], + 'should fail when --reachAnalysisMemoryLimit is used with non-default value without --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'The --reachAnalysisMemoryLimit flag requires --reach to be set', + ) + expect(output).toContain('missing --reach flag') + expect( + code, + 'should exit with non-zero code when validation fails', + ).not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reachAnalysisTimeout', + '3600', + '--config', + '{"apiToken": "abc"}', + ], + 'should fail when --reachAnalysisTimeout is used without --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'The --reachAnalysisTimeout flag requires --reach to be set', + ) + expect(output).toContain('missing --reach flag') + expect( + code, + 'should exit with non-zero code when validation fails', + ).not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reachEcosystems', + 'npm', + '--reachEcosystems', + 'pypi', + '--config', + '{"apiToken": "abc"}', + ], + 'should fail when --reachEcosystems is used without --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'The --reachEcosystems flag requires --reach to be set', + ) + expect(output).toContain('missing --reach flag') + expect( + code, + 'should exit with non-zero code when validation fails', + ).not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach-continue-on-failing-projects', + '--config', + '{"apiToken": "abc"}', + ], + 'should fail when --reach-continue-on-failing-projects is used without --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'The --reachContinueOnFailingProjects flag requires --reach to be set', + ) + expect(output).toContain('missing --reach flag') + expect( + code, + 'should exit with non-zero code when validation fails', + ).not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach', + '--reachDisableAnalytics', + '--reachAnalysisMemoryLimit', + '4096', + '--reachAnalysisTimeout', + '3600', + '--reachEcosystems', + 'npm', + '--config', + '{"apiToken": "abc"}', + ], + 'should succeed when reachability options are used with --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0 when all flags are valid').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach-exclude-paths', + 'node_modules', + '--reach-exclude-paths', + 'dist', + '--config', + '{"apiToken": "abc"}', + ], + 'should fail when --reach-exclude-paths is used without --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'The --reachExcludePaths flag requires --reach to be set', + ) + expect(output).toContain('missing --reach flag') + expect( + code, + 'should exit with non-zero code when validation fails', + ).not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach', + '--reach-continue-on-failing-projects', + '--reach-disable-analytics', + '--reach-analysis-memory-limit', + '4096', + '--reach-analysis-timeout', + '3600', + '--reach-ecosystems', + 'npm', + '--config', + '{"apiToken": "abc"}', + ], + 'should succeed when all reachability options including reachContinueOnFailingProjects are used with --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0 when all flags are valid').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach', + '--reach-continue-on-failing-projects', + '--reach-disable-analytics', + '--reach-analysis-memory-limit', + '4096', + '--reach-analysis-timeout', + '3600', + '--reach-ecosystems', + 'npm', + '--reach-exclude-paths', + 'node_modules', + '--reach-exclude-paths', + 'dist', + '--config', + '{"apiToken": "abc"}', + ], + 'should succeed when all reachability options including reachExcludePaths are used with --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0 when all flags are valid').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach', + '--reach-ecosystems', + 'npm,pypi,cargo', + '--config', + '{"apiToken": "abc"}', + ], + 'should succeed when --reach-ecosystems is used 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 when comma-separated values are used', + ).toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach', + '--reach-exclude-paths', + 'node_modules,dist,build', + '--config', + '{"apiToken": "abc"}', + ], + 'should succeed when --reach-exclude-paths is used 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 when comma-separated values are used', + ).toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach-ecosystems', + 'npm,pypi', + '--config', + '{"apiToken": "abc"}', + ], + 'should fail when --reach-ecosystems with comma-separated values is used without --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'The --reachEcosystems flag requires --reach to be set', + ) + expect(output).toContain('missing --reach flag') + expect( + code, + 'should exit with non-zero code when validation fails', + ).not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach-exclude-paths', + 'node_modules,dist', + '--config', + '{"apiToken": "abc"}', + ], + 'should fail when --reach-exclude-paths with comma-separated values is used without --reach', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'The --reachExcludePaths flag requires --reach to be set', + ) + expect(output).toContain('missing --reach flag') + expect( + code, + 'should exit with non-zero code when validation fails', + ).not.toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach', + '--reach-ecosystems', + 'npm,invalid-ecosystem', + '--config', + '{"apiToken": "abc"}', + ], + 'should fail when --reach-ecosystems contains invalid values', + 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 when invalid ecosystem is provided', + ).not.toBe(0) + }, + ) }) diff --git a/src/commands/scan/create-scan-from-github.mts b/src/commands/scan/create-scan-from-github.mts index 409ab0389..9b10f1394 100644 --- a/src/commands/scan/create-scan-from-github.mts +++ b/src/commands/scan/create-scan-from-github.mts @@ -239,7 +239,15 @@ async function scanOneRepo( outputKind, pendingHead: true, pullRequest: 0, - reach: false, + reach: { + runReachabilityAnalysis: false, + reachContinueOnFailingProjects: false, + reachDisableAnalytics: false, + reachAnalysisTimeout: 0, + reachAnalysisMemoryLimit: 0, + reachEcosystems: [], + reachExcludePaths: [], + }, readOnly: false, repoName: repoSlug, report: false, diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 2cc752850..8af4687c3 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -14,6 +14,10 @@ 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' @@ -55,7 +59,15 @@ export async function handleCreateNewScan({ pendingHead: boolean pullRequest: number outputKind: OutputKind - reach: boolean + reach: { + runReachabilityAnalysis: boolean + reachContinueOnFailingProjects: boolean + reachDisableAnalytics: boolean + reachAnalysisTimeout: number + reachAnalysisMemoryLimit: number + reachEcosystems: EcosystemString[] + reachExcludePaths: string[] + } readOnly: boolean repoName: string report: boolean @@ -122,7 +134,7 @@ export async function handleCreateNewScan({ let tier1ReachabilityScanId: string | undefined // If reachability is enabled, perform reachability analysis. - if (reach) { + if (reach.runReachabilityAnalysis) { logger.error('') logger.info('Starting reachability analysis...') @@ -135,8 +147,7 @@ export async function handleCreateNewScan({ cwd, repoName, branchName, - outputKind, - interactive, + reachabilityOptions: reach, }, { spinner }, ) @@ -217,13 +228,19 @@ export async function handleCreateNewScan({ } type ReachabilityAnalysisConfig = { - packagePaths: string[] - orgSlug: string + branchName: string cwd: string + orgSlug: string + packagePaths: string[] + reachabilityOptions: { + reachContinueOnFailingProjects: boolean + reachDisableAnalytics: boolean + reachAnalysisTimeout: number + reachAnalysisMemoryLimit: number + reachEcosystems: EcosystemString[] + reachExcludePaths: string[] + } repoName: string - branchName: string - outputKind: OutputKind - interactive: boolean } type ReachabilityAnalysisOptions = { @@ -241,6 +258,7 @@ async function performReachabilityAnalysis( cwd, orgSlug, packagePaths, + reachabilityOptions, repoName, }: ReachabilityAnalysisConfig, options?: ReachabilityAnalysisOptions | undefined, @@ -310,6 +328,36 @@ async function performReachabilityAnalysis( '--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, ], diff --git a/src/utils/ecosystem.mts b/src/utils/ecosystem.mts new file mode 100644 index 000000000..c6c813a6f --- /dev/null +++ b/src/utils/ecosystem.mts @@ -0,0 +1,120 @@ +import type { components } from '@socketsecurity/sdk/types/api' + +// Use the SDK type which matches what the API actually accepts +export type EcosystemString = components['schemas']['SocketPURL_Type'] + +// This array must contain ALL ecosystem values +// These match the SocketPURL_Type from the SDK +const ALL_ECOSYSTEMS = [ + 'apk', + 'bitbucket', + 'cargo', + 'chrome', + 'cocoapods', + 'composer', + 'conan', + 'conda', + 'cran', + 'deb', + 'docker', + 'gem', + 'generic', + 'github', + 'golang', + 'hackage', + 'hex', + 'huggingface', + 'maven', + 'mlflow', + 'npm', + 'nuget', + 'oci', + 'pub', + 'pypi', + 'qpkg', + 'rpm', + 'swift', + 'swid', + 'unknown', +] as const + +const COANA_SUPPORTED_ECOSYSTEMS: Set = new Set([ + 'composer', + 'hex', + 'github', + 'golang', + 'maven', + 'npm', + 'nuget', + 'pypi', + 'pub', + 'gem', + 'cargo', + 'swift', +]) + +// Helper type to check if our array contains all possible EcosystemString values +type CheckExhaustive = + EcosystemString extends T[number] ? T : never + +// This will cause a TypeScript error if ALL_ECOSYSTEMS doesn't contain all EcosystemString values +export const ecosystemChoices: CheckExhaustive = + ALL_ECOSYSTEMS + +// Type guard to check if a string is a valid ecosystem +export function isValidEcosystem(value: string): value is EcosystemString { + return (ecosystemChoices as readonly string[]).includes(value) +} + +// Parse and validate ecosystem values from string or array +export function parseEcosystems( + value: string | string[] | undefined, +): EcosystemString[] { + if (!value) { + return [] + } + + const values = + typeof value === 'string' + ? value.split(',').map(v => v.trim().toLowerCase()) + : value.map(v => v.toLowerCase()) + + return values.filter(isValidEcosystem) +} + +// Get string array for use with meow choices +/** + * Ecosystems/Purl types are slightly different in Coana. + * This function converts the EcosystemString[] to a string of Coana compatible ecosystem names. + * Ecosystems that are not supported by Coana are ignored. + */ +export function getEcosystemChoicesForMeow(): string[] { + return ecosystemChoices as unknown as string[] +} + +export function convertToCoanaEcosystems( + ecosystems: EcosystemString[], +): string[] { + return ecosystems + .filter(ecosystem => + COANA_SUPPORTED_ECOSYSTEMS.has(ecosystem as EcosystemString), + ) + .map(ecosystem => { + switch (ecosystem) { + case 'hex': + return 'ERLANG' + case 'github': + return 'ACTIONS' + case 'golang': + return 'GO' + case 'pypi': + return 'PIP' + case 'gem': + return 'RUBYGEMS' + case 'cargo': + return 'RUST' + default: + return ecosystem.toUpperCase() + } + }) +}