diff --git a/.changeset/eight-weeks-double.md b/.changeset/eight-weeks-double.md new file mode 100644 index 0000000000..cc0b7bed50 --- /dev/null +++ b/.changeset/eight-weeks-double.md @@ -0,0 +1,6 @@ +--- +"@pnpm/plugin-commands-audit": minor +"pnpm": minor +--- + +New command added: `pnpm audit --fix`. This command adds overrides to `package.json` that force versions of packages that do not have the vulnerabilities. diff --git a/packages/plugin-commands-audit/package.json b/packages/plugin-commands-audit/package.json index 394bf05792..634e4a153e 100644 --- a/packages/plugin-commands-audit/package.json +++ b/packages/plugin-commands-audit/package.json @@ -29,11 +29,14 @@ }, "homepage": "https://github.com/pnpm/pnpm/blob/master/packages/plugin-commands-audit#readme", "devDependencies": { + "@pnpm/test-fixtures": "workspace:*", "@pnpm/types": "workspace:7.3.0", "@types/ramda": "0.27.39", "@types/zkochan__table": "npm:@types/table@6.0.0", + "load-json-file": "^6.2.0", "nock": "12.0.3", - "strip-ansi": "^6.0.0" + "strip-ansi": "^6.0.0", + "tempy": "^1.0.0" }, "dependencies": { "@pnpm/audit": "workspace:2.1.6", @@ -42,6 +45,7 @@ "@pnpm/constants": "workspace:5.0.0", "@pnpm/error": "workspace:2.0.0", "@pnpm/lockfile-file": "workspace:4.1.0", + "@pnpm/read-project-manifest": "workspace:2.0.4", "@zkochan/table": "^1.0.0", "chalk": "^4.1.0", "ramda": "^0.27.1", diff --git a/packages/plugin-commands-audit/src/audit.ts b/packages/plugin-commands-audit/src/audit.ts index c9ba83b9b5..831b13508d 100644 --- a/packages/plugin-commands-audit/src/audit.ts +++ b/packages/plugin-commands-audit/src/audit.ts @@ -9,6 +9,7 @@ import { table } from '@zkochan/table' import chalk from 'chalk' import pick from 'ramda/src/pick' import renderHelp from 'render-help' +import fix from './fix' // eslint-disable const AUDIT_LEVEL_NUMBER = { @@ -39,6 +40,7 @@ export function cliOptionsTypes () { 'registry', ], allTypes), 'audit-level': ['low', 'moderate', 'high', 'critical'], + fix: Boolean, 'ignore-registry-errors': Boolean, } } @@ -58,6 +60,10 @@ export function help () { title: 'Options', list: [ + { + description: 'Add overrides to the package.json file in order to force non-vulnerable versions of the dependencies', + name: '--fix', + }, { description: 'Output audit report in JSON format', name: '--json', @@ -95,6 +101,7 @@ export function help () { export async function handler ( opts: Pick & { auditLevel?: 'low' | 'moderate' | 'high' | 'critical' + fix?: boolean ignoreRegistryErrors?: boolean json?: boolean lockfileDir?: string @@ -131,6 +138,23 @@ export async function handler ( } } } + if (opts.fix) { + const newOverrides = await fix(opts.dir, auditReport) + if (Object.values(newOverrides).length === 0) { + return { + exitCode: 0, + output: 'No fixes were made', + } + } + return { + exitCode: 0, + output: `${Object.values(newOverrides).length} overrides were added to package.json to fix vulnerabilities. +Run "pnpm install" to apply the fixes. + +The added overrides: +${JSON.stringify(newOverrides, null, 2)}`, + } + } const vulnerabilities = auditReport.metadata.vulnerabilities const totalVulnerabilityCount = Object.values(vulnerabilities) .reduce((sum: number, vulnerabilitiesCount: number) => sum + vulnerabilitiesCount, 0) diff --git a/packages/plugin-commands-audit/src/fix.ts b/packages/plugin-commands-audit/src/fix.ts new file mode 100644 index 0000000000..5349d18012 --- /dev/null +++ b/packages/plugin-commands-audit/src/fix.ts @@ -0,0 +1,27 @@ +import { AuditReport, AuditAdvisory } from '@pnpm/audit' +import readProjectManifest from '@pnpm/read-project-manifest' +import fromPairs from 'ramda/src/fromPairs' + +export default async function fix (dir: string, auditReport: AuditReport) { + const { manifest, writeProjectManifest } = await readProjectManifest(dir) + const vulnOverrides = createOverrides(Object.values(auditReport.advisories)) + if (Object.values(vulnOverrides).length === 0) return vulnOverrides + await writeProjectManifest({ + ...manifest, + pnpm: { + ...manifest.pnpm, + overrides: { + ...manifest.pnpm?.overrides, + ...vulnOverrides, + }, + }, + }) + return vulnOverrides +} + +function createOverrides (advisories: AuditAdvisory[]) { + return fromPairs(advisories.map((advisory) => [ + `${advisory.module_name}@${advisory.vulnerable_versions}`, + advisory.patched_versions, + ])) +} diff --git a/packages/plugin-commands-audit/test/fix.ts b/packages/plugin-commands-audit/test/fix.ts new file mode 100644 index 0000000000..b750ee06f6 --- /dev/null +++ b/packages/plugin-commands-audit/test/fix.ts @@ -0,0 +1,43 @@ +import path from 'path' +import { copyFixture } from '@pnpm/test-fixtures' +import { ProjectManifest } from '@pnpm/types' +import { audit } from '@pnpm/plugin-commands-audit' +import loadJsonFile from 'load-json-file' +import tempy from 'tempy' + +test('overrides are added for vulnerable dependencies', async () => { + const tmp = tempy.directory() + await copyFixture('has-vulnerabilities', tmp, __dirname) + + const { exitCode, output } = await audit.handler({ + auditLevel: 'moderate', + dir: tmp, + fix: true, + registries: { + default: 'https://registry.npmjs.org/', + }, + }) + + expect(exitCode).toBe(0) + expect(output).toMatch(/Run "pnpm install"/) + + const manifest = await loadJsonFile(path.join(tmp, 'package.json')) + expect(manifest.pnpm?.overrides?.['axios@<0.18.1']).toBe('>=0.18.1') +}) + +test('no overrides are added if no vulnerabilities are found', async () => { + const tmp = tempy.directory() + await copyFixture('fixture', tmp, __dirname) + + const { exitCode, output } = await audit.handler({ + auditLevel: 'moderate', + dir: tmp, + fix: true, + registries: { + default: 'https://registry.npmjs.org/', + }, + }) + + expect(exitCode).toBe(0) + expect(output).toBe('No fixes were made') +}) diff --git a/packages/plugin-commands-audit/test/packages/.gitignore b/packages/plugin-commands-audit/test/fixtures/.gitignore similarity index 100% rename from packages/plugin-commands-audit/test/packages/.gitignore rename to packages/plugin-commands-audit/test/fixtures/.gitignore diff --git a/packages/plugin-commands-audit/test/packages/.npmrc b/packages/plugin-commands-audit/test/fixtures/.npmrc similarity index 100% rename from packages/plugin-commands-audit/test/packages/.npmrc rename to packages/plugin-commands-audit/test/fixtures/.npmrc diff --git a/packages/plugin-commands-audit/test/packages/has-vulnerabilities/package.json b/packages/plugin-commands-audit/test/fixtures/has-vulnerabilities/package.json similarity index 100% rename from packages/plugin-commands-audit/test/packages/has-vulnerabilities/package.json rename to packages/plugin-commands-audit/test/fixtures/has-vulnerabilities/package.json diff --git a/packages/plugin-commands-audit/test/packages/has-vulnerabilities/pnpm-lock.yaml b/packages/plugin-commands-audit/test/fixtures/has-vulnerabilities/pnpm-lock.yaml similarity index 100% rename from packages/plugin-commands-audit/test/packages/has-vulnerabilities/pnpm-lock.yaml rename to packages/plugin-commands-audit/test/fixtures/has-vulnerabilities/pnpm-lock.yaml diff --git a/packages/plugin-commands-audit/test/packages/pnpm-workspace.yaml b/packages/plugin-commands-audit/test/fixtures/pnpm-workspace.yaml similarity index 100% rename from packages/plugin-commands-audit/test/packages/pnpm-workspace.yaml rename to packages/plugin-commands-audit/test/fixtures/pnpm-workspace.yaml diff --git a/packages/plugin-commands-audit/test/index.ts b/packages/plugin-commands-audit/test/index.ts index 8a6f9b349b..fcacc47ea4 100644 --- a/packages/plugin-commands-audit/test/index.ts +++ b/packages/plugin-commands-audit/test/index.ts @@ -8,7 +8,7 @@ const skipOnNode10 = process.version.split('.')[0] === 'v10' ? test.skip : test // The audits give different results on Node 10, for some reason skipOnNode10('audit', async () => { const { output, exitCode } = await audit.handler({ - dir: path.join(__dirname, 'packages/has-vulnerabilities'), + dir: path.join(__dirname, 'fixtures/has-vulnerabilities'), registries: { default: 'https://registry.npmjs.org/', }, @@ -19,7 +19,7 @@ skipOnNode10('audit', async () => { test('audit --dev', async () => { const { output, exitCode } = await audit.handler({ - dir: path.join(__dirname, 'packages/has-vulnerabilities'), + dir: path.join(__dirname, 'fixtures/has-vulnerabilities'), dev: true, production: false, registries: { @@ -34,7 +34,7 @@ test('audit --dev', async () => { test('audit --audit-level', async () => { const { output, exitCode } = await audit.handler({ auditLevel: 'moderate', - dir: path.join(__dirname, 'packages/has-vulnerabilities'), + dir: path.join(__dirname, 'fixtures/has-vulnerabilities'), registries: { default: 'https://registry.npmjs.org/', }, @@ -58,7 +58,7 @@ test('audit: no vulnerabilities', async () => { test('audit --json', async () => { const { output, exitCode } = await audit.handler({ - dir: path.join(__dirname, 'packages/has-vulnerabilities'), + dir: path.join(__dirname, 'fixtures/has-vulnerabilities'), json: true, registries: { default: 'https://registry.npmjs.org/', @@ -73,7 +73,7 @@ test('audit --json', async () => { test.skip('audit does not exit with code 1 if the found vulnerabilities are having lower severity then what we asked for', async () => { const { output, exitCode } = await audit.handler({ auditLevel: 'high', - dir: path.join(__dirname, 'packages/has-vulnerabilities'), + dir: path.join(__dirname, 'fixtures/has-vulnerabilities'), dev: true, registries: { default: 'https://registry.npmjs.org/', @@ -91,7 +91,7 @@ test('audit does not exit with code 1 if the registry responds with a non-200 re .post('/-/npm/v1/security/audits') .reply(500, { message: 'Something bad happened' }) const { output, exitCode } = await audit.handler({ - dir: path.join(__dirname, 'packages/has-vulnerabilities'), + dir: path.join(__dirname, 'fixtures/has-vulnerabilities'), dev: true, ignoreRegistryErrors: true, production: false, diff --git a/packages/plugin-commands-audit/tsconfig.json b/packages/plugin-commands-audit/tsconfig.json index e432c4c940..c83b38b556 100644 --- a/packages/plugin-commands-audit/tsconfig.json +++ b/packages/plugin-commands-audit/tsconfig.json @@ -9,6 +9,9 @@ "../../typings/**/*.d.ts" ], "references": [ + { + "path": "../../privatePackages/test-fixtures" + }, { "path": "../audit" }, @@ -27,6 +30,9 @@ { "path": "../lockfile-file" }, + { + "path": "../read-project-manifest" + }, { "path": "../types" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d60ecfc121..1d31d59ba6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1722,15 +1722,19 @@ importers: '@pnpm/error': workspace:2.0.0 '@pnpm/lockfile-file': workspace:4.1.0 '@pnpm/plugin-commands-audit': 'link:' + '@pnpm/read-project-manifest': workspace:2.0.4 + '@pnpm/test-fixtures': workspace:* '@pnpm/types': workspace:7.3.0 '@types/ramda': 0.27.39 '@types/zkochan__table': npm:@types/table@6.0.0 '@zkochan/table': ^1.0.0 chalk: ^4.1.0 + load-json-file: ^6.2.0 nock: 12.0.3 ramda: ^0.27.1 render-help: ^1.0.1 strip-ansi: ^6.0.0 + tempy: ^1.0.0 dependencies: '@pnpm/audit': link:../audit '@pnpm/cli-utils': link:../cli-utils @@ -1738,17 +1742,21 @@ importers: '@pnpm/constants': link:../constants '@pnpm/error': link:../error '@pnpm/lockfile-file': link:../lockfile-file + '@pnpm/read-project-manifest': link:../read-project-manifest '@zkochan/table': 1.0.0 chalk: 4.1.1 ramda: 0.27.1 render-help: 1.0.2 devDependencies: '@pnpm/plugin-commands-audit': 'link:' + '@pnpm/test-fixtures': link:../../privatePackages/test-fixtures '@pnpm/types': link:../types '@types/ramda': 0.27.39 '@types/zkochan__table': /@types/table/6.0.0 + load-json-file: 6.2.0 nock: 12.0.3 strip-ansi: 6.0.0 + tempy: 1.0.1 packages/plugin-commands-import: specifiers: diff --git a/privatePackages/test-fixtures/src/index.ts b/privatePackages/test-fixtures/src/index.ts index 275e77c6cc..085d858576 100644 --- a/privatePackages/test-fixtures/src/index.ts +++ b/privatePackages/test-fixtures/src/index.ts @@ -5,14 +5,14 @@ import ncpCB from 'ncp' const ncp = promisify(ncpCB) -export async function copyFixture (fixtureName: string, dest: string) { - const fixturePath = pathToLocalPkg(fixtureName) +export async function copyFixture (fixtureName: string, dest: string, searchFromDir?: string) { + const fixturePath = pathToLocalPkg(fixtureName, searchFromDir) if (!fixturePath) throw new Error(`${fixtureName} not found`) return ncp(fixturePath, dest) } -export function pathToLocalPkg (pkgName: string) { - let dir = __dirname +export function pathToLocalPkg (pkgName: string, _dir?: string) { + let dir = _dir ?? __dirname const { root } = path.parse(dir) while (true) { const checkDir = path.join(dir, 'fixtures', pkgName)