diff --git a/eslint.config.js b/eslint.config.js index e4a03d72b..af1239526 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -161,9 +161,17 @@ module.exports = [ parser: tsParser, parserOptions: { projectService: { - allowDefaultProject: ['test/*.ts'], + allowDefaultProject: [ + 'test/*.ts', + // src/utils/* + 'src/*/*.test.ts', + // src/commands/xyz/* + 'src/*/*/*.test.ts' + ], defaultProject: 'tsconfig.json', - tsconfigRootDir: rootPath + tsconfigRootDir: rootPath, + // Need this to glob the test files in /src. Otherwise it won't work. + maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 1_000_000 } } }, diff --git a/src/commands/organization/cmd-organization-list.test.ts b/src/commands/organization/cmd-organization-list.test.ts new file mode 100644 index 000000000..314a18f63 --- /dev/null +++ b/src/commands/organization/cmd-organization-list.test.ts @@ -0,0 +1,66 @@ +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 organization list', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit( + ['organization', 'list', '--help'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "List organizations associated with the API key used + + Usage + $ socket organization list + + Options + --dryRun Do input validation for a command and exit 0 when input is ok + --help Print this help. + --json Output result as json + --markdown Output result as markdown" + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization list\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect( + stderr, + 'header should include command (without params)' + ).toContain('`socket organization list`') + } + ) + + cmdit( + ['organization', 'list', '--dry-run'], + 'should be ok with org name and id', + 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 organization list\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + } + ) +}) diff --git a/src/commands/organization/cmd-organization-list.ts b/src/commands/organization/cmd-organization-list.ts new file mode 100644 index 000000000..ece712251 --- /dev/null +++ b/src/commands/organization/cmd-organization-list.ts @@ -0,0 +1,72 @@ +import { stripIndents } from 'common-tags' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { getOrganization } from './get-organization' +import constants from '../../constants' +import { commonFlags, outputFlags } from '../../flags' +import { meowOrExit } from '../../utils/meow-with-subcommands' +import { getFlagListOutput } from '../../utils/output-formatting' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands' + +const { DRY_RUN_BAIL_TEXT } = constants + +const config: CliCommandConfig = { + commandName: 'list', + description: 'List organizations associated with the API key used', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags + }, + help: (command, _config) => ` + Usage + $ ${command} + + Options + ${getFlagListOutput(config.flags, 6)} + ` +} + +export const cmdOrganizationList = { + 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 + }) + + const json = Boolean(cli.flags['json']) + const markdown = Boolean(cli.flags['markdown']) + if (json && markdown) { + // 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 + logger.fail(stripIndents` +${colors.bgRed(colors.white('Input error'))}: Please provide the required fields: + + - The json and markdown flags cannot be both set, pick one + `) + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAIL_TEXT) + return + } + + await getOrganization(json ? 'json' : markdown ? 'markdown' : 'text') +} diff --git a/src/commands/organization/cmd-organization-policy-security.test.ts b/src/commands/organization/cmd-organization-policy-security.test.ts new file mode 100644 index 000000000..bc1e78d5b --- /dev/null +++ b/src/commands/organization/cmd-organization-policy-security.test.ts @@ -0,0 +1,96 @@ +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 organization list', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit( + ['organization', 'policy', 'security', '--help'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Retrieve the security policy of an organization. + + Usage + $ socket organization policy security + + Options + --dryRun Do input validation for a command and exit 0 when input is ok + --help Print this help. + --json Output result as json + --markdown Output result as markdown + + Your API token will need the \`security-policy:read\` permission otherwise + the request will fail with an authentication error. + + Examples + $ socket organization policy security mycorp + $ socket organization policy security mycorp --json" + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy security\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect( + stderr, + 'header should include command (without params)' + ).toContain('`socket organization policy security`') + } + ) + + cmdit( + ['organization', 'policy', 'security', '--dry-run'], + 'should reject dry run without proper args', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy security\`, cwd: + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[37mInput error\\x1b[39m\\x1b[49m: Please provide the required fields: + + - Org name as the first argument \\x1b[31m(missing!)\\x1b[39m + - The json and markdown flags cannot be both set \\x1b[32m(ok)\\x1b[39m" + `) + + expect(code, 'dry-run should exit with code 2 if input bad').toBe(2) + } + ) + + cmdit( + ['organization', 'policy', 'security', 'fakeorg', '--dry-run'], + 'should be ok with org name and id', + 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 organization policy security\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + } + ) +}) diff --git a/src/commands/organization/cmd-organization-policy-security.ts b/src/commands/organization/cmd-organization-policy-security.ts new file mode 100644 index 000000000..51d717f4d --- /dev/null +++ b/src/commands/organization/cmd-organization-policy-security.ts @@ -0,0 +1,87 @@ +import { stripIndents } from 'common-tags' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { getSecurityPolicy } from './get-security-policy' +import constants from '../../constants' +import { commonFlags, outputFlags } from '../../flags' +import { meowOrExit } from '../../utils/meow-with-subcommands' +import { getFlagListOutput } from '../../utils/output-formatting' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands' + +const { DRY_RUN_BAIL_TEXT } = constants + +// TODO: secret toplevel alias `socket security policy`? +const config: CliCommandConfig = { + commandName: 'security', + description: 'Retrieve the security policy of an organization.', + hidden: true, + flags: { + ...commonFlags, + ...outputFlags + }, + help: (command, _config) => ` + Usage + $ ${command} + + Options + ${getFlagListOutput(config.flags, 6)} + + Your API token will need the \`security-policy:read\` permission otherwise + the request will fail with an authentication error. + + Examples + $ ${command} mycorp + $ ${command} mycorp --json + ` +} + +export const cmdOrganizationPolicyPolicy = { + 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 + }) + + const json = Boolean(cli.flags['json']) + const markdown = Boolean(cli.flags['markdown']) + + const [orgSlug = ''] = cli.input + + if (!orgSlug || (json && markdown)) { + // 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 + logger.fail(stripIndents` +${colors.bgRed(colors.white('Input error'))}: Please provide the required fields: + + - Org name as the first argument ${!orgSlug ? colors.red('(missing!)') : colors.green('(ok)')} + - The json and markdown flags cannot be both set ${json && markdown ? colors.red('(pick one!)') : colors.green('(ok)')} + `) + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAIL_TEXT) + return + } + + await getSecurityPolicy( + orgSlug, + json ? 'json' : markdown ? 'markdown' : 'text' + ) +} diff --git a/src/commands/organization/cmd-organization-policy.test.ts b/src/commands/organization/cmd-organization-policy.test.ts new file mode 100644 index 000000000..7509e29b3 --- /dev/null +++ b/src/commands/organization/cmd-organization-policy.test.ts @@ -0,0 +1,52 @@ +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 organization list', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit( + ['organization', 'policy', '--help'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Organization policy details + + Usage + $ socket organization policy + + Commands + + + Options + --dryRun Do input validation for a command and exit 0 when input is ok + --help Print this help. + + Examples + $ socket organization policy --help" + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect( + stderr, + 'header should include command (without params)' + ).toContain('`socket organization policy`') + } + ) +}) diff --git a/src/commands/organization/cmd-organization-policy.ts b/src/commands/organization/cmd-organization-policy.ts new file mode 100644 index 000000000..06a2c775d --- /dev/null +++ b/src/commands/organization/cmd-organization-policy.ts @@ -0,0 +1,29 @@ +import { cmdOrganizationPolicyPolicy } from './cmd-organization-policy-security' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands' + +const description = 'Organization policy details' + +export const cmdOrganizationPolicy: CliSubcommand = { + description, + // Hidden because it was broken all this time (nobody could be using it) + // and we're not sure if it's useful to anyone in its current state. + // Until we do, we'll hide this to keep the help tidier. + // And later, we may simply move this under `scan`, anyways. + hidden: true, + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + security: cmdOrganizationPolicyPolicy + }, + { + argv, + description, + defaultSub: 'list', // Backwards compat + importMeta, + name: parentName + ' policy' + } + ) + } +} diff --git a/src/commands/organization/cmd-organization-quota.test.ts b/src/commands/organization/cmd-organization-quota.test.ts new file mode 100644 index 000000000..bda3dbdae --- /dev/null +++ b/src/commands/organization/cmd-organization-quota.test.ts @@ -0,0 +1,66 @@ +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 organization quota', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit( + ['organization', 'quota', '--help'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "List organizations associated with the API key used + + Usage + $ socket organization quota + + Options + --dryRun Do input validation for a command and exit 0 when input is ok + --help Print this help. + --json Output result as json + --markdown Output result as markdown" + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization quota\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect( + stderr, + 'header should include command (without params)' + ).toContain('`socket organization quota`') + } + ) + + cmdit( + ['organization', 'quota', '--dry-run'], + 'should be ok with org name and id', + 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 organization quota\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + } + ) +}) diff --git a/src/commands/organization/cmd-organization-quota.ts b/src/commands/organization/cmd-organization-quota.ts new file mode 100644 index 000000000..777e90b7b --- /dev/null +++ b/src/commands/organization/cmd-organization-quota.ts @@ -0,0 +1,72 @@ +import { stripIndents } from 'common-tags' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { getQuota } from './get-quota' +import constants from '../../constants' +import { commonFlags, outputFlags } from '../../flags' +import { meowOrExit } from '../../utils/meow-with-subcommands' +import { getFlagListOutput } from '../../utils/output-formatting' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands' + +const { DRY_RUN_BAIL_TEXT } = constants + +const config: CliCommandConfig = { + commandName: 'quota', + description: 'List organizations associated with the API key used', + hidden: true, + flags: { + ...commonFlags, + ...outputFlags + }, + help: (command, _config) => ` + Usage + $ ${command} + + Options + ${getFlagListOutput(config.flags, 6)} + ` +} + +export const cmdOrganizationQuota = { + 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 + }) + + const json = Boolean(cli.flags['json']) + const markdown = Boolean(cli.flags['markdown']) + if (json && markdown) { + // 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 + logger.fail(stripIndents` +${colors.bgRed(colors.white('Input error'))}: Please provide the required fields: + + - The json and markdown flags cannot be both set, pick one + `) + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAIL_TEXT) + return + } + + await getQuota(json ? 'json' : markdown ? 'markdown' : 'text') +} diff --git a/src/commands/organization/cmd-organization.test.ts b/src/commands/organization/cmd-organization.test.ts new file mode 100644 index 000000000..039426677 --- /dev/null +++ b/src/commands/organization/cmd-organization.test.ts @@ -0,0 +1,67 @@ +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 organization', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit(['organization', '--help'], 'should support --help', async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Account details + + Usage + $ socket organization + + Commands + list List organizations associated with the API key used + + Options + --dryRun Do input validation for a command and exit 0 when input is ok + --help Print this help. + + Examples + $ socket organization --help" + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect(stderr, 'header should include command (without params)').toContain( + '`socket organization`' + ) + }) + + cmdit( + ['organization', '--dry-run'], + 'should be ok with org name and id', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + } + ) +}) diff --git a/src/commands/organization/cmd-organization.ts b/src/commands/organization/cmd-organization.ts index f5cbd7a9c..2f566f403 100644 --- a/src/commands/organization/cmd-organization.ts +++ b/src/commands/organization/cmd-organization.ts @@ -1,72 +1,33 @@ -import { stripIndents } from 'common-tags' -import colors from 'yoctocolors-cjs' - -import { logger } from '@socketsecurity/registry/lib/logger' - -import { getOrganization } from './get-organization' -import constants from '../../constants' -import { commonFlags, outputFlags } from '../../flags' -import { meowOrExit } from '../../utils/meow-with-subcommands' -import { getFlagListOutput } from '../../utils/output-formatting' - -import type { CliCommandConfig } from '../../utils/meow-with-subcommands' - -const { DRY_RUN_BAIL_TEXT } = constants - -const config: CliCommandConfig = { - commandName: 'organizations', - description: 'List organizations associated with the API key used', - hidden: false, - flags: { - ...commonFlags, - ...outputFlags - }, - help: (command, _config) => ` - Usage - $ ${command} - - Options - ${getFlagListOutput(config.flags, 6)} - ` -} - -export const cmdOrganization = { - 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 - }) - - const json = Boolean(cli.flags['json']) - const markdown = Boolean(cli.flags['markdown']) - if (json && markdown) { - // 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 - logger.fail(stripIndents` -${colors.bgRed(colors.white('Input error'))}: Please provide the required fields: - - - The json and markdown flags cannot be both set, pick one - `) - return +import { cmdOrganizationList } from './cmd-organization-list' +import { cmdOrganizationPolicy } from './cmd-organization-policy' +import { cmdOrganizationQuota } from './cmd-organization-quota' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands' + +const description = 'Account details' + +export const cmdOrganization: CliSubcommand = { + description, + // Hidden because it was broken all this time (nobody could be using it) + // and we're not sure if it's useful to anyone in its current state. + // Until we do, we'll hide this to keep the help tidier. + // And later, we may simply move this under `scan`, anyways. + hidden: true, + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + list: cmdOrganizationList, + quota: cmdOrganizationQuota, + policy: cmdOrganizationPolicy + }, + { + argv, + description, + defaultSub: 'list', // Backwards compat + importMeta, + name: parentName + ' organization' + } + ) } - - if (cli.flags['dryRun']) { - logger.log(DRY_RUN_BAIL_TEXT) - return - } - - await getOrganization(json ? 'json' : markdown ? 'markdown' : 'text') } diff --git a/src/commands/organization/get-license-policy.ts b/src/commands/organization/get-license-policy.ts new file mode 100644 index 000000000..d26fcbfdf --- /dev/null +++ b/src/commands/organization/get-license-policy.ts @@ -0,0 +1,102 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants' +import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api' +import { AuthError } from '../../utils/errors' +import { getDefaultToken, setupSdk } from '../../utils/sdk' + +export async function getLicensePolicy( + orgSlug: string, + format: 'text' | 'json' | 'markdown' +): Promise { + 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.' + ) + } + await getLicensePolicyWithToken(apiToken, orgSlug, format) +} + +async function getLicensePolicyWithToken( + apiToken: string, + orgSlug: string, + format: 'text' | 'json' | 'markdown' +) { + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.start('Fetching organization quota...') + + const socketSdk = await setupSdk(apiToken) + const result = await handleApiCall( + // socketSdk.getOrgLicensePolicy(orgSlug), + socketSdk.getOrgSecurityPolicy(orgSlug), // tmp + "looking up organization's license policy" + ) + + if (!result.success) { + // handleUnsuccessfulApiResponse('getOrgLicensePolicy', result) + handleUnsuccessfulApiResponse('getOrgSecurityPolicy', result) + return + } + + spinner.stop() + + switch (format) { + case 'json': { + logger.log(JSON.stringify(result.data, null, 2)) + return + } + default: { + // logger.log('# License policy\n') + // logger.log( + // `These are the license policies set up for the requested organization:\n` + // ) + // const data = result.data + // const rules = data.securityPolicyRules + // const entries: Array< + // [string, { action: 'defer' | 'error' | 'warn' | 'monitor' | 'ignore' | undefined}] + // > = Object.entries(rules) + // const mapped: Array<[string, string]> = entries.map(([key, value]) => [ + // key, + // value.action + // ]) + // mapped.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + // logger.log(mdTable(mapped, ['name', 'action'])) + } + } +} + +// function mdTable( +// arr: Array<[string, string]>, +// // This is saying "an array of strings and the strings are a valid key of elements of T" +// // In turn, T is defined above as the audit log event type from our OpenAPI docs. +// cols: string[] +// ): string { +// // Max col width required to fit all data in that column +// const cws = cols.map(col => col.length) +// +// for (const [key, val] of arr) { +// cws[0] = Math.max(cws[0] ?? 0, String(key).length) +// cws[1] = Math.max(cws[1] ?? 0, String(val ?? '').length) +// } +// +// let div = '|' +// for (const cw of cws) div += ' ' + '-'.repeat(cw) + ' |' +// +// let header = '|' +// for (let i = 0; i < cols.length; ++i) { +// header += ' ' + String(cols[i]).padEnd(cws[i] ?? 0, ' ') + ' |' +// } +// +// let body = '' +// for (const [key, val] of arr) { +// body += '|' +// body += ' ' + String(key).padEnd(cws[0] ?? 0, ' ') + ' |' +// body += ' ' + String(val ?? '').padEnd(cws[1] ?? 0, ' ') + ' |' +// body += '\n' +// } +// +// return [div, header, div, body.trim(), div].filter(s => !!s.trim()).join('\n') +// } diff --git a/src/commands/organization/get-quota.ts b/src/commands/organization/get-quota.ts new file mode 100644 index 000000000..c6ed1ebc1 --- /dev/null +++ b/src/commands/organization/get-quota.ts @@ -0,0 +1,64 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants' +import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api' +import { AuthError } from '../../utils/errors' +import { getDefaultToken, setupSdk } from '../../utils/sdk' + +export async function getQuota( + format: 'text' | 'json' | 'markdown' = 'text' +): Promise { + 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.' + ) + } + await getQuotaWithToken(apiToken, format) +} + +async function getQuotaWithToken( + apiToken: string, + format: 'text' | 'json' | 'markdown' = 'text' +) { + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.start('Fetching organization quota...') + + const socketSdk = await setupSdk(apiToken) + const result = await handleApiCall( + socketSdk.getQuota(), + 'looking up organization quota' + ) + + if (!result.success) { + handleUnsuccessfulApiResponse('getQuota', result) + return + } + + spinner.stop() + + switch (format) { + case 'json': { + logger.log( + JSON.stringify( + { + quota: result.data.quota + }, + null, + 2 + ) + ) + return + } + case 'markdown': { + logger.log('# Quota\n') + logger.log(`Quota left on the current API token: ${result.data.quota}\n`) + return + } + default: { + logger.log(`Quota left on the current API token: ${result.data.quota}\n`) + } + } +} diff --git a/src/commands/organization/get-security-policy.ts b/src/commands/organization/get-security-policy.ts new file mode 100644 index 000000000..b58f53fe1 --- /dev/null +++ b/src/commands/organization/get-security-policy.ts @@ -0,0 +1,72 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants' +import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api' +import { AuthError } from '../../utils/errors' +import { mdTableOfPairs } from '../../utils/markdown' +import { getDefaultToken, setupSdk } from '../../utils/sdk' + +export async function getSecurityPolicy( + orgSlug: string, + format: 'text' | 'json' | 'markdown' +): Promise { + 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.' + ) + } + await getSecurityPolicyWithToken(apiToken, orgSlug, format) +} + +async function getSecurityPolicyWithToken( + apiToken: string, + orgSlug: string, + format: 'text' | 'json' | 'markdown' +) { + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.start('Fetching organization quota...') + + const socketSdk = await setupSdk(apiToken) + const result = await handleApiCall( + socketSdk.getOrgSecurityPolicy(orgSlug), + 'looking up organization quota' + ) + + if (!result.success) { + handleUnsuccessfulApiResponse('getOrgSecurityPolicy', result) + return + } + + spinner.stop() + + switch (format) { + case 'json': { + logger.log(JSON.stringify(result.data, null, 2)) + return + } + default: { + logger.log('# Security policy\n') + logger.log( + `The default security policy setting is: "${result.data.securityPolicyDefault}"\n` + ) + logger.log( + 'These are the security policies per setting for your organization:\n' + ) + const data = result.data + const rules = data.securityPolicyRules + const entries: Array< + [string, { action: 'defer' | 'error' | 'warn' | 'monitor' | 'ignore' }] + // @ts-ignore -- not sure why TS is complaining tbh but it does not like it + > = Object.entries(rules) + const mapped: Array<[string, string]> = entries.map(([key, value]) => [ + key, + value.action + ]) + mapped.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + logger.log(mdTableOfPairs(mapped, ['name', 'action'])) + } + } +} diff --git a/src/commands/report/cmd-report-create.ts b/src/commands/report/cmd-report-create.ts index 7b5329fff..8c4eb86de 100644 --- a/src/commands/report/cmd-report-create.ts +++ b/src/commands/report/cmd-report-create.ts @@ -88,8 +88,7 @@ async function run( await viewReport(reportId, { all: includeAllIssues, commandName, - json, - markdown, + outputKind: json ? 'json' : markdown ? 'markdown' : 'print', strict }) } else if (json) { diff --git a/src/commands/report/cmd-report-view.ts b/src/commands/report/cmd-report-view.ts index 4c849cb70..ce7a40ad6 100644 --- a/src/commands/report/cmd-report-view.ts +++ b/src/commands/report/cmd-report-view.ts @@ -69,8 +69,11 @@ async function run( await viewReport(reportId, { all: Boolean(cli.flags['all']), commandName: `${parentName} ${config.commandName}`, - json: Boolean(cli.flags['json']), - markdown: Boolean(cli.flags['markdown']), + outputKind: cli.flags['json'] + ? 'json' + : cli.flags['markdown'] + ? 'markdown' + : 'print', strict: Boolean(cli.flags['strict']) }) } diff --git a/src/commands/report/fetch-report-data.ts b/src/commands/report/fetch-report-data.ts index d09626e89..b439ade57 100644 --- a/src/commands/report/fetch-report-data.ts +++ b/src/commands/report/fetch-report-data.ts @@ -24,7 +24,8 @@ export async function fetchReportData( // Lazily access constants.spinner. const { spinner } = constants - spinner.start(`Fetching report with ID ${reportId} (this could take a while)`) + spinner.log('Fetching report with ID ${reportId} (this could take a while)') + spinner.start(`Fetch started... (this could take a while)`) const socketSdk = await setupSdk() let result: SocketSdkResultType<'getReport'> | undefined @@ -41,9 +42,10 @@ export async function fetchReportData( !(err instanceof Error) || (err.cause as any)?.cause?.response?.statusCode !== HTTP_CODE_TIMEOUT ) { - spinner.stop() + spinner.stop(`Failed to fetch report`) throw err } + spinner?.fail(`Retrying report fetch ${retry} / ${MAX_TIMEOUT_RETRY}`) } } diff --git a/src/commands/report/format-report-data.ts b/src/commands/report/format-report-data.ts index 36b87929c..5d658d38f 100644 --- a/src/commands/report/format-report-data.ts +++ b/src/commands/report/format-report-data.ts @@ -13,24 +13,27 @@ export function formatReportDataOutput( reportId: string, data: ReportData, commandName: string, - outputJson: boolean, - outputMarkdown: boolean, - strict: boolean + outputKind: 'json' | 'markdown' | 'print', + strict: boolean, + artifacts: any ): void { - if (outputJson) { + if (outputKind === 'json') { logger.log(JSON.stringify(data, undefined, 2)) } else { - const format = new ColorOrMarkdown(outputMarkdown) + const format = new ColorOrMarkdown(outputKind === 'markdown') logger.log(stripIndents` Detailed info on socket.dev: ${format.hyperlink(reportId, data.url, { fallbackToUrl: true })}`) - if (!outputMarkdown) { + if (outputKind === 'print') { + logger.log(data) logger.log( colors.dim( `Or rerun ${colors.italic(commandName)} using the ${colors.italic('--json')} flag to get full JSON output` ) ) + logger.log('The scan:') + logger.log(artifacts) } } diff --git a/src/commands/report/view-report.ts b/src/commands/report/view-report.ts index 8936dae91..dd9b6eddf 100644 --- a/src/commands/report/view-report.ts +++ b/src/commands/report/view-report.ts @@ -1,31 +1,36 @@ +import { components } from '@socketsecurity/sdk/types/api' + import { fetchReportData } from './fetch-report-data' import { formatReportDataOutput } from './format-report-data' +import { getFullScan } from '../scan/get-full-scan' export async function viewReport( reportId: string, { all, commandName, - json, - markdown, + outputKind, strict }: { commandName: string all: boolean - json: boolean - markdown: boolean + outputKind: 'json' | 'markdown' | 'print' strict: boolean } ) { const result = await fetchReportData(reportId, all, strict) + + const artifacts: Array | undefined = + await getFullScan('socketdev', reportId) + if (result) { formatReportDataOutput( reportId, result, commandName, - json, - markdown, - strict + outputKind, + strict, + artifacts ) } } diff --git a/src/commands/scan/cmd-scan-report.test.ts b/src/commands/scan/cmd-scan-report.test.ts new file mode 100644 index 000000000..b2b118c63 --- /dev/null +++ b/src/commands/scan/cmd-scan-report.test.ts @@ -0,0 +1,107 @@ +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 scan report', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit(['scan', 'report', '--help'], 'should support --help', async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Check whether a scan result passes the organizational policies (security, license) + + Usage + $ socket scan report [path to output file] + + Options + --dryRun Do input validation for a command and exit 0 when input is ok + --fold Fold reported alerts to some degree + --help Print this help. + --json Output result as json + --markdown Output result as markdown + --reportLevel Which policy level alerts should be reported + --security Report the security policy status. Default: true + + This consumes 1 quota unit plus 1 for each of the requested policy types. + + Note: By default it reports both so by default it consumes 3 quota units. + + Your API token will need the \`full-scans:list\` scope regardless. Additionally + it needs \`security-policy:read\` to report on the security policy. + + By default the result is a nested object that looks like this: + \`{[ecosystem]: {[pkgName]: {[version]: {[file]: {[type:loc]: policy}}}}\` + You can fold this up to given level: 'pkg', 'version', 'file', and 'none'. + + By default only the warn and error policy level alerts are reported. You can + override this and request more ('defer' < 'ignore' < 'monitor' < 'warn' < 'error') + + Examples + $ socket scan report FakeOrg 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json --fold=version" + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan report\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect(stderr, 'header should include command (without params)').toContain( + '`socket scan report`' + ) + }) + + cmdit( + ['scan', 'report', '--dry-run'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan report\`, cwd: + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[37mInput error\\x1b[39m\\x1b[49m: Please provide the required fields: + + - Org name as the first argument \\x1b[31m(missing!)\\x1b[39m + + - Full Scan ID to fetch as second argument \\x1b[31m(missing!)\\x1b[39m + + - Not both the --json and --markdown flags \\x1b[32m(ok)\\x1b[39m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + } + ) + + cmdit( + ['scan', 'report', 'org', 'report-id', '--dry-run'], + 'should be ok with org name and id', + 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 scan report\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + } + ) +}) diff --git a/src/commands/scan/cmd-scan-report.ts b/src/commands/scan/cmd-scan-report.ts new file mode 100644 index 000000000..ea4d9d319 --- /dev/null +++ b/src/commands/scan/cmd-scan-report.ts @@ -0,0 +1,148 @@ +import { stripIndents } from 'common-tags' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { reportFullScan } from './report-full-scan' +import constants from '../../constants' +import { commonFlags, outputFlags } from '../../flags' +import { meowOrExit } from '../../utils/meow-with-subcommands' +import { getFlagListOutput } from '../../utils/output-formatting' + +import type { + CliCommandConfig, + CliSubcommand +} from '../../utils/meow-with-subcommands' + +const { DRY_RUN_BAIL_TEXT } = constants + +const config: CliCommandConfig = { + commandName: 'report', + description: + 'Check whether a scan result passes the organizational policies (security, license)', + hidden: true, // [beta] + flags: { + ...commonFlags, + ...outputFlags, + fold: { + type: 'string', + default: 'none', + description: 'Fold reported alerts to some degree' + }, + reportLevel: { + type: 'string', + default: 'warn', + description: 'Which policy level alerts should be reported' + }, + // license: { + // type: 'boolean', + // default: true, + // description: 'Report the license policy status. Default: true' + // }, + security: { + type: 'boolean', + default: true, + description: 'Report the security policy status. Default: true' + } + }, + help: (command, config) => ` + Usage + $ ${command} [path to output file] + + Options + ${getFlagListOutput(config.flags, 6)} + + This consumes 1 quota unit plus 1 for each of the requested policy types. + + Note: By default it reports both so by default it consumes 3 quota units. + + Your API token will need the \`full-scans:list\` scope regardless. Additionally + it needs \`security-policy:read\` to report on the security policy. + + By default the result is a nested object that looks like this: + \`{[ecosystem]: {[pkgName]: {[version]: {[file]: {[type:loc]: policy}}}}\` + You can fold this up to given level: 'pkg', 'version', 'file', and 'none'. + + By default only the warn and error policy level alerts are reported. You can + override this and request more ('defer' < 'ignore' < 'monitor' < 'warn' < 'error') + + Examples + $ ${command} FakeOrg 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json --fold=version + ` +} + +export const cmdScanReport: CliSubcommand = { + 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 + }) + + const { + fold = 'none', + json, + // license, + markdown, + reportLevel = 'warn', + security + } = cli.flags + + const [orgSlug = '', fullScanId = '', file = '-'] = cli.input + + if ( + !orgSlug || + !fullScanId || + // (!license && !security) || + (json && markdown) + ) { + // 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 + logger.fail( + stripIndents` + ${colors.bgRed(colors.white('Input error'))}: Please provide the required fields: + + - Org name as the first argument ${!orgSlug ? colors.red('(missing!)') : colors.green('(ok)')} + + - Full Scan ID to fetch as second argument ${!fullScanId ? colors.red('(missing!)') : colors.green('(ok)')} + + - Not both the --json and --markdown flags ${json && markdown ? colors.red('(pick one!)') : colors.green('(ok)')} + ` + // - At least one policy to report ${!license && !security ? colors.red('(do not omit both!)') : colors.green('(ok)')} + ) + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAIL_TEXT) + return + } + + await reportFullScan({ + orgSlug, + fullScanId, + includeLicensePolicy: false, // !!license, + includeSecurityPolicy: !!security, + outputKind: json ? 'json' : markdown ? 'markdown' : 'text', + filePath: file, + fold: fold as 'none' | 'file' | 'pkg' | 'version', + reportLevel: reportLevel as + | 'warn' + | 'error' + | 'defer' + | 'ignore' + | 'monitor' + }) +} diff --git a/src/commands/scan/cmd-scan.ts b/src/commands/scan/cmd-scan.ts index 56277d111..2443182c2 100644 --- a/src/commands/scan/cmd-scan.ts +++ b/src/commands/scan/cmd-scan.ts @@ -2,6 +2,7 @@ import { cmdScanCreate } from './cmd-scan-create' import { cmdScanDel } from './cmd-scan-del' import { cmdScanList } from './cmd-scan-list' import { cmdScanMetadata } from './cmd-scan-metadata' +import { cmdScanReport } from './cmd-scan-report' import { cmdScanView } from './cmd-scan-view' import { meowWithSubcommands } from '../../utils/meow-with-subcommands' @@ -18,6 +19,7 @@ export const cmdScan: CliSubcommand = { list: cmdScanList, del: cmdScanDel, metadata: cmdScanMetadata, + report: cmdScanReport, view: cmdScanView }, { diff --git a/src/commands/scan/fetch-report-data.ts b/src/commands/scan/fetch-report-data.ts new file mode 100644 index 000000000..450bc79c5 --- /dev/null +++ b/src/commands/scan/fetch-report-data.ts @@ -0,0 +1,212 @@ +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { SocketSdkResultType, SocketSdkReturnType } from '@socketsecurity/sdk' +import { components } from '@socketsecurity/sdk/types/api' + +import constants from '../../constants' +import { handleAPIError, handleApiCall, queryAPI } from '../../utils/api' +import { AuthError } from '../../utils/errors' +import { getDefaultToken, setupSdk } from '../../utils/sdk' + +/** + * This fetches all the relevant pieces of data to generate a report, given a + * full scan ID. + * It can optionally only fetch the security or license side of things. + */ +export async function fetchReportData( + orgSlug: string, + fullScanId: string, + // includeLicensePolicy: boolean, + includeSecurityPolicy: boolean +): Promise< + | { + ok: true + scan: Array + // licensePolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'> + securityPolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'> + } + | { + ok: false + scan: undefined + // licensePolicy: undefined + securityPolicy: undefined + } +> { + let haveScan = false + // let haveLicensePolicy = false + let haveSecurityPolicy = false + + // Lazily access constants.spinner. + const { spinner } = constants + + function updateProgress() { + const needs = [ + !haveScan ? 'scan' : undefined, + // includeLicensePolicy && !haveLicensePolicy ? 'license policy' : undefined, + includeSecurityPolicy && !haveSecurityPolicy + ? 'security policy' + : undefined + ].filter(Boolean) + if (needs.length > 2) { + // .toOxford() + needs[needs.length - 1] = `and ${needs[needs.length - 1]}` + } + const haves = [ + haveScan ? 'scan' : undefined, + // includeLicensePolicy && haveLicensePolicy ? 'license policy' : undefined, + includeSecurityPolicy && haveSecurityPolicy + ? 'security policy' + : undefined + ].filter(Boolean) + if (haves.length > 2) { + // .toOxford() + haves[haves.length - 1] = `and ${haves[haves.length - 1]}` + } + + if (needs.length) { + spinner.start( + `Fetching ${needs.join(needs.length > 2 ? ', ' : ' and ')}...${haves.length ? ` Completed fetching ${haves.join(haves.length > 2 ? ', ' : ' and ')}.` : ''}` + ) + } else { + spinner?.successAndStop( + `Completed fetching ${haves.join(haves.length > 2 ? ', ' : ' and ')}.` + ) + } + } + + 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.' + ) + } + + updateProgress() + + const socketSdk = await setupSdk(apiToken) + + // @ts-ignore + const [ + scan, + // licensePolicyMaybe, + securityPolicyMaybe + ]: [ + undefined | Array, + // undefined | SocketSdkResultType<'getOrgSecurityPolicy'>, + undefined | SocketSdkResultType<'getOrgSecurityPolicy'> + ] = await Promise.all([ + (async () => { + try { + const response = await queryAPI( + `orgs/${orgSlug}/full-scans/${encodeURIComponent(fullScanId)}`, + apiToken + ) + + haveScan = true + updateProgress() + + if (!response.ok) { + const err = await handleAPIError(response.status) + logger.fail( + `${colors.bgRed(colors.white(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 + } + })(), + // includeLicensePolicy && + // (async () => { + // const r = await socketSdk.getOrgSecurityPolicy(orgSlug) + // haveLicensePolicy = true + // updateProgress() + // return await handleApiCall( + // r, + // "looking up organization's license policy" + // ) + // })(), + includeSecurityPolicy && + (async () => { + const r = await socketSdk.getOrgSecurityPolicy(orgSlug) + haveSecurityPolicy = true + updateProgress() + return await handleApiCall( + r, + "looking up organization's security policy" + ) + })() + ]).finally(() => spinner.stop()) + + if (!Array.isArray(scan)) { + logger.error('Was unable to fetch scan, bailing') + process.exitCode = 1 + return { + ok: false, + scan: undefined, + // licensePolicy: undefined, + securityPolicy: undefined + } + } + + // // Note: security->license once the api ships in the sdk + // let licensePolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'> = + // undefined + // if (includeLicensePolicy) { + // if (licensePolicyMaybe && licensePolicyMaybe.success) { + // licensePolicy = licensePolicyMaybe + // } else { + // logger.error('Was unable to fetch license policy, bailing') + // process.exitCode = 1 + // return { + // ok: false, + // scan: undefined, + // licensePolicy: undefined, + // securityPolicy: undefined + // } + // } + // } + + let securityPolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'> = + undefined + if (includeSecurityPolicy) { + if (securityPolicyMaybe && securityPolicyMaybe.success) { + securityPolicy = securityPolicyMaybe + } else { + logger.error('Was unable to fetch security policy, bailing') + process.exitCode = 1 + return { + ok: false, + scan: undefined, + // licensePolicy: undefined, + securityPolicy: undefined + } + } + } + + return { + ok: true, + scan, + // licensePolicy, + securityPolicy + } +} diff --git a/src/commands/scan/generate-report.test.ts b/src/commands/scan/generate-report.test.ts new file mode 100644 index 000000000..d75413349 --- /dev/null +++ b/src/commands/scan/generate-report.test.ts @@ -0,0 +1,816 @@ +import { describe, expect, it } from 'vitest' + +import { generateReport } from './generate-report' + +import type { SocketSdkReturnType } from '@socketsecurity/sdk' +import type { components } from '@socketsecurity/sdk/types/api' + +describe('generate-report', () => { + it('should accept empty args', () => { + const result = generateReport([], undefined, undefined, { + fold: 'none', + reportLevel: 'warn' + }) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + }) + + describe('report shape', () => { + describe('report-level=warn', () => { + it('should return a healthy report without alerts when there are no violations', () => { + const result = generateReport( + getSimpleCleanScan(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + gptSecurity: { + action: 'ignore' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(0) + }) + + it('should return a sick report with alert when an alert violates at error', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'error' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => "error", + "envVars at 200:250" => "error", + }, + }, + }, + }, + }, + "healthy": false, + } + `) + expect(result.healthy).toBe(false) + expect(result.alerts.size).toBe(1) + }) + + it('should return a healthy report with alert when an alert violates at warn', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'warn' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => "warn", + "envVars at 200:250" => "warn", + }, + }, + }, + }, + }, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(1) + }) + + it('should return a healthy report without alerts when an alert violates at monitor', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'monitor' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert violates at ignore', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'ignore' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert violates at defer', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'defer' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert has no policy value', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: {} + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert has no policy entry', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: {}, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(0) + }) + }) + + describe('report-level=ignore', () => { + it('should return a healthy report without alerts when there are no violations', () => { + const result = generateReport( + getSimpleCleanScan(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + gptSecurity: { + action: 'ignore' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'ignore' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(0) + }) + + it('should return a sick report with alert when an alert violates at error', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'error' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'ignore' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => "error", + "envVars at 200:250" => "error", + }, + }, + }, + }, + }, + "healthy": false, + } + `) + expect(result.healthy).toBe(false) + expect(result.alerts.size).toBe(1) + }) + + it('should return a healthy report with alert when an alert violates at warn', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'warn' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'ignore' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => "warn", + "envVars at 200:250" => "warn", + }, + }, + }, + }, + }, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(1) + }) + + it('should return a healthy report with alert when an alert violates at monitor', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'monitor' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'ignore' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => "monitor", + "envVars at 200:250" => "monitor", + }, + }, + }, + }, + }, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(1) + }) + + it('should return a healthy report with alert when an alert violates at ignore', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'ignore' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'ignore' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => "ignore", + "envVars at 200:250" => "ignore", + }, + }, + }, + }, + }, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(1) + }) + + it('should return a healthy report without alerts when an alert violates at defer', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'defer' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'ignore' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert has no policy value', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: {} + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'ignore' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert has no policy entry', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: {}, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'ignore' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map {}, + "healthy": true, + } + `) + expect(result.healthy).toBe(true) + expect(result.alerts.size).toBe(0) + }) + }) + }) + + describe('fold', () => { + it('should not fold anything when fold=none', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'error' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'none', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => "error", + "envVars at 200:250" => "error", + }, + }, + }, + }, + }, + "healthy": false, + } + `) + }) + + it('should fold the file locations when fold=file', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'error' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'file', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => "error", + }, + }, + }, + }, + "healthy": false, + } + `) + }) + + it('should fold the files up when fold=version', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'error' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'version', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => "error", + }, + }, + }, + "healthy": false, + } + `) + }) + + it('should fold the versions up when fold=pkg', () => { + const result = generateReport( + getScanWithEnvVars(), + undefined, + { + success: true, + data: { + securityPolicyRules: { + envVars: { + action: 'error' + } + }, + securityPolicyDefault: 'medium' + } + } as SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold: 'pkg', + reportLevel: 'warn' + } + ) + + expect(result).toMatchInlineSnapshot(` + { + "alerts": Map { + "npm" => Map { + "tslib" => "error", + }, + }, + "healthy": false, + } + `) + }) + }) +}) + +function getSimpleCleanScan(): Array { + return [ + { + id: '12521', + author: ['typescript-bot'], + size: 33965, + type: 'npm', + name: 'tslib', + version: '1.14.1', + license: '0BSD', + licenseDetails: [], + score: { + license: 1, + maintenance: 0.86, + overall: 0.86, + quality: 1, + supplyChain: 1, + vulnerability: 1 + }, + alerts: [], + manifestFiles: [ + { + file: 'package-lock.json', + start: 600172, + end: 600440 + } + ], + topLevelAncestors: ['15903631404'] + } + ] +} + +function getScanWithEnvVars(): Array { + return [ + { + id: '12521', + author: ['typescript-bot'], + size: 33965, + type: 'npm', + name: 'tslib', + version: '1.14.1', + license: '0BSD', + licenseDetails: [], + score: { + license: 1, + maintenance: 0.86, + overall: 0.86, + quality: 1, + supplyChain: 1, + vulnerability: 1 + }, + alerts: [ + { + key: 'QEW1uRmLsj4EBOTv3wb0NZ3W4ziYZVheU5uTpYPC6txs', + type: 'envVars', + severity: 'low', + category: 'supplyChainRisk', + file: 'package/which.js', + start: 54, + end: 72, + props: { + // @ts-ignore + envVars: 'XYZ' + } + }, + { + key: 'QEW1uRmLsj4EBOTv3wb0NZ3W4ziYZVheU5uTpYPC6txy', + type: 'envVars', + severity: 'low', + category: 'supplyChainRisk', + file: 'package/which.js', + start: 200, + end: 250, + props: { + // @ts-ignore + envVars: 'ABC' + } + } + ], + manifestFiles: [ + { + file: 'package-lock.json', + start: 600172, + end: 600440 + } + ], + topLevelAncestors: ['15903631404'] + } + ] +} diff --git a/src/commands/scan/generate-report.ts b/src/commands/scan/generate-report.ts new file mode 100644 index 000000000..32ef0cf65 --- /dev/null +++ b/src/commands/scan/generate-report.ts @@ -0,0 +1,213 @@ +import { SocketSdkReturnType } from '@socketsecurity/sdk' +import { components } from '@socketsecurity/sdk/types/api' + +import constants from '../../constants' + +type AlertAction = 'defer' | 'ignore' | 'monitor' | 'error' | 'warn' +type AlertKey = string + +type FileMap = Map> +type VersionMap = Map +type PackageMap = Map +type ViolationsMap = Map + +export function generateReport( + scan: Array, + _licensePolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'>, + securityPolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'>, + { + fold, + reportLevel + }: { + fold: 'pkg' | 'version' | 'file' | 'none' + reportLevel: 'defer' | 'ignore' | 'monitor' | 'warn' | 'error' + } +) { + const now = Date.now() + + // Lazily access constants.spinner. + const { spinner } = constants + spinner.start('Generating report...') + + // Create an object that includes: + // healthy: boolean + // worst violation level; + // per eco + // per package + // per version + // per offending file + // reported issue -> policy action + + // In the context of a report; + // - the alert.severity is irrelevant + // - the securityPolicyDefault is irrelevant + // - the report defaults to healthy:true with no alerts + // - the appearance of an alert will trigger the policy action; + // - error: healthy will end up as false, add alerts to report + // - warn: healthy unchanged, add alerts to report + // - monitor/ignore: no action + // - defer: unknown (no action) + + const violations = new Map() + + let healthy = true + + const securityRules: SocketSdkReturnType<'getOrgSecurityPolicy'>['data']['securityPolicyRules'] = + securityPolicy?.data.securityPolicyRules + if (securityPolicy && securityRules) { + // Note: reportLevel: error > warn > monitor > ignore > defer + scan.forEach(art => { + const { + alerts, + name: pkgName = '', + type: ecosystem, + version = '' + } = art + alerts?.forEach( + ( + alert: NonNullable< + components['schemas']['SocketArtifact']['alerts'] + >[number] + ) => { + const alertName = alert.type as keyof typeof securityRules // => policy[type] + const action = securityRules[alertName]?.action || '' + switch (action) { + case 'error': { + healthy = false + addAlert( + violations, + fold, + ecosystem, + pkgName, + version, + alert, + action + ) + break + } + case 'warn': { + if (reportLevel !== 'error') { + addAlert( + violations, + fold, + ecosystem, + pkgName, + version, + alert, + action + ) + } + break + } + case 'monitor': { + if (reportLevel !== 'warn' && reportLevel !== 'error') { + addAlert( + violations, + fold, + ecosystem, + pkgName, + version, + alert, + action + ) + } + break + } + + case 'ignore': { + if ( + reportLevel !== 'warn' && + reportLevel !== 'error' && + reportLevel !== 'monitor' + ) { + addAlert( + violations, + fold, + ecosystem, + pkgName, + version, + alert, + action + ) + } + break + } + + case 'defer': { + // Not sure but ignore for now. Defer to later ;) + if (reportLevel === 'defer') { + addAlert( + violations, + fold, + ecosystem, + pkgName, + version, + alert, + action + ) + } + break + } + + default: { + // This value was not emitted from the api at the time of writing. + } + } + } + ) + }) + } + + spinner.successAndStop(`Generated reported in ${Date.now() - now} ms`) + + const report = { + healthy, + alerts: violations + } + + return report +} + +function addAlert( + violations: ViolationsMap, + foldSetting: 'pkg' | 'version' | 'file' | 'none', + ecosystem: string, + pkgName: string, + version: string, + alert: NonNullable[number], + policyAction: AlertAction +) { + if (!violations.has(ecosystem)) { + violations.set(ecosystem, new Map()) + } + const ecomap = violations.get(ecosystem)! + if (!ecomap.has(pkgName)) ecomap.set(pkgName, new Map()) + + if (foldSetting === 'pkg') { + if (policyAction === 'error') ecomap.set(pkgName, 'error') + else if (!ecomap.get(pkgName)) ecomap.set(pkgName, 'warn') + } else { + const pkgmap = ecomap.get(pkgName) as VersionMap + if (!pkgmap.has(version)) pkgmap.set(version, new Map()) + + if (foldSetting === 'version') { + if (policyAction === 'error') pkgmap.set(version, 'error') + else if (!pkgmap.get(version)) pkgmap.set(version, 'warn') + } else { + const file = alert.file || '' + const vermap = pkgmap.get(version) as FileMap + if (!vermap.has(file)) vermap.set(file, new Map()) + + if (foldSetting === 'file') { + if (policyAction === 'error') vermap.set(file, 'error') + else if (!vermap.get(file)) vermap.set(file, 'warn') + } else { + const filemap = vermap.get(file) as Map + filemap.set( + `${alert.type} at ${alert.start}:${alert.end}`, + policyAction + ) + } + } + } +} diff --git a/src/commands/scan/report-full-scan.ts b/src/commands/scan/report-full-scan.ts new file mode 100644 index 000000000..23c579d28 --- /dev/null +++ b/src/commands/scan/report-full-scan.ts @@ -0,0 +1,82 @@ +import fs from 'node:fs/promises' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { fetchReportData } from './fetch-report-data' +import { generateReport } from './generate-report' +import { mapToObject } from '../../utils/map-to-object' + +export async function reportFullScan({ + filePath, + fold, + fullScanId, + includeLicensePolicy, + includeSecurityPolicy, + orgSlug, + outputKind, + reportLevel +}: { + orgSlug: string + fullScanId: string + includeLicensePolicy: boolean + includeSecurityPolicy: boolean + outputKind: 'json' | 'markdown' | 'text' + filePath: string + fold: 'pkg' | 'version' | 'file' | 'none' + reportLevel: 'defer' | 'ignore' | 'monitor' | 'warn' | 'error' +}): Promise { + logger.error( + 'output:', + outputKind, + ', file:', + filePath, + ', fold:', + fold, + ', reportLevel:', + reportLevel + ) + if (!includeLicensePolicy && !includeSecurityPolicy) { + return // caller should assert + } + + const { + // licensePolicy, + ok, + scan, + securityPolicy + } = await fetchReportData( + orgSlug, + fullScanId, + includeLicensePolicy + // includeSecurityPolicy + ) + + if (!ok) { + return + } + + const report = generateReport( + scan, + undefined, // licensePolicy, + securityPolicy, + { + fold, + reportLevel + } + ) + + if (outputKind === 'json') { + const obj = mapToObject(report.alerts) + + const json = JSON.stringify(obj, null, 2) + + if (filePath && filePath !== '-') { + return await fs.writeFile(filePath, json) + } + + logger.log(json) + return + } + + logger.dir(report, { depth: null }) +} diff --git a/src/utils/api.ts b/src/utils/api.ts index 10ed29a6b..c4a8874d7 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -25,6 +25,10 @@ export function handleUnsuccessfulApiResponse( ? resultErrorMessage : 'No error message returned' if (result.status === 401 || result.status === 403) { + // Lazily access constants.spinner. + const { spinner } = constants + spinner.stop() + throw new AuthError(message) } logger.fail( diff --git a/src/utils/map-to-object.test.ts b/src/utils/map-to-object.test.ts new file mode 100644 index 000000000..0d27e2b9c --- /dev/null +++ b/src/utils/map-to-object.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' + +import { mapToObject } from './map-to-object' + +describe('map-to-object', () => { + it('should convert a map string string', () => { + expect( + mapToObject( + new Map([ + ['a', 'b'], + ['c', 'd'] + ]) + ) + ).toMatchInlineSnapshot(` + { + "a": "b", + "c": "d", + } + `) + }) + + it('should convert a map string map string string', () => { + expect( + mapToObject( + new Map([ + [ + 'x', + new Map([ + ['a', 'b'], + ['c', 'd'] + ]) + ] + ]) + ) + ).toMatchInlineSnapshot(` + { + "x": { + "a": "b", + "c": "d", + }, + } + `) + }) + + it('should convert a map string map string map string string', () => { + expect( + mapToObject( + new Map([ + [ + 'a123', + new Map([ + [ + 'x', + new Map([ + ['a', 'b'], + ['c', 'd'] + ]) + ], + [ + 'y', + new Map([ + ['a', 'b'], + ['c', 'd'] + ]) + ] + ]) + ], + [ + 'b456', + new Map([ + [ + 'x', + new Map([ + ['a', 'b'], + ['c', 'd'] + ]) + ], + [ + 'y', + new Map([ + ['a', 'b'], + ['c', 'd'] + ]) + ] + ]) + ] + ]) + ) + ).toMatchInlineSnapshot(` + { + "a123": { + "x": { + "a": "b", + "c": "d", + }, + "y": { + "a": "b", + "c": "d", + }, + }, + "b456": { + "x": { + "a": "b", + "c": "d", + }, + "y": { + "a": "b", + "c": "d", + }, + }, + } + `) + }) +}) diff --git a/src/utils/map-to-object.ts b/src/utils/map-to-object.ts new file mode 100644 index 000000000..721ef11a0 --- /dev/null +++ b/src/utils/map-to-object.ts @@ -0,0 +1,18 @@ +interface NestedRecord { + [key: string]: T | NestedRecord +} + +/** + * Convert a Map to a nested object of similar shape. + * The goal is to serialize it with JSON.stringify, which Map can't do. + */ +export function mapToObject( + map: Map>> +): NestedRecord { + return Object.fromEntries( + Array.from(map.entries()).map(([k, v]) => [ + k, + v instanceof Map ? mapToObject(v) : v + ]) + ) +} diff --git a/src/utils/markdown.test.ts b/src/utils/markdown.test.ts new file mode 100644 index 000000000..749313605 --- /dev/null +++ b/src/utils/markdown.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { mdTableOfPairs } from './markdown' + +describe('markdown', () => { + describe('mdTableOfPairs', () => { + it('should convert an array of tuples to markdown', () => { + expect( + mdTableOfPairs( + [ + ['apple', 'green'], + ['banana', 'yellow'], + ['orange', 'orange'] + ], + ['name', 'color'] + ) + ).toMatchInlineSnapshot(` + "| ------ | ------ | + | name | color | + | ------ | ------ | + | apple | green | + | banana | yellow | + | orange | orange | + | ------ | ------ |" + `) + }) + }) +}) diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index f868a5bdf..fd3bc6761 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -64,3 +64,36 @@ export function mdTable>>( return [div, header, div, body.trim(), div].filter(s => !!s.trim()).join('\n') } + +export function mdTableOfPairs( + arr: Array<[string, string]>, + // This is saying "an array of strings and the strings are a valid key of elements of T" + // In turn, T is defined above as the audit log event type from our OpenAPI docs. + cols: string[] +): string { + // Max col width required to fit all data in that column + const cws = cols.map(col => col.length) + + for (const [key, val] of arr) { + cws[0] = Math.max(cws[0] ?? 0, String(key).length) + cws[1] = Math.max(cws[1] ?? 0, String(val ?? '').length) + } + + let div = '|' + for (const cw of cws) div += ' ' + '-'.repeat(cw) + ' |' + + let header = '|' + for (let i = 0; i < cols.length; ++i) { + header += ' ' + String(cols[i]).padEnd(cws[i] ?? 0, ' ') + ' |' + } + + let body = '' + for (const [key, val] of arr) { + body += '|' + body += ' ' + String(key).padEnd(cws[0] ?? 0, ' ') + ' |' + body += ' ' + String(val ?? '').padEnd(cws[1] ?? 0, ' ') + ' |' + body += '\n' + } + + return [div, header, div, body.trim(), div].filter(s => !!s.trim()).join('\n') +} diff --git a/src/utils/meow-with-subcommands.ts b/src/utils/meow-with-subcommands.ts index a58a90f10..929325f25 100644 --- a/src/utils/meow-with-subcommands.ts +++ b/src/utils/meow-with-subcommands.ts @@ -52,6 +52,8 @@ interface MeowOptions extends Options { aliases?: CliAliases | undefined argv: readonly string[] name: string + // When no sub-command is given, default to this sub-command + defaultSub?: string } // For debugging. Whenever you call meowOrExit it will store the command here @@ -69,11 +71,16 @@ export async function meowWithSubcommands( const { aliases = {}, argv, + defaultSub, importMeta, name, ...additionalOptions } = { __proto__: null, ...options } - const [commandOrAliasName, ...rawCommandArgv] = argv + const [commandOrAliasNamex, ...rawCommandArgv] = argv + let commandOrAliasName = commandOrAliasNamex + if (!commandOrAliasName && defaultSub) { + commandOrAliasName = defaultSub + } // If we got at least some args, then lets find out if we can find a command. if (commandOrAliasName) { const alias = aliases[commandOrAliasName] diff --git a/test/dry-run.test.ts b/test/dry-run.test.ts index 024b77f3f..345776f9a 100644 --- a/test/dry-run.test.ts +++ b/test/dry-run.test.ts @@ -12,96 +12,17 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' -import { spawn } from '@socketsecurity/registry/lib/spawn' - +import { cmdit, invokeNpm } from './utils' import constants from '../dist/constants.js' -type TestCollectorOptions = Exclude[1], undefined> - const { CLI } = constants -const testPath = __dirname -const npmFixturesPath = path.join(testPath, 'socket-npm-fixtures') - -/** - * This is a simple template wrapper for this pattern: - * `it('should do: socket scan', (['socket', 'scan']) => {})` - */ -function cmdit( - cmd: string[], - title: string, - cb: (cmd: string[]) => Promise, - options?: TestCollectorOptions | undefined -) { - it( - `${title}: \`${cmd.join(' ')}\``, - { - timeout: 10_000, - ...options - }, - cb.bind(null, cmd) - ) -} - -async function invoke( - entryPath: string, - args: string[] -): Promise<{ - status: boolean - code: number - stdout: string - stderr: string -}> { - try { - const thing = await spawn( - // Lazily access constants.execPath. - constants.execPath, - [entryPath, ...args], - { - cwd: npmFixturesPath - } - ) - return { - status: true, - code: 0, - stdout: toAsciiSafeString(normalizeLogSymbols(thing.stdout)), - stderr: toAsciiSafeString(normalizeLogSymbols(thing.stderr)) - } - } catch (e) { - return { - status: false, - code: e?.code, - stdout: toAsciiSafeString(normalizeLogSymbols(e?.stdout ?? '')), - stderr: toAsciiSafeString(normalizeLogSymbols(e?.stderr ?? '')) - } - } -} - -function normalizeLogSymbols(str: string): string { - return str - .replaceAll('✖️', '×') - .replaceAll('ℹ', 'i') - .replaceAll('✔', '√') - .replaceAll('⚠', '‼') -} - -function toAsciiSafeString(str: string): string { - // eslint-disable-next-line no-control-regex - const asciiSafeRegex = /[\u0000-\u0007\u0009\u000b-\u001f\u0080-\uffff]/g - return str.replace(asciiSafeRegex, (m: string) => { - const code = m.charCodeAt(0) - return code < 255 - ? `\\x${code.toString(16).padStart(2, '0')}` - : `\\u${code.toString(16).padStart(4, '0')}` - }) -} - describe('dry-run on all commands', async () => { // Lazily access constants.rootBinPath. const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) cmdit(['--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot( `"[DryRun]: No-op, call a sub-command; ok"` ) @@ -120,7 +41,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['analytics', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -137,7 +58,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['audit-log', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -158,7 +79,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['cdxgen', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot( ` @@ -179,7 +100,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['dependencies', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -196,7 +117,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['diff-scan', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot( `"[DryRun]: No-op, call a sub-command; ok"` ) @@ -215,7 +136,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['diff-scan', 'get', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -241,7 +162,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['fix', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -258,7 +179,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['info', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -281,7 +202,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['login', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -298,7 +219,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['logout', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -315,7 +236,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['manifest', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot( `"[DryRun]: No-op, call a sub-command; ok"` ) @@ -334,7 +255,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['manifest', 'auto', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(` "Auto-detect build and attempt to generate manifest file @@ -365,7 +286,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['manifest', 'gradle', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -388,7 +309,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['manifest', 'kotlin', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -411,7 +332,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['manifest', 'scala', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -434,7 +355,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['npm', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -451,7 +372,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['npx', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -468,7 +389,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['oops', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -485,7 +406,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['optimize', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -501,25 +422,8 @@ describe('dry-run on all commands', async () => { ) }) - cmdit(['organization', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | Socket.dev CLI ver - |__ | . | _| '_| -_| _| | Node: , API token set: - |_____|___|___|_,_|___|_|.dev | Command: \`socket organizations\`, cwd: " - `) - - expect(code, 'dry-run should exit with code 0 if input is ok').toBe(0) - expect(stderr, 'header should include command (without params)').toContain( - cmd.slice(0, cmd.indexOf('--dry-run')).join(' ') - ) - }) - cmdit(['raw-npm', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -536,7 +440,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['raw-npx', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -553,7 +457,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['report', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot( `"[DryRun]: No-op, call a sub-command; ok"` ) @@ -572,7 +476,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['report', 'create', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -589,7 +493,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['report', 'view', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -612,7 +516,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['repos', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot( `"[DryRun]: No-op, call a sub-command; ok"` ) @@ -631,7 +535,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['repos', 'create', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -654,7 +558,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['repos', 'del', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -679,7 +583,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['repos', 'list', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -702,7 +606,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['repos', 'update', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -727,7 +631,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['repos', 'view', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -800,7 +704,7 @@ describe('dry-run on all commands', async () => { // }) cmdit(['scan', 'del', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -823,7 +727,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['scan', 'list', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -844,7 +748,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['scan', 'metadata', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -867,7 +771,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['scan', 'view', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -890,7 +794,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['threat-feed', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " @@ -907,7 +811,7 @@ describe('dry-run on all commands', async () => { }) cmdit(['wrapper', '--dry-run'], 'should support', async cmd => { - const { code, stderr, stdout } = await invoke(entryPath, cmd) + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot(`""`) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 000000000..fcebbb657 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,85 @@ +import path from 'node:path' + +import { it } from 'vitest' + +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../src/constants' + +type TestCollectorOptions = Exclude[1], undefined> + +// Note: the fixture dir is in the same dir as this utils file +const npmFixturesPath = path.join(__dirname, 'socket-npm-fixtures') + +/** + * This is a simple template wrapper for this pattern: + * `it('should do: socket scan', (['socket', 'scan']) => {})` + */ +export function cmdit( + cmd: string[], + title: string, + cb: (cmd: string[]) => Promise, + options?: TestCollectorOptions | undefined +) { + it( + `${title}: \`${cmd.join(' ')}\``, + { + timeout: 10_000, + ...options + }, + cb.bind(null, cmd) + ) +} + +export async function invokeNpm( + entryPath: string, + args: string[] +): Promise<{ + status: boolean + code: number + stdout: string + stderr: string +}> { + try { + const thing = await spawn( + // Lazily access constants.execPath. + constants.execPath, + [entryPath, ...args], + { + cwd: npmFixturesPath + } + ) + return { + status: true, + code: 0, + stdout: toAsciiSafeString(normalizeLogSymbols(thing.stdout)), + stderr: toAsciiSafeString(normalizeLogSymbols(thing.stderr)) + } + } catch (e) { + return { + status: false, + code: e?.code, + stdout: toAsciiSafeString(normalizeLogSymbols(e?.stdout ?? '')), + stderr: toAsciiSafeString(normalizeLogSymbols(e?.stderr ?? '')) + } + } +} + +function normalizeLogSymbols(str: string): string { + return str + .replaceAll('✖️', '×') + .replaceAll('ℹ', 'i') + .replaceAll('✔', '√') + .replaceAll('⚠', '‼') +} + +function toAsciiSafeString(str: string): string { + // eslint-disable-next-line no-control-regex + const asciiSafeRegex = /[\u0000-\u0007\u0009\u000b-\u001f\u0080-\uffff]/g + return str.replace(asciiSafeRegex, (m: string) => { + const code = m.charCodeAt(0) + return code < 255 + ? `\\x${code.toString(16).padStart(2, '0')}` + : `\\u${code.toString(16).padStart(4, '0')}` + }) +} diff --git a/tsconfig.json b/tsconfig.json index f9fd6ce2b..21fb968e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { "extends": "./.config/tsconfig.rollup.json", - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] }