From a4fb24a3179893258ffd6d857a8f3c3380b74ba7 Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Fri, 28 Feb 2025 16:19:45 +0100 Subject: [PATCH 1/2] Add suggestion flows to `socket scan create` --- src/commands/scan/cmd-scan-create.ts | 96 +++++---------- src/commands/scan/create-full-scan.ts | 149 +++++++++++++++++++++-- src/commands/scan/suggest-org-slug.ts | 38 ++++++ src/commands/scan/suggest-repo-slug.ts | 109 +++++++++++++++++ src/commands/scan/suggest_branch_slug.ts | 41 +++++++ src/commands/scan/suggest_target.ts | 25 ++++ 6 files changed, 381 insertions(+), 77 deletions(-) create mode 100644 src/commands/scan/suggest-org-slug.ts create mode 100644 src/commands/scan/suggest-repo-slug.ts create mode 100644 src/commands/scan/suggest_branch_slug.ts create mode 100644 src/commands/scan/suggest_target.ts diff --git a/src/commands/scan/cmd-scan-create.ts b/src/commands/scan/cmd-scan-create.ts index 1a0d41aeb..6245f95ab 100644 --- a/src/commands/scan/cmd-scan-create.ts +++ b/src/commands/scan/cmd-scan-create.ts @@ -2,15 +2,10 @@ import process from 'node:process' import colors from 'yoctocolors-cjs' -import { Spinner } from '@socketsecurity/registry/lib/spinner' - import { createFullScan } from './create-full-scan' -import { handleUnsuccessfulApiResponse } from '../../utils/api' -import { AuthError } from '../../utils/errors' import { meowOrExit } from '../../utils/meow-with-subcommands' import { getFlagListOutput } from '../../utils/output-formatting' -import { getPackageFilesFullScans } from '../../utils/path-resolve' -import { getDefaultToken, setupSdk } from '../../utils/sdk' +import { getDefaultToken } from '../../utils/sdk' import type { CliCommandConfig } from '../../utils/meow-with-subcommands' @@ -75,6 +70,12 @@ const config: CliCommandConfig = { default: false, description: 'Set as pending head' }, + readOnly: { + type: 'boolean', + default: false, + description: + 'Similar to --dry-run except it can read from remote, stops before it would create an actual report' + }, tmp: { type: 'boolean', shortFlag: 't', @@ -125,79 +126,46 @@ async function run( ? String(cli.flags['cwd']) : process.cwd() - // Note exiting earlier to skirt a hidden auth requirement - if (cli.flags['dryRun']) { - return console.log('[DryRun] Bailing now') - } - - const socketSdk = await setupSdk() - const supportedFiles = await socketSdk - .getReportSupportedFiles() - .then(res => { - if (!res.success) - handleUnsuccessfulApiResponse( - 'getReportSupportedFiles', - res, - new Spinner() - ) - // TODO: verify type at runtime? Consider it trusted data and assume type? - return (res as any).data - }) - .catch((cause: Error) => { - throw new Error('Failed getting supported files for report', { cause }) - }) - - const packagePaths = await getPackageFilesFullScans( - cwd, - targets, - supportedFiles - ) + let { branch: branchName, repo: repoName } = cli.flags - const { branch: branchName, repo: repoName } = cli.flags + const apiToken = getDefaultToken() - if (!orgSlug || !repoName || !branchName || !packagePaths.length) { + if (!apiToken && (!orgSlug || !repoName || !branchName || !targets.length)) { + // Without api token we cannot recover because we can't request more info + // from the server, to match and help with the current cwd/git status. // Use exit status of 2 to indicate incorrect usage, generally invalid // options or missing arguments. // https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html process.exitCode = 2 - console.error(`${colors.bgRed(colors.white('Input error'))}: Please provide the required fields:\n - - Org name as the first argument ${!orgSlug ? colors.red('(missing!)') : colors.green('(ok)')}\n - - Repository name using --repo ${!repoName ? colors.red('(missing!)') : colors.green('(ok)')}\n - - Branch name using --branch ${!branchName ? colors.red('(missing!)') : colors.green('(ok)')}\n - - At least one TARGET (e.g. \`.\` or \`./package.json\`) ${ - !packagePaths.length - ? colors.red( - targets.length > 0 - ? '(TARGET' + - (targets.length ? 's' : '') + - ' contained no matching/supported files!)' - : '(missing)' - ) - : colors.green('(ok)') - }\n`) + console.error(` + ${colors.bgRed(colors.white('Input error'))}: Please provide the required fields:\n + - Org name as the first argument ${!orgSlug ? colors.red('(missing!)') : colors.green('(ok)')}\n + - Repository name using --repo ${!repoName ? colors.red('(missing!)') : colors.green('(ok)')}\n + - Branch name using --branch ${!branchName ? colors.red('(missing!)') : colors.green('(ok)')}\n + - At least one TARGET (e.g. \`.\` or \`./package.json\`) ${!targets.length ? '(missing)' : colors.green('(ok)')}\n + (Additionally, no API Token was set so we cannot auto-discover these details)\n + `) return } - const apiToken = getDefaultToken() - if (!apiToken) { - throw new AuthError( - 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.' - ) + // Note exiting earlier to skirt a hidden auth requirement + if (cli.flags['dryRun']) { + return console.log('[DryRun] Bailing now') } await createFullScan({ - apiToken, - orgSlug, - repoName: repoName as string, branchName: branchName as string, + commitHash: (cli.flags['commitHash'] as string) ?? '', commitMessage: (cli.flags['commitMessage'] as string) ?? '', + committers: (cli.flags['committers'] as string) ?? '', + cwd, defaultBranch: Boolean(cli.flags['defaultBranch']), + orgSlug, pendingHead: Boolean(cli.flags['pendingHead']), - tmp: Boolean(cli.flags['tmp']), - packagePaths, - cwd, - commitHash: (cli.flags['commitHash'] as string) ?? '', - committers: (cli.flags['committers'] as string) ?? '', - pullRequest: (cli.flags['pullRequest'] as number) ?? undefined + pullRequest: (cli.flags['pullRequest'] as number) ?? undefined, + readOnly: Boolean(cli.flags['readOnly']), + repoName: repoName as string, + targets, + tmp: Boolean(cli.flags['tmp']) }) } diff --git a/src/commands/scan/create-full-scan.ts b/src/commands/scan/create-full-scan.ts index af5e1fd3a..c65f23d56 100644 --- a/src/commands/scan/create-full-scan.ts +++ b/src/commands/scan/create-full-scan.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert' import process from 'node:process' import readline from 'node:readline/promises' @@ -6,11 +7,16 @@ import colors from 'yoctocolors-cjs' import { Spinner } from '@socketsecurity/registry/lib/spinner' +import { suggestOrgSlug } from './suggest-org-slug.ts' +import { suggestRepoSlug } from './suggest-repo-slug.ts' +import { suggestBranchSlug } from './suggest_branch_slug.ts' +import { suggestTarget } from './suggest_target.ts' import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api' -import { setupSdk } from '../../utils/sdk' +import { AuthError } from '../../utils/errors.ts' +import { getPackageFilesFullScans } from '../../utils/path-resolve.ts' +import { getDefaultToken, setupSdk } from '../../utils/sdk' export async function createFullScan({ - apiToken, branchName, commitHash: _commitHash, commitMessage, @@ -18,30 +24,147 @@ export async function createFullScan({ cwd, defaultBranch, orgSlug, - packagePaths, pendingHead, pullRequest: _pullRequest, + readOnly, repoName, + targets, tmp }: { - apiToken: string - orgSlug: string - repoName: string branchName: string - committers: string - commitMessage: string commitHash: string - pullRequest: number | undefined + commitMessage: string + committers: string + cwd: string defaultBranch: boolean + orgSlug: string pendingHead: boolean + pullRequest: number | undefined + readOnly: boolean + repoName: string + targets: Array tmp: boolean - packagePaths: string[] - cwd: string }): Promise { + const socketSdk = await setupSdk() + const supportedFiles = await socketSdk + .getReportSupportedFiles() + .then(res => { + if (!res.success) { + handleUnsuccessfulApiResponse( + 'getReportSupportedFiles', + res, + new Spinner() + ) + assert( + false, + 'handleUnsuccessfulApiResponse should unconditionally throw' + ) + } + + return res.data + }) + .catch((cause: Error) => { + throw new Error('Failed getting supported files for report', { cause }) + }) + + // If we updated any inputs then we should print the command line to repeat + // the command without requiring user input, as a suggestion. + let updatedInput = false + + if (!targets.length) { + const received = await suggestTarget() + targets = received ?? [] + updatedInput = true + } + + const packagePaths = await getPackageFilesFullScans( + cwd, + targets, + supportedFiles + ) + + // We're going to need an api token to suggest data because those suggestions + // must come from data we already know. Don't error on missing api token yet. + // If the api-token is not set, ignore it for the sake of suggestions. + const apiToken = getDefaultToken() + + if (apiToken && !orgSlug) { + const suggestion = await suggestOrgSlug(socketSdk) + if (suggestion) orgSlug = suggestion + updatedInput = true + } + + // If the current cwd is unknown and is used as a repo slug anyways, we will + // first need to register the slug before we can use it. + let repoDefaultBranch = '' + + // (Don't bother asking for the rest if we didn't get an org slug above) + if (apiToken && orgSlug && !repoName) { + const suggestion = await suggestRepoSlug(socketSdk, orgSlug) + if (suggestion) { + ;({ defaultBranch: repoDefaultBranch, slug: repoName } = suggestion) + } + updatedInput = true + } + + // (Don't bother asking for the rest if we didn't get an org/repo above) + if (apiToken && orgSlug && repoName && !branchName) { + const suggestion = await suggestBranchSlug(repoDefaultBranch) + if (suggestion) branchName = suggestion + updatedInput = true + } + + if (!orgSlug || !repoName || !branchName || !packagePaths.length) { + // Use exit status of 2 to indicate incorrect usage, generally invalid + // options or missing arguments. + // https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html + process.exitCode = 2 + console.error(` + ${colors.bgRed(colors.white('Input error'))}: Please provide the required fields:\n + - Org name as the first argument ${!orgSlug ? colors.red('(missing!)') : colors.green('(ok)')}\n + - Repository name using --repo ${!repoName ? colors.red('(missing!)') : colors.green('(ok)')}\n + - Branch name using --branch ${!branchName ? colors.red('(missing!)') : colors.green('(ok)')}\n + - At least one TARGET (e.g. \`.\` or \`./package.json\`) ${ + !packagePaths.length + ? colors.red( + targets.length > 0 + ? '(TARGET' + + (targets.length ? 's' : '') + + ' contained no matching/supported files!)' + : '(missing)' + ) + : colors.green('(ok)') + }\n + ${!apiToken ? 'Note: was unable to make suggestions because no API Token was found; this would make command fail regardless\n' : ''} + `) + return + } + + if (updatedInput) { + console.log( + 'Note: You can invoke this command next time to skip the interactive questions:' + ) + console.log('```') + console.log( + ` socket scan create [other flags...] --repo ${repoName} --branch ${branchName} ${orgSlug} ${targets.join(' ')}` + ) + console.log('```') + } + + if (!apiToken) { + throw new AuthError( + 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.' + ) + } + + if (readOnly) { + console.log('[ReadOnly] Bailing now') + return + } + const spinnerText = 'Creating a scan... \n' const spinner = new Spinner({ text: spinnerText }).start() - const socketSdk = await setupSdk(apiToken) const result = await handleApiCall( socketSdk.createOrgFullScan( orgSlug, @@ -64,7 +187,7 @@ export async function createFullScan({ return } - spinner.success('Scan created successfully') + spinner.successAndStop('Scan created successfully') const link = colors.underline(colors.cyan(`${result.data.html_report_url}`)) console.log(`Available at: ${link}`) diff --git a/src/commands/scan/suggest-org-slug.ts b/src/commands/scan/suggest-org-slug.ts new file mode 100644 index 000000000..0d1dd1bf1 --- /dev/null +++ b/src/commands/scan/suggest-org-slug.ts @@ -0,0 +1,38 @@ +import { select } from '@socketsecurity/registry/lib/prompts' +import { SocketSdk } from '@socketsecurity/sdk' + +import { handleApiCall } from '../../utils/api.ts' + +export async function suggestOrgSlug( + socketSdk: SocketSdk +): Promise { + const result = await handleApiCall( + socketSdk.getOrganizations(), + 'looking up organizations' + ) + // Ignore a failed request here. It was not the primary goal of + // running this command and reporting it only leads to end-user confusion. + if (result.success) { + const proceed = await select({ + message: + 'Missing org name; do you want to use any of these orgs for this scan?', + choices: Array.from(Object.values(result.data.organizations)) + .map(({ name: slug }) => ({ + name: 'Yes [' + slug + ']', + value: slug, + description: `Use "${slug}" as the organization` + })) + .concat({ + name: 'No', + value: '', + description: + 'Do not use any of these organizations (will end in a no-op)' + }) + }) + if (proceed) { + return proceed + } + } else { + // TODO: in verbose mode, report this error to stderr + } +} diff --git a/src/commands/scan/suggest-repo-slug.ts b/src/commands/scan/suggest-repo-slug.ts new file mode 100644 index 000000000..613a9b9ea --- /dev/null +++ b/src/commands/scan/suggest-repo-slug.ts @@ -0,0 +1,109 @@ +import path from 'node:path' +import process from 'node:process' + +import { select } from '@socketsecurity/registry/lib/prompts' +import { SocketSdk } from '@socketsecurity/sdk' + +import { handleApiCall } from '../../utils/api.ts' + +export async function suggestRepoSlug( + socketSdk: SocketSdk, + orgSlug: string +): Promise<{ + slug: string + defaultBranch: string +} | void> { + // Same as above, but if there's a repo with the same name as cwd then + // default the selection to that name. + const result = await handleApiCall( + socketSdk.getOrgRepoList(orgSlug, { + orgSlug, + sort: 'name', + direction: 'asc', + // There's no guarantee that the cwd is part of this page. If it's not + // then do an additional request and specific search for it instead. + // This way we can offer the tip of "do you want to create [cwd]?". + perPage: 10, + page: 0 + }), + 'looking up known repos' + ) + // Ignore a failed request here. It was not the primary goal of + // running this command and reporting it only leads to end-user confusion. + if (result.success) { + const currentDirName = dirNameToSlug(path.basename(process.cwd())) + + let cwdIsKnown = + !!currentDirName && + result.data.results.some(obj => obj.slug === currentDirName) + if (!cwdIsKnown && currentDirName) { + // Do an explicit request so we can assert that the cwd exists or not + const result = await handleApiCall( + socketSdk.getOrgRepo(orgSlug, currentDirName), + 'checking if current cwd is a known repo' + ) + if (result.success) { + cwdIsKnown = true + } + } + + const proceed = await select({ + message: + 'Missing repo name; do you want to use any of these known repo names for this scan?', + choices: + // Put the CWD suggestion at the top, whether it exists or not + (currentDirName + ? [ + { + name: `Yes, current dir [${cwdIsKnown ? currentDirName : `create repo for ${currentDirName}`}]`, + value: currentDirName, + description: cwdIsKnown + ? 'Register a new repo name under the given org and use it' + : 'Use current dir as repo' + } + ] + : [] + ).concat( + result.data.results + .filter(({ slug }) => !!slug && slug !== currentDirName) + .map(({ slug }) => ({ + name: 'Yes [' + slug + ']', + value: slug || '', // Filtered above but TS is like nah. + description: `Use "${slug}" as the repo name` + })), + { + name: 'No', + value: '', + description: 'Do not use any of these repos (will end in a no-op)' + } + ) + }) + + if (proceed) { + const repoName = proceed + let repoDefaultBranch = '' + // Store the default branch to help with the branch name question next + result.data.results.some(obj => { + if (obj.slug === proceed && obj.default_branch) { + repoDefaultBranch = obj.default_branch + return + } + }) + return { slug: repoName, defaultBranch: repoDefaultBranch } + } + } else { + // TODO: in verbose mode, report this error to stderr + } +} + +function dirNameToSlug(name: string): string { + // Uses slug specs asserted by our servers + // Note: this can lead to collisions; eg. slug for `x--y` and `x---y` is `x-y` + return name + .toLowerCase() + .replace(/[^[a-zA-Z0-9_.-]/g, '_') + .replace(/--+/g, '-') + .replace(/__+/g, '_') + .replace(/\.\.+/g, '.') + .replace(/[._-]+$/, '') +} diff --git a/src/commands/scan/suggest_branch_slug.ts b/src/commands/scan/suggest_branch_slug.ts new file mode 100644 index 000000000..06c9c2469 --- /dev/null +++ b/src/commands/scan/suggest_branch_slug.ts @@ -0,0 +1,41 @@ +import { spawnSync } from 'node:child_process' + +import { select } from '@socketsecurity/registry/lib/prompts' + +export async function suggestBranchSlug( + repoDefaultBranch: string | undefined +): Promise { + const spawnResult = spawnSync('git', ['branch', '--show-current']) + const currentBranch = spawnResult.stdout.toString('utf8').trim() + if (spawnResult.status === 0 && currentBranch) { + const proceed = await select({ + message: 'Use the current git branch as target branch name?', + choices: [ + { + name: `Yes [${currentBranch}]`, + value: currentBranch, + description: 'Use the current git branch for branch name' + }, + ...(repoDefaultBranch && repoDefaultBranch !== currentBranch + ? [ + { + name: `No, use the default branch [${repoDefaultBranch}]`, + value: repoDefaultBranch, + description: + 'Use the default branch for target repo as the target branch name' + } + ] + : []), + { + name: 'No', + value: '', + description: + 'Do not use the current git branch as name (will end in a no-op)' + } + ].filter(Boolean) + }) + if (proceed) { + return proceed + } + } +} diff --git a/src/commands/scan/suggest_target.ts b/src/commands/scan/suggest_target.ts new file mode 100644 index 000000000..54abb025e --- /dev/null +++ b/src/commands/scan/suggest_target.ts @@ -0,0 +1,25 @@ +import { select } from '@socketsecurity/registry/lib/prompts' + +export async function suggestTarget(): Promise | void> { + // We could prefill this with sub-dirs of the current + // dir ... but is that going to be useful? + const proceed = await select({ + message: 'No TARGET given. Do you want to use the current directory?', + choices: [ + { + name: 'Yes', + value: true, + description: 'Target the current directory' + }, + { + name: 'No', + value: false, + description: + 'Do not use the current directory (this will end in a no-op)' + } + ] + }) + if (proceed) { + return ['.'] + } +} From 0e14a5cd73a6a28e3b789900e8348aade5fe876c Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Fri, 28 Feb 2025 16:55:15 +0100 Subject: [PATCH 2/2] Update snapshot for scan create --- test/dry-run.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/dry-run.test.ts b/test/dry-run.test.ts index 760b9e5c0..14312edbb 100644 --- a/test/dry-run.test.ts +++ b/test/dry-run.test.ts @@ -789,13 +789,23 @@ describe('dry-run on all commands', async () => { _____ _ _ /--------------- | __|___ ___| |_ ___| |_ | Socket.dev CLI ver |__ | . | _| '_| -_| _| | Node: , API token set: - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan create\`, cwd: + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan create\`, cwd: " + `) + expect(stderr).toMatchInlineSnapshot(` + "\\x1b[41m\\x1b[37mInput error\\x1b[39m\\x1b[49m: Please provide the required fields: - [DryRun] Bailing now" + - Org name as the first argument \\x1b[31m(missing!)\\x1b[39m + + - Repository name using --repo \\x1b[31m(missing!)\\x1b[39m + + - Branch name using --branch \\x1b[31m(missing!)\\x1b[39m + + - At least one TARGET (e.g. \`.\` or \`./package.json\`) (missing) + + (Additionally, no API Token was set so we cannot auto-discover these details)" `) - expect(stderr).toMatchInlineSnapshot(`""`) - expect(code, 'dry-run should exit with code 0 if input is ok').toBe(0) + expect(code).toBe(2) expect(stdout, 'header should include command (without params)').toContain( cmd.slice(0, cmd.indexOf('--dry-run')).join(' ') )