diff --git a/packages/plugin-js-packages/src/bin.ts b/packages/plugin-js-packages/src/bin.ts index be3729ada..c2ae92523 100644 --- a/packages/plugin-js-packages/src/bin.ts +++ b/packages/plugin-js-packages/src/bin.ts @@ -1,3 +1,3 @@ import { executeRunner } from './lib/runner'; -executeRunner(); +await executeRunner(); diff --git a/packages/plugin-js-packages/src/lib/config.ts b/packages/plugin-js-packages/src/lib/config.ts index eb0d4c4d8..4cd855e46 100644 --- a/packages/plugin-js-packages/src/lib/config.ts +++ b/packages/plugin-js-packages/src/lib/config.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; import { IssueSeverity, issueSeveritySchema } from '@code-pushup/models'; +import { defaultAuditLevelMapping } from './constants'; + +export const packageDependencies = ['prod', 'dev', 'optional'] as const; +export type PackageDependency = (typeof packageDependencies)[number]; const packageCommandSchema = z.enum(['audit', 'outdated']); export type PackageCommand = z.infer; @@ -12,23 +16,16 @@ const packageManagerSchema = z.enum([ ]); export type PackageManager = z.infer; -const packageAuditLevelSchema = z.enum([ - 'info', - 'low', - 'moderate', - 'high', +export const packageAuditLevels = [ 'critical', -]); + 'high', + 'moderate', + 'low', + 'info', +] as const; +const packageAuditLevelSchema = z.enum(packageAuditLevels); export type PackageAuditLevel = z.infer; -const defaultAuditLevelMapping: Record = { - critical: 'error', - high: 'error', - moderate: 'warning', - low: 'warning', - info: 'info', -}; - export function fillAuditLevelMapping( mapping: Partial>, ): Record { @@ -66,5 +63,3 @@ export type JSPackagesPluginConfig = z.input< export type FinalJSPackagesPluginConfig = z.infer< typeof jsPackagesPluginConfigSchema >; - -export type PackageDependencyType = 'prod' | 'dev' | 'optional'; diff --git a/packages/plugin-js-packages/src/lib/constants.ts b/packages/plugin-js-packages/src/lib/constants.ts index 1353c02b4..863ab27d8 100644 --- a/packages/plugin-js-packages/src/lib/constants.ts +++ b/packages/plugin-js-packages/src/lib/constants.ts @@ -1,5 +1,20 @@ -import { MaterialIcon } from '@code-pushup/models'; -import { PackageDependencyType, PackageManager } from './config'; +import { IssueSeverity, MaterialIcon } from '@code-pushup/models'; +import type { + PackageAuditLevel, + PackageDependency, + PackageManager, +} from './config'; + +export const defaultAuditLevelMapping: Record< + PackageAuditLevel, + IssueSeverity +> = { + critical: 'error', + high: 'error', + moderate: 'warning', + low: 'warning', + info: 'info', +}; export const pkgManagerNames: Record = { npm: 'NPM', @@ -35,7 +50,7 @@ export const outdatedDocs: Record = { pnpm: 'https://pnpm.io/cli/outdated', }; -export const dependencyDocs: Record = { +export const dependencyDocs: Record = { prod: 'https://classic.yarnpkg.com/docs/dependency-types#toc-dependencies', dev: 'https://classic.yarnpkg.com/docs/dependency-types#toc-devdependencies', optional: diff --git a/packages/plugin-js-packages/src/lib/js-packages-plugin.ts b/packages/plugin-js-packages/src/lib/js-packages-plugin.ts index 109cf0cd1..a3c7b504b 100644 --- a/packages/plugin-js-packages/src/lib/js-packages-plugin.ts +++ b/packages/plugin-js-packages/src/lib/js-packages-plugin.ts @@ -1,11 +1,11 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Audit, Group, PluginConfig } from '@code-pushup/models'; +import type { Audit, Group, PluginConfig } from '@code-pushup/models'; import { name, version } from '../../package.json'; import { JSPackagesPluginConfig, PackageCommand, - PackageDependencyType, + PackageDependency, PackageManager, jsPackagesPluginConfigSchema, } from './config'; @@ -126,7 +126,7 @@ function createAudits( function getAuditTitle( pkgManager: PackageManager, check: PackageCommand, - dependencyType: PackageDependencyType, + dependencyType: PackageDependency, ) { return check === 'audit' ? `Vulnerabilities for ${pkgManagerNames[pkgManager]} ${dependencyType} dependencies.` @@ -135,7 +135,7 @@ function getAuditTitle( function getAuditDescription( check: PackageCommand, - dependencyType: PackageDependencyType, + dependencyType: PackageDependency, ) { return check === 'audit' ? `Runs security audit on ${dependencyType} dependencies.` diff --git a/packages/plugin-js-packages/src/lib/runner/audit/transform.ts b/packages/plugin-js-packages/src/lib/runner/audit/transform.ts new file mode 100644 index 000000000..79df8f8e7 --- /dev/null +++ b/packages/plugin-js-packages/src/lib/runner/audit/transform.ts @@ -0,0 +1,81 @@ +import type { AuditOutput, Issue, IssueSeverity } from '@code-pushup/models'; +import { objectToEntries } from '@code-pushup/utils'; +import { + PackageAuditLevel, + PackageDependency, + packageAuditLevels, +} from '../../config'; +import { NpmAuditResultJson, Vulnerabilities } from './types'; + +export function auditResultToAuditOutput( + result: NpmAuditResultJson, + dependenciesType: PackageDependency, + auditLevelMapping: Record, +): AuditOutput { + const issues = vulnerabilitiesToIssues( + result.vulnerabilities, + auditLevelMapping, + ); + return { + slug: `npm-audit-${dependenciesType}`, + score: result.metadata.vulnerabilities.total === 0 ? 1 : 0, + value: result.metadata.vulnerabilities.total, + displayValue: vulnerabilitiesToDisplayValue( + result.metadata.vulnerabilities, + ), + ...(issues.length > 0 && { details: { issues } }), + }; +} + +export function vulnerabilitiesToDisplayValue( + vulnerabilities: Record, +): string { + if (vulnerabilities.total === 0) { + return 'passed'; + } + + const displayValue = packageAuditLevels + .map(level => + vulnerabilities[level] > 0 ? `${vulnerabilities[level]} ${level}` : '', + ) + .filter(text => text !== '') + .join(', '); + return `${displayValue} ${ + vulnerabilities.total === 1 ? 'vulnerability' : 'vulnerabilities' + }`; +} + +export function vulnerabilitiesToIssues( + vulnerabilities: Vulnerabilities, + auditLevelMapping: Record, +): Issue[] { + if (Object.keys(vulnerabilities).length === 0) { + return []; + } + + return objectToEntries(vulnerabilities).map(([, detail]) => { + // Advisory details via can refer to another vulnerability + // For now, only direct context is supported + if ( + Array.isArray(detail.via) && + detail.via.length > 0 && + typeof detail.via[0] === 'object' + ) { + return { + message: `${detail.name} dependency has a vulnerability "${ + detail.via[0].title + }" for versions ${detail.range}. Fix is ${ + detail.fixAvailable ? '' : 'not ' + }available. More information [here](${detail.via[0].url})`, + severity: auditLevelMapping[detail.severity], + }; + } + + return { + message: `${detail.name} dependency has a vulnerability for versions ${ + detail.range + }. Fix is ${detail.fixAvailable ? '' : 'not '}available.`, + severity: auditLevelMapping[detail.severity], + }; + }); +} diff --git a/packages/plugin-js-packages/src/lib/runner/audit/transform.unit.test.ts b/packages/plugin-js-packages/src/lib/runner/audit/transform.unit.test.ts new file mode 100644 index 000000000..365579bc8 --- /dev/null +++ b/packages/plugin-js-packages/src/lib/runner/audit/transform.unit.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; +import type { AuditOutput, Issue } from '@code-pushup/models'; +import { defaultAuditLevelMapping } from '../../constants'; +import { + auditResultToAuditOutput, + vulnerabilitiesToDisplayValue, + vulnerabilitiesToIssues, +} from './transform'; +import { Vulnerability } from './types'; + +describe('auditResultToAuditOutput', () => { + it('should return audit output with no vulnerabilities', () => { + expect( + auditResultToAuditOutput( + { + vulnerabilities: {}, + metadata: { + vulnerabilities: { + critical: 0, + high: 0, + moderate: 0, + low: 0, + info: 0, + total: 0, + }, + }, + }, + 'prod', + defaultAuditLevelMapping, + ), + ).toEqual({ + slug: 'npm-audit-prod', + score: 1, + value: 0, + displayValue: 'passed', + }); + }); + + it('should return audit output with a vulnerability', () => { + expect( + auditResultToAuditOutput( + { + vulnerabilities: { + request: { + name: 'request', + severity: 'critical', + range: '2.0.0 - 3.0.0', + via: [ + { title: 'SSR forgery', url: 'https://github.com/advisories/' }, + ], + fixAvailable: false, + }, + }, + metadata: { + vulnerabilities: { + critical: 1, + high: 0, + moderate: 0, + low: 0, + info: 0, + total: 1, + }, + }, + }, + 'prod', + defaultAuditLevelMapping, + ), + ).toEqual({ + slug: 'npm-audit-prod', + score: 0, + value: 1, + displayValue: '1 critical vulnerability', + details: { + issues: [ + { + message: expect.stringContaining( + 'request dependency has a vulnerability "SSR forgery"', + ), + severity: 'error', + }, + ], + }, + }); + }); + + it('should return audit output with multiple vulnerabilities', () => { + expect( + auditResultToAuditOutput( + { + vulnerabilities: { + request: { + name: 'request', + severity: 'critical', + range: '2.0.0 - 3.0.0', + via: [ + { title: 'SSR forgery', url: 'https://github.com/advisories/' }, + ], + fixAvailable: false, + }, + '@babel/traverse': { + name: '@babel/traverse', + severity: 'high', + range: '<7.23.2', + via: [ + { + title: 'Malicious code execution', + url: 'https://github.com/advisories/', + }, + ], + fixAvailable: true, + }, + verdaccio: { + name: 'verdaccio', + severity: 'critical', + range: '*', + via: ['request'], + fixAvailable: true, + }, + }, + metadata: { + vulnerabilities: { + critical: 2, + high: 1, + moderate: 0, + low: 0, + info: 0, + total: 3, + }, + }, + }, + 'dev', + defaultAuditLevelMapping, + ), + ).toEqual({ + slug: 'npm-audit-dev', + score: 0, + value: 3, + displayValue: '2 critical, 1 high vulnerabilities', + details: { + issues: [ + expect.objectContaining({ + message: expect.stringContaining('request'), + }), + expect.objectContaining({ + message: expect.stringContaining('@babel/traverse'), + }), + expect.objectContaining({ + message: expect.stringContaining('verdaccio'), + }), + ], + }, + }); + }); +}); + +describe('vulnerabilitiesToDisplayValue', () => { + it('should return passed for no vulnerabilities', () => { + expect( + vulnerabilitiesToDisplayValue({ + critical: 0, + high: 0, + moderate: 0, + low: 0, + info: 0, + total: 0, + }), + ).toBe('passed'); + }); + + it('should return a summary of vulnerabilities', () => { + expect( + vulnerabilitiesToDisplayValue({ + critical: 1, + high: 0, + moderate: 2, + low: 0, + info: 3, + total: 6, + }), + ).toBe('1 critical, 2 moderate, 3 info vulnerabilities'); + }); +}); + +describe('vulnerabilitiesToIssues', () => { + it('should create an issue with a vulnerability URL based on provided vulnerability', () => { + expect( + vulnerabilitiesToIssues( + { + 'tough-cookie': { + name: 'tough-cookie', + severity: 'moderate', + fixAvailable: true, + range: '<4.1.3', + via: [ + { + title: 'tough-cookie Prototype Pollution vulnerability', + url: 'https://github.com/advisories/GHSA-72xf-g2v4-qvf3', + }, + ], + }, + }, + defaultAuditLevelMapping, + ), + ).toEqual([ + { + message: expect.stringMatching( + /tough-cookie dependency has a vulnerability "tough-cookie Prototype Pollution vulnerability" for versions <4.1.3.* More information.*https:\/\/github\.com\/advisories/, + ), + severity: 'warning', + }, + ]); + }); + + it('should provide shorter message when context is in a different vulnerability', () => { + expect( + vulnerabilitiesToIssues( + { + verdaccio: { + name: 'verdaccio', + severity: 'high', + fixAvailable: true, + range: '<=5.28.0', + via: ['request'], + }, + }, + defaultAuditLevelMapping, + ), + ).toEqual([ + { + message: expect.stringMatching( + /verdaccio dependency has a vulnerability for versions <=5.28.0. Fix is available./, + ), + severity: 'error', + }, + ]); + }); + + it('should correctly map vulnerability level to issue severity', () => { + expect( + vulnerabilitiesToIssues( + { + verdaccio: { + severity: 'high', + } as Vulnerability, + }, + { ...defaultAuditLevelMapping, high: 'info' }, + ), + ).toEqual([ + { + message: expect.any(String), + severity: 'info', + }, + ]); + }); + + it('should return empty array for no vulnerabilities', () => { + expect(vulnerabilitiesToIssues({}, defaultAuditLevelMapping)).toEqual([]); + }); +}); diff --git a/packages/plugin-js-packages/src/lib/runner/audit/types.ts b/packages/plugin-js-packages/src/lib/runner/audit/types.ts new file mode 100644 index 000000000..8d7362a1d --- /dev/null +++ b/packages/plugin-js-packages/src/lib/runner/audit/types.ts @@ -0,0 +1,26 @@ +import type { PackageAuditLevel } from '../../config'; + +// NPM audit JSON types +type Advisory = { + title: string; + url: string; +}; + +export type Vulnerability = { + name: string; + severity: PackageAuditLevel; + via: Advisory[] | string[]; + range: string; + fixAvailable: boolean; +}; + +export type Vulnerabilities = { + [key: string]: Vulnerability; +}; + +export type NpmAuditResultJson = { + vulnerabilities: Vulnerabilities; + metadata: { + vulnerabilities: Record; + }; +}; diff --git a/packages/plugin-js-packages/src/lib/runner/index.ts b/packages/plugin-js-packages/src/lib/runner/index.ts index f5836cc06..48b73f273 100644 --- a/packages/plugin-js-packages/src/lib/runner/index.ts +++ b/packages/plugin-js-packages/src/lib/runner/index.ts @@ -1,12 +1,61 @@ import { writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import { RunnerConfig } from '@code-pushup/models'; -import { ensureDirectoryExists } from '@code-pushup/utils'; -import { FinalJSPackagesPluginConfig } from '../config'; +import { dirname, join } from 'node:path'; +import type { AuditOutput, RunnerConfig } from '@code-pushup/models'; +import { + ensureDirectoryExists, + executeProcess, + readJsonFile, +} from '@code-pushup/utils'; +import { + FinalJSPackagesPluginConfig, + PackageDependency, + packageDependencies, +} from '../config'; +import { auditResultToAuditOutput } from './audit/transform'; +import { NpmAuditResultJson } from './audit/types'; import { PLUGIN_CONFIG_PATH, RUNNER_OUTPUT_PATH } from './constants'; -export function executeRunner(): void { - return; +export async function executeRunner(): Promise { + const outputPath = join( + process.cwd(), + 'node_modules', + '.code-pushup', + 'js-packages', + ); + + const { packageManager, checks, auditLevelMapping } = + await readJsonFile(PLUGIN_CONFIG_PATH); + + const results = await Promise.allSettled( + checks.flatMap(check => + packageDependencies.map>(async dep => { + await executeProcess({ + command: 'npm', + args: [ + check, + ...createAuditFlags(dep), + '--json', + '--audit-level=none', + '>', + join(outputPath, `${packageManager}-${check}-${dep}.json`), + ], + }); + + const auditResult = await readJsonFile( + join(outputPath, `${packageManager}-${check}-${dep}.json`), + ); + return auditResultToAuditOutput(auditResult, dep, auditLevelMapping); + }), + ), + ); + const auditOutputs = results + .filter( + (x): x is PromiseFulfilledResult => x.status === 'fulfilled', + ) + .map(x => x.value); + + await ensureDirectoryExists(dirname(RUNNER_OUTPUT_PATH)); + await writeFile(RUNNER_OUTPUT_PATH, JSON.stringify(auditOutputs)); } export async function createRunnerConfig( @@ -22,3 +71,16 @@ export async function createRunnerConfig( outputFile: RUNNER_OUTPUT_PATH, }; } + +function createAuditFlags(currentDep: PackageDependency) { + if (currentDep === 'optional') { + return packageDependencies.map(dep => `--include=${dep}`); + } + + return [ + `--include${currentDep}`, + ...packageDependencies + .filter(dep => dep !== currentDep) + .map(dep => `--omit=${dep}`), + ]; +}