mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-29 11:34:18 -04:00
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:
10
.changeset/audit-fix-minimum-release-age-exclude.md
Normal file
10
.changeset/audit-fix-minimum-release-age-exclude.md
Normal 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.
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
23
deps/compliance/commands/src/audit/audit.ts
vendored
23
deps/compliance/commands/src/audit/audit.ts
vendored
@@ -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) {
|
||||
|
||||
47
deps/compliance/commands/src/audit/fix.ts
vendored
47
deps/compliance/commands/src/audit/fix.ts
vendored
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
12
deps/compliance/commands/test/audit/fix.ts
vendored
12
deps/compliance/commands/test/audit/fix.ts
vendored
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user