feat(audit): add --ignore and --ignore-unfixable flags (#8474)

* feat(audit): add --ignore-vulnerabilities flag for CVE

* fix: no need for changes

* feat: add changeset

* fix: need the GHSA id

* docs: update changeset

* test: fix

* test: fix

* feat(audit): --ignore and --ignore-unfixable

* refactor: change output

* fix: reading audit settings from pnpm-workspace.yaml

* test: ignoring a list of cves

* docs: add changeset

---------

Co-authored-by: Ian Krieger <ian.krieger@gc.com@mac.lan>
Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Ian Krieger
2025-05-07 11:59:35 -04:00
committed by GitHub
parent fdb1d98f4d
commit 5ec7255b2a
7 changed files with 263 additions and 39 deletions

View File

@@ -0,0 +1,5 @@
---
"@pnpm/types": minor
---
Export AuditConfig.

View File

@@ -0,0 +1,18 @@
---
"@pnpm/plugin-commands-audit": minor
"pnpm": minor
---
Added two new flags to the `pnpm audit` command, `--ignore` and `--ignore-unfixable` [#8474](https://github.com/pnpm/pnpm/pull/8474).
Ignore all vulnerabilities that have no solution:
```shell
> pnpm audit --ignore-unfixable
```
Provide a list of CVE's to ignore those specifically, even if they have a resolution.
```shell
> pnpm audit --ignore=CVE-2021-1234 --ignore=CVE-2021-5678
```

View File

@@ -13,6 +13,7 @@ import pick from 'ramda/src/pick'
import pickBy from 'ramda/src/pickBy'
import renderHelp from 'render-help'
import { fix } from './fix'
import { ignore } from './ignore'
// eslint-disable
const AUDIT_LEVEL_NUMBER = {
@@ -57,6 +58,8 @@ export function cliOptionsTypes (): Record<string, unknown> {
'audit-level': ['low', 'moderate', 'high', 'critical'],
fix: Boolean,
'ignore-registry-errors': Boolean,
ignore: [String, Array],
'ignore-unfixable': Boolean,
}
}
@@ -105,6 +108,14 @@ export function help (): string {
description: 'Use exit code 0 if the registry responds with an error. Useful when audit checks are used in CI. A build should fail because the registry has issues.',
name: '--ignore-registry-errors',
},
{
description: 'Ignore a vulnerability by CVE',
name: '--ignore <vulnerability>',
},
{
description: 'Ignore all CVEs with no resolution',
name: '--ignore-unfixable',
},
],
},
],
@@ -120,6 +131,8 @@ export type AuditOptions = Pick<UniversalOptions, 'dir'> & {
json?: boolean
lockfileDir?: string
registries: Registries
ignore?: string[]
ignoreUnfixable?: boolean
} & Pick<Config, 'auditConfig'
| 'ca'
| 'cert'
@@ -213,6 +226,29 @@ The added overrides:
${JSON.stringify(newOverrides, null, 2)}`,
}
}
if (opts.ignore !== undefined || opts.ignoreUnfixable) {
const newIgnores = await ignore({
auditConfig: opts.auditConfig,
auditReport,
ignore: opts.ignore,
ignoreUnfixable: opts.ignoreUnfixable === true,
dir: opts.dir,
rootProjectManifest: opts.rootProjectManifest,
rootProjectManifestDir: opts.rootProjectManifestDir,
workspaceDir: opts.workspaceDir ?? opts.rootProjectManifestDir,
})
if (newIgnores.length === 0) {
return {
exitCode: 0,
output: 'No new vulnerabilities were ignored',
}
}
return {
exitCode: 0,
output: `${newIgnores.length} new vulnerabilities were ignored:
${newIgnores.join('\n')}`,
}
}
const vulnerabilities = auditReport.metadata.vulnerabilities
const ignoredVulnerabilities: IgnoredAuditVulnerabilityCounts = {
low: 0,
@@ -222,7 +258,7 @@ ${JSON.stringify(newOverrides, null, 2)}`,
}
const totalVulnerabilityCount = Object.values(vulnerabilities)
.reduce((sum: number, vulnerabilitiesCount: number) => sum + vulnerabilitiesCount, 0)
const ignoreGhsas = opts.rootProjectManifest?.pnpm?.auditConfig?.ignoreGhsas
const ignoreGhsas = opts.auditConfig?.ignoreGhsas
if (ignoreGhsas) {
// eslint-disable-next-line @typescript-eslint/naming-convention
auditReport.advisories = pickBy(({ github_advisory_id, severity }) => {
@@ -233,7 +269,7 @@ ${JSON.stringify(newOverrides, null, 2)}`,
return false
}, auditReport.advisories)
}
const ignoreCves = opts.rootProjectManifest?.pnpm?.auditConfig?.ignoreCves
const ignoreCves = opts.auditConfig?.ignoreCves
if (ignoreCves) {
auditReport.advisories = pickBy(({ cves, severity }) => {
if (cves.length === 0 || difference(cves, ignoreCves).length > 0) {

View File

@@ -0,0 +1,47 @@
import { type AuditAdvisory, type AuditReport } from '@pnpm/audit'
import { type ProjectManifest, type AuditConfig } from '@pnpm/types'
import { writeSettings } from '@pnpm/config.config-writer'
import difference from 'ramda/src/difference'
export interface IgnoreVulnerabilitiesOptions {
dir: string
ignore?: string[]
ignoreUnfixable: boolean
auditReport: AuditReport
rootProjectManifest?: ProjectManifest
rootProjectManifestDir: string
workspaceDir: string
auditConfig?: AuditConfig
}
export async function ignore (opts: IgnoreVulnerabilitiesOptions): Promise<string[]> {
const currentCves = opts?.auditConfig?.ignoreCves ?? []
const currentUniqueCves = new Set(currentCves)
const advisoryWthNoResolutions = filterAdvisoriesWithNoResolutions(Object.values(opts.auditReport.advisories))
if (opts.ignoreUnfixable) {
Object.values(advisoryWthNoResolutions).forEach((advisory: AuditAdvisory) => {
advisory.cves.forEach((cve) => currentUniqueCves.add(cve))
})
} else {
opts.ignore?.forEach((cve) => currentUniqueCves.add(cve))
}
const newIgnoreCves = currentUniqueCves.size > 0 ? Array.from(currentUniqueCves) : undefined
const diffCve = difference(newIgnoreCves ?? [], currentCves)
await writeSettings({
...opts,
updatedSettings: {
auditConfig: {
...opts.auditConfig,
ignoreCves: newIgnoreCves,
},
},
})
return [...diffCve]
}
function filterAdvisoriesWithNoResolutions (advisories: AuditAdvisory[]) {
// eslint-disable-next-line @typescript-eslint/naming-convention
return advisories.filter(({ patched_versions }) => patched_versions === '<0.0.0')
}

View File

@@ -0,0 +1,125 @@
import path from 'path'
import { fixtures } from '@pnpm/test-fixtures'
import { audit } from '@pnpm/plugin-commands-audit'
import nock from 'nock'
import { sync as readYamlFile } from 'read-yaml-file'
import * as responses from './utils/responses'
const f = fixtures(__dirname)
const registries = {
default: 'https://registry.npmjs.org/',
}
const rawConfig = {
registry: registries.default,
}
test('ignores are added for vulnerable dependencies with no resolutions', async () => {
const tmp = f.prepare('has-vulnerabilities')
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
fix: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: 120,
ignoreUnfixable: true,
})
expect(exitCode).toBe(0)
expect(output).toContain('2 new vulnerabilities were ignored')
const manifest = readYamlFile<any>(path.join(tmp, 'pnpm-workspace.yaml')) // eslint-disable-line
const cveList = manifest.auditConfig?.ignoreCves
expect(cveList?.length).toBe(2)
expect(cveList).toStrictEqual(expect.arrayContaining(['CVE-2017-16115', 'CVE-2017-16024']))
})
test('the specified vulnerabilities are ignored', async () => {
const tmp = f.prepare('has-vulnerabilities')
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
fix: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: 120,
ignore: ['CVE-2017-16115'],
})
expect(exitCode).toBe(0)
expect(output).toContain('1 new vulnerabilities were ignored')
const manifest = readYamlFile<any>(path.join(tmp, 'pnpm-workspace.yaml')) // eslint-disable-line
expect(manifest.auditConfig?.ignoreCves).toStrictEqual(['CVE-2017-16115'])
})
test('no ignores are added if no vulnerabilities are found', async () => {
const tmp = f.prepare('fixture')
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.NO_VULN_RESP)
const { exitCode, output } = await audit.handler({
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
fix: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: 120,
ignoreUnfixable: true,
})
expect(exitCode).toBe(0)
expect(output).toBe('No new vulnerabilities were ignored')
})
test('ignored CVEs are not duplicated', async () => {
const tmp = f.prepare('has-vulnerabilities')
const existingCves = [
'CVE-2019-10742',
'CVE-2020-7598',
'CVE-2017-16115',
'CVE-2017-16024',
]
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
auditLevel: 'moderate',
auditConfig: {
ignoreCves: existingCves,
},
dir: tmp,
rootProjectManifestDir: tmp,
fix: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: 120,
ignoreUnfixable: true,
})
expect(exitCode).toBe(0)
expect(output).toBe('No new vulnerabilities were ignored')
const manifest = readYamlFile<any>(path.join(tmp, 'pnpm-workspace.yaml')) // eslint-disable-line
expect(manifest.auditConfig?.ignoreCves).toStrictEqual(expect.arrayContaining(existingCves))
})

View File

@@ -267,17 +267,14 @@ describe('plugin-commands-audit', () => {
userConfig: {},
rawConfig,
registries,
rootProjectManifest: {
pnpm: {
auditConfig: {
ignoreCves: [
'CVE-2019-10742',
'CVE-2020-28168',
'CVE-2021-3749',
'CVE-2020-7598',
],
},
},
rootProjectManifest: {},
auditConfig: {
ignoreCves: [
'CVE-2019-10742',
'CVE-2020-28168',
'CVE-2021-3749',
'CVE-2020-7598',
],
},
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
@@ -300,17 +297,14 @@ describe('plugin-commands-audit', () => {
userConfig: {},
rawConfig,
registries,
rootProjectManifest: {
pnpm: {
auditConfig: {
ignoreGhsas: [
'GHSA-42xw-2xvc-qx8m',
'GHSA-4w2v-q235-vp99',
'GHSA-cph5-m8f7-6c5x',
'GHSA-vh95-rmgr-6w4m',
],
},
},
rootProjectManifest: {},
auditConfig: {
ignoreGhsas: [
'GHSA-42xw-2xvc-qx8m',
'GHSA-4w2v-q235-vp99',
'GHSA-cph5-m8f7-6c5x',
'GHSA-vh95-rmgr-6w4m',
],
},
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
@@ -334,17 +328,14 @@ describe('plugin-commands-audit', () => {
userConfig: {},
rawConfig,
registries,
rootProjectManifest: {
pnpm: {
auditConfig: {
ignoreCves: [
'CVE-2019-10742',
'CVE-2020-28168',
'CVE-2021-3749',
'CVE-2020-7598',
],
},
},
rootProjectManifest: {},
auditConfig: {
ignoreCves: [
'CVE-2019-10742',
'CVE-2020-28168',
'CVE-2021-3749',
'CVE-2020-7598',
],
},
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})

View File

@@ -135,6 +135,11 @@ export type AllowedDeprecatedVersions = Record<string, string>
export type ConfigDependencies = Record<string, string>
export interface AuditConfig {
ignoreCves?: string[]
ignoreGhsas?: string[]
}
export interface PnpmSettings {
configDependencies?: ConfigDependencies
neverBuiltDependencies?: string[]
@@ -153,10 +158,7 @@ export interface PnpmSettings {
updateConfig?: {
ignoreDependencies?: string[]
}
auditConfig?: {
ignoreCves?: string[]
ignoreGhsas?: string[]
}
auditConfig?: AuditConfig
requiredScripts?: string[]
supportedArchitectures?: SupportedArchitectures
executionEnv?: ExecutionEnv