feat(audit): add patched versions to minimumReleaseAgeExclude on audit --fix (#11216)

When `pnpm audit --fix` adds overrides to fix vulnerabilities, it now
also adds the minimum patched version for each advisory to
`minimumReleaseAgeExclude` in pnpm-workspace.yaml. This allows
`pnpm install` to install the security fix without waiting for it to
satisfy the minimumReleaseAge constraint.

Closes #10263
This commit is contained in:
Zoltan Kochan
2026-04-07 16:38:57 +02:00
committed by GitHub
parent 51b04c3e9a
commit 7721d2e7f0
7 changed files with 106 additions and 21 deletions

View File

@@ -0,0 +1,10 @@
---
"@pnpm/deps.compliance.commands": minor
"@pnpm/config.writer": minor
"@pnpm/workspace.workspace-manifest-writer": minor
"pnpm": minor
---
`pnpm audit --fix` now adds the minimum patched versions to `minimumReleaseAgeExclude` in `pnpm-workspace.yaml` [#10263](https://github.com/pnpm/pnpm/issues/10263).
When `minimumReleaseAge` is configured, security patches suggested by `pnpm audit` may be blocked because the patched versions are too new. Now, `pnpm audit --fix` automatically adds the minimum patched version for each vulnerability (e.g., `axios@0.21.2`) to `minimumReleaseAgeExclude`, so that `pnpm install` can install the security fix without waiting for it to mature.

View File

@@ -4,6 +4,7 @@ import { updateWorkspaceManifest } from '@pnpm/workspace.workspace-manifest-writ
export interface WriteSettingsOptions {
updatedSettings?: PnpmSettings
updatedOverrides?: Record<string, string>
addedMinimumReleaseAgeExcludes?: string[]
rootProjectManifest?: ProjectManifest
rootProjectManifestDir: string
workspaceDir: string
@@ -13,5 +14,6 @@ export async function writeSettings (opts: WriteSettingsOptions): Promise<void>
await updateWorkspaceManifest(opts.workspaceDir, {
updatedFields: opts.updatedSettings,
updatedOverrides: opts.updatedOverrides,
addedMinimumReleaseAgeExcludes: opts.addedMinimumReleaseAgeExcludes,
})
}

View File

@@ -146,6 +146,7 @@ export type AuditOptions = Pick<UniversalOptions, 'dir'> & {
ignoreUnfixable?: boolean
} & Pick<Config, 'auditConfig'
| 'auditLevel'
| 'minimumReleaseAge'
| 'ca'
| 'cert'
| 'httpProxy'
@@ -237,26 +238,34 @@ export async function handler (opts: AuditOptions): Promise<{ exitCode: number,
}
if (fixMethod === 'update') {
const result = await fixWithUpdate(auditReport, { ...opts, include })
let output = formatFixWithUpdateOutput(result, auditReport)
if (result.addedAgeExcludes.length > 0) {
output += `\n${result.addedAgeExcludes.length} entries were added to minimumReleaseAgeExclude to allow installing the patched versions:\n${result.addedAgeExcludes.join('\n')}\n`
}
return {
exitCode: result.remaining.length > 0 ? 1 : 0,
output: formatFixWithUpdateOutput(result, auditReport),
output,
}
}
if (fixMethod === 'override') {
const newOverrides = await fix(auditReport, opts)
if (Object.values(newOverrides).length === 0) {
const { vulnOverrides, addedAgeExcludes } = await fix(auditReport, opts)
if (Object.values(vulnOverrides).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.
let output = `${Object.values(vulnOverrides).length} overrides were added to pnpm-workspace.yaml to fix vulnerabilities.
Run "pnpm install" to apply the fixes.
The added overrides:
${JSON.stringify(newOverrides, null, 2)}`,
${JSON.stringify(vulnOverrides, null, 2)}`
if (addedAgeExcludes.length > 0) {
output += `\n\n${addedAgeExcludes.length} entries were added to minimumReleaseAgeExclude to allow installing the patched versions:\n${addedAgeExcludes.join('\n')}`
}
return {
exitCode: 0,
output,
}
}
if (opts.ignore !== undefined || opts.ignoreUnfixable) {

View File

@@ -1,31 +1,56 @@
import { writeSettings } from '@pnpm/config.writer'
import type { AuditAdvisory, AuditReport } from '@pnpm/deps.compliance.audit'
import { difference } from 'ramda'
import semver from 'semver'
import type { AuditOptions } from './audit.js'
export async function fix (auditReport: AuditReport, opts: AuditOptions): Promise<Record<string, string>> {
const vulnOverrides = createOverrides(Object.values(auditReport.advisories), opts.auditConfig?.ignoreCves)
if (Object.values(vulnOverrides).length === 0) return vulnOverrides
export interface FixResult {
vulnOverrides: Record<string, string>
addedAgeExcludes: string[]
}
export async function fix (auditReport: AuditReport, opts: AuditOptions): Promise<FixResult> {
const fixableAdvisories = getFixableAdvisories(Object.values(auditReport.advisories), opts.auditConfig?.ignoreCves)
const vulnOverrides = createOverrides(fixableAdvisories)
if (Object.values(vulnOverrides).length === 0) return { vulnOverrides, addedAgeExcludes: [] }
const addedAgeExcludes = opts.minimumReleaseAge ? createMinimumReleaseAgeExcludes(fixableAdvisories) : []
await writeSettings({
updatedOverrides: vulnOverrides,
addedMinimumReleaseAgeExcludes: addedAgeExcludes.length > 0 ? addedAgeExcludes : undefined,
rootProjectManifest: opts.rootProjectManifest,
rootProjectManifestDir: opts.rootProjectManifestDir,
workspaceDir: opts.workspaceDir ?? opts.rootProjectManifestDir,
})
return vulnOverrides
return { vulnOverrides, addedAgeExcludes }
}
function createOverrides (advisories: AuditAdvisory[], ignoreCves?: string[]): Record<string, string> {
function getFixableAdvisories (advisories: AuditAdvisory[], ignoreCves?: string[]): AuditAdvisory[] {
if (ignoreCves) {
advisories = advisories.filter(({ cves }) => difference(cves, ignoreCves).length > 0)
}
return advisories
.filter(({ vulnerable_versions: vulnerableVersions, patched_versions: patchedVersions }) => vulnerableVersions !== '>=0.0.0' && patchedVersions !== '<0.0.0')
}
function createOverrides (advisories: AuditAdvisory[]): Record<string, string> {
return Object.fromEntries(
advisories
.filter(({ vulnerable_versions: vulnerableVersions, patched_versions: patchedVersions }) => vulnerableVersions !== '>=0.0.0' && patchedVersions !== '<0.0.0')
.map((advisory) => [
`${advisory.module_name}@${advisory.vulnerable_versions}`,
advisory.patched_versions,
])
advisories.map((advisory) => [
`${advisory.module_name}@${advisory.vulnerable_versions}`,
advisory.patched_versions,
])
)
}
export function createMinimumReleaseAgeExcludes (advisories: AuditAdvisory[]): string[] {
const excludes = new Set<string>()
for (const advisory of advisories) {
if (advisory.patched_versions === '<0.0.0') continue
if (advisory.vulnerable_versions === '>=0.0.0' || advisory.vulnerable_versions === '*') continue
const minVersion = semver.minVersion(advisory.patched_versions)
if (minVersion) {
excludes.add(`${advisory.module_name}@${minVersion.version}`)
}
}
return Array.from(excludes)
}

View File

@@ -1,3 +1,4 @@
import { writeSettings } from '@pnpm/config.writer'
import { WANTED_LOCKFILE } from '@pnpm/constants'
import type { AuditReport } from '@pnpm/deps.compliance.audit'
import { PnpmError } from '@pnpm/error'
@@ -12,6 +13,7 @@ import type {
import semver from 'semver'
import type { AuditOptions } from './audit.js'
import { createMinimumReleaseAgeExcludes } from './fix.js'
import { lockfileToPackages } from './lockfileToPackages.js'
interface ExtendedPackageVulnerability {
@@ -25,6 +27,8 @@ export interface FixWithUpdateResult {
fixed: number[]
// IDs of packages that could not be fixed
remaining: number[]
// Entries added to minimumReleaseAgeExclude
addedAgeExcludes: string[]
}
export type FixWithUpdateOptions = AuditOptions & {
@@ -86,8 +90,23 @@ export async function fixWithUpdate (auditReport: AuditReport, opts: FixWithUpda
},
}
// Add minimum patched versions to minimumReleaseAgeExclude so the resolver
// can install them even when minimumReleaseAge would otherwise block them.
const addedAgeExcludes = opts.minimumReleaseAge ? createMinimumReleaseAgeExcludes(Object.values(auditReport.advisories)) : []
const updateOpts = { ...opts } as Record<string, unknown>
if (addedAgeExcludes.length > 0) {
const existing = (updateOpts.minimumReleaseAgeExclude as string[] | undefined) ?? []
updateOpts.minimumReleaseAgeExclude = [...existing, ...addedAgeExcludes]
await writeSettings({
addedMinimumReleaseAgeExcludes: addedAgeExcludes,
rootProjectManifest: opts.rootProjectManifest,
rootProjectManifestDir: opts.rootProjectManifestDir,
workspaceDir: opts.workspaceDir ?? opts.rootProjectManifestDir,
})
}
await update.handler({
...opts,
...updateOpts as FixWithUpdateOptions,
packageVulnerabilityAudit,
}, [])
@@ -136,5 +155,5 @@ export async function fixWithUpdate (auditReport: AuditReport, opts: FixWithUpda
}
}
return { fixed, remaining }
return { fixed, remaining, addedAgeExcludes }
}

View File

@@ -28,6 +28,7 @@ test('overrides are added for vulnerable dependencies', async () => {
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
minimumReleaseAge: 1440,
dir: tmp,
rootProjectManifestDir: tmp,
fix: true,
@@ -35,10 +36,19 @@ test('overrides are added for vulnerable dependencies', async () => {
expect(exitCode).toBe(0)
expect(output).toMatch(/Run "pnpm install"/)
expect(output).toContain('entries were added to minimumReleaseAgeExclude')
const manifest = readYamlFileSync<{ overrides?: Record<string, string> }>(path.join(tmp, 'pnpm-workspace.yaml'))
const manifest = readYamlFileSync<{ overrides?: Record<string, string>, minimumReleaseAgeExclude?: string[] }>(path.join(tmp, 'pnpm-workspace.yaml'))
expect(manifest.overrides?.['axios@<=0.18.0']).toBe('>=0.18.1')
expect(manifest.overrides?.['sync-exec@>=0.0.0']).toBeFalsy()
// minimumReleaseAgeExclude should contain the minimum patched versions
expect(manifest.minimumReleaseAgeExclude).toContain('axios@0.18.1')
expect(manifest.minimumReleaseAgeExclude).toContain('axios@0.21.1')
expect(manifest.minimumReleaseAgeExclude).toContain('axios@0.21.2')
// unfixable advisories (patched_versions: "<0.0.0") should not be included
expect(manifest.minimumReleaseAgeExclude).not.toContain('sync-exec@0.0.0')
expect(manifest.minimumReleaseAgeExclude).not.toContain('timespan@0.0.0')
})
test('no overrides are added if no vulnerabilities are found', async () => {

View File

@@ -46,6 +46,7 @@ export async function updateWorkspaceManifest (dir: string, opts: {
updatedFields?: Partial<WorkspaceManifest>
updatedCatalogs?: Catalogs
updatedOverrides?: Record<string, string>
addedMinimumReleaseAgeExcludes?: string[]
fileName?: FileName
cleanupUnusedCatalogs?: boolean
allProjects?: Project[]
@@ -88,6 +89,15 @@ export async function updateWorkspaceManifest (dir: string, opts: {
}
}
}
if (opts.addedMinimumReleaseAgeExcludes?.length) {
const existing: string[] = manifest.minimumReleaseAgeExclude ?? []
const existingSet = new Set(existing)
const newEntries = [...new Set(opts.addedMinimumReleaseAgeExcludes)].filter((entry) => !existingSet.has(entry))
if (newEntries.length > 0) {
shouldBeUpdated = true
manifest.minimumReleaseAgeExclude = [...existing, ...newEntries]
}
}
if (!shouldBeUpdated) {
return
}