diff --git a/src/commands/audit-log/output-audit-log.ts b/src/commands/audit-log/output-audit-log.ts index 56f31beed..419afd71e 100644 --- a/src/commands/audit-log/output-audit-log.ts +++ b/src/commands/audit-log/output-audit-log.ts @@ -88,7 +88,7 @@ async function outputAsJson( logger.log(json) } -async function outputAsMarkdown( +export async function outputAsMarkdown( auditLogs: SocketSdkReturnType<'getAuditLogEvents'>['data']['results'], orgSlug: string, logType: string, diff --git a/src/commands/package/cmd-package-score.test.ts b/src/commands/package/cmd-package-score.test.ts new file mode 100644 index 000000000..df3508a0c --- /dev/null +++ b/src/commands/package/cmd-package-score.test.ts @@ -0,0 +1,110 @@ +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 package score', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit(['package', 'score', '--help'], 'should support --help', async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Look up score for one package which reflects all of its transitive dependencies as well + + Usage + $ socket package score < | > + + 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 + + Requirements + - quota: 100 + - scope: \`packages:list\` + + Show deep scoring details for one package. The score will reflect the package + itself, any of its dependencies, and any of its transitive dependencies. + + When you want to know whether to trust a package, this is the command to run. + + See also the \`socket package shallow\` command, which returns the shallow + score for any number of packages. That will not reflect the dependency scores. + + Only a few ecosystems are supported like npm, golang, and maven. + + A "purl" is a standard package name formatting: \`pkg:eco/name@version\` + This command will automatically prepend "pkg:" when not present. + + The version is optional but when given should be a direct match. + + Examples + $ socket package score npm babel-cli + $ socket package score npm babel-cli@1.9.1 + $ socket package score npm/babel-cli@1.9.1 + $ socket package score pkg:npm/babel-cli@1.9.1" + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect(stderr, 'header should include command (without params)').toContain( + '`socket package score`' + ) + }) + + cmdit( + ['package', 'score', '--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 package score\`, cwd: + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[37mInput error\\x1b[39m\\x1b[49m: Please provide the required fields: + + - First parameter should be an ecosystem or the arg must be a purl \\x1b[31m(bad!)\\x1b[39m + + - Expecting the package to check \\x1b[31m(missing!)\\x1b[39m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + } + ) + + cmdit( + ['package', 'score', 'npm', 'babel', '--dry-run'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + } + ) +}) diff --git a/src/commands/package/cmd-package-score.ts b/src/commands/package/cmd-package-score.ts new file mode 100644 index 000000000..f3c3b0028 --- /dev/null +++ b/src/commands/package/cmd-package-score.ts @@ -0,0 +1,103 @@ +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handlePurlDeepScore } from './handle-purl-deep-score' +import { parsePackageSpecifiers } from './parse-package-specifiers' +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: 'score', + description: + 'Look up score for one package which reflects all of its transitive dependencies as well', + hidden: true, + flags: { + ...commonFlags, + ...outputFlags + }, + help: (command, config) => ` + Usage + $ ${command} < | > + + Options + ${getFlagListOutput(config.flags, 6)} + + Requirements + - quota: 100 + - scope: \`packages:list\` + + Show deep scoring details for one package. The score will reflect the package + itself, any of its dependencies, and any of its transitive dependencies. + + When you want to know whether to trust a package, this is the command to run. + + See also the \`socket package shallow\` command, which returns the shallow + score for any number of packages. That will not reflect the dependency scores. + + Only a few ecosystems are supported like npm, golang, and maven. + + A "purl" is a standard package name formatting: \`pkg:eco/name@version\` + This command will automatically prepend "pkg:" when not present. + + The version is optional but when given should be a direct match. + + Examples + $ ${command} npm babel-cli + $ ${command} npm babel-cli@1.9.1 + $ ${command} npm/babel-cli@1.9.1 + $ ${command} pkg:npm/babel-cli@1.9.1 + ` +} + +export const cmdPackageScore = { + 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, markdown } = cli.flags + const [ecosystem = '', purl] = cli.input + + const { purls, valid } = parsePackageSpecifiers(ecosystem, purl ? [purl] : []) + + if (!valid || !purls.length) { + // Use exit status of 2 to indicate incorrect usage, generally invalid + // options or missing arguments. + // https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html + process.exitCode = 2 + logger.fail(`${colors.bgRed(colors.white('Input error'))}: Please provide the required fields:\n + - First parameter should be an ecosystem or the arg must be a purl ${!valid ? colors.red('(bad!)') : colors.green('(ok)')}\n + - Expecting the package to check ${!purls.length ? colors.red('(missing!)') : colors.green('(ok)')}\n + `) + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAIL_TEXT) + return + } + + await handlePurlDeepScore( + purls[0] || '', + json ? 'json' : markdown ? 'markdown' : 'text' + ) +} diff --git a/src/commands/package/cmd-package.ts b/src/commands/package/cmd-package.ts index 9bfad8d39..60e564981 100644 --- a/src/commands/package/cmd-package.ts +++ b/src/commands/package/cmd-package.ts @@ -1,3 +1,4 @@ +import { cmdPackageScore } from './cmd-package-score' import { cmdPackageShallow } from './cmd-package-shallow' import { meowWithSubcommands } from '../../utils/meow-with-subcommands' @@ -11,14 +12,15 @@ export const cmdPackage: CliSubcommand = { async run(argv, importMeta, { parentName }) { await meowWithSubcommands( { + score: cmdPackageScore, shallow: cmdPackageShallow }, { aliases: { - pkg: { + deep: { description, hidden: true, - argv: [] + argv: ['score'] } }, argv, diff --git a/src/commands/package/fetch-purl-deep-score.ts b/src/commands/package/fetch-purl-deep-score.ts new file mode 100644 index 000000000..21c63daf7 --- /dev/null +++ b/src/commands/package/fetch-purl-deep-score.ts @@ -0,0 +1,60 @@ +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants' +import { handleApiCall, handleApiError, queryApi } from '../../utils/api' +import { AuthError } from '../../utils/errors' +import { getDefaultToken } from '../../utils/sdk' + +export async function fetchPurlDeepScore(purl: string) { + // Lazily access constants.spinner. + const { spinner } = constants + + 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.' + ) + } + + spinner.start('Getting deep package score...') + let result + try { + result = await queryApi(`purl/score/${encodeURIComponent(purl)}`, apiToken) + spinner?.successAndStop('Received deep package score response.') + } catch (e) { + spinner?.failAndStop('The request was unsuccessful.') + const msg = (e as undefined | { message: string })?.message + if (msg) { + logger.fail(msg) + logger.log( + 'Please report this if the error persists or use the cli version that includes error reporting to automate that' + ) + } else { + logger.log( + 'An error happened but no reason was given. If this persists please let us know about it and what you were trying to achieve. Thank you.' + ) + } + return + } + + if (!result.ok) { + const err = await handleApiError(result.status) + logger.fail( + `${colors.bgRed(colors.bold(colors.white(' ' + result.statusText + ' ')))}: ${err}` + ) + process.exitCode = 1 + return + } + + const data = await handleApiCall(await result.text(), 'Reading text') + + try { + return JSON.parse(data) + } catch (e) { + throw new Error( + 'Was unable to JSON parse the input from the server. It may not have been a proper JSON response. Please report this problem.' + ) + } +} diff --git a/src/commands/package/handle-purl-deep-score.ts b/src/commands/package/handle-purl-deep-score.ts new file mode 100644 index 000000000..f77fc563d --- /dev/null +++ b/src/commands/package/handle-purl-deep-score.ts @@ -0,0 +1,14 @@ +import { fetchPurlDeepScore } from './fetch-purl-deep-score' +import { outputPurlScore } from './output-purl-score' + +export async function handlePurlDeepScore( + purl: string, + outputKind: 'json' | 'markdown' | 'text' +) { + const data = await fetchPurlDeepScore(purl) + if (!data) { + return + } + + await outputPurlScore(purl, data, outputKind) +} diff --git a/src/commands/package/output-purl-score.ts b/src/commands/package/output-purl-score.ts new file mode 100644 index 000000000..4d9801d34 --- /dev/null +++ b/src/commands/package/output-purl-score.ts @@ -0,0 +1,259 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { mdTable } from '../../utils/markdown' + +export async function outputPurlScore( + purl: string, + data: unknown, + outputKind: 'json' | 'markdown' | 'text' +) { + if (outputKind === 'json') { + let json + try { + json = JSON.stringify(data, null, 2) + } catch { + console.error( + 'Failed to convert the server response to JSON... Please try again or reach out to customer support.' + ) + process.exitCode = 1 + return + } + + logger.error(`Score report for "${purl}":\n`) + logger.log(json) + logger.log('') + return + } + + if (outputKind === 'markdown') { + const { + purl: requestedPurl, + self: { + alerts: selfAlerts, + capabilities: selfCaps, + purl, + score: selfScore + }, + transitively: { + alerts, + capabilities, + dependencyCount, + func, + lowest, + score + } + } = data as { + purl: string + self: { + purl: string + score: { + license: number + maintenance: number + overall: number + quality: number + supplyChain: number + vulnerability: number + } + capabilities: string[] + alerts: Array<{ + name: string + severity: string + category: string + example: string + }> + } + transitively: { + dependencyCount: number + func: string + score: { + license: number + maintenance: number + overall: number + quality: number + supplyChain: number + vulnerability: number + } + lowest: { + license: string + maintenance: string + overall: string + quality: string + supplyChain: string + vulnerability: string + } + capabilities: string[] + alerts: Array<{ + name: string + severity: string + category: string + example: string + }> + } + } + + logger.error(`Score report for "${requestedPurl}" ("${purl}"):\n`) + logger.log('# Complete Package Score') + logger.log('') + if (dependencyCount) { + logger.log( + `This is a Socket report for the package *"${purl}"* and its *${dependencyCount}* direct/transitive dependencies.` + ) + } else { + logger.log( + `This is a Socket report for the package *"${purl}"*. It has *no dependencies*.` + ) + } + logger.log('') + if (dependencyCount) { + logger.log( + `It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it.` + ) + } else { + logger.log( + `It will show you the shallow score for the package itself, which capabilities were found, and its top alerts.` + ) + logger.log('') + logger.log( + 'Since it has no dependencies, the shallow score is also the deep score.' + ) + } + logger.log('') + if (dependencyCount) { + // This doesn't make much sense if there are no dependencies. Better to omit it. + logger.log( + 'The report should give you a good insight into the status of this package.' + ) + logger.log('') + logger.log('## Package itself') + logger.log('') + logger.log( + 'Here are results for the package itself (excluding data from dependencies).' + ) + } else { + logger.log('## Report') + logger.log('') + logger.log( + 'The report should give you a good insight into the status of this package.' + ) + } + logger.log('') + logger.log('### Shallow Score') + logger.log('') + logger.log('This score is just for the package itself:') + logger.log('') + logger.log('- Overall: ' + selfScore.overall) + logger.log('- Maintenance: ' + selfScore.maintenance) + logger.log('- Quality: ' + selfScore.quality) + logger.log('- Supply Chain: ' + selfScore.supplyChain) + logger.log('- Vulnerability: ' + selfScore.vulnerability) + logger.log('- License: ' + selfScore.license) + logger.log('') + logger.log('### Capabilities') + logger.log('') + if (selfCaps.length) { + logger.log('These are the capabilities detected in the package itself:') + logger.log('') + selfCaps.forEach(cap => { + logger.log(`- ${cap}`) + }) + } else { + logger.log('No capabilities were found in the package.') + } + logger.log('') + logger.log('### Alerts for this package') + logger.log('') + if (selfAlerts.length) { + if (dependencyCount) { + logger.log('These are the alerts found for the package itself:') + } else { + logger.log('These are the alerts found for this package:') + } + logger.log('') + logger.log( + mdTable(selfAlerts, ['severity', 'name'], ['Severity', 'Alert Name']) + ) + } else { + logger.log('There are currently no alerts for this package.') + } + logger.log('') + if (dependencyCount) { + logger.log('## Transitive Package Results') + logger.log('') + logger.log( + 'Here are results for the package and its direct/transitive dependencies.' + ) + logger.log('') + logger.log('### Deep Score') + logger.log('') + logger.log( + 'This score represents the package and and its direct/transitive dependencies:' + ) + logger.log( + `The function used to calculate the values in aggregate is: *"${func}"*` + ) + logger.log('') + logger.log('- Overall: ' + score.overall) + logger.log('- Maintenance: ' + score.maintenance) + logger.log('- Quality: ' + score.quality) + logger.log('- Supply Chain: ' + score.supplyChain) + logger.log('- Vulnerability: ' + score.vulnerability) + logger.log('- License: ' + score.license) + logger.log('') + logger.log('### Capabilities') + logger.log('') + logger.log( + 'These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores.' + ) + logger.log('') + logger.log('- Overall: ' + lowest.overall) + logger.log('- Maintenance: ' + lowest.maintenance) + logger.log('- Quality: ' + lowest.quality) + logger.log('- Supply Chain: ' + lowest.supplyChain) + logger.log('- Vulnerability: ' + lowest.vulnerability) + logger.log('- License: ' + lowest.license) + logger.log('') + logger.log('### Capabilities') + logger.log('') + if (capabilities.length) { + logger.log( + 'These are the capabilities detected in at least one package:' + ) + logger.log('') + capabilities.forEach(cap => { + logger.log(`- ${cap}`) + }) + } else { + logger.log( + 'This package had no capabilities and neither did any of its direct/transitive dependencies.' + ) + } + logger.log('') + logger.log('### Alerts') + logger.log('') + if (alerts.length) { + logger.log('These are the alerts found:') + logger.log('') + + logger.log( + mdTable( + alerts, + ['severity', 'name', 'example'], + ['Severity', 'Alert Name', 'Example package reporting it'] + ) + ) + } else { + logger.log( + 'This package had no alerts and neither did any of its direct/transitive dependencies.' + ) + } + logger.log('') + } + return + } + + logger.log( + `Score report for "${purl}" (use --json for raw and --markdown for formatted reports):` + ) + logger.log(data) + logger.log('') +} diff --git a/src/utils/api.ts b/src/utils/api.ts index 2e3547a44..bc2b5de18 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -77,11 +77,19 @@ export function getDefaultApiBaseUrl(): string | undefined { } export async function queryApi(path: string, apiToken: string) { - const API_V0_URL = getDefaultApiBaseUrl() - return await fetch(`${API_V0_URL}/${path}`, { - method: 'GET', - headers: { - Authorization: `Basic ${btoa(`${apiToken}:`)}` + const API_V0_URL = getDefaultApiBaseUrl() || '' + if (!API_V0_URL) { + logger.warn( + 'API endpoint is not set and default was empty. Request is likely to fail.' + ) + } + return await fetch( + `${API_V0_URL}${API_V0_URL.endsWith('/') ? '' : '/'}${path}`, + { + method: 'GET', + headers: { + Authorization: `Basic ${btoa(`${apiToken}:`)}` + } } - }) + ) } diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index 2d7741748..68edeef80 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -31,7 +31,8 @@ export function mdTable>>( logs: T, // 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: Array + cols: Array, + titles: string[] = cols ): string { // Max col width required to fit all data in that column const cws = cols.map(col => col.length) @@ -40,7 +41,11 @@ export function mdTable>>( for (let i = 0, { length } = cols; i < length; i += 1) { // @ts-ignore const val: unknown = log[cols[i] ?? ''] ?? '' - cws[i] = Math.max(cws[i] ?? 0, String(val).length) + cws[i] = Math.max( + cws[i] ?? 0, + String(val).length, + (titles[i] || '').length + ) } } @@ -50,8 +55,8 @@ export function mdTable>>( } let header = '|' - for (let i = 0, { length } = cols; i < length; i += 1) { - header += ' ' + String(cols[i]).padEnd(cws[i] ?? 0, ' ') + ' |' + for (let i = 0, { length } = titles; i < length; i += 1) { + header += ' ' + String(titles[i]).padEnd(cws[i] ?? 0, ' ') + ' |' } let body = ''