diff --git a/src/cli.ts b/src/cli.ts index b1fe4b2f1..c9ea53b2c 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,6 +25,7 @@ import { cmdNpx } from './commands/npx/cmd-npx' import { cmdOops } from './commands/oops/cmd-oops' import { cmdOptimize } from './commands/optimize/cmd-optimize' import { cmdOrganization } from './commands/organization/cmd-organization' +import { cmdPackage } from './commands/package/cmd-package' import { cmdRawNpm } from './commands/raw-npm/cmd-raw-npm' import { cmdRawNpx } from './commands/raw-npx/cmd-raw-npx' import { cmdReport } from './commands/report/cmd-report' @@ -61,6 +62,7 @@ void (async () => { oops: cmdOops, optimize: cmdOptimize, organization: cmdOrganization, + package: cmdPackage, 'raw-npm': cmdRawNpm, 'raw-npx': cmdRawNpx, report: cmdReport, diff --git a/src/commands/package/cmd-package-shallow.test.ts b/src/commands/package/cmd-package-shallow.test.ts new file mode 100644 index 000000000..62eba840b --- /dev/null +++ b/src/commands/package/cmd-package-shallow.test.ts @@ -0,0 +1,115 @@ +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 shallow', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit( + ['package', 'shallow', '--help'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Look up info regarding one or more packages but not their transitives + + Usage + $ socket package shallow < [ ...] | [ ...]> + + 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 scoring details for one or more packages purely based on their own package. + This means that any dependency scores are not reflected by the score. You can + use the \`socket package score \` command to get its full transitive score. + + 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. + + If the first arg is an ecosystem, remaining args that are not a purl are + assumed to be scoped to that ecosystem. + + Examples + $ socket package shallow npm webtorrent + $ socket package shallow npm webtorrent@1.9.1 + $ socket package shallow npm/webtorrent@1.9.1 + $ socket package shallow pkg:npm/webtorrent@1.9.1 + $ socket package shallow maven webtorrent babel + $ socket package shallow npm/webtorrent golang/babel + $ socket package shallow npm npm/webtorrent@1.0.1 babel" + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket package shallow\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect( + stderr, + 'header should include command (without params)' + ).toContain('`socket package shallow`') + } + ) + + cmdit( + ['package', 'shallow', '--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 shallow\`, 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 all args must be purls \\x1b[31m(bad!)\\x1b[39m + + - Expecting at least one package \\x1b[31m(missing!)\\x1b[39m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + } + ) + + cmdit( + ['package', 'shallow', '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 shallow\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + } + ) +}) diff --git a/src/commands/package/cmd-package-shallow.ts b/src/commands/package/cmd-package-shallow.ts new file mode 100644 index 000000000..d11342103 --- /dev/null +++ b/src/commands/package/cmd-package-shallow.ts @@ -0,0 +1,110 @@ +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { parsePackageSpecifiers } from './parse-package-specifiers' +import { showPurlInfo } from './show-purl-info' +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: 'shallow', + description: + 'Look up info regarding one or more packages but not their transitives', + hidden: true, + flags: { + ...commonFlags, + ...outputFlags + }, + help: (command, config) => ` + Usage + $ ${command} < [ ...] | [ ...]> + + Options + ${getFlagListOutput(config.flags, 6)} + + Requirements + - quota: 100 + - scope: \`packages:list\` + + Show scoring details for one or more packages purely based on their own package. + This means that any dependency scores are not reflected by the score. You can + use the \`socket package score \` command to get its full transitive score. + + 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. + + If the first arg is an ecosystem, remaining args that are not a purl are + assumed to be scoped to that ecosystem. + + Examples + $ ${command} npm webtorrent + $ ${command} npm webtorrent@1.9.1 + $ ${command} npm/webtorrent@1.9.1 + $ ${command} pkg:npm/webtorrent@1.9.1 + $ ${command} maven webtorrent babel + $ ${command} npm/webtorrent golang/babel + $ ${command} npm npm/webtorrent@1.0.1 babel + ` +} + +export const cmdPackageShallow = { + description: config.description, + hidden: config.hidden, + alias: { + shallowScore: { + description: config.description, + hidden: true, + argv: [] + } + }, + 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 = '', ...pkgs] = cli.input + + const { purls, valid } = parsePackageSpecifiers(ecosystem, pkgs) + + 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 all args must be purls ${!valid ? colors.red('(bad!)') : colors.green('(ok)')}\n + - Expecting at least one package ${!purls.length ? colors.red('(missing!)') : colors.green('(ok)')}\n + `) + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAIL_TEXT) + return + } + + await showPurlInfo({ + outputKind: json ? 'json' : markdown ? 'markdown' : 'text', + purls + }) +} diff --git a/src/commands/package/cmd-package.test.ts b/src/commands/package/cmd-package.test.ts new file mode 100644 index 000000000..90f9a3af0 --- /dev/null +++ b/src/commands/package/cmd-package.test.ts @@ -0,0 +1,47 @@ +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', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit(['package', '--help'], 'should support --help', async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Commands relating to looking up published packages + + Usage + $ socket package + + Commands + + + Options + --dryRun Do input validation for a command and exit 0 when input is ok + --help Print this help. + + Examples + $ socket package --help" + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket package\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect(stderr, 'header should include command (without params)').toContain( + '`socket package`' + ) + }) +}) diff --git a/src/commands/package/cmd-package.ts b/src/commands/package/cmd-package.ts new file mode 100644 index 000000000..9bfad8d39 --- /dev/null +++ b/src/commands/package/cmd-package.ts @@ -0,0 +1,31 @@ +import { cmdPackageShallow } from './cmd-package-shallow' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands' + +const description = 'Commands relating to looking up published packages' + +export const cmdPackage: CliSubcommand = { + description, + hidden: true, // [beta] + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + shallow: cmdPackageShallow + }, + { + aliases: { + pkg: { + description, + hidden: true, + argv: [] + } + }, + argv, + description, + importMeta, + name: parentName + ' package' + } + ) + } +} diff --git a/src/commands/package/fetch-package-info.ts b/src/commands/package/fetch-package-info.ts new file mode 100644 index 000000000..fa9150e8c --- /dev/null +++ b/src/commands/package/fetch-package-info.ts @@ -0,0 +1,47 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants' +import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api' +import { getPublicToken, setupSdk } from '../../utils/sdk' + +import type { + SocketSdkResultType, + SocketSdkReturnType +} from '@socketsecurity/sdk' + +export async function fetchPackageInfo( + purls: string[] +): Promise> { + const socketSdk = await setupSdk(getPublicToken()) + + // Lazily access constants.spinner. + const { spinner } = constants + + logger.error( + `Requesting shallow score data for ${purls.length} package urls (purl): ${purls.join(', ')}` + ) + spinner.start(`Requesting data ...`) + + const result: Awaited> = + await handleApiCall( + socketSdk.batchPackageFetch( + { + alerts: 'true' + // compact: false, + // fixable: false, + // licenseattrib: false, + // licensedetails: false + }, + { components: purls.map(purl => ({ purl })) } + ), + 'looking up package' + ) + + spinner.successAndStop('Request completed') + + if (result.success) { + return result + } else { + handleUnsuccessfulApiResponse('batchPackageFetch', result) + } +} diff --git a/src/commands/package/log-package-info.ts b/src/commands/package/log-package-info.ts new file mode 100644 index 000000000..fc31adc33 --- /dev/null +++ b/src/commands/package/log-package-info.ts @@ -0,0 +1,163 @@ +import { stripIndents } from 'common-tags' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import type { components } from '@socketsecurity/sdk/types/api' + +export function logPackageInfo( + purls: string[], + packageData: Array, + outputKind: 'json' | 'markdown' | 'text' +): void { + if (outputKind === 'json') { + // In JSON simply return what the server responds with. Don't bother trying + // to match the response with the requested packages/purls. + logger.log(JSON.stringify(packageData, undefined, 2)) + return + } + + // Make some effort to match the requested data with the response + + const set = new Set() + packageData.forEach(data => { + set.add('pkg:' + data.type + '/' + data.name + '@' + data.version) + set.add('pkg:' + data.type + '/' + data.name) + }) + const missing = purls.filter(purl => { + if (set.has(purl)) return false + if (purl.endsWith('@latest') && set.has(purl.slice(0, -'@latest'.length))) + return false + return true // not found + }) + + if (outputKind === 'markdown') { + logger.log(stripIndents` + # Shallow Package Report + + This report contains the response for requesting data on some package url(s). + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + ${missing.length ? `\n## Missing response\n\nAt least one package had no response or the purl was not canonical:\n\n${missing.map(purl => '- ' + purl + '\n').join('')}` : ''} + + ${packageData.map(data => '## ' + formatReportCard(data, false)).join('\n\n\n')} + `) + return + } + + logger.log('\n' + colors.bold('Shallow Package Score') + '\n') + logger.log( + 'Please note: The listed scores are ONLY for the package itself. It does NOT\n' + + ' reflect the scores of any dependencies, transitive or otherwise.' + ) + + if (missing.length) { + logger.log( + `\nAt least one package had no response or the purl was not canonical:\n${missing.map(purl => '\n- ' + colors.bold(purl)).join('')}` + ) + } + + packageData.forEach(data => { + logger.log('\n') + logger.log(formatReportCard(data, true)) + }) + logger.log('') +} + +function formatReportCard( + data: components['schemas']['SocketArtifact'], + color: boolean +): string { + const scoreResult = { + 'Supply Chain Risk': Math.floor((data.score?.supplyChain ?? 0) * 100), + Maintenance: Math.floor((data.score?.maintenance ?? 0) * 100), + Quality: Math.floor((data.score?.quality ?? 0) * 100), + Vulnerabilities: Math.floor((data.score?.vulnerability ?? 0) * 100), + License: Math.floor((data.score?.license ?? 0) * 100) + } + const alertString = getAlertString(data.alerts, !color) + const purl = 'pkg:' + data.type + '/' + data.name + '@' + data.version + + return [ + 'Package: ' + (color ? colors.bold(purl) : purl), + '', + ...Object.entries(scoreResult).map( + score => + `- ${score[0]}:`.padEnd(20, ' ') + + ` ${formatScore(score[1], !color, true)}` + ), + alertString + ].join('\n') +} + +function formatScore(score: number, noColor = false, pad = false): string { + const padded = String(score).padStart(pad ? 3 : 0, ' ') + + if (noColor) return padded + else if (score >= 80) return colors.green(padded) + else if (score >= 60) return colors.yellow(padded) + else return colors.red(padded) +} + +function getAlertString( + alerts: Array | undefined, + noColor = false +) { + if (!alerts?.length) { + return noColor ? `- Alerts: none!` : `- Alerts: ${colors.green('none')}!` + } else { + const bad = alerts + .filter(alert => alert.severity !== 'low' && alert.severity !== 'middle') + .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)) + const mid = alerts + .filter(alert => alert.severity === 'middle') + .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)) + const low = alerts + .filter(alert => alert.severity === 'low') + .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)) + + // We need to create the no-color string regardless because the actual string + // contains a bunch of invisible ANSI chars which would screw up length checks. + const colorless = `- Alerts (${bad.length}/${mid.length.toString()}/${low.length}):` + + if (noColor) { + return ( + colorless + + ' '.repeat(Math.max(0, 20 - colorless.length)) + + ' ' + + [ + bad.map(alert => `[${alert.severity}] ` + alert.type).join(', '), + mid.map(alert => `[${alert.severity}] ` + alert.type).join(', '), + low.map(alert => `[${alert.severity}] ` + alert.type).join(', ') + ] + .filter(Boolean) + .join(', ') + ) + } + + return ( + `- Alerts (${colors.red(bad.length.toString())}/${colors.yellow(mid.length.toString())}/${low.length}):` + + ' '.repeat(Math.max(0, 20 - colorless.length)) + + ' ' + + [ + bad + .map(alert => + colors.red(colors.dim(`[${alert.severity}] `) + alert.type) + ) + .join(', '), + mid + .map(alert => + colors.yellow(colors.dim(`[${alert.severity}] `) + alert.type) + ) + .join(', '), + low + .map(alert => colors.dim(`[${alert.severity}] `) + alert.type) + .join(', ') + ] + .filter(Boolean) + .join(', ') + ) + } +} diff --git a/src/commands/package/parse-package-specifiers.test.ts b/src/commands/package/parse-package-specifiers.test.ts new file mode 100644 index 000000000..f023cc099 --- /dev/null +++ b/src/commands/package/parse-package-specifiers.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { parsePackageSpecifiers } from './parse-package-specifiers' +import constants from '../../../dist/constants.js' + +describe('parse-package-specifiers', async () => { + it('should parse a simple `npm babel`', () => { + const { purls, valid } = parsePackageSpecifiers('npm', ['babel']) + expect(valid).toBe(true) + expect(purls).toStrictEqual(['pkg:npm/babel']) + }) + + it('should parse a simple purl with prefix', () => { + expect(parsePackageSpecifiers('pkg:npm/babel', [])).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:npm/babel", + ], + "valid": true, + } + `) + }) + + it('should parse a simple purl without prefix', () => { + expect(parsePackageSpecifiers('npm/babel', [])).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:npm/babel", + ], + "valid": true, + } + `) + }) + + it('should parse a multiple purls', () => { + expect( + parsePackageSpecifiers('npm/babel', ['golang/foo']) + ).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:npm/babel", + "pkg:golang/foo", + ], + "valid": true, + } + `) + }) + + it('should parse a mixed names and purls', () => { + expect( + parsePackageSpecifiers('npm', ['golang/foo', 'babel', 'pkg:npm/tenko']) + ).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:golang/foo", + "pkg:npm/babel", + "pkg:npm/tenko", + ], + "valid": true, + } + `) + }) + + it('should complain when seeing an unscoped package without namespace', () => { + expect( + parsePackageSpecifiers('golang/foo', ['babel', 'pkg:npm/tenko']) + ).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:golang/foo", + ], + "valid": false, + } + `) + }) + + it('should complain when only getting a namespace', () => { + expect(parsePackageSpecifiers('npm', [])).toMatchInlineSnapshot(` + { + "purls": [], + "valid": false, + } + `) + }) + + it('should complain when getting an empty namespace', () => { + expect(parsePackageSpecifiers('', [])).toMatchInlineSnapshot(` + { + "purls": [], + "valid": false, + } + `) + }) +}) diff --git a/src/commands/package/parse-package-specifiers.ts b/src/commands/package/parse-package-specifiers.ts new file mode 100644 index 000000000..3b4be2618 --- /dev/null +++ b/src/commands/package/parse-package-specifiers.ts @@ -0,0 +1,55 @@ +// Either an ecosystem was given or all args must be (namespaced) purls +// The `pkg:` part is optional here. We'll scan for `eco/name@version`. +// Not hardcoding the namespace since we don't know what the server accepts. +// The ecosystem is considered as the first package if it is not an a-z string. +export function parsePackageSpecifiers( + ecosystem: string, + pkgs: string[] +): { purls: string[]; valid: boolean } { + let valid = true + const purls = [] + if (!ecosystem) { + valid = false + } else if (/^[a-zA-Z]+$/.test(ecosystem)) { + for (let i = 0; i < pkgs.length; ++i) { + const pkg = pkgs[i] ?? '' + if (!pkg) { + valid = false + break + } else if (pkg.startsWith('pkg:')) { + // keep + purls.push(pkg) + } else if (pkg.includes('/')) { + // Looks like this arg was already namespaced + purls.push('pkg:' + pkg) + } else { + purls.push('pkg:' + ecosystem + '/' + pkg) + } + } + if (!purls.length) { + valid = false + } + } else { + // Assume ecosystem is a purl, too + pkgs.unshift(ecosystem) + + for (let i = 0; i < pkgs.length; ++i) { + const pkg = pkgs[i] ?? '' + if (!/^(?:pkg:)?[a-zA-Z]+\/./.test(pkg)) { + // At least one purl did not start with `pkg:eco/x` or `eco/x` + valid = false + break + } else if (pkg.startsWith('pkg:')) { + purls.push(pkg) + } else { + purls.push('pkg:' + pkg) + } + } + + if (!purls.length) { + valid = false + } + } + + return { purls, valid } +} diff --git a/src/commands/package/show-purl-info.ts b/src/commands/package/show-purl-info.ts new file mode 100644 index 000000000..498155db5 --- /dev/null +++ b/src/commands/package/show-purl-info.ts @@ -0,0 +1,21 @@ +import { fetchPackageInfo } from './fetch-package-info' +import { logPackageInfo } from './log-package-info' + +import type { components } from '@socketsecurity/sdk/types/api' + +export async function showPurlInfo({ + outputKind, + purls +}: { + outputKind: 'json' | 'markdown' | 'text' + purls: string[] +}) { + const packageData = await fetchPackageInfo(purls) + if (packageData) { + logPackageInfo( + purls, + packageData.data as Array, + outputKind + ) + } +} diff --git a/src/utils/api.ts b/src/utils/api.ts index c4a8874d7..23a01a192 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -17,7 +17,7 @@ const { API_V0_URL } = constants export function handleUnsuccessfulApiResponse( _name: T, result: SocketSdkErrorType -) { +): never { // SocketSdkErrorType['error'] is not typed. const resultErrorMessage = (result as { error?: Error }).error?.message const message =