From 914b14b03e0dbc4167d0cc6eaccfa644f1debf5c Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Tue, 18 Mar 2025 12:05:24 +0100 Subject: [PATCH 1/3] [package score] Add replacement for info command --- src/cli.ts | 2 + .../package/cmd-package-score.test.ts | 103 ++++++++++++ src/commands/package/cmd-package-score.ts | 99 ++++++++++++ src/commands/package/cmd-package.test.ts | 70 ++++++++ src/commands/package/cmd-package.ts | 31 ++++ src/commands/package/fetch-package-info.ts | 43 +++++ src/commands/package/log-package-info.ts | 153 ++++++++++++++++++ src/commands/package/show-purl-info.ts | 34 ++++ src/utils/api.ts | 2 +- 9 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 src/commands/package/cmd-package-score.test.ts create mode 100644 src/commands/package/cmd-package-score.ts create mode 100644 src/commands/package/cmd-package.test.ts create mode 100644 src/commands/package/cmd-package.ts create mode 100644 src/commands/package/fetch-package-info.ts create mode 100644 src/commands/package/log-package-info.ts create mode 100644 src/commands/package/show-purl-info.ts 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-score.test.ts b/src/commands/package/cmd-package-score.test.ts new file mode 100644 index 000000000..8d372ea17 --- /dev/null +++ b/src/commands/package/cmd-package-score.test.ts @@ -0,0 +1,103 @@ +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 info regarding a package + + 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 + + Show scoring details for one or more packages. + Only a few ecosystems are supported like npm, golang, and maven. + + If the first arg is an ecosystem, remaining args that are not a "purl" are + assumed to be scoped in that ecosystem. If the first arg is in "purl" form + then all args must be in purl form ("package url": \`pkg:eco/name@version\`). + + This command takes 100 quota units. + This command requires \`packages:list\` scope access on your API token. + + Examples + $ socket package score npm webtorrent + $ socket package score npm webtorrent@1.9.1 + $ socket package score npm/webtorrent@1.9.1 + $ socket package score maven webtorrent babel + $ socket package score npm/webtorrent golang/babel + $ socket package score npm npm/webtorrent@1.0.1 babel" + ` + ) + 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: + + - Expecting an ecosystem \\x1b[31m(missing!)\\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', '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..90471d6a6 --- /dev/null +++ b/src/commands/package/cmd-package-score.ts @@ -0,0 +1,99 @@ +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +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: 'score', + description: 'Look up info regarding a package', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags + }, + help: (command, config) => ` + Usage + $ ${command} < [ ...] | [ ...]> + + Options + ${getFlagListOutput(config.flags, 6)} + + Show scoring details for one or more packages. + Only a few ecosystems are supported like npm, golang, and maven. + + If the first arg is an ecosystem, remaining args that are not a "purl" are + assumed to be scoped in that ecosystem. If the first arg is in "purl" form + then all args must be in purl form ("package url": \`pkg:eco/name@version\`). + + This command takes 100 quota units. + This command requires \`packages:list\` scope access on your API token. + + Examples + $ ${command} npm webtorrent + $ ${command} npm webtorrent@1.9.1 + $ ${command} npm/webtorrent@1.9.1 + $ ${command} maven webtorrent babel + $ ${command} npm/webtorrent golang/babel + $ ${command} npm npm/webtorrent@1.0.1 babel + ` +} + +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, ...pkgs] = cli.input + + if (!ecosystem || !pkgs.length || !pkgs[0]) { + // 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 + - Expecting an ecosystem ${!ecosystem ? colors.red('(missing!)') : colors.green('(ok)')}\n + - Expecting at least one package ${!pkgs.length || !pkgs[0] ? colors.red('(missing!)') : colors.green('(ok)')}\n + `) + return + } + + const purls = pkgs.map(pkg => { + return 'pkg:' + ecosystem + '/' + pkg + }) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAIL_TEXT) + return + } + + await showPurlInfo({ + // commandName: `${parentName} ${config.commandName}`, + // includeAllIssues: Boolean(all), + outputKind: json ? 'json' : markdown ? 'markdown' : 'text', + purls + // strict: Boolean(strict) + }) +} diff --git a/src/commands/package/cmd-package.test.ts b/src/commands/package/cmd-package.test.ts new file mode 100644 index 000000000..fdd87edd5 --- /dev/null +++ b/src/commands/package/cmd-package.test.ts @@ -0,0 +1,70 @@ +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 manifest', async () => { + // Lazily access constants.rootBinPath. + const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) + + cmdit(['manifest', '--help'], 'should support --help', async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Generate a dependency manifest for given file or dir + + Usage + $ socket manifest + + Commands + auto Auto-detect build and attempt to generate manifest file + gradle [beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Gradle/Java/Kotlin/etc project + kotlin [beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Kotlin project + scala [beta] Generate a manifest file (\`pom.xml\`) from Scala's \`build.sbt\` file + + Options + --dryRun Do input validation for a command and exit 0 when input is ok + --help Print this help. + + Examples + $ socket manifest --help" + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest\`, cwd: " + `) + + expect(code, 'help should exit with code 2').toBe(2) + expect(stderr, 'header should include command (without params)').toContain( + '`socket manifest`' + ) + }) + + cmdit( + ['manifest', 'mootools', '--dry-run'], + 'should require args with just dry-run', + 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 manifest\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + } + ) +}) diff --git a/src/commands/package/cmd-package.ts b/src/commands/package/cmd-package.ts new file mode 100644 index 000000000..cc88bd29e --- /dev/null +++ b/src/commands/package/cmd-package.ts @@ -0,0 +1,31 @@ +import { cmdPackageScore } from './cmd-package-score' +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( + { + score: cmdPackageScore + }, + { + 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..c6ca30962 --- /dev/null +++ b/src/commands/package/fetch-package-info.ts @@ -0,0 +1,43 @@ +import { logger } from '@socketsecurity/registry/lib/logger' +import { SocketSdkResultType, SocketSdkReturnType } from '@socketsecurity/sdk' + +import constants from '../../constants' +import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api' +import { getPublicToken, setupSdk } from '../../utils/sdk' + +export async function fetchPackageInfo( + purls: string[] +): Promise> { + const socketSdk = await setupSdk(getPublicToken()) + + // Lazily access constants.spinner. + const { spinner } = constants + + logger.error( + `Requesting 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..6ceadc8e4 --- /dev/null +++ b/src/commands/package/log-package-info.ts @@ -0,0 +1,153 @@ +import { stripIndents } from 'common-tags' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { 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` + # Package report + + This report contains the response for requesting data on some package url(s). + + ${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 + } + + 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/show-purl-info.ts b/src/commands/package/show-purl-info.ts new file mode 100644 index 000000000..3a14fc1dc --- /dev/null +++ b/src/commands/package/show-purl-info.ts @@ -0,0 +1,34 @@ +import { components } from '@socketsecurity/sdk/types/api' + +import { fetchPackageInfo } from './fetch-package-info' +import { logPackageInfo } from './log-package-info' + +import type { SocketSdkAlert } from '../../utils/alert/severity' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export interface PackageData { + data: SocketSdkReturnType<'getIssuesByNPMPackage'>['data'] + severityCount: Record + score: SocketSdkReturnType<'getScoreByNPMPackage'>['data'] +} + +export async function showPurlInfo({ + // commandName, + outputKind, + purls + // strict +}: { + // commandName: string + outputKind: 'json' | 'markdown' | 'text' + purls: string[] + // strict: boolean +}) { + 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 = From 222420659a956d43c5094c4aabec990f2ec50f3a Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Tue, 18 Mar 2025 12:51:23 +0100 Subject: [PATCH 2/3] tests --- .../package/cmd-package-score.test.ts | 13 ++- src/commands/package/cmd-package-score.ts | 26 ++--- .../package/parse-package-specifiers.test.ts | 94 +++++++++++++++++++ .../package/parse-package-specifiers.ts | 55 +++++++++++ 4 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 src/commands/package/parse-package-specifiers.test.ts create mode 100644 src/commands/package/parse-package-specifiers.ts diff --git a/src/commands/package/cmd-package-score.test.ts b/src/commands/package/cmd-package-score.test.ts index 8d372ea17..172f88947 100644 --- a/src/commands/package/cmd-package-score.test.ts +++ b/src/commands/package/cmd-package-score.test.ts @@ -29,17 +29,20 @@ describe('socket package score', async () => { Show scoring details for one or more packages. Only a few ecosystems are supported like npm, golang, and maven. - If the first arg is an ecosystem, remaining args that are not a "purl" are - assumed to be scoped in that ecosystem. If the first arg is in "purl" form - then all args must be in purl form ("package url": \`pkg:eco/name@version\`). + A "purl" is a standard package formatting: \`pkg:eco/name@version\` + The "pkg:" prefix is automatically prepended when not present. - This command takes 100 quota units. + If the first arg is an ecosystem, remaining args that are not a purl are + assumed to be scoped in that ecosystem or to be purls. + + This command takes 100 quota units (regardless of arg count). This command requires \`packages:list\` scope access on your API token. Examples $ socket package score npm webtorrent $ socket package score npm webtorrent@1.9.1 $ socket package score npm/webtorrent@1.9.1 + $ socket package score pkg:npm/webtorrent@1.9.1 $ socket package score maven webtorrent babel $ socket package score npm/webtorrent golang/babel $ socket package score npm npm/webtorrent@1.0.1 babel" @@ -74,7 +77,7 @@ describe('socket package score', async () => { \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[37mInput error\\x1b[39m\\x1b[49m: Please provide the required fields: - - Expecting an ecosystem \\x1b[31m(missing!)\\x1b[39m + - 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" `) diff --git a/src/commands/package/cmd-package-score.ts b/src/commands/package/cmd-package-score.ts index 90471d6a6..e2b69c485 100644 --- a/src/commands/package/cmd-package-score.ts +++ b/src/commands/package/cmd-package-score.ts @@ -2,6 +2,7 @@ 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' @@ -30,17 +31,20 @@ const config: CliCommandConfig = { Show scoring details for one or more packages. Only a few ecosystems are supported like npm, golang, and maven. - If the first arg is an ecosystem, remaining args that are not a "purl" are - assumed to be scoped in that ecosystem. If the first arg is in "purl" form - then all args must be in purl form ("package url": \`pkg:eco/name@version\`). + A "purl" is a standard package formatting: \`pkg:eco/name@version\` + The "pkg:" prefix is automatically prepended when not present. - This command takes 100 quota units. + If the first arg is an ecosystem, remaining args that are not a purl are + assumed to be scoped in that ecosystem or to be purls. + + This command takes 100 quota units (regardless of arg count). This command requires \`packages:list\` scope access on your API token. 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 @@ -66,24 +70,22 @@ async function run( }) const { json, markdown } = cli.flags - const [ecosystem, ...pkgs] = cli.input + const [ecosystem = '', ...pkgs] = cli.input + + const { purls, valid } = parsePackageSpecifiers(ecosystem, pkgs) - if (!ecosystem || !pkgs.length || !pkgs[0]) { + 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 - - Expecting an ecosystem ${!ecosystem ? colors.red('(missing!)') : colors.green('(ok)')}\n - - Expecting at least one package ${!pkgs.length || !pkgs[0] ? colors.red('(missing!)') : colors.green('(ok)')}\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 } - const purls = pkgs.map(pkg => { - return 'pkg:' + ecosystem + '/' + pkg - }) - if (cli.flags['dryRun']) { logger.log(DRY_RUN_BAIL_TEXT) return 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 } +} From dbfe9ab796c4837e5e27a800f8fb23a8e72fc5e2 Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Tue, 18 Mar 2025 21:27:59 +0100 Subject: [PATCH 3/3] In light of recent findings, rename to `socket package shallow` --- ...re.test.ts => cmd-package-shallow.test.ts} | 75 +++++++++++-------- ...ackage-score.ts => cmd-package-shallow.ts} | 37 +++++---- src/commands/package/cmd-package.test.ts | 39 ++-------- src/commands/package/cmd-package.ts | 4 +- src/commands/package/fetch-package-info.ts | 8 +- src/commands/package/log-package-info.ts | 14 +++- src/commands/package/show-purl-info.ts | 15 +--- 7 files changed, 94 insertions(+), 98 deletions(-) rename src/commands/package/{cmd-package-score.test.ts => cmd-package-shallow.test.ts} (59%) rename src/commands/package/{cmd-package-score.ts => cmd-package-shallow.ts} (76%) diff --git a/src/commands/package/cmd-package-score.test.ts b/src/commands/package/cmd-package-shallow.test.ts similarity index 59% rename from src/commands/package/cmd-package-score.test.ts rename to src/commands/package/cmd-package-shallow.test.ts index 172f88947..62eba840b 100644 --- a/src/commands/package/cmd-package-score.test.ts +++ b/src/commands/package/cmd-package-shallow.test.ts @@ -7,18 +7,21 @@ import { cmdit, invokeNpm } from '../../../test/utils' const { CLI } = constants -describe('socket package score', async () => { +describe('socket package shallow', 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 info regarding a package + 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 score < [ ...] | [ ...]> + $ socket package shallow < [ ...] | [ ...]> Options --dryRun Do input validation for a command and exit 0 when input is ok @@ -26,44 +29,50 @@ describe('socket package score', async () => { --json Output result as json --markdown Output result as markdown - Show scoring details for one or more packages. + 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 formatting: \`pkg:eco/name@version\` - The "pkg:" prefix is automatically prepended when not present. + 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 in that ecosystem or to be purls. - - This command takes 100 quota units (regardless of arg count). - This command requires \`packages:list\` scope access on your API token. + assumed to be scoped to that ecosystem. Examples - $ socket package score npm webtorrent - $ socket package score npm webtorrent@1.9.1 - $ socket package score npm/webtorrent@1.9.1 - $ socket package score pkg:npm/webtorrent@1.9.1 - $ socket package score maven webtorrent babel - $ socket package score npm/webtorrent golang/babel - $ socket package score npm npm/webtorrent@1.0.1 babel" + $ 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(` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` " _____ _ _ /--------------- | __|___ ___| |_ ___| |_ | Socket.dev CLI ver |__ | . | _| '_| -_| _| | Node: , API token set: - |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: " + |_____|___|___|_,_|___|_|.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 score`' - ) - }) + expect(code, 'help should exit with code 2').toBe(2) + expect( + stderr, + 'header should include command (without params)' + ).toContain('`socket package shallow`') + } + ) cmdit( - ['package', 'score', '--dry-run'], + ['package', 'shallow', '--dry-run'], 'should require args with just dry-run', async cmd => { const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) @@ -73,7 +82,7 @@ describe('socket package score', async () => { _____ _ _ /--------------- | __|___ ___| |_ ___| |_ | Socket.dev CLI ver |__ | . | _| '_| -_| _| | Node: , API token set: - |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: + |_____|___|___|_,_|___|_|.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: @@ -87,7 +96,7 @@ describe('socket package score', async () => { ) cmdit( - ['package', 'score', 'npm', 'babel', '--dry-run'], + ['package', 'shallow', 'npm', 'babel', '--dry-run'], 'should require args with just dry-run', async cmd => { const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) @@ -97,7 +106,7 @@ describe('socket package score', async () => { _____ _ _ /--------------- | __|___ ___| |_ ___| |_ | Socket.dev CLI ver |__ | . | _| '_| -_| _| | Node: , API token set: - |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: " + |_____|___|___|_,_|___|_|.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-score.ts b/src/commands/package/cmd-package-shallow.ts similarity index 76% rename from src/commands/package/cmd-package-score.ts rename to src/commands/package/cmd-package-shallow.ts index e2b69c485..d11342103 100644 --- a/src/commands/package/cmd-package-score.ts +++ b/src/commands/package/cmd-package-shallow.ts @@ -14,9 +14,10 @@ import type { CliCommandConfig } from '../../utils/meow-with-subcommands' const { DRY_RUN_BAIL_TEXT } = constants const config: CliCommandConfig = { - commandName: 'score', - description: 'Look up info regarding a package', - hidden: false, + commandName: 'shallow', + description: + 'Look up info regarding one or more packages but not their transitives', + hidden: true, flags: { ...commonFlags, ...outputFlags @@ -28,17 +29,21 @@ const config: CliCommandConfig = { Options ${getFlagListOutput(config.flags, 6)} - Show scoring details for one or more packages. + 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 formatting: \`pkg:eco/name@version\` - The "pkg:" prefix is automatically prepended when not present. + 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 in that ecosystem or to be purls. - - This command takes 100 quota units (regardless of arg count). - This command requires \`packages:list\` scope access on your API token. + assumed to be scoped to that ecosystem. Examples $ ${command} npm webtorrent @@ -51,9 +56,16 @@ const config: CliCommandConfig = { ` } -export const cmdPackageScore = { +export const cmdPackageShallow = { description: config.description, hidden: config.hidden, + alias: { + shallowScore: { + description: config.description, + hidden: true, + argv: [] + } + }, run } @@ -92,10 +104,7 @@ async function run( } await showPurlInfo({ - // commandName: `${parentName} ${config.commandName}`, - // includeAllIssues: Boolean(all), outputKind: json ? 'json' : markdown ? 'markdown' : 'text', purls - // strict: Boolean(strict) }) } diff --git a/src/commands/package/cmd-package.test.ts b/src/commands/package/cmd-package.test.ts index fdd87edd5..90f9a3af0 100644 --- a/src/commands/package/cmd-package.test.ts +++ b/src/commands/package/cmd-package.test.ts @@ -7,31 +7,28 @@ import { cmdit, invokeNpm } from '../../../test/utils' const { CLI } = constants -describe('socket manifest', async () => { +describe('socket package', async () => { // Lazily access constants.rootBinPath. const entryPath = path.join(constants.rootBinPath, `${CLI}.js`) - cmdit(['manifest', '--help'], 'should support --help', async cmd => { + cmdit(['package', '--help'], 'should support --help', async cmd => { const { code, stderr, stdout } = await invokeNpm(entryPath, cmd) expect(stdout).toMatchInlineSnapshot( ` - "Generate a dependency manifest for given file or dir + "Commands relating to looking up published packages Usage - $ socket manifest + $ socket package Commands - auto Auto-detect build and attempt to generate manifest file - gradle [beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Gradle/Java/Kotlin/etc project - kotlin [beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Kotlin project - scala [beta] Generate a manifest file (\`pom.xml\`) from Scala's \`build.sbt\` file + Options --dryRun Do input validation for a command and exit 0 when input is ok --help Print this help. Examples - $ socket manifest --help" + $ socket package --help" ` ) expect(`\n ${stderr}`).toMatchInlineSnapshot(` @@ -39,32 +36,12 @@ describe('socket manifest', async () => { _____ _ _ /--------------- | __|___ ___| |_ ___| |_ | Socket.dev CLI ver |__ | . | _| '_| -_| _| | Node: , API token set: - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest\`, cwd: " + |_____|___|___|_,_|___|_|.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 manifest`' + '`socket package`' ) }) - - cmdit( - ['manifest', 'mootools', '--dry-run'], - 'should require args with just dry-run', - 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 manifest\`, cwd: " - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - } - ) }) diff --git a/src/commands/package/cmd-package.ts b/src/commands/package/cmd-package.ts index cc88bd29e..9bfad8d39 100644 --- a/src/commands/package/cmd-package.ts +++ b/src/commands/package/cmd-package.ts @@ -1,4 +1,4 @@ -import { cmdPackageScore } from './cmd-package-score' +import { cmdPackageShallow } from './cmd-package-shallow' import { meowWithSubcommands } from '../../utils/meow-with-subcommands' import type { CliSubcommand } from '../../utils/meow-with-subcommands' @@ -11,7 +11,7 @@ export const cmdPackage: CliSubcommand = { async run(argv, importMeta, { parentName }) { await meowWithSubcommands( { - score: cmdPackageScore + shallow: cmdPackageShallow }, { aliases: { diff --git a/src/commands/package/fetch-package-info.ts b/src/commands/package/fetch-package-info.ts index c6ca30962..fa9150e8c 100644 --- a/src/commands/package/fetch-package-info.ts +++ b/src/commands/package/fetch-package-info.ts @@ -1,10 +1,14 @@ import { logger } from '@socketsecurity/registry/lib/logger' -import { SocketSdkResultType, SocketSdkReturnType } from '@socketsecurity/sdk' 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> { @@ -14,7 +18,7 @@ export async function fetchPackageInfo( const { spinner } = constants logger.error( - `Requesting data for ${purls.length} package urls (purl): ${purls.join(', ')}` + `Requesting shallow score data for ${purls.length} package urls (purl): ${purls.join(', ')}` ) spinner.start(`Requesting data ...`) diff --git a/src/commands/package/log-package-info.ts b/src/commands/package/log-package-info.ts index 6ceadc8e4..fc31adc33 100644 --- a/src/commands/package/log-package-info.ts +++ b/src/commands/package/log-package-info.ts @@ -2,7 +2,8 @@ import { stripIndents } from 'common-tags' import colors from 'yoctocolors-cjs' import { logger } from '@socketsecurity/registry/lib/logger' -import { components } from '@socketsecurity/sdk/types/api' + +import type { components } from '@socketsecurity/sdk/types/api' export function logPackageInfo( purls: string[], @@ -32,10 +33,13 @@ export function logPackageInfo( if (outputKind === 'markdown') { logger.log(stripIndents` - # Package report + # 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')} @@ -43,6 +47,12 @@ export function logPackageInfo( 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('')}` diff --git a/src/commands/package/show-purl-info.ts b/src/commands/package/show-purl-info.ts index 3a14fc1dc..498155db5 100644 --- a/src/commands/package/show-purl-info.ts +++ b/src/commands/package/show-purl-info.ts @@ -1,27 +1,14 @@ -import { components } from '@socketsecurity/sdk/types/api' - import { fetchPackageInfo } from './fetch-package-info' import { logPackageInfo } from './log-package-info' -import type { SocketSdkAlert } from '../../utils/alert/severity' -import type { SocketSdkReturnType } from '@socketsecurity/sdk' - -export interface PackageData { - data: SocketSdkReturnType<'getIssuesByNPMPackage'>['data'] - severityCount: Record - score: SocketSdkReturnType<'getScoreByNPMPackage'>['data'] -} +import type { components } from '@socketsecurity/sdk/types/api' export async function showPurlInfo({ - // commandName, outputKind, purls - // strict }: { - // commandName: string outputKind: 'json' | 'markdown' | 'text' purls: string[] - // strict: boolean }) { const packageData = await fetchPackageInfo(purls) if (packageData) {