diff --git a/lib/commands/info/index.js b/lib/commands/info/index.js index c6feb37c..65fdf849 100644 --- a/lib/commands/info/index.js +++ b/lib/commands/info/index.js @@ -7,8 +7,8 @@ import ora from 'ora' import { outputFlags, validationFlags } from '../../flags/index.js' import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api-helpers.js' import { ChalkOrMarkdown } from '../../utils/chalk-markdown.js' -import { InputError } from '../../utils/errors.js' -import { getSeverityCount, formatSeverityCount } from '../../utils/format-issues.js' +import { prepareFlags } from '../../utils/flags.js' +import { formatSeverityCount, getCountSeverity } from '../../utils/format-issues.js' import { printFlagList } from '../../utils/formatting.js' import { objectSome } from '../../utils/misc.js' import { FREE_API_KEY, getDefaultKey, setupSdk } from '../../utils/sdk.js' @@ -21,9 +21,9 @@ export const info = { const input = setupCommand(name, info.description, argv, importMeta) if (input) { - const spinnerText = input.pkgVersion === 'latest' ? `Looking up data for the latest version of ${input.pkgName}\n` : `Looking up data for version ${input.pkgVersion} of ${input.pkgName}\n` + const spinnerText = `Looking up data for packages: ${input.packages.join(', ')}\n` const spinner = ora(spinnerText).start() - const packageData = await fetchPackageData(input.pkgName, input.pkgVersion, input, spinner) + const packageData = await fetchPackageData(input.packages, input.includeAlerts, spinner) if (packageData) { formatPackageDataOutput(packageData, { name, ...input }, spinner) } @@ -31,15 +31,31 @@ export const info = { } } +const infoFlags = prepareFlags({ + // At the moment in API v0, alerts and license do the same thing. + // The license parameter will be implemented later. + // license: { + // type: 'boolean', + // shortFlag: 'l', + // default: false, + // description: 'Include license - Default is false', + // }, + alerts: { + type: 'boolean', + shortFlag: 'a', + default: false, + description: 'Include alerts - Default is false', + } +}) + // Internal functions /** * @typedef CommandContext - * @property {boolean} includeAllIssues + * @property {boolean} includeAlerts * @property {boolean} outputJson * @property {boolean} outputMarkdown - * @property {string} pkgName - * @property {string} pkgVersion + * @property {string[]} packages * @property {boolean} strict */ @@ -54,18 +70,19 @@ function setupCommand (name, description, argv, importMeta) { const flags = { ...outputFlags, ...validationFlags, + ...infoFlags } const cli = meow(` Usage - $ ${name} + $ ${name} :@ Options ${printFlagList(flags, 6)} Examples - $ ${name} webtorrent - $ ${name} webtorrent@1.9.1 + $ ${name} npm:webtorrent + $ ${name} npm:webtorrent@1.9.1 `, { argv, description, @@ -74,138 +91,162 @@ function setupCommand (name, description, argv, importMeta) { }) const { - all: includeAllIssues, + alerts: includeAlerts, json: outputJson, markdown: outputMarkdown, strict, } = cli.flags - if (cli.input.length > 1) { - throw new InputError('Only one package lookup supported at once') - } - const [rawPkgName = ''] = cli.input if (!rawPkgName) { + console.error(`${chalk.bgRed('Input error')}: Please provide an ecosystem and package name`) cli.showHelp() return } - const versionSeparator = rawPkgName.lastIndexOf('@') + const /** @type {string[]} */inputPkgs = [] - const pkgName = versionSeparator < 1 ? rawPkgName : rawPkgName.slice(0, versionSeparator) - const pkgVersion = versionSeparator < 1 ? 'latest' : rawPkgName.slice(versionSeparator + 1) + cli.input.map(pkg => { + const ecosystem = pkg.split(':')[0] + if (!ecosystem) { + console.error(`Package name ${pkg} formatted incorrectly.`) + return cli.showHelp() + } else { + const versionSeparator = pkg.lastIndexOf('@') + const ecosystemSeparator = pkg.lastIndexOf(ecosystem) + const pkgName = versionSeparator < 1 ? pkg.slice(ecosystemSeparator + ecosystem.length + 1) : pkg.slice(ecosystemSeparator + ecosystem.length + 1, versionSeparator) + const pkgVersion = versionSeparator < 1 ? 'latest' : pkg.slice(versionSeparator + 1) + inputPkgs.push(`${ecosystem}/${pkgName}@${pkgVersion}`) + } + return inputPkgs + }) return { - includeAllIssues, + includeAlerts, outputJson, outputMarkdown, - pkgName, - pkgVersion, + packages: inputPkgs, strict, } } /** * @typedef PackageData - * @property {import('@socketsecurity/sdk').SocketSdkReturnType<'getIssuesByNPMPackage'>["data"]} data - * @property {Record} severityCount - * @property {import('@socketsecurity/sdk').SocketSdkReturnType<'getScoreByNPMPackage'>["data"]} score + * @property {import('@socketsecurity/sdk').SocketSdkReturnType<'batchPackageFetch'>["data"]} data */ /** - * @param {string} pkgName - * @param {string} pkgVersion - * @param {Pick} context + * @param {string[]} packages + * @param {boolean} includeAlerts * @param {import('ora').Ora} spinner * @returns {Promise} */ -async function fetchPackageData (pkgName, pkgVersion, { includeAllIssues }, spinner) { +async function fetchPackageData (packages, includeAlerts, spinner) { const socketSdk = await setupSdk(getDefaultKey() || FREE_API_KEY) - const result = await handleApiCall(socketSdk.getIssuesByNPMPackage(pkgName, pkgVersion), 'looking up package') - const scoreResult = await handleApiCall(socketSdk.getScoreByNPMPackage(pkgName, pkgVersion), 'looking up package score') - if (result.success === false) { - return handleUnsuccessfulApiResponse('getIssuesByNPMPackage', result, spinner) - } + const components = packages.map(pkg => { + return { 'purl': `pkg:${pkg}` } + }) - if (scoreResult.success === false) { - return handleUnsuccessfulApiResponse('getScoreByNPMPackage', scoreResult, spinner) + const result = await handleApiCall(socketSdk.batchPackageFetch( + { alerts: includeAlerts.toString() }, + { + components + }), 'looking up package') + + if (!result.success) { + return handleUnsuccessfulApiResponse('batchPackageFetch', result, spinner) } - // Conclude the status of the API call - const severityCount = getSeverityCount(result.data, includeAllIssues ? undefined : 'high') + // @ts-ignore + result.data.map(pkg => { + const severityCount = pkg.alerts && getCountSeverity(pkg.alerts, includeAlerts ? undefined : 'high') + pkg.severityCount = severityCount + return pkg + }) + + spinner.stop() return { - data: result.data, - severityCount, - score: scoreResult.data + data: result.data } } /** - * @param {PackageData} packageData + * @param {CommandContext} data * @param {{ name: string } & CommandContext} context * @param {import('ora').Ora} spinner * @returns {void} */ - function formatPackageDataOutput ({ data, severityCount, score }, { name, outputJson, outputMarkdown, pkgName, pkgVersion, strict }, spinner) { +function formatPackageDataOutput (/** @type {{ [key: string]: any }} */ { data }, { outputJson, outputMarkdown, strict }, spinner) { if (outputJson) { console.log(JSON.stringify(data, undefined, 2)) } else { - console.log('\nPackage report card:') - const scoreResult = { - 'Supply Chain Risk': Math.floor(score.supplyChainRisk.score * 100), - 'Maintenance': Math.floor(score.maintenance.score * 100), - 'Quality': Math.floor(score.quality.score * 100), - 'Vulnerabilities': Math.floor(score.vulnerability.score * 100), - 'License': Math.floor(score.license.score * 100) - } - Object.entries(scoreResult).map(score => console.log(`- ${score[0]}: ${formatScore(score[1])}`)) - - // Package issues list - if (objectSome(severityCount)) { - const issueSummary = formatSeverityCount(severityCount) - console.log('\n') - spinner[strict ? 'fail' : 'succeed'](`Package has these issues: ${issueSummary}`) - formatPackageIssuesDetails(data, outputMarkdown) - } else { - console.log('\n') - spinner.succeed('Package has no issues') - } + data.map((/** @type {{[key:string]: any}} */ d) => { + const { score, license, name, severityCount, version } = d + console.log(`\nPackage metrics for ${name}:`) + + const scoreResult = { + 'Supply Chain Risk': Math.floor(score.supplyChain * 100), + 'Maintenance': Math.floor(score.maintenance * 100), + 'Quality': Math.floor(score.quality * 100), + 'Vulnerabilities': Math.floor(score.vulnerability * 100), + 'License': Math.floor(score.license * 100), + 'Overall': Math.floor(score.overall * 100) + } - // Link to issues list - const format = new ChalkOrMarkdown(!!outputMarkdown) - const url = `https://socket.dev/npm/package/${pkgName}/overview/${pkgVersion}` - if (pkgVersion === 'latest') { - console.log('\nDetailed info on socket.dev: ' + format.hyperlink(`${pkgName}`, url, { fallbackToUrl: true })) - } else { - console.log('\nDetailed info on socket.dev: ' + format.hyperlink(`${pkgName} v${pkgVersion}`, url, { fallbackToUrl: true })) - } - if (!outputMarkdown) { - console.log(chalk.dim('\nOr rerun', chalk.italic(name), 'using the', chalk.italic('--json'), 'flag to get full JSON output')) - } - } + Object.entries(scoreResult).map(score => console.log(`- ${score[0]}: ${formatScore(score[1])}`)) + + // Package license + console.log('\nPackage license:') + console.log(`${license}`) + + // Package issues list + if (objectSome(severityCount)) { + const issueSummary = formatSeverityCount(severityCount) + console.log('\n') + spinner[strict ? 'fail' : 'succeed'](`Package has these issues: ${issueSummary}`) + formatPackageIssuesDetails(data.alerts, outputMarkdown) + } else if (severityCount && !objectSome(severityCount)) { + console.log('\n') + spinner.succeed('Package has no issues') + } - if (strict && objectSome(severityCount)) { - process.exit(1) + // Link to issues list + const format = new ChalkOrMarkdown(!!outputMarkdown) + const url = `https://socket.dev/npm/package/${name}/overview/${version}` + if (version === 'latest') { + console.log('\nDetailed info on socket.dev: ' + format.hyperlink(`${name}`, url, { fallbackToUrl: true })) + } else { + console.log('\nDetailed info on socket.dev: ' + format.hyperlink(`${name} v${version}`, url, { fallbackToUrl: true })) + } + if (!outputMarkdown) { + console.log(chalk.dim('\nOr rerun', chalk.italic(name), 'using the', chalk.italic('--json'), 'flag to get full JSON output')) + } + + if (strict && objectSome(severityCount)) { + process.exit(1) + } + return d + }) } } /** - * @param {import('@socketsecurity/sdk').SocketSdkReturnType<'getIssuesByNPMPackage'>["data"]} packageData + * @param {{[key: string]: any}[]} alertsData * @param {boolean} outputMarkdown * @returns {void[]} */ -function formatPackageIssuesDetails (packageData, outputMarkdown) { - const issueDetails = packageData.filter(d => d.value?.severity === 'high' || d.value?.severity === 'critical') +function formatPackageIssuesDetails (alertsData, outputMarkdown) { + const issueDetails = alertsData.filter(d => d['severity'] === 'high' || d['severity'] === 'critical') const uniqueIssues = issueDetails.reduce((/** @type {{ [key: string]: {count: Number, label: string | undefined} }} */ acc, issue) => { const { type } = issue if (type) { if (!acc[type]) { acc[type] = { - label: issue.value?.label, + label: issue['type'], count: 1 } } else { @@ -217,6 +258,7 @@ function formatPackageIssuesDetails (packageData, outputMarkdown) { }, {}) const format = new ChalkOrMarkdown(!!outputMarkdown) + return Object.keys(uniqueIssues).map(issue => { const issueWithLink = format.hyperlink(`${uniqueIssues[issue]?.label}`, `https://socket.dev/npm/issue/${issue}`, { fallbackToUrl: true }) if (uniqueIssues[issue]?.count === 1) { diff --git a/lib/utils/format-issues.js b/lib/utils/format-issues.js index bf4a1b72..5c09d2cd 100644 --- a/lib/utils/format-issues.js +++ b/lib/utils/format-issues.js @@ -27,7 +27,7 @@ const SEVERITIES_BY_ORDER = /** @type {const} */ ([ return result } - +/* TODO: Delete this function when we remove the report command */ /** * @param {SocketIssueList} issues * @param {SocketIssue['severity']} [lowestToInclude] @@ -54,6 +54,33 @@ export function getSeverityCount (issues, lowestToInclude) { return severityCount } +/* The following function is the updated one */ +/** + * @param {Array} issues + * @param {SocketIssue['severity']} [lowestToInclude] + * @returns {Record} + */ +export function getCountSeverity (issues, lowestToInclude) { + const severityCount = pick( + { low: 0, middle: 0, high: 0, critical: 0 }, + getDesiredSeverities(lowestToInclude) + ) + + for (const issue of issues) { + const severity = issue.severity + + if (!severity) { + continue + } + + if (severityCount[severity] !== undefined) { + severityCount[severity] += 1 + } + } + + return severityCount +} + /** * @param {Record} severityCount * @returns {string} diff --git a/package-lock.json b/package-lock.json index 50e0ccb7..9e5a7c91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@cyclonedx/cdxgen": "^10.7.0", "@inquirer/select": "^2.3.5", "@socketsecurity/config": "^2.1.3", - "@socketsecurity/sdk": "^1.1.1", + "@socketsecurity/sdk": "^1.2.0", "chalk": "^5.3.0", "chalk-table": "^1.0.2", "execa": "^9.1.0", @@ -1619,9 +1619,9 @@ } }, "node_modules/@socketsecurity/sdk": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@socketsecurity/sdk/-/sdk-1.1.1.tgz", - "integrity": "sha512-ACVBe0UiZxy3S1C/2OvetAOoaZDumanKRzr2gXq5qOjhI6neLr2tZxC5xI+UcMKAcTDkwOZM8mpqvMdSjd6EEw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/sdk/-/sdk-1.2.0.tgz", + "integrity": "sha512-XvOIJJsmzivaJWyUwNOcCAxcBBQtRLoE4mYbdrpgi1gagdgmau3dzSq/OC3vgrTV27iS9zfJLP8gqjrposuhGQ==", "dependencies": { "formdata-node": "^5.0.0", "got": "^12.5.3", diff --git a/package.json b/package.json index 189dea94..9feee8a7 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "@cyclonedx/cdxgen": "^10.7.0", "@inquirer/select": "^2.3.5", "@socketsecurity/config": "^2.1.3", - "@socketsecurity/sdk": "^1.1.1", + "@socketsecurity/sdk": "^1.2.0", "chalk": "^5.3.0", "chalk-table": "^1.0.2", "execa": "^9.1.0",