From 175007454c538a510dec7879f7b724824aa495b6 Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Tue, 8 Apr 2025 14:02:46 +0200 Subject: [PATCH] Update CI command to use scan create flow --- src/cli.ts | 9 +- src/commands/ci/cmd-ci.test.ts | 59 ++++++++ src/commands/ci/cmd-ci.ts | 56 ++++++++ src/commands/ci/fetch-default-org-slug.ts | 55 ++++++++ src/commands/ci/handle-ci.ts | 33 +++++ src/commands/scan/cmd-scan-create.test.ts | 2 +- src/commands/scan/cmd-scan-create.ts | 27 +++- src/commands/scan/fetch-report-data.ts | 155 ++++++++++++---------- src/commands/scan/generate-report.test.ts | 2 +- src/commands/scan/generate-report.ts | 6 +- src/commands/scan/output-scan-report.ts | 2 +- 11 files changed, 324 insertions(+), 82 deletions(-) create mode 100644 src/commands/ci/cmd-ci.test.ts create mode 100644 src/commands/ci/cmd-ci.ts create mode 100644 src/commands/ci/fetch-default-org-slug.ts create mode 100644 src/commands/ci/handle-ci.ts diff --git a/src/cli.ts b/src/cli.ts index bd904ac1e..94abbbc2f 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { cmdAnalytics } from './commands/analytics/cmd-analytics' import { cmdAuditLog } from './commands/audit-log/cmd-audit-log' import { cmdCdxgen } from './commands/cdxgen/cmd-cdxgen' +import { cmdCI } from './commands/ci/cmd-ci' import { cmdConfig } from './commands/config/cmd-config' import { cmdScanCreate } from './commands/dependencies/cmd-dependencies' import { cmdDiffScan } from './commands/diff-scan/cmd-diff-scan' @@ -53,6 +54,7 @@ void (async () => { await meowWithSubcommands( { cdxgen: cmdCdxgen, + ci: cmdCI, config: cmdConfig, fix: cmdFix, info: cmdInfo, @@ -78,12 +80,7 @@ void (async () => { manifest: cmdManifest }, { - aliases: { - ci: { - description: 'Alias for "report create --view --strict"', - argv: ['report', 'create', '--view', '--strict'] - } - }, + aliases: {}, argv: process.argv.slice(2), name: SOCKET_CLI_BIN_NAME, importMeta: { url: `${pathToFileURL(__filename)}` } as ImportMeta diff --git a/src/commands/ci/cmd-ci.test.ts b/src/commands/ci/cmd-ci.test.ts new file mode 100644 index 000000000..b9b5a78af --- /dev/null +++ b/src/commands/ci/cmd-ci.test.ts @@ -0,0 +1,59 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../dist/constants.js' +import { cmdit, invokeNpm } from '../../../test/utils' + +const { CLI } = constants + +describe('socket oops', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit( + ['oops', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Trigger an intentional error (for development) + + Usage + $ socket oops oops + + Don't run me." + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket oops\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect(stderr, 'banner includes base command').toContain('`socket oops`') + } + ) + + cmdit( + ['oops', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket oops\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + } + ) +}) diff --git a/src/commands/ci/cmd-ci.ts b/src/commands/ci/cmd-ci.ts new file mode 100644 index 000000000..ad952b6f6 --- /dev/null +++ b/src/commands/ci/cmd-ci.ts @@ -0,0 +1,56 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleCI } from './handle-ci' +import constants from '../../constants' +import { commonFlags } from '../../flags' +import { meowOrExit } from '../../utils/meow-with-subcommands' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands' + +const { DRY_RUN_BAIL_TEXT } = constants + +const config: CliCommandConfig = { + commandName: 'ci', + description: + 'Create a new scan and report whether it passes your security policy', + hidden: true, + flags: { + ...commonFlags + }, + help: (parentName, _config) => ` + Usage + $ ${parentName} + + This command is intended to use in CI runs to allow automated systems to + accept or reject a current build. When the scan does not pass your security + policy, the exit code will be non-zero. + + It will use the default org for the set API token. + ` +} + +export const cmdCI = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName + }) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAIL_TEXT) + return + } + + await handleCI() +} diff --git a/src/commands/ci/fetch-default-org-slug.ts b/src/commands/ci/fetch-default-org-slug.ts new file mode 100644 index 000000000..b608e9f1d --- /dev/null +++ b/src/commands/ci/fetch-default-org-slug.ts @@ -0,0 +1,55 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleApiCall } from '../../utils/api' +import { getConfigValue } from '../../utils/config' +import { setupSdk } from '../../utils/sdk' + +// Use the config defaultOrg when set, otherwise discover from remote +export async function getDefaultOrgSlug(): Promise { + let defaultOrg = getConfigValue('defaultOrg') + if (defaultOrg) { + logger.info(`Using default org: ${defaultOrg}`) + } else { + const sockSdk = await setupSdk() + const result = await handleApiCall( + sockSdk.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) { + logger.fail( + 'Failed to fetch default organization from API. Unable to continue.' + ) + process.exitCode = 1 + return + } + const orgs = result.data.organizations + const keys = Object.keys(orgs) + + if (!keys[0]) { + logger.fail( + 'Could not find default organization for the current API token. Unable to continue.' + ) + process.exitCode = 1 + return + } + + const slug = (keys[0] in orgs && orgs?.[keys[0]]?.name) ?? undefined + + if (slug) { + defaultOrg = slug + logger.info(`Resolved org to: ${defaultOrg}`) + } + } + + if (!defaultOrg) { + logger.fail( + 'Could not find the default organization for the current API token. Unable to continue.' + ) + process.exitCode = 1 + return + } + + return defaultOrg +} diff --git a/src/commands/ci/handle-ci.ts b/src/commands/ci/handle-ci.ts new file mode 100644 index 000000000..93d4ef5f8 --- /dev/null +++ b/src/commands/ci/handle-ci.ts @@ -0,0 +1,33 @@ +import { getDefaultOrgSlug } from './fetch-default-org-slug' +import { handleCreateNewScan } from '../scan/handle-create-new-scan' + +export async function handleCI(): Promise { + // ci: { + // description: 'Alias for "report create --view --strict"', + // argv: ['report', 'create', '--view', '--strict'] + // } + const orgSlug = await getDefaultOrgSlug() + if (!orgSlug) { + return + } + + // TODO: does it make sense to discover the commit details from local git? + // TODO: does it makes sense to use custom branch/repo names here? probably socket.yml, right + await handleCreateNewScan({ + branchName: 'socket-default-branch', + commitMessage: '', + commitHash: '', + committers: '', + cwd: process.cwd(), + defaultBranch: false, + orgSlug, + outputKind: 'json', + pendingHead: true, // when true, requires branch name set, tmp false + pullRequest: 0, + repoName: 'socket-default-repository', + readOnly: false, + report: true, + targets: ['.'], + tmp: false // don't set when pendingHead is true + }) +} diff --git a/src/commands/scan/cmd-scan-create.test.ts b/src/commands/scan/cmd-scan-create.test.ts index 5b9976e74..c0f9d8444 100644 --- a/src/commands/scan/cmd-scan-create.test.ts +++ b/src/commands/scan/cmd-scan-create.test.ts @@ -62,7 +62,7 @@ describe('socket scan create', async () => { --readOnly 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 - --tmp Set the visibility (true/false) of the scan in your dashboard + --tmp Set the visibility (true/false) of the scan in your dashboard. Can not be used when --pendingHead is set. Examples $ socket scan create --repo=test-repo --branch=main FakeOrg ./package.json" diff --git a/src/commands/scan/cmd-scan-create.ts b/src/commands/scan/cmd-scan-create.ts index f10c16f22..35f77b16f 100644 --- a/src/commands/scan/cmd-scan-create.ts +++ b/src/commands/scan/cmd-scan-create.ts @@ -95,7 +95,7 @@ const config: CliCommandConfig = { shortFlag: 't', default: false, description: - 'Set the visibility (true/false) of the scan in your dashboard' + 'Set the visibility (true/false) of the scan in your dashboard. Can not be used when --pendingHead is set.' } }, // TODO: your project's "socket.yml" file's "projectIgnorePaths" @@ -154,7 +154,7 @@ async function run( }) const { - branch: branchName = '', + branch: branchName = 'socket-default-branch', commitHash, commitMessage, committers, @@ -166,7 +166,7 @@ async function run( pendingHead, pullRequest, readOnly, - repo: repoName = '', + repo: repoName = 'socket-default-repository', report, tmp } = cli.flags as { @@ -264,6 +264,27 @@ async function run( message: 'This command requires an API token for access', pass: 'ok', fail: 'missing (try `socket login`)' + }, + { + nook: true, + test: !pendingHead || !tmp, + message: 'Can not use --pendingHead and --tmp at the same time', + pass: 'ok', + fail: 'remove at least one flag' + }, + { + nook: true, + test: !pendingHead || !!branchName, + message: 'When --pendingHead is set, --branch is mandatory', + pass: 'ok', + fail: 'missing branch name' + }, + { + nook: true, + test: !defaultBranch || !!branchName, + message: 'When --defaultBranch is set, --branch is mandatory', + pass: 'ok', + fail: 'missing branch name' } ) if (wasBadInput) { diff --git a/src/commands/scan/fetch-report-data.ts b/src/commands/scan/fetch-report-data.ts index de58f51db..f39992e67 100644 --- a/src/commands/scan/fetch-report-data.ts +++ b/src/commands/scan/fetch-report-data.ts @@ -1,3 +1,4 @@ +import { debugLog } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' import constants from '../../constants' @@ -24,7 +25,7 @@ export async function fetchReportData( | { ok: true scan: Array - securityPolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'> + securityPolicy: SocketSdkReturnType<'getOrgSecurityPolicy'> } | { ok: false @@ -41,84 +42,108 @@ export async function fetchReportData( const sockSdk = await setupSdk(apiToken) - let haveScan = false - let haveSecurityPolicy = false + let scanStatus = 'requested..' + let policyStatus = 'requested..' + let finishedFetching = false // Lazily access constants.spinner. const { spinner } = constants + function updateScan(desc: string) { + scanStatus = desc + updateProgress() + } + + function updatePolicy(desc: string) { + policyStatus = desc + updateProgress() + } + function updateProgress() { - const needs = [ - !haveScan ? 'scan' : undefined, - !haveSecurityPolicy ? 'security policy' : undefined - ].filter(Boolean) - const haves = [ - haveScan ? 'scan' : undefined, - haveSecurityPolicy ? 'security policy' : undefined - ].filter(Boolean) - - if (needs.length) { - spinner.start( - `Fetching ${needs.join(' and ')}...${haves.length ? ` Completed fetching ${haves.join(' and ')}.` : ''}` + if (finishedFetching) { + spinner.stop() + logger.info( + `Scan result: ${scanStatus}. Security policy: ${policyStatus}.` ) } else { - spinner.successAndStop(`Completed fetching ${haves.join(' and ')}.`) + spinner.start( + `Scan result: ${scanStatus}. Security policy: ${policyStatus}.` + ) } } + async function fetchScanResult(apiToken: string) { + const response = await queryApi( + `orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}${includeLicensePolicy ? '?include_license_details=true' : ''}`, + apiToken + ) + updateScan('received response') + + if (!response.ok) { + spinner.stop() + const err = await handleApiError(response.status) + logger.fail(failMsgWithBadge(response.statusText, `Fetch error: ${err}`)) + debugLog(err) + updateScan(`request resulted in status code ${response.status}`) + return undefined + } + + updateScan(`ok, downloading response..`) + const jsons = await response.text() + updateScan(`received`) + + const lines = jsons.split('\n').filter(Boolean) + const data = lines.map(line => { + try { + return JSON.parse(line) + } catch { + scanStatus = `received invalid JSON response` + spinner.stop() + logger.error( + 'Response was not valid JSON but it ought to be (please report if this persists)' + ) + debugLog(line) + updateProgress() + return + } + }) as unknown as Array + + return data + } + + async function fetchSecurityPolicy() { + const r = await sockSdk.getOrgSecurityPolicy(orgSlug) + updatePolicy('received response') + + const s = await handleApiCall( + r, + "looking up organization's security policy" + ) + updatePolicy('received') + return s + } + updateProgress() const [scan, securityPolicyMaybe]: [ undefined | Array, SocketSdkResultType<'getOrgSecurityPolicy'> ] = await Promise.all([ - (async () => { - try { - const response = await queryApi( - `orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}${includeLicensePolicy ? '?include_license_details=true' : ''}`, - apiToken - ) - - haveScan = true - updateProgress() - - if (!response.ok) { - const err = await handleApiError(response.status) - logger.fail( - failMsgWithBadge(response.statusText, `Fetch error: ${err}`) - ) - return undefined - } - - const jsons = await response.text() - const lines = jsons.split('\n').filter(Boolean) - const data = lines.map(line => { - try { - return JSON.parse(line) - } catch { - console.error( - 'At least one line item was returned that could not be parsed as JSON...' - ) - return - } - }) as unknown as Array - - return data - } catch (e) { - spinner.errorAndStop('There was an issue while fetching full scan data') - throw e - } - })(), - (async () => { - const r = await sockSdk.getOrgSecurityPolicy(orgSlug) - haveSecurityPolicy = true - updateProgress() - return await handleApiCall(r, "looking up organization's security policy") - })() - ]).finally(() => spinner.stop()) + fetchScanResult(apiToken).catch(e => { + updateScan(`failure; unknown blocking problem occurred`) + throw e + }), + fetchSecurityPolicy().catch(e => { + updatePolicy(`failure; unknown blocking problem occurred`) + throw e + }) + ]).finally(() => { + finishedFetching = true + updateProgress() + }) if (!Array.isArray(scan)) { - logger.error('Was unable to fetch scan, bailing') + logger.error('Was unable to fetch scan result, bailing') process.exitCode = 1 return { ok: false, @@ -127,11 +152,7 @@ export async function fetchReportData( } } - let securityPolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'> = - undefined - if (securityPolicyMaybe && securityPolicyMaybe.success) { - securityPolicy = securityPolicyMaybe - } else { + if (!securityPolicyMaybe?.success) { logger.error('Was unable to fetch security policy, bailing') process.exitCode = 1 return { @@ -144,6 +165,6 @@ export async function fetchReportData( return { ok: true, scan, - securityPolicy + securityPolicy: securityPolicyMaybe } } diff --git a/src/commands/scan/generate-report.test.ts b/src/commands/scan/generate-report.test.ts index 39ee4fbcc..835468617 100644 --- a/src/commands/scan/generate-report.test.ts +++ b/src/commands/scan/generate-report.test.ts @@ -7,7 +7,7 @@ import type { components } from '@socketsecurity/sdk/types/api' describe('generate-report', () => { it('should accept empty args', () => { - const result = generateReport([], undefined, { + const result = generateReport([], {data: {securityPolicyRules: []}}, { orgSlug: 'fakeorg', scanId: 'scan-ai-dee', fold: 'none', diff --git a/src/commands/scan/generate-report.ts b/src/commands/scan/generate-report.ts index b77ab9df7..25f4c48c0 100644 --- a/src/commands/scan/generate-report.ts +++ b/src/commands/scan/generate-report.ts @@ -31,7 +31,7 @@ export type ReportLeafNode = { export function generateReport( scan: Array, - securityPolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'>, + securityPolicy: SocketSdkReturnType<'getOrgSecurityPolicy'>, { fold, orgSlug, @@ -84,8 +84,8 @@ export function generateReport( let healthy = true const securityRules: SocketSdkReturnType<'getOrgSecurityPolicy'>['data']['securityPolicyRules'] = - securityPolicy?.data.securityPolicyRules - if (securityPolicy && securityRules) { + securityPolicy.data.securityPolicyRules + if (securityRules) { // Note: reportLevel: error > warn > monitor > ignore > defer scan.forEach(artifact => { const { diff --git a/src/commands/scan/output-scan-report.ts b/src/commands/scan/output-scan-report.ts index 5533f36c0..730b8062f 100644 --- a/src/commands/scan/output-scan-report.ts +++ b/src/commands/scan/output-scan-report.ts @@ -14,7 +14,7 @@ import type { components } from '@socketsecurity/sdk/types/api' export async function outputScanReport( scan: Array, - securityPolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'>, + securityPolicy: SocketSdkReturnType<'getOrgSecurityPolicy'>, { filePath, fold,