Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update info command with PURL endpoint #123

Merged
merged 8 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 122 additions & 80 deletions lib/commands/info/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -21,25 +21,41 @@ 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)
}
}
}
}

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
*/

Expand All @@ -54,18 +70,19 @@ function setupCommand (name, description, argv, importMeta) {
const flags = {
...outputFlags,
...validationFlags,
...infoFlags
}

const cli = meow(`
Usage
$ ${name} <name>
$ ${name} <ecosystem>:<name>@<version>

Options
${printFlagList(flags, 6)}

Examples
$ ${name} webtorrent
$ ${name} [email protected]
$ ${name} npm:webtorrent
$ ${name} npm:[email protected]
`, {
argv,
description,
Expand All @@ -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<import('../../utils/format-issues.js').SocketIssue['severity'], number>} 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<CommandContext, 'includeAllIssues'>} context
* @param {string[]} packages
* @param {boolean} includeAlerts
* @param {import('ora').Ora} spinner
* @returns {Promise<void|PackageData>}
*/
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 {
Expand All @@ -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) {
Expand Down
29 changes: 28 additions & 1 deletion lib/utils/format-issues.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -54,6 +54,33 @@ export function getSeverityCount (issues, lowestToInclude) {
return severityCount
}

/* The following function is the updated one */
/**
* @param {Array<SocketIssue>} issues
* @param {SocketIssue['severity']} [lowestToInclude]
* @returns {Record<SocketIssue['severity'], number>}
*/
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<SocketIssue['severity'], number>} severityCount
* @returns {string}
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"@cyclonedx/cdxgen": "^10.5.2",
"@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",
Expand Down
Loading