feat(audit): add fix update mode (#10341)

* feat(audit): add fix update mode

Add the ability to fix vulnerabilities by updating packages in the
lockfile instead of adding overrides.

* revert: remove audit-registry parameter

* fix: properly invoke audit command recursively on workspace

* fix: negative weight version priority & top-level pinned dep updating

* refactor: apply packageVulnerabilityAudit version preferences earlier

* chore: update changeset

* fix: vulnerability penalties are greater than direct dep weight

* test: use nock on mock registry directly

* fix: exit with 1 if it can't resolve all vulnerabilities to match npm

* fix: properly update workspace top-level pinned vulnerable dependencies

* fix: update lockfile

* fix: update vulnerabilities in catalogs

* chore: sync pnpm-lock.yaml with main
This commit is contained in:
Jason Paulos
2026-03-12 16:42:49 -04:00
committed by GitHub
parent 6fcf5bafbd
commit 15549a9445
60 changed files with 2659 additions and 218 deletions

View File

@@ -0,0 +1,12 @@
---
"@pnpm/plugin-commands-installation": minor
"@pnpm/resolve-dependencies": minor
"@pnpm/plugin-commands-audit": minor
"@pnpm/npm-resolver": minor
"@pnpm/core": minor
"@pnpm/types": minor
"@pnpm-private/updater": minor
"pnpm": minor
---
Add the ability to fix vulnerabilities by updating packages in the lockfile instead of adding overrides.

View File

@@ -269,6 +269,7 @@ async function updateManifest (workspaceDir: string, manifest: ProjectManifest,
case '@pnpm/plugin-commands-script-runners':
case '@pnpm/plugin-commands-store':
case '@pnpm/plugin-commands-deploy':
case '@pnpm/plugin-commands-audit':
case CLI_PKG_NAME:
case '@pnpm/core': {
preset = '@pnpm/jest-config/with-registry'

View File

@@ -177,6 +177,7 @@
"noent",
"nonexec",
"noninjected",
"nonvulnerable",
"nopadding",
"noproxy",
"nosystem",

View File

@@ -40,20 +40,27 @@
"@pnpm/constants": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/lockfile.utils": "workspace:*",
"@pnpm/lockfile.walker": "workspace:*",
"@pnpm/network.auth-header": "workspace:*",
"@pnpm/plugin-commands-installation": "workspace:*",
"@pnpm/read-project-manifest": "workspace:*",
"@pnpm/types": "workspace:*",
"@zkochan/table": "catalog:",
"chalk": "catalog:",
"memoize": "catalog:",
"ramda": "catalog:",
"render-help": "catalog:"
"render-help": "catalog:",
"semver": "catalog:"
},
"devDependencies": {
"@pnpm/filter-workspace-packages": "workspace:*",
"@pnpm/plugin-commands-audit": "workspace:*",
"@pnpm/plugin-commands-installation": "workspace:*",
"@pnpm/registry-mock": "catalog:",
"@pnpm/test-fixtures": "workspace:*",
"@types/ramda": "catalog:",
"@types/semver": "catalog:",
"@types/zkochan__table": "catalog:",
"load-json-file": "catalog:",
"nock": "catalog:",
@@ -64,6 +71,6 @@
"node": ">=22.13"
},
"jest": {
"preset": "@pnpm/jest-config"
"preset": "@pnpm/jest-config/with-registry"
}
}

View File

@@ -1,4 +1,4 @@
import { audit, type AuditLevelNumber, type AuditLevelString, type AuditReport, type AuditVulnerabilityCounts, type IgnoredAuditVulnerabilityCounts } from '@pnpm/audit'
import { audit, type AuditLevelNumber, type AuditLevelString, type AuditReport, type AuditAdvisory, type AuditVulnerabilityCounts, type IgnoredAuditVulnerabilityCounts } from '@pnpm/audit'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import { docsUrl, TABLE_OPTIONS } from '@pnpm/cli-utils'
import { type Config, types as allTypes, type UniversalOptions } from '@pnpm/config'
@@ -6,14 +6,15 @@ import { WANTED_LOCKFILE } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import { readEnvLockfile, readWantedLockfile } from '@pnpm/lockfile.fs'
import type { Registries } from '@pnpm/types'
import { update, type InstallCommandOptions } from '@pnpm/plugin-commands-installation'
import { table } from '@zkochan/table'
import chalk, { type ChalkInstance } from 'chalk'
import { difference, pick, pickBy } from 'ramda'
import { renderHelp } from 'render-help'
import { fix } from './fix.js'
import { fixWithUpdate, type FixWithUpdateResult } from './fixWithUpdate.js'
import { ignore } from './ignore.js'
// eslint-disable
const AUDIT_LEVEL_NUMBER = {
low: 0,
moderate: 1,
@@ -37,14 +38,12 @@ const AUDIT_TABLE_OPTIONS = {
},
},
}
// eslint-enable
const MAX_PATHS_COUNT = 3
export const rcOptionsTypes = cliOptionsTypes
export function cliOptionsTypes (): Record<string, unknown> {
export function rcOptionsTypes (): Record<string, unknown> {
return {
...update.rcOptionsTypes(),
...pick([
'dev',
'json',
@@ -54,13 +53,25 @@ export function cliOptionsTypes (): Record<string, unknown> {
'registry',
], allTypes),
'audit-level': ['low', 'moderate', 'high', 'critical'],
fix: Boolean,
// For fix, use String instead of a list of allowed string values.
// Otherwise, an unexpected value will get coerced to true because of the Boolean type.
fix: [String, Boolean],
'ignore-registry-errors': Boolean,
ignore: [String, Array],
'ignore-unfixable': Boolean,
}
}
export function cliOptionsTypes (): Record<string, unknown> {
return {
...pick([
'recursive',
'workspace',
], update.cliOptionsTypes()),
...rcOptionsTypes(),
}
}
export const shorthands: Record<string, string> = {
D: '--dev',
P: '--production',
@@ -77,8 +88,8 @@ export function help (): string {
list: [
{
description: 'Add overrides to the package.json file in order to force non-vulnerable versions of the dependencies',
name: '--fix',
description: 'Fix the audited vulnerabilities using the specified method: "override" or "update". "override" adds overrides to the package.json file in order to force non-vulnerable versions of the dependencies. "update" attempts to update the vulnerable packages in the lockfile to non-vulnerable versions. If no method is specified, "override" is used by default.',
name: '--fix [method]',
},
{
description: 'Output audit report in JSON format',
@@ -123,7 +134,7 @@ export function help (): string {
}
export type AuditOptions = Pick<UniversalOptions, 'dir'> & {
fix?: boolean
fix?: boolean | 'override' | 'update'
ignoreRegistryErrors?: boolean
json?: boolean
lockfileDir?: string
@@ -156,7 +167,9 @@ export type AuditOptions = Pick<UniversalOptions, 'dir'> & {
| 'rootProjectManifestDir'
| 'virtualStoreDirMaxLength'
| 'workspaceDir'
>
> & InstallCommandOptions
const DEFAULT_FIX_METHOD = 'override'
export async function handler (opts: AuditOptions): Promise<{ exitCode: number, output: string }> {
const lockfileDir = opts.lockfileDir ?? opts.dir
@@ -209,7 +222,24 @@ export async function handler (opts: AuditOptions): Promise<{ exitCode: number,
throw err
}
if (opts.fix) {
let fixMethod: 'update' | 'override' | undefined
if (opts.fix === 'update' || opts.fix === 'override') {
fixMethod = opts.fix
} else if (opts.fix === true) {
fixMethod = DEFAULT_FIX_METHOD
} else if (!opts.fix) {
fixMethod = undefined
} else {
throw new PnpmError('INVALID_FIX_OPTION', `Invalid value for --fix: ${opts.fix as string}. Should be one of "override" or "update"`)
}
if (fixMethod === 'update') {
const result = await fixWithUpdate(auditReport, { ...opts, include })
return {
exitCode: result.remaining.length > 0 ? 1 : 0,
output: formatFixWithUpdateOutput(result, auditReport),
}
}
if (fixMethod === 'override') {
const newOverrides = await fix(auditReport, opts)
if (Object.values(newOverrides).length === 0) {
return {
@@ -327,3 +357,57 @@ function reportSummary (vulnerabilities: AuditVulnerabilityCounts, totalVulnerab
.join(' | ')
}`
}
export function formatFixWithUpdateOutput (result: FixWithUpdateResult, auditReport: AuditReport): string {
const output: string[] = []
interface IdAndAdvisory {
id: number
advisory?: AuditAdvisory
}
/**
* Sort the given array of advisory IDs by severity descending
*/
function sortBySeverity (ids: number[]): IdAndAdvisory[] {
return ids.map(id => ({ id, advisory: auditReport.advisories[id] })).sort((a, b) => {
const aValue = a.advisory ? AUDIT_LEVEL_NUMBER[a.advisory.severity] : -1
const bValue = b.advisory ? AUDIT_LEVEL_NUMBER[b.advisory.severity] : -1
return bValue - aValue
})
}
const fixed = sortBySeverity(result.fixed)
const remaining = sortBySeverity(result.remaining)
const fixedString = fixed.length === 1 ? 'vulnerability was fixed' : 'vulnerabilities were fixed'
const remainingString = remaining.length === 1 ? 'vulnerability remains' : 'vulnerabilities remain'
output.push(`${chalk.green(fixed.length)} ${fixedString}, ${chalk.red(remaining.length)} ${remainingString}.`)
function summarizeAdvisory (fixed: boolean, { id, advisory }: IdAndAdvisory): string {
if (advisory) {
const color = fixed ? chalk.green : AUDIT_COLOR[advisory.severity]
return `- (${color(advisory.severity)}) "${color(advisory.title)}" ${chalk.blue(advisory.module_name)}`
}
return `- Advisory with ID ${id} (details not found in the audit report)`
}
if (fixed.length > 0) {
output.push('\nThe fixed vulnerabilities are:')
for (const f of fixed) {
output.push(summarizeAdvisory(true, f))
}
}
if (remaining.length > 0) {
output.push('\nThe remaining vulnerabilities are:')
for (const r of remaining) {
output.push(summarizeAdvisory(false, r))
}
}
// Add trailing newline
output.push('')
return output.join('\n')
}

View File

@@ -0,0 +1,139 @@
import type { AuditReport } from '@pnpm/audit'
import type {
VulnerabilitySeverity,
PackageVulnerability,
PackageVulnerabilityAudit,
DependenciesField,
} from '@pnpm/types'
import { update } from '@pnpm/plugin-commands-installation'
import semver from 'semver'
import { WANTED_LOCKFILE } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import { readWantedLockfile } from '@pnpm/lockfile.fs'
import type { AuditOptions } from './audit.js'
import { lockfileToPackages } from './lockfileToPackages.js'
interface ExtendedPackageVulnerability {
vulnerability: PackageVulnerability
id: number
semverRange?: semver.Range
}
export interface FixWithUpdateResult {
// IDs of packages that were fixed
fixed: number[]
// IDs of packages that could not be fixed
remaining: number[]
}
export type FixWithUpdateOptions = AuditOptions & {
include?: { [dependenciesField in DependenciesField]: boolean }
}
export async function fixWithUpdate (auditReport: AuditReport, opts: FixWithUpdateOptions): Promise<FixWithUpdateResult> {
const vulnerabilitiesByPackage = new Map<string, ExtendedPackageVulnerability[]>()
const unfixableVulnerabilities = new Map<string, Set<number>>()
for (const advisory of Object.values(auditReport.advisories)) {
let packageVulnerabilities = vulnerabilitiesByPackage.get(advisory.module_name)
if (!packageVulnerabilities) {
packageVulnerabilities = []
vulnerabilitiesByPackage.set(advisory.module_name, packageVulnerabilities)
}
const severity: VulnerabilitySeverity = advisory.severity
const versionRange = advisory.vulnerable_versions
if (versionRange === '>=0.0.0' || versionRange === '*') {
// skip unfixable vulnerabilities
let unfixableForPackage = unfixableVulnerabilities.get(advisory.module_name)
if (!unfixableForPackage) {
unfixableForPackage = new Set()
unfixableVulnerabilities.set(advisory.module_name, unfixableForPackage)
}
unfixableForPackage.add(advisory.id)
continue
}
packageVulnerabilities.push({
vulnerability: {
versionRange,
severity,
},
id: advisory.id,
})
}
const packageVulnerabilityAudit: PackageVulnerabilityAudit = {
isVulnerable (packageName: string, version: string): boolean {
const vulnerabilities = vulnerabilitiesByPackage.get(packageName)
if (!vulnerabilities) return false
for (const vulnerabilityWithRange of vulnerabilities) {
let { semverRange } = vulnerabilityWithRange
if (!semverRange) {
semverRange = new semver.Range(vulnerabilityWithRange.vulnerability.versionRange)
vulnerabilityWithRange.semverRange = semverRange
}
if (semver.satisfies(version, semverRange)) {
return true
}
}
return false
},
getVulnerabilities (): Map<string, PackageVulnerability[]> {
const allVulnerabilities = new Map<string, PackageVulnerability[]>()
for (const [pkgName, vulnerabilities] of vulnerabilitiesByPackage) {
allVulnerabilities.set(pkgName, vulnerabilities.map(v => v.vulnerability))
}
return allVulnerabilities
},
}
await update.handler({
...opts,
packageVulnerabilityAudit,
}, [])
const lockfileDir = opts.lockfileDir ?? opts.dir
const lockfile = await readWantedLockfile(lockfileDir, { ignoreIncompatible: true })
if (lockfile == null) {
throw new PnpmError('AUDIT_NO_LOCKFILE', `No ${WANTED_LOCKFILE} found after update: Cannot report fixed vulnerabilities`)
}
const updatedPackages = lockfileToPackages(lockfile, { include: opts.include })
const fixed: number[] = []
const remaining: number[] = []
for (const [pkgName, vulnerabilities] of vulnerabilitiesByPackage) {
const updatedVersions = updatedPackages.get(pkgName)
if (!updatedVersions) {
fixed.push(...vulnerabilities.map(v => v.id))
continue
}
for (const vulnerability of vulnerabilities) {
let wasFixed = true
for (const updatedVersion of updatedVersions) {
let { semverRange } = vulnerability
if (!semverRange) {
semverRange = new semver.Range(vulnerability.vulnerability.versionRange)
vulnerability.semverRange = semverRange
}
if (semver.satisfies(updatedVersion, semverRange)) {
wasFixed = false
break
}
}
if (wasFixed) {
fixed.push(vulnerability.id)
} else {
remaining.push(vulnerability.id)
}
}
}
for (const [pkgName, unfixableIds] of unfixableVulnerabilities) {
if (updatedPackages.has(pkgName)) {
remaining.push(...unfixableIds)
} else {
fixed.push(...unfixableIds)
}
}
return { fixed, remaining }
}

View File

@@ -0,0 +1,31 @@
import type { LockfileObject } from '@pnpm/lockfile.types'
import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils'
import { lockfileWalkerGroupImporterSteps, type LockfileWalkerStep } from '@pnpm/lockfile.walker'
import type { DependenciesField, ProjectId } from '@pnpm/types'
export function lockfileToPackages (
lockfile: LockfileObject,
opts: {
include?: { [dependenciesField in DependenciesField]: boolean }
}
): Map<string, Set<string>> {
const importerWalkers = lockfileWalkerGroupImporterSteps(lockfile, Object.keys(lockfile.importers) as ProjectId[], { include: opts?.include })
const packages = new Map<string, Set<string>>()
for (const importerWalker of importerWalkers) {
addPackages(packages, importerWalker.step)
}
return packages
}
function addPackages (packages: Map<string, Set<string>>, step: LockfileWalkerStep) {
for (const { depPath, pkgSnapshot, next } of step.dependencies) {
const { name, version } = nameVerFromPkgSnapshot(depPath, pkgSnapshot)
if (version != null) {
if (!packages.has(name)) {
packages.set(name, new Set())
}
packages.get(name)!.add(version)
}
addPackages(packages, next())
}
}

View File

@@ -4,31 +4,23 @@ import { audit } from '@pnpm/plugin-commands-audit'
import { readYamlFileSync } from 'read-yaml-file'
import nock from 'nock'
import * as responses from './utils/responses/index.js'
import { AUDIT_REGISTRY_OPTS, AUDIT_REGISTRY } from './utils/options.js'
const f = fixtures(import.meta.dirname)
const registries = {
default: 'https://registry.npmjs.org/',
}
const rawConfig = {
registry: registries.default,
}
test('overrides are added for vulnerable dependencies', async () => {
const tmp = f.prepare('has-vulnerabilities')
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
fix: true,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(0)
@@ -42,19 +34,16 @@ test('overrides are added for vulnerable dependencies', async () => {
test('no overrides are added if no vulnerabilities are found', async () => {
const tmp = f.prepare('fixture')
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.NO_VULN_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
fix: true,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(0)
@@ -64,11 +53,12 @@ test('no overrides are added if no vulnerabilities are found', async () => {
test('CVEs found in the allow list are not added as overrides', async () => {
const tmp = f.prepare('has-vulnerabilities')
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
auditConfig: {
ignoreCves: [
@@ -81,10 +71,6 @@ test('CVEs found in the allow list are not added as overrides', async () => {
dir: tmp,
rootProjectManifestDir: tmp,
fix: true,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(0)
expect(output).toMatch(/Run "pnpm install"/)

View File

@@ -0,0 +1,795 @@
import { join } from 'path'
import { readFile } from 'fs/promises'
import { fixtures } from '@pnpm/test-fixtures'
import { audit } from '@pnpm/plugin-commands-audit'
import { readWantedLockfile } from '@pnpm/lockfile.fs'
import { readProjectManifest } from '@pnpm/read-project-manifest'
import { filterPackagesFromDir } from '@pnpm/filter-workspace-packages'
import type { DepPath } from '@pnpm/types'
import { addDistTag } from '@pnpm/registry-mock'
import chalk from 'chalk'
import nock from 'nock'
import { readYamlFileSync } from 'read-yaml-file'
import { MOCK_REGISTRY, MOCK_REGISTRY_OPTS } from './utils/options.js'
const f = fixtures(import.meta.dirname)
describe('audit fix with update', () => {
afterEach(() => nock.cleanAll())
test('top-level vulnerability is fixed by updating the vulnerable package', async () => {
const tmp = f.prepare('update-single-depth-2')
const originalPkgId = '@pnpm.e2e/pkg-with-1-dep@100.0.0' as DepPath
const expectedPkgId = '@pnpm.e2e/pkg-with-1-dep@100.1.0' as DepPath
const { manifest: originalManifest } = await readProjectManifest(tmp)
expect(originalManifest).toBeTruthy()
expect(originalManifest.dependencies).toBeDefined()
expect(originalManifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('^100.0.0')
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![originalPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedPkgId]).toBeUndefined()
const mockResponse = await readFile(join(tmp, 'responses', 'top-level-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
rootProjectManifestDir: tmp,
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(output).toBe(`${chalk.green(1)} vulnerability was fixed, ${chalk.red(0)} vulnerabilities remain.
The fixed vulnerabilities are:
- (${chalk.green('high')}) "${chalk.green('Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep')}" ${chalk.blue('@pnpm.e2e/pkg-with-1-dep')}
`)
expect(exitCode).toBe(0)
const { manifest } = await readProjectManifest(tmp)
expect(manifest).toBeTruthy()
expect(manifest.dependencies).toBeDefined()
expect(manifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('^100.1.0')
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// The vulnerable dependency should be updated
expect(packagesArray).not.toContain(originalPkgId)
expect(packagesArray).toContain(expectedPkgId)
// All other packages should remain the same
for (const pkgId of Object.keys(originalLockfile!.packages!)) {
if (pkgId === originalPkgId) continue
expect(packagesArray).toContain(pkgId)
}
})
test('top-level pinned vulnerability is fixed by updating the vulnerable package', async () => {
const tmp = f.prepare('update-single-pinned')
const originalPkgId = '@pnpm.e2e/pkg-with-1-dep@100.0.0' as DepPath
const expectedPkgId = '@pnpm.e2e/pkg-with-1-dep@100.1.0' as DepPath
const originalDepPkgId = '@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0' as DepPath
const expectedDepPkgId = '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0' as DepPath
const { manifest: originalManifest } = await readProjectManifest(tmp)
expect(originalManifest).toBeTruthy()
expect(originalManifest.dependencies).toBeDefined()
expect(originalManifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('100.0.0')
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![originalPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedPkgId]).toBeUndefined()
expect(originalLockfile!.packages![originalDepPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedDepPkgId]).toBeUndefined()
const mockResponse = await readFile(join(tmp, 'responses', 'top-level-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
rootProjectManifestDir: tmp,
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(output).toBe(`${chalk.green(1)} vulnerability was fixed, ${chalk.red(0)} vulnerabilities remain.
The fixed vulnerabilities are:
- (${chalk.green('high')}) "${chalk.green('Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep')}" ${chalk.blue('@pnpm.e2e/pkg-with-1-dep')}
`)
expect(exitCode).toBe(0)
const { manifest } = await readProjectManifest(tmp)
expect(manifest).toBeTruthy()
expect(manifest.dependencies).toBeDefined()
expect(manifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('100.1.0')
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// The vulnerable dependency should be updated
expect(packagesArray).not.toContain(originalPkgId)
expect(packagesArray).toContain(expectedPkgId)
// The vulnerable dependency's dependencies should also be updated
expect(packagesArray).not.toContain(originalDepPkgId)
expect(packagesArray).toContain(expectedDepPkgId)
// All other packages should remain the same
for (const pkgId of Object.keys(originalLockfile!.packages!)) {
if (pkgId === originalPkgId) continue
if (pkgId === originalDepPkgId) continue
expect(packagesArray).toContain(pkgId)
}
})
test('depth 2 vulnerability is fixed by updating the vulnerable package', async () => {
const tmp = f.prepare('update-single-depth-2')
const originalPkgId = '@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0' as DepPath
const expectedPkgId = '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0' as DepPath
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![originalPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedPkgId]).toBeUndefined()
const mockResponse = await readFile(join(tmp, 'responses', 'depth-2-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
rootProjectManifestDir: tmp,
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(output).toBe(`${chalk.green(1)} vulnerability was fixed, ${chalk.red(0)} vulnerabilities remain.
The fixed vulnerabilities are:
- (${chalk.green('high')}) "${chalk.green('Title: mock vulnerability in @pnpm.e2e/dep-of-pkg-with-1-dep')}" ${chalk.blue('@pnpm.e2e/dep-of-pkg-with-1-dep')}
`)
expect(exitCode).toBe(0)
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// The vulnerable dependency should be updated
expect(packagesArray).not.toContain(originalPkgId)
expect(packagesArray).toContain(expectedPkgId)
// All other packages should remain the same
for (const pkgId of Object.keys(originalLockfile!.packages!)) {
if (pkgId === originalPkgId) continue
expect(packagesArray).toContain(pkgId)
}
})
test('depth 3 vulnerability is fixed by updating the vulnerable package', async () => {
const tmp = f.prepare('update-single-depth-3')
const originalPkgId = '@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0' as DepPath
const expectedPkgId = '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0' as DepPath
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![originalPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedPkgId]).toBeUndefined()
const mockResponse = await readFile(join(tmp, 'responses', 'depth-3-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
rootProjectManifestDir: tmp,
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(output).toBe(`${chalk.green(1)} vulnerability was fixed, ${chalk.red(0)} vulnerabilities remain.
The fixed vulnerabilities are:
- (${chalk.green('high')}) "${chalk.green('Title: mock vulnerability in @pnpm.e2e/dep-of-pkg-with-1-dep')}" ${chalk.blue('@pnpm.e2e/dep-of-pkg-with-1-dep')}
`)
expect(exitCode).toBe(0)
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// The vulnerable dependency should be updated
expect(packagesArray).not.toContain(originalPkgId)
expect(packagesArray).toContain(expectedPkgId)
// All other packages should remain the same
for (const pkgId of Object.keys(originalLockfile!.packages!)) {
if (pkgId === originalPkgId) continue
expect(packagesArray).toContain(pkgId)
}
})
test('unfixable vulnerability remains unresolved', async () => {
const tmp = f.prepare('update-single-depth-2')
const pkgId = '@pnpm.e2e/pkg-with-1-dep@100.0.0' as DepPath
const { manifest: originalManifest } = await readProjectManifest(tmp)
expect(originalManifest).toBeTruthy()
expect(originalManifest.dependencies).toBeDefined()
expect(originalManifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('^100.0.0')
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![pkgId]).toBeDefined()
const mockResponse = await readFile(join(tmp, 'responses', 'unfixable-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
rootProjectManifestDir: tmp,
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(output).toBe(`${chalk.green(0)} vulnerabilities were fixed, ${chalk.red(1)} vulnerability remains.
The remaining vulnerabilities are:
- (${chalk.bold.red('high')}) "${chalk.bold.red('Title: unfixable vulnerability in @pnpm.e2e/pkg-with-1-dep')}" ${chalk.blue('@pnpm.e2e/pkg-with-1-dep')}
`)
expect(exitCode).toBe(1)
// The manifest should remain unchanged
const { manifest } = await readProjectManifest(tmp)
expect(manifest).toBeTruthy()
expect(manifest.dependencies).toBeDefined()
expect(manifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('^100.0.0')
// The lockfile should remain unchanged
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// All packages should remain the same
for (const pkgId of Object.keys(originalLockfile!.packages!)) {
expect(packagesArray).toContain(pkgId)
}
})
test('vulnerable package with multiple versions is updated', async () => {
await addDistTag({ package: 'form-data', version: '4.0.4', distTag: 'latest' })
const tmp = f.prepare('update-multiple')
const originalPkgId1 = 'form-data@3.0.1' as DepPath
const originalPkgId2 = 'form-data@4.0.0' as DepPath
const expectedPkgId1 = 'form-data@3.0.4' as DepPath
const expectedPkgId2 = 'form-data@4.0.4' as DepPath
const { manifest: originalManifest } = await readProjectManifest(tmp)
expect(originalManifest).toBeTruthy()
expect(originalManifest.dependencies).toBeDefined()
expect(originalManifest.dependencies?.['form-data']).toBe('^3.0.1')
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![originalPkgId1]).toBeDefined()
expect(originalLockfile!.packages![originalPkgId2]).toBeDefined()
expect(originalLockfile!.packages![expectedPkgId1]).toBeUndefined()
expect(originalLockfile!.packages![expectedPkgId2]).toBeUndefined()
const mockResponse = await readFile(join(tmp, 'responses', 'form-data-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
rootProjectManifestDir: tmp,
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(output).toBe(`${chalk.green(2)} vulnerabilities were fixed, ${chalk.red(0)} vulnerabilities remain.
The fixed vulnerabilities are:
- (${chalk.green('critical')}) "${chalk.green('form-data uses unsafe random function in form-data for choosing boundary')}" ${chalk.blue('form-data')}
- (${chalk.green('critical')}) "${chalk.green('form-data uses unsafe random function in form-data for choosing boundary')}" ${chalk.blue('form-data')}
`)
expect(exitCode).toBe(0)
const { manifest } = await readProjectManifest(tmp)
expect(manifest).toBeTruthy()
expect(manifest.dependencies).toBeDefined()
expect(manifest.dependencies?.['form-data']).toBe('^3.0.4')
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// The vulnerable dependency should be updated
expect(packagesArray).not.toContain(originalPkgId1)
expect(packagesArray).not.toContain(originalPkgId2)
expect(packagesArray).toContain(expectedPkgId1)
expect(packagesArray).toContain(expectedPkgId2)
// All other packages should remain the same
for (const pkgId of Object.keys(originalLockfile!.packages!)) {
if (pkgId === originalPkgId1 || pkgId === originalPkgId2) continue
expect(packagesArray).toContain(pkgId)
}
})
test('top-level workspace subpackage vulnerability is fixed by recursive update from root', async () => {
const tmp = f.prepare('update-workspace-depth-2')
const originalPkgId = '@pnpm.e2e/pkg-with-1-dep@100.0.0' as DepPath
const expectedPkgId = '@pnpm.e2e/pkg-with-1-dep@100.1.0' as DepPath
const subPkgDir = join(tmp, 'packages', 'sub-pkg')
const { manifest: originalManifest } = await readProjectManifest(subPkgDir)
expect(originalManifest).toBeTruthy()
expect(originalManifest.dependencies).toBeDefined()
expect(originalManifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('^100.0.0')
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![originalPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedPkgId]).toBeUndefined()
const mockResponse = await readFile(join(tmp, 'responses', 'top-level-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(tmp, [], {
workspaceDir: tmp,
prefix: tmp,
})
expect(allProjects).toHaveLength(2)
expect(new Set(allProjects.map(p => p.manifest.name))).toEqual(new Set(['update-workspace-depth-2', 'sub-pkg']))
expect(allProjectsGraph).toBeTruthy()
expect(selectedProjectsGraph).toEqual(allProjectsGraph)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
workspaceDir: tmp,
lockfileDir: tmp,
rootProjectManifestDir: tmp,
allProjects,
allProjectsGraph,
selectedProjectsGraph,
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(output).toBe(`${chalk.green(1)} vulnerability was fixed, ${chalk.red(0)} vulnerabilities remain.
The fixed vulnerabilities are:
- (${chalk.green('high')}) "${chalk.green('Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep')}" ${chalk.blue('@pnpm.e2e/pkg-with-1-dep')}
`)
expect(exitCode).toBe(0)
const { manifest } = await readProjectManifest(subPkgDir)
expect(manifest).toBeTruthy()
expect(manifest.dependencies).toBeDefined()
expect(manifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('^100.1.0')
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// The vulnerable dependency should be updated
expect(packagesArray).not.toContain(originalPkgId)
expect(packagesArray).toContain(expectedPkgId)
// All other packages should remain the same
for (const pkgId of Object.keys(originalLockfile!.packages!)) {
if (pkgId === originalPkgId) continue
expect(packagesArray).toContain(pkgId)
}
})
test('depth 2 workspace subpackage vulnerability is fixed by recursive update from root', async () => {
const tmp = f.prepare('update-workspace-depth-2')
const originalPkgId = '@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0' as DepPath
const expectedPkgId = '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0' as DepPath
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![originalPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedPkgId]).toBeUndefined()
const mockResponse = await readFile(join(tmp, 'responses', 'depth-2-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(tmp, [], {
workspaceDir: tmp,
prefix: tmp,
})
expect(allProjects).toHaveLength(2)
expect(new Set(allProjects.map(p => p.manifest.name))).toEqual(new Set(['update-workspace-depth-2', 'sub-pkg']))
expect(allProjectsGraph).toBeTruthy()
expect(selectedProjectsGraph).toEqual(allProjectsGraph)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
workspaceDir: tmp,
lockfileDir: tmp,
rootProjectManifestDir: tmp,
allProjects,
allProjectsGraph,
selectedProjectsGraph,
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(exitCode).toBe(0)
expect(output).toBe(`${chalk.green(1)} vulnerability was fixed, ${chalk.red(0)} vulnerabilities remain.
The fixed vulnerabilities are:
- (${chalk.green('high')}) "${chalk.green('Title: mock vulnerability in @pnpm.e2e/dep-of-pkg-with-1-dep')}" ${chalk.blue('@pnpm.e2e/dep-of-pkg-with-1-dep')}
`)
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// The vulnerable dependency should be updated
expect(packagesArray).not.toContain(originalPkgId)
expect(packagesArray).toContain(expectedPkgId)
// All other packages should remain the same
for (const pkgId of Object.keys(originalLockfile!.packages!)) {
if (pkgId === originalPkgId) continue
expect(packagesArray).toContain(pkgId)
}
})
test('top-level pinned workspace subpackage vulnerability is fixed by recursive update from root', async () => {
const tmp = f.prepare('update-workspace-pinned')
const originalPkgId = '@pnpm.e2e/pkg-with-1-dep@100.0.0' as DepPath
const expectedPkgId = '@pnpm.e2e/pkg-with-1-dep@100.1.0' as DepPath
const originalDepPkgId = '@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0' as DepPath
const expectedDepPkgId = '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0' as DepPath
const subPkgDir = join(tmp, 'packages', 'sub-pkg')
const { manifest: originalManifest } = await readProjectManifest(subPkgDir)
expect(originalManifest).toBeTruthy()
expect(originalManifest.dependencies).toBeDefined()
expect(originalManifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('100.0.0')
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![originalPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedPkgId]).toBeUndefined()
expect(originalLockfile!.packages![originalDepPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedDepPkgId]).toBeUndefined()
const mockResponse = await readFile(join(tmp, 'responses', 'top-level-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(tmp, [], {
workspaceDir: tmp,
prefix: tmp,
})
expect(allProjects).toHaveLength(2)
expect(new Set(allProjects.map(p => p.manifest.name))).toEqual(new Set(['update-workspace-pinned', 'sub-pkg']))
expect(allProjectsGraph).toBeTruthy()
expect(selectedProjectsGraph).toEqual(allProjectsGraph)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
workspaceDir: tmp,
lockfileDir: tmp,
rootProjectManifestDir: tmp,
allProjects,
allProjectsGraph,
selectedProjectsGraph,
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(output).toBe(`${chalk.green(1)} vulnerability was fixed, ${chalk.red(0)} vulnerabilities remain.
The fixed vulnerabilities are:
- (${chalk.green('high')}) "${chalk.green('Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep')}" ${chalk.blue('@pnpm.e2e/pkg-with-1-dep')}
`)
expect(exitCode).toBe(0)
const { manifest } = await readProjectManifest(subPkgDir)
expect(manifest).toBeTruthy()
expect(manifest.dependencies).toBeDefined()
expect(manifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('100.1.0')
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// The vulnerable dependency should be updated
expect(packagesArray).not.toContain(originalPkgId)
expect(packagesArray).toContain(expectedPkgId)
// The vulnerable dependency's dependencies should also be updated
expect(packagesArray).not.toContain(originalDepPkgId)
expect(packagesArray).toContain(expectedDepPkgId)
// All other packages should remain the same
for (const pkgId of Object.keys(originalLockfile!.packages!)) {
if (pkgId === originalPkgId) continue
if (pkgId === originalDepPkgId) continue
expect(packagesArray).toContain(pkgId)
}
})
test('top-level pinned workspace catalog vulnerability is fixed by updating the catalog entry', async () => {
const tmp = f.prepare('update-workspace-catalog-pinned')
const originalPkgId = '@pnpm.e2e/pkg-with-1-dep@100.0.0' as DepPath
const expectedPkgId = '@pnpm.e2e/pkg-with-1-dep@100.1.0' as DepPath
const subPkgDir = join(tmp, 'packages', 'sub-pkg')
// Verify the sub-package uses catalog: protocol
const { manifest: originalManifest } = await readProjectManifest(subPkgDir)
expect(originalManifest).toBeTruthy()
expect(originalManifest.dependencies).toBeDefined()
expect(originalManifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('catalog:')
// Verify the workspace catalog has the pinned version
const originalWorkspaceManifest = readYamlFileSync<{ catalog?: Record<string, string> }>(join(tmp, 'pnpm-workspace.yaml'))
expect(originalWorkspaceManifest.catalog?.['@pnpm.e2e/pkg-with-1-dep']).toBe('100.0.0')
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![originalPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedPkgId]).toBeUndefined()
const mockResponse = await readFile(join(tmp, 'responses', 'top-level-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(tmp, [], {
workspaceDir: tmp,
prefix: tmp,
})
expect(allProjects).toHaveLength(2)
expect(new Set(allProjects.map(p => p.manifest.name))).toEqual(new Set(['update-workspace-catalog-pinned', 'sub-pkg']))
expect(allProjectsGraph).toBeTruthy()
expect(selectedProjectsGraph).toEqual(allProjectsGraph)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
workspaceDir: tmp,
lockfileDir: tmp,
rootProjectManifestDir: tmp,
allProjects,
allProjectsGraph,
selectedProjectsGraph,
catalogs: {
default: { '@pnpm.e2e/pkg-with-1-dep': '100.0.0' },
},
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(output).toBe(`${chalk.green(1)} vulnerability was fixed, ${chalk.red(0)} vulnerabilities remain.
The fixed vulnerabilities are:
- (${chalk.green('high')}) "${chalk.green('Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep')}" ${chalk.blue('@pnpm.e2e/pkg-with-1-dep')}
`)
expect(exitCode).toBe(0)
// The sub-package manifest should still use catalog: protocol
const { manifest } = await readProjectManifest(subPkgDir)
expect(manifest).toBeTruthy()
expect(manifest.dependencies).toBeDefined()
expect(manifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('catalog:')
// The workspace catalog should be updated to the fixed version
const workspaceManifest = readYamlFileSync<{ catalog?: Record<string, string> }>(join(tmp, 'pnpm-workspace.yaml'))
expect(workspaceManifest.catalog?.['@pnpm.e2e/pkg-with-1-dep']).toBe('100.1.0')
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// The vulnerable dependency should be updated
expect(packagesArray).not.toContain(originalPkgId)
expect(packagesArray).toContain(expectedPkgId)
})
test('top-level workspace catalog vulnerability is fixed by updating the catalog entry', async () => {
const tmp = f.prepare('update-workspace-catalog')
const originalPkgId = '@pnpm.e2e/pkg-with-1-dep@100.0.0' as DepPath
const expectedPkgId = '@pnpm.e2e/pkg-with-1-dep@100.1.0' as DepPath
const subPkgDir = join(tmp, 'packages', 'sub-pkg')
// Verify the sub-package uses catalog: protocol
const { manifest: originalManifest } = await readProjectManifest(subPkgDir)
expect(originalManifest).toBeTruthy()
expect(originalManifest.dependencies).toBeDefined()
expect(originalManifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('catalog:')
// Verify the workspace catalog has the ranged version
const originalWorkspaceManifest = readYamlFileSync<{ catalog?: Record<string, string> }>(join(tmp, 'pnpm-workspace.yaml'))
expect(originalWorkspaceManifest.catalog?.['@pnpm.e2e/pkg-with-1-dep']).toBe('^100.0.0')
const originalLockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(originalLockfile).toBeTruthy()
expect(originalLockfile!.packages).toBeDefined()
expect(originalLockfile!.packages![originalPkgId]).toBeDefined()
expect(originalLockfile!.packages![expectedPkgId]).toBeUndefined()
const mockResponse = await readFile(join(tmp, 'responses', 'top-level-vulnerability.json'), 'utf-8')
expect(mockResponse).toBeTruthy()
nock(MOCK_REGISTRY, { allowUnmocked: true })
.post('/-/npm/v1/security/audits/quick')
.reply(200, mockResponse)
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(tmp, [], {
workspaceDir: tmp,
prefix: tmp,
})
expect(allProjects).toHaveLength(2)
expect(new Set(allProjects.map(p => p.manifest.name))).toEqual(new Set(['update-workspace-catalog', 'sub-pkg']))
expect(allProjectsGraph).toBeTruthy()
expect(selectedProjectsGraph).toEqual(allProjectsGraph)
const { exitCode, output } = await audit.handler({
...MOCK_REGISTRY_OPTS,
dir: tmp,
workspaceDir: tmp,
lockfileDir: tmp,
rootProjectManifestDir: tmp,
allProjects,
allProjectsGraph,
selectedProjectsGraph,
catalogs: {
default: { '@pnpm.e2e/pkg-with-1-dep': '^100.0.0' },
},
auditLevel: 'moderate',
fix: 'update',
lockfileOnly: true,
})
expect(output).toBe(`${chalk.green(1)} vulnerability was fixed, ${chalk.red(0)} vulnerabilities remain.
The fixed vulnerabilities are:
- (${chalk.green('high')}) "${chalk.green('Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep')}" ${chalk.blue('@pnpm.e2e/pkg-with-1-dep')}
`)
expect(exitCode).toBe(0)
// The sub-package manifest should still use catalog: protocol
const { manifest } = await readProjectManifest(subPkgDir)
expect(manifest).toBeTruthy()
expect(manifest.dependencies).toBeDefined()
expect(manifest.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('catalog:')
// The workspace catalog should be updated, preserving the ^ range style
const workspaceManifest = readYamlFileSync<{ catalog?: Record<string, string> }>(join(tmp, 'pnpm-workspace.yaml'))
expect(workspaceManifest.catalog?.['@pnpm.e2e/pkg-with-1-dep']).toBe('^100.1.0')
const lockfile = await readWantedLockfile(tmp, { ignoreIncompatible: true })
expect(lockfile).toBeTruthy()
expect(lockfile!.packages).toBeDefined()
const packagesArray = Object.keys(lockfile!.packages!)
// The vulnerable dependency should be updated
expect(packagesArray).not.toContain(originalPkgId)
expect(packagesArray).toContain(expectedPkgId)
})
})

View File

@@ -0,0 +1,8 @@
{
"name": "update-multiple",
"version": "1.0.0",
"dependencies": {
"axios": "^1.8.2",
"form-data": "^3.0.1"
}
}

View File

@@ -0,0 +1,100 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
axios:
specifier: ^1.8.2
version: 1.8.2
form-data:
specifier: ^3.0.1
version: 3.0.1
packages:
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.8.2:
resolution: {integrity: sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@3.0.1:
resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
engines: {node: '>= 6'}
form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
snapshots:
asynckit@0.4.0: {}
axios@1.8.2:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
delayed-stream@1.0.0: {}
follow-redirects@1.15.11: {}
form-data@3.0.1:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
form-data@4.0.0:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
proxy-from-env@1.1.0: {}

View File

@@ -0,0 +1,126 @@
{
"actions": [
{
"action": "update",
"resolves": [
{
"id": 1109538,
"path": ".>axios>form-data",
"dev": false,
"optional": false,
"bundled": false
}
],
"module": "form-data",
"target": "4.0.5",
"depth": 3
},
{
"action": "review",
"module": "form-data",
"resolves": [
{
"id": 1109539,
"path": ".>form-data",
"dev": false,
"optional": false,
"bundled": false
}
]
}
],
"advisories": {
"1109538": {
"findings": [
{
"version": "4.0.0",
"paths": [
".>axios>form-data"
]
}
],
"found_by": null,
"deleted": null,
"references": "- https://github.com/form-data/form-data/security/advisories/GHSA-fjxv-7rqg-78g4\n- https://nvd.nist.gov/vuln/detail/CVE-2025-7783\n- https://github.com/form-data/form-data/commit/3d1723080e6577a66f17f163ecd345a21d8d0fd0\n- https://github.com/benweissmann/CVE-2025-7783-poc\n- https://lists.debian.org/debian-lts-announce/2025/07/msg00023.html\n- https://github.com/advisories/GHSA-fjxv-7rqg-78g4",
"created": "2025-07-21T19:04:54.000Z",
"id": 1109538,
"npm_advisory_id": null,
"overview": "### Summary\n\nform-data uses `Math.random()` to select a boundary value for multipart form-encoded data. This can lead to a security issue if an attacker:\n1. can observe other values produced by Math.random in the target application, and\n2. can control one field of a request made using form-data\n\nBecause the values of Math.random() are pseudo-random and predictable (see: https://blog.securityevaluators.com/hacking-the-javascript-lottery-80cc437e3b7f), an attacker who can observe a few sequential values can determine the state of the PRNG and predict future values, includes those used to generate form-data's boundary value. The allows the attacker to craft a value that contains a boundary value, allowing them to inject additional parameters into the request.\n\nThis is largely the same vulnerability as was [recently found in `undici`](https://hackerone.com/reports/2913312) by [`parrot409`](https://hackerone.com/parrot409?type=user) -- I'm not affiliated with that researcher but want to give credit where credit is due! My PoC is largely based on their work.\n\n### Details\n\nThe culprit is this line here: https://github.com/form-data/form-data/blob/426ba9ac440f95d1998dac9a5cd8d738043b048f/lib/form_data.js#L347\n\nAn attacker who is able to predict the output of Math.random() can predict this boundary value, and craft a payload that contains the boundary value, followed by another, fully attacker-controlled field. This is roughly equivalent to any sort of improper escaping vulnerability, with the caveat that the attacker must find a way to observe other Math.random() values generated by the application to solve for the state of the PRNG. However, Math.random() is used in all sorts of places that might be visible to an attacker (including by form-data itself, if the attacker can arrange for the vulnerable application to make a request to an attacker-controlled server using form-data, such as a user-controlled webhook -- the attacker could observe the boundary values from those requests to observe the Math.random() outputs). A common example would be a `x-request-id` header added by the server. These sorts of headers are often used for distributed tracing, to correlate errors across the frontend and backend. `Math.random()` is a fine place to get these sorts of IDs (in fact, [opentelemetry uses Math.random for this purpose](https://github.com/open-telemetry/opentelemetry-js/blob/2053f0d3a44631ade77ea04f656056a2c8a2ae76/packages/opentelemetry-sdk-trace-base/src/platform/node/RandomIdGenerator.ts#L22))\n\n### PoC\n\nPoC here: https://github.com/benweissmann/CVE-2025-7783-poc\n\nInstructions are in that repo. It's based on the PoC from https://hackerone.com/reports/2913312 but simplified somewhat; the vulnerable application has a more direct side-channel from which to observe Math.random() values (a separate endpoint that happens to include a randomly-generated request ID). \n\n### Impact\n\nFor an application to be vulnerable, it must:\n- Use `form-data` to send data including user-controlled data to some other system. The attacker must be able to do something malicious by adding extra parameters (that were not intended to be user-controlled) to this request. Depending on the target system's handling of repeated parameters, the attacker might be able to overwrite values in addition to appending values (some multipart form handlers deal with repeats by overwriting values instead of representing them as an array)\n- Reveal values of Math.random(). It's easiest if the attacker can observe multiple sequential values, but more complex math could recover the PRNG state to some degree of confidence with non-sequential values. \n\nIf an application is vulnerable, this allows an attacker to make arbitrary requests to internal systems.",
"reported_by": null,
"title": "form-data uses unsafe random function in form-data for choosing boundary",
"metadata": null,
"cves": [
"CVE-2025-7783"
],
"access": "public",
"severity": "critical",
"module_name": "form-data",
"vulnerable_versions": ">=4.0.0 <4.0.4",
"github_advisory_id": "GHSA-fjxv-7rqg-78g4",
"recommendation": "Upgrade to version 4.0.4 or later",
"patched_versions": ">=4.0.4",
"updated": "2025-11-03T21:34:09.000Z",
"cvss": {
"score": 0,
"vectorString": null
},
"cwe": [
"CWE-330"
],
"url": "https://github.com/advisories/GHSA-fjxv-7rqg-78g4"
},
"1109539": {
"findings": [
{
"version": "3.0.1",
"paths": [
".>form-data"
]
}
],
"found_by": null,
"deleted": null,
"references": "- https://github.com/form-data/form-data/security/advisories/GHSA-fjxv-7rqg-78g4\n- https://nvd.nist.gov/vuln/detail/CVE-2025-7783\n- https://github.com/form-data/form-data/commit/3d1723080e6577a66f17f163ecd345a21d8d0fd0\n- https://github.com/benweissmann/CVE-2025-7783-poc\n- https://lists.debian.org/debian-lts-announce/2025/07/msg00023.html\n- https://github.com/advisories/GHSA-fjxv-7rqg-78g4",
"created": "2025-07-21T19:04:54.000Z",
"id": 1109539,
"npm_advisory_id": null,
"overview": "### Summary\n\nform-data uses `Math.random()` to select a boundary value for multipart form-encoded data. This can lead to a security issue if an attacker:\n1. can observe other values produced by Math.random in the target application, and\n2. can control one field of a request made using form-data\n\nBecause the values of Math.random() are pseudo-random and predictable (see: https://blog.securityevaluators.com/hacking-the-javascript-lottery-80cc437e3b7f), an attacker who can observe a few sequential values can determine the state of the PRNG and predict future values, includes those used to generate form-data's boundary value. The allows the attacker to craft a value that contains a boundary value, allowing them to inject additional parameters into the request.\n\nThis is largely the same vulnerability as was [recently found in `undici`](https://hackerone.com/reports/2913312) by [`parrot409`](https://hackerone.com/parrot409?type=user) -- I'm not affiliated with that researcher but want to give credit where credit is due! My PoC is largely based on their work.\n\n### Details\n\nThe culprit is this line here: https://github.com/form-data/form-data/blob/426ba9ac440f95d1998dac9a5cd8d738043b048f/lib/form_data.js#L347\n\nAn attacker who is able to predict the output of Math.random() can predict this boundary value, and craft a payload that contains the boundary value, followed by another, fully attacker-controlled field. This is roughly equivalent to any sort of improper escaping vulnerability, with the caveat that the attacker must find a way to observe other Math.random() values generated by the application to solve for the state of the PRNG. However, Math.random() is used in all sorts of places that might be visible to an attacker (including by form-data itself, if the attacker can arrange for the vulnerable application to make a request to an attacker-controlled server using form-data, such as a user-controlled webhook -- the attacker could observe the boundary values from those requests to observe the Math.random() outputs). A common example would be a `x-request-id` header added by the server. These sorts of headers are often used for distributed tracing, to correlate errors across the frontend and backend. `Math.random()` is a fine place to get these sorts of IDs (in fact, [opentelemetry uses Math.random for this purpose](https://github.com/open-telemetry/opentelemetry-js/blob/2053f0d3a44631ade77ea04f656056a2c8a2ae76/packages/opentelemetry-sdk-trace-base/src/platform/node/RandomIdGenerator.ts#L22))\n\n### PoC\n\nPoC here: https://github.com/benweissmann/CVE-2025-7783-poc\n\nInstructions are in that repo. It's based on the PoC from https://hackerone.com/reports/2913312 but simplified somewhat; the vulnerable application has a more direct side-channel from which to observe Math.random() values (a separate endpoint that happens to include a randomly-generated request ID). \n\n### Impact\n\nFor an application to be vulnerable, it must:\n- Use `form-data` to send data including user-controlled data to some other system. The attacker must be able to do something malicious by adding extra parameters (that were not intended to be user-controlled) to this request. Depending on the target system's handling of repeated parameters, the attacker might be able to overwrite values in addition to appending values (some multipart form handlers deal with repeats by overwriting values instead of representing them as an array)\n- Reveal values of Math.random(). It's easiest if the attacker can observe multiple sequential values, but more complex math could recover the PRNG state to some degree of confidence with non-sequential values. \n\nIf an application is vulnerable, this allows an attacker to make arbitrary requests to internal systems.",
"reported_by": null,
"title": "form-data uses unsafe random function in form-data for choosing boundary",
"metadata": null,
"cves": [
"CVE-2025-7783"
],
"access": "public",
"severity": "critical",
"module_name": "form-data",
"vulnerable_versions": ">=3.0.0 <3.0.4",
"github_advisory_id": "GHSA-fjxv-7rqg-78g4",
"recommendation": "Upgrade to version 3.0.4 or later",
"patched_versions": ">=3.0.4",
"updated": "2025-11-03T21:34:09.000Z",
"cvss": {
"score": 0,
"vectorString": null
},
"cwe": [
"CWE-330"
],
"url": "https://github.com/advisories/GHSA-fjxv-7rqg-78g4"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 0,
"critical": 2
},
"dependencies": 11,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 11
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "update-single-depth-2",
"version": "1.0.0",
"dependencies": {
"@pnpm.e2e/bar": "^100.0.0",
"@pnpm.e2e/pkg-with-1-dep": "^100.0.0"
}
}

View File

@@ -0,0 +1,37 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@pnpm.e2e/bar':
specifier: ^100.0.0
version: 100.0.0
'@pnpm.e2e/pkg-with-1-dep':
specifier: ^100.0.0
version: 100.0.0
packages:
'@pnpm.e2e/bar@100.0.0':
resolution: {integrity: sha512-ZiNxDXOAD0K8nguFnyz3TmV8L9kz6xipLTQIWbIe7ojrRtjblv+NDt8oh5TPObUN92pDnWIEiRVh+ZjN+JWygg==}
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-atUXGBNAbym4OioYcKt1qTjiH23CSfZ1K2N8JgCUewSE5gY/i9YoK7Ez6+CuEZbH+O3R+HKNrRIaZfnkv/93tg==}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-aoavufOsqVp0D9pbv/dJRTYKFkmL8O1Pvj9lnWDFuAbNQnwWCDvxp5Dem12+DArhHmTJjGl3RccUATXLJxe1iQ==}
snapshots:
'@pnpm.e2e/bar@100.0.0': {}
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0': {}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
dependencies:
'@pnpm.e2e/dep-of-pkg-with-1-dep': 100.0.0

View File

@@ -0,0 +1,66 @@
{
"actions": [
{
"action": "update",
"resolves": [
{
"id": 123456,
"path": ".>@pnpm.e2e/pkg-with-1-dep>@pnpm.e2e/dep-of-pkg-with-1-dep",
"dev": false,
"optional": false,
"bundled": false
}
],
"module": "@pnpm.e2e/dep-of-pkg-with-1-dep",
"target": "100.0.0",
"depth": 3
}
],
"advisories": {
"123456": {
"findings": [
{
"version": "100.0.0",
"paths": [
".>@pnpm.e2e/pkg-with-1-dep>@pnpm.e2e/dep-of-pkg-with-1-dep"
]
}
],
"metadata": null,
"vulnerable_versions": "<100.1.0",
"module_name": "@pnpm.e2e/dep-of-pkg-with-1-dep",
"severity": "high",
"github_advisory_id": "GHSA-mock-mock-mock",
"cves": [],
"access": "public",
"patched_versions": ">=100.1.0",
"updated": "2025-12-19T19:00:00.000Z",
"recommendation": "Upgrade to version 100.1.0 or later",
"cwe": "",
"found_by": null,
"deleted": null,
"id": 123456,
"references": "",
"created": "2025-12-19T19:00:00.000Z",
"reported_by": null,
"title": "Title: mock vulnerability in @pnpm.e2e/dep-of-pkg-with-1-dep",
"npm_advisory_id": null,
"overview": "Overview: mock vulnerability in @pnpm.e2e/dep-of-pkg-with-1-dep",
"url": "https://example.com"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 1,
"critical": 0
},
"dependencies": 2,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 2
}
}

View File

@@ -0,0 +1,66 @@
{
"actions": [
{
"action": "update",
"resolves": [
{
"id": 123456,
"path": ".>@pnpm.e2e/pkg-with-1-dep",
"dev": false,
"optional": false,
"bundled": false
}
],
"module": "@pnpm.e2e/pkg-with-1-dep",
"target": "100.0.0",
"depth": 2
}
],
"advisories": {
"123456": {
"findings": [
{
"version": "100.0.0",
"paths": [
".>@pnpm.e2e/pkg-with-1-dep"
]
}
],
"metadata": null,
"vulnerable_versions": "<100.1.0",
"module_name": "@pnpm.e2e/pkg-with-1-dep",
"severity": "high",
"github_advisory_id": "GHSA-mock-mock-mock",
"cves": [],
"access": "public",
"patched_versions": ">=100.1.0",
"updated": "2025-12-19T19:00:00.000Z",
"recommendation": "Upgrade to version 100.1.0 or later",
"cwe": "",
"found_by": null,
"deleted": null,
"id": 123456,
"references": "",
"created": "2025-12-19T19:00:00.000Z",
"reported_by": null,
"title": "Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"npm_advisory_id": null,
"overview": "Overview: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"url": "https://example.com"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 1,
"critical": 0
},
"dependencies": 2,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 2
}
}

View File

@@ -0,0 +1,50 @@
{
"actions": [],
"advisories": {
"123456": {
"findings": [
{
"version": "100.0.0",
"paths": [
".>@pnpm.e2e/pkg-with-1-dep"
]
}
],
"metadata": null,
"vulnerable_versions": ">=0.0.0",
"module_name": "@pnpm.e2e/pkg-with-1-dep",
"severity": "high",
"github_advisory_id": "GHSA-mock-mock-mock",
"cves": [],
"access": "public",
"patched_versions": "<0.0.0",
"updated": "2025-12-19T19:00:00.000Z",
"recommendation": "No fix available",
"cwe": "",
"found_by": null,
"deleted": null,
"id": 123456,
"references": "",
"created": "2025-12-19T19:00:00.000Z",
"reported_by": null,
"title": "Title: unfixable vulnerability in @pnpm.e2e/pkg-with-1-dep",
"npm_advisory_id": null,
"overview": "Overview: unfixable vulnerability in @pnpm.e2e/pkg-with-1-dep",
"url": "https://example.com"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 1,
"critical": 0
},
"dependencies": 2,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 2
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "update-single-depth-3",
"version": "1.0.0",
"dependencies": {
"@pnpm.e2e/parent-of-pkg-with-1-dep": "^1.0.0"
}
}

View File

@@ -0,0 +1,36 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@pnpm.e2e/parent-of-pkg-with-1-dep':
specifier: ^1.0.0
version: 1.0.0
packages:
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-atUXGBNAbym4OioYcKt1qTjiH23CSfZ1K2N8JgCUewSE5gY/i9YoK7Ez6+CuEZbH+O3R+HKNrRIaZfnkv/93tg==}
'@pnpm.e2e/parent-of-pkg-with-1-dep@1.0.0':
resolution: {integrity: sha512-u2S6Vmz7W3aeIQL4N0g1GGjyD1YIYqj0vqLwvpddmsrc4KBkJLGliK2Knc+2PAM27DoILKXonQ9fQLoqzuF8bA==}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-aoavufOsqVp0D9pbv/dJRTYKFkmL8O1Pvj9lnWDFuAbNQnwWCDvxp5Dem12+DArhHmTJjGl3RccUATXLJxe1iQ==}
snapshots:
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0': {}
'@pnpm.e2e/parent-of-pkg-with-1-dep@1.0.0':
dependencies:
'@pnpm.e2e/pkg-with-1-dep': 100.0.0
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
dependencies:
'@pnpm.e2e/dep-of-pkg-with-1-dep': 100.0.0

View File

@@ -0,0 +1,66 @@
{
"actions": [
{
"action": "update",
"resolves": [
{
"id": 123456,
"path": ".>@pnpm.e2e/parent-of-pkg-with-1-dep>@pnpm.e2e/pkg-with-1-dep>@pnpm.e2e/dep-of-pkg-with-1-dep",
"dev": false,
"optional": false,
"bundled": false
}
],
"module": "@pnpm.e2e/dep-of-pkg-with-1-dep",
"target": "100.0.0",
"depth": 4
}
],
"advisories": {
"123456": {
"findings": [
{
"version": "100.0.0",
"paths": [
".>@pnpm.e2e/parent-of-pkg-with-1-dep>@pnpm.e2e/pkg-with-1-dep>@pnpm.e2e/dep-of-pkg-with-1-dep"
]
}
],
"metadata": null,
"vulnerable_versions": "<100.1.0",
"module_name": "@pnpm.e2e/dep-of-pkg-with-1-dep",
"severity": "high",
"github_advisory_id": "GHSA-mock-mock-mock",
"cves": [],
"access": "public",
"patched_versions": ">=100.1.0",
"updated": "2025-12-19T19:00:00.000Z",
"recommendation": "Upgrade to version 100.1.0 or later",
"cwe": "",
"found_by": null,
"deleted": null,
"id": 123456,
"references": "",
"created": "2025-12-19T19:00:00.000Z",
"reported_by": null,
"title": "Title: mock vulnerability in @pnpm.e2e/dep-of-pkg-with-1-dep",
"npm_advisory_id": null,
"overview": "Overview: mock vulnerability in @pnpm.e2e/dep-of-pkg-with-1-dep",
"url": "https://example.com"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 1,
"critical": 0
},
"dependencies": 3,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 3
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "update-single-pinned",
"version": "1.0.0",
"dependencies": {
"@pnpm.e2e/bar": "100.0.0",
"@pnpm.e2e/pkg-with-1-dep": "100.0.0"
}
}

View File

@@ -0,0 +1,37 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@pnpm.e2e/bar':
specifier: 100.0.0
version: 100.0.0
'@pnpm.e2e/pkg-with-1-dep':
specifier: 100.0.0
version: 100.0.0
packages:
'@pnpm.e2e/bar@100.0.0':
resolution: {integrity: sha512-ZiNxDXOAD0K8nguFnyz3TmV8L9kz6xipLTQIWbIe7ojrRtjblv+NDt8oh5TPObUN92pDnWIEiRVh+ZjN+JWygg==}
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-atUXGBNAbym4OioYcKt1qTjiH23CSfZ1K2N8JgCUewSE5gY/i9YoK7Ez6+CuEZbH+O3R+HKNrRIaZfnkv/93tg==}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-aoavufOsqVp0D9pbv/dJRTYKFkmL8O1Pvj9lnWDFuAbNQnwWCDvxp5Dem12+DArhHmTJjGl3RccUATXLJxe1iQ==}
snapshots:
'@pnpm.e2e/bar@100.0.0': {}
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0': {}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
dependencies:
'@pnpm.e2e/dep-of-pkg-with-1-dep': 100.0.0

View File

@@ -0,0 +1,66 @@
{
"actions": [
{
"action": "update",
"resolves": [
{
"id": 123456,
"path": ".>@pnpm.e2e/pkg-with-1-dep",
"dev": false,
"optional": false,
"bundled": false
}
],
"module": "@pnpm.e2e/pkg-with-1-dep",
"target": "100.0.0",
"depth": 2
}
],
"advisories": {
"123456": {
"findings": [
{
"version": "100.0.0",
"paths": [
".>@pnpm.e2e/pkg-with-1-dep"
]
}
],
"metadata": null,
"vulnerable_versions": "<100.1.0",
"module_name": "@pnpm.e2e/pkg-with-1-dep",
"severity": "high",
"github_advisory_id": "GHSA-mock-mock-mock",
"cves": [],
"access": "public",
"patched_versions": ">=100.1.0",
"updated": "2025-12-19T19:00:00.000Z",
"recommendation": "Upgrade to version 100.1.0 or later",
"cwe": "",
"found_by": null,
"deleted": null,
"id": 123456,
"references": "",
"created": "2025-12-19T19:00:00.000Z",
"reported_by": null,
"title": "Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"npm_advisory_id": null,
"overview": "Overview: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"url": "https://example.com"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 1,
"critical": 0
},
"dependencies": 2,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 2
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "update-workspace-catalog-pinned",
"version": "1.0.0",
"private": true
}

View File

@@ -0,0 +1,7 @@
{
"name": "sub-pkg",
"version": "1.0.0",
"dependencies": {
"@pnpm.e2e/pkg-with-1-dep": "catalog:"
}
}

View File

@@ -0,0 +1,37 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
catalogs:
default:
'@pnpm.e2e/pkg-with-1-dep':
specifier: 100.0.0
version: 100.0.0
importers:
.: {}
packages/sub-pkg:
dependencies:
'@pnpm.e2e/pkg-with-1-dep':
specifier: 'catalog:'
version: 100.0.0
packages:
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-atUXGBNAbym4OioYcKt1qTjiH23CSfZ1K2N8JgCUewSE5gY/i9YoK7Ez6+CuEZbH+O3R+HKNrRIaZfnkv/93tg==}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-aoavufOsqVp0D9pbv/dJRTYKFkmL8O1Pvj9lnWDFuAbNQnwWCDvxp5Dem12+DArhHmTJjGl3RccUATXLJxe1iQ==}
snapshots:
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0': {}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
dependencies:
'@pnpm.e2e/dep-of-pkg-with-1-dep': 100.0.0

View File

@@ -0,0 +1,5 @@
packages:
- "packages/*"
catalog:
'@pnpm.e2e/pkg-with-1-dep': 100.0.0

View File

@@ -0,0 +1,66 @@
{
"actions": [
{
"action": "update",
"resolves": [
{
"id": 123456,
"path": "packages__sub-pkg>@pnpm.e2e/pkg-with-1-dep",
"dev": false,
"optional": false,
"bundled": false
}
],
"module": "@pnpm.e2e/pkg-with-1-dep",
"target": "100.0.0",
"depth": 2
}
],
"advisories": {
"123456": {
"findings": [
{
"version": "100.0.0",
"paths": [
"packages__sub-pkg>@pnpm.e2e/pkg-with-1-dep"
]
}
],
"metadata": null,
"vulnerable_versions": "<100.1.0",
"module_name": "@pnpm.e2e/pkg-with-1-dep",
"severity": "high",
"github_advisory_id": "GHSA-mock-mock-mock",
"cves": [],
"access": "public",
"patched_versions": ">=100.1.0",
"updated": "2025-12-19T19:00:00.000Z",
"recommendation": "Upgrade to version 100.1.0 or later",
"cwe": "",
"found_by": null,
"deleted": null,
"id": 123456,
"references": "",
"created": "2025-12-19T19:00:00.000Z",
"reported_by": null,
"title": "Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"npm_advisory_id": null,
"overview": "Overview: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"url": "https://example.com"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 1,
"critical": 0
},
"dependencies": 2,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 2
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "update-workspace-catalog",
"version": "1.0.0",
"private": true
}

View File

@@ -0,0 +1,7 @@
{
"name": "sub-pkg",
"version": "1.0.0",
"dependencies": {
"@pnpm.e2e/pkg-with-1-dep": "catalog:"
}
}

View File

@@ -0,0 +1,37 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
catalogs:
default:
'@pnpm.e2e/pkg-with-1-dep':
specifier: ^100.0.0
version: 100.0.0
importers:
.: {}
packages/sub-pkg:
dependencies:
'@pnpm.e2e/pkg-with-1-dep':
specifier: 'catalog:'
version: 100.0.0
packages:
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-atUXGBNAbym4OioYcKt1qTjiH23CSfZ1K2N8JgCUewSE5gY/i9YoK7Ez6+CuEZbH+O3R+HKNrRIaZfnkv/93tg==}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-aoavufOsqVp0D9pbv/dJRTYKFkmL8O1Pvj9lnWDFuAbNQnwWCDvxp5Dem12+DArhHmTJjGl3RccUATXLJxe1iQ==}
snapshots:
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0': {}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
dependencies:
'@pnpm.e2e/dep-of-pkg-with-1-dep': 100.0.0

View File

@@ -0,0 +1,5 @@
packages:
- "packages/*"
catalog:
'@pnpm.e2e/pkg-with-1-dep': ^100.0.0

View File

@@ -0,0 +1,66 @@
{
"actions": [
{
"action": "update",
"resolves": [
{
"id": 123456,
"path": "packages__sub-pkg>@pnpm.e2e/pkg-with-1-dep",
"dev": false,
"optional": false,
"bundled": false
}
],
"module": "@pnpm.e2e/pkg-with-1-dep",
"target": "100.0.0",
"depth": 2
}
],
"advisories": {
"123456": {
"findings": [
{
"version": "100.0.0",
"paths": [
"packages__sub-pkg>@pnpm.e2e/pkg-with-1-dep"
]
}
],
"metadata": null,
"vulnerable_versions": "<100.1.0",
"module_name": "@pnpm.e2e/pkg-with-1-dep",
"severity": "high",
"github_advisory_id": "GHSA-mock-mock-mock",
"cves": [],
"access": "public",
"patched_versions": ">=100.1.0",
"updated": "2025-12-19T19:00:00.000Z",
"recommendation": "Upgrade to version 100.1.0 or later",
"cwe": "",
"found_by": null,
"deleted": null,
"id": 123456,
"references": "",
"created": "2025-12-19T19:00:00.000Z",
"reported_by": null,
"title": "Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"npm_advisory_id": null,
"overview": "Overview: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"url": "https://example.com"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 1,
"critical": 0
},
"dependencies": 2,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 2
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "update-workspace-depth-2",
"version": "1.0.0",
"private": true
}

View File

@@ -0,0 +1,7 @@
{
"name": "sub-pkg",
"version": "1.0.0",
"dependencies": {
"@pnpm.e2e/pkg-with-1-dep": "^100.0.0"
}
}

View File

@@ -0,0 +1,31 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
packages/sub-pkg:
dependencies:
'@pnpm.e2e/pkg-with-1-dep':
specifier: ^100.0.0
version: 100.0.0
packages:
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-atUXGBNAbym4OioYcKt1qTjiH23CSfZ1K2N8JgCUewSE5gY/i9YoK7Ez6+CuEZbH+O3R+HKNrRIaZfnkv/93tg==}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-aoavufOsqVp0D9pbv/dJRTYKFkmL8O1Pvj9lnWDFuAbNQnwWCDvxp5Dem12+DArhHmTJjGl3RccUATXLJxe1iQ==}
snapshots:
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0': {}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
dependencies:
'@pnpm.e2e/dep-of-pkg-with-1-dep': 100.0.0

View File

@@ -0,0 +1,2 @@
packages:
- "packages/*"

View File

@@ -0,0 +1,66 @@
{
"actions": [
{
"action": "update",
"resolves": [
{
"id": 123456,
"path": "packages__sub-pkg>@pnpm.e2e/pkg-with-1-dep>@pnpm.e2e/dep-of-pkg-with-1-dep",
"dev": false,
"optional": false,
"bundled": false
}
],
"module": "@pnpm.e2e/dep-of-pkg-with-1-dep",
"target": "100.0.0",
"depth": 3
}
],
"advisories": {
"123456": {
"findings": [
{
"version": "100.0.0",
"paths": [
"packages__sub-pkg>@pnpm.e2e/pkg-with-1-dep>@pnpm.e2e/dep-of-pkg-with-1-dep"
]
}
],
"metadata": null,
"vulnerable_versions": "<100.1.0",
"module_name": "@pnpm.e2e/dep-of-pkg-with-1-dep",
"severity": "high",
"github_advisory_id": "GHSA-mock-mock-mock",
"cves": [],
"access": "public",
"patched_versions": ">=100.1.0",
"updated": "2025-12-19T19:00:00.000Z",
"recommendation": "Upgrade to version 100.1.0 or later",
"cwe": "",
"found_by": null,
"deleted": null,
"id": 123456,
"references": "",
"created": "2025-12-19T19:00:00.000Z",
"reported_by": null,
"title": "Title: mock vulnerability in @pnpm.e2e/dep-of-pkg-with-1-dep",
"npm_advisory_id": null,
"overview": "Overview: mock vulnerability in @pnpm.e2e/dep-of-pkg-with-1-dep",
"url": "https://example.com"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 1,
"critical": 0
},
"dependencies": 2,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 2
}
}

View File

@@ -0,0 +1,66 @@
{
"actions": [
{
"action": "update",
"resolves": [
{
"id": 123456,
"path": "packages__sub-pkg>@pnpm.e2e/pkg-with-1-dep",
"dev": false,
"optional": false,
"bundled": false
}
],
"module": "@pnpm.e2e/pkg-with-1-dep",
"target": "100.0.0",
"depth": 2
}
],
"advisories": {
"123456": {
"findings": [
{
"version": "100.0.0",
"paths": [
"packages__sub-pkg>@pnpm.e2e/pkg-with-1-dep"
]
}
],
"metadata": null,
"vulnerable_versions": "<100.1.0",
"module_name": "@pnpm.e2e/pkg-with-1-dep",
"severity": "high",
"github_advisory_id": "GHSA-mock-mock-mock",
"cves": [],
"access": "public",
"patched_versions": ">=100.1.0",
"updated": "2025-12-19T19:00:00.000Z",
"recommendation": "Upgrade to version 100.1.0 or later",
"cwe": "",
"found_by": null,
"deleted": null,
"id": 123456,
"references": "",
"created": "2025-12-19T19:00:00.000Z",
"reported_by": null,
"title": "Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"npm_advisory_id": null,
"overview": "Overview: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"url": "https://example.com"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 1,
"critical": 0
},
"dependencies": 2,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 2
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "update-workspace-pinned",
"version": "1.0.0",
"private": true
}

View File

@@ -0,0 +1,7 @@
{
"name": "sub-pkg",
"version": "1.0.0",
"dependencies": {
"@pnpm.e2e/pkg-with-1-dep": "100.0.0"
}
}

View File

@@ -0,0 +1,31 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
packages/sub-pkg:
dependencies:
'@pnpm.e2e/pkg-with-1-dep':
specifier: 100.0.0
version: 100.0.0
packages:
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-atUXGBNAbym4OioYcKt1qTjiH23CSfZ1K2N8JgCUewSE5gY/i9YoK7Ez6+CuEZbH+O3R+HKNrRIaZfnkv/93tg==}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
resolution: {integrity: sha512-aoavufOsqVp0D9pbv/dJRTYKFkmL8O1Pvj9lnWDFuAbNQnwWCDvxp5Dem12+DArhHmTJjGl3RccUATXLJxe1iQ==}
snapshots:
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0': {}
'@pnpm.e2e/pkg-with-1-dep@100.0.0':
dependencies:
'@pnpm.e2e/dep-of-pkg-with-1-dep': 100.0.0

View File

@@ -0,0 +1,2 @@
packages:
- "packages/*"

View File

@@ -0,0 +1,66 @@
{
"actions": [
{
"action": "update",
"resolves": [
{
"id": 123456,
"path": "packages__sub-pkg>@pnpm.e2e/pkg-with-1-dep",
"dev": false,
"optional": false,
"bundled": false
}
],
"module": "@pnpm.e2e/pkg-with-1-dep",
"target": "100.0.0",
"depth": 2
}
],
"advisories": {
"123456": {
"findings": [
{
"version": "100.0.0",
"paths": [
"packages__sub-pkg>@pnpm.e2e/pkg-with-1-dep"
]
}
],
"metadata": null,
"vulnerable_versions": "<100.1.0",
"module_name": "@pnpm.e2e/pkg-with-1-dep",
"severity": "high",
"github_advisory_id": "GHSA-mock-mock-mock",
"cves": [],
"access": "public",
"patched_versions": ">=100.1.0",
"updated": "2025-12-19T19:00:00.000Z",
"recommendation": "Upgrade to version 100.1.0 or later",
"cwe": "",
"found_by": null,
"deleted": null,
"id": 123456,
"references": "",
"created": "2025-12-19T19:00:00.000Z",
"reported_by": null,
"title": "Title: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"npm_advisory_id": null,
"overview": "Overview: mock vulnerability in @pnpm.e2e/pkg-with-1-dep",
"url": "https://example.com"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 1,
"critical": 0
},
"dependencies": 2,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 2
}
}

View File

@@ -4,31 +4,23 @@ import { audit } from '@pnpm/plugin-commands-audit'
import nock from 'nock'
import { readYamlFileSync } from 'read-yaml-file'
import * as responses from './utils/responses/index.js'
import { AUDIT_REGISTRY_OPTS, AUDIT_REGISTRY } from './utils/options.js'
const f = fixtures(import.meta.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)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
fix: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: 120,
ignoreUnfixable: true,
})
@@ -44,19 +36,16 @@ test('ignores are added for vulnerable dependencies with no resolutions', async
test('the specified vulnerabilities are ignored', async () => {
const tmp = f.prepare('has-vulnerabilities')
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
fix: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: 120,
ignore: ['CVE-2017-16115'],
})
@@ -70,19 +59,16 @@ test('the specified vulnerabilities are ignored', async () => {
test('no ignores are added if no vulnerabilities are found', async () => {
const tmp = f.prepare('fixture')
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.NO_VULN_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
fix: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: 120,
ignoreUnfixable: true,
})
@@ -99,11 +85,12 @@ test('ignored CVEs are not duplicated', async () => {
'CVE-2017-16024',
]
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
auditConfig: {
ignoreCves: existingCves,
@@ -111,10 +98,6 @@ test('ignored CVEs are not duplicated', async () => {
dir: tmp,
rootProjectManifestDir: tmp,
fix: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: 120,
ignoreUnfixable: true,
})
expect(exitCode).toBe(0)

View File

@@ -6,65 +6,9 @@ import { AuditEndpointNotExistsError } from '@pnpm/audit'
import nock from 'nock'
import { stripVTControlCharacters as stripAnsi } from 'util'
import * as responses from './utils/responses/index.js'
import { DEFAULT_OPTS, AUDIT_REGISTRY_OPTS, AUDIT_REGISTRY } from './utils/options.js'
const f = fixtures(path.join(import.meta.dirname, 'fixtures'))
const registries = {
default: 'https://registry.npmjs.org/',
}
const rawConfig = {
registry: registries.default,
}
export const DEFAULT_OPTS = {
argv: {
original: [],
},
bail: true,
bin: 'node_modules/.bin',
ca: undefined,
cacheDir: '../cache',
cert: undefined,
excludeLinksFromLockfile: false,
extraEnv: {},
cliOptions: {},
fetchRetries: 2,
fetchRetryFactor: 90,
fetchRetryMaxtimeout: 90,
fetchRetryMintimeout: 10,
filter: [] as string[],
httpsProxy: undefined,
include: {
dependencies: true,
devDependencies: true,
optionalDependencies: true,
},
key: undefined,
linkWorkspacePackages: true,
localAddress: undefined,
lock: false,
lockStaleDuration: 90,
networkConcurrency: 16,
offline: false,
pending: false,
pnpmfile: ['./.pnpmfile.cjs'],
pnpmHomeDir: '',
preferWorkspacePackages: true,
proxy: undefined,
rawConfig,
rawLocalConfig: {},
registries,
rootProjectManifestDir: '',
// registry: REGISTRY,
sort: true,
storeDir: '../store',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
peersSuffixMaxLength: 1000,
}
describe('plugin-commands-audit', () => {
const hasVulnerabilitiesDir = f.prepare('has-vulnerabilities')
@@ -75,37 +19,34 @@ describe('plugin-commands-audit', () => {
dir: hasVulnerabilitiesDir,
})
})
afterEach(() => {
nock.cleanAll()
})
test('audit', async () => {
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { output, exitCode } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
})
test('audit --dev', async () => {
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.DEV_VULN_ONLY_RESP)
const { output, exitCode } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
dev: true,
production: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(1)
@@ -113,18 +54,15 @@ describe('plugin-commands-audit', () => {
})
test('audit --audit-level', async () => {
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { output, exitCode } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(1)
@@ -132,17 +70,14 @@ describe('plugin-commands-audit', () => {
})
test('audit: no vulnerabilities', async () => {
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.NO_VULN_RESP)
const { output, exitCode } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(stripAnsi(output)).toBe('No known vulnerabilities found\n')
@@ -150,18 +85,15 @@ describe('plugin-commands-audit', () => {
})
test('audit --json', async () => {
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { output, exitCode } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
json: true,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
const json = JSON.parse(output)
@@ -170,19 +102,16 @@ describe('plugin-commands-audit', () => {
})
test.skip('audit does not exit with code 1 if the found vulnerabilities are having lower severity then what we asked for', async () => {
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.DEV_VULN_ONLY_RESP)
const { output, exitCode } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'high',
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
userConfig: {},
rawConfig,
dev: true,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(0)
@@ -191,20 +120,17 @@ describe('plugin-commands-audit', () => {
})
test('audit --json respects audit-level', async () => {
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.DEV_VULN_ONLY_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'critical',
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
json: true,
userConfig: {},
rawConfig,
dev: true,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(0)
@@ -213,20 +139,17 @@ describe('plugin-commands-audit', () => {
})
test('audit --json filters advisories by audit-level', async () => {
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.DEV_VULN_ONLY_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'high',
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
json: true,
userConfig: {},
rawConfig,
dev: true,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(1)
@@ -240,46 +163,41 @@ describe('plugin-commands-audit', () => {
})
test('audit does not exit with code 1 if the registry responds with a non-200 response and ignoreRegistryErrors is used', async () => {
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(500, { message: 'Something bad happened' })
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits')
.reply(500, { message: 'Fallback failed too' })
const { output, exitCode } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
dev: true,
fetchRetries: 0,
ignoreRegistryErrors: true,
production: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(0)
expect(stripAnsi(output)).toBe(`The audit endpoint (at ${registries.default}-/npm/v1/security/audits/quick) responded with 500: {"message":"Something bad happened"}. Fallback endpoint (at ${registries.default}-/npm/v1/security/audits) responded with 500: {"message":"Fallback failed too"}`)
expect(stripAnsi(output)).toBe(`The audit endpoint (at ${AUDIT_REGISTRY}-/npm/v1/security/audits/quick) responded with 500: {"message":"Something bad happened"}. Fallback endpoint (at ${AUDIT_REGISTRY}-/npm/v1/security/audits) responded with 500: {"message":"Fallback failed too"}`)
})
test('audit sends authToken', async () => {
nock(registries.default, {
nock(AUDIT_REGISTRY, {
reqheaders: { authorization: 'Bearer 123' },
})
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.NO_VULN_RESP)
const { output, exitCode } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
userConfig: {},
rawConfig: {
registry: registries.default,
[`${registries.default.replace(/^https?:/, '')}:_authToken`]: '123',
registry: AUDIT_REGISTRY,
[`${AUDIT_REGISTRY.replace(/^https?:/, '')}:_authToken`]: '123',
},
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(stripAnsi(output)).toBe('No known vulnerabilities found\n')
@@ -287,41 +205,36 @@ describe('plugin-commands-audit', () => {
})
test('audit endpoint does not exist', async () => {
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(404, {})
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits')
.reply(404, {})
await expect(audit.handler({
...AUDIT_REGISTRY_OPTS,
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
dev: true,
fetchRetries: 0,
ignoreRegistryErrors: false,
production: false,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})).rejects.toThrow(AuditEndpointNotExistsError)
})
test('audit: CVEs in ignoreCves do not show up', async () => {
const tmp = f.prepare('has-vulnerabilities')
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
userConfig: {},
rawConfig,
registries,
rootProjectManifest: {},
auditConfig: {
ignoreCves: [
@@ -331,7 +244,6 @@ describe('plugin-commands-audit', () => {
'CVE-2020-7598',
],
},
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(1)
@@ -341,17 +253,15 @@ describe('plugin-commands-audit', () => {
test('audit: CVEs in ignoreGhsas do not show up', async () => {
const tmp = f.prepare('has-vulnerabilities')
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
userConfig: {},
rawConfig,
registries,
rootProjectManifest: {},
auditConfig: {
ignoreGhsas: [
@@ -361,7 +271,6 @@ describe('plugin-commands-audit', () => {
'GHSA-vh95-rmgr-6w4m',
],
},
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(1)
@@ -371,18 +280,16 @@ describe('plugin-commands-audit', () => {
test('audit: CVEs in ignoreCves do not show up when JSON output is used', async () => {
const tmp = f.prepare('has-vulnerabilities')
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
json: true,
userConfig: {},
rawConfig,
registries,
rootProjectManifest: {},
auditConfig: {
ignoreCves: [
@@ -392,7 +299,6 @@ describe('plugin-commands-audit', () => {
'CVE-2020-7598',
],
},
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
})
expect(exitCode).toBe(1)

View File

@@ -5,34 +5,26 @@ import { readProjectManifest } from '@pnpm/read-project-manifest'
import { readYamlFileSync } from 'read-yaml-file'
import nock from 'nock'
import * as responses from './utils/responses/index.js'
import { AUDIT_REGISTRY_OPTS, AUDIT_REGISTRY } from './utils/options.js'
const f = fixtures(import.meta.dirname)
const registries = {
default: 'https://registry.npmjs.org/',
}
const rawConfig = {
registry: registries.default,
}
test('overrides with references (via $) are preserved during audit --fix', async () => {
const tmp = f.prepare('preserve-reference-overrides')
nock(registries.default)
nock(AUDIT_REGISTRY)
.post('/-/npm/v1/security/audits/quick')
.reply(200, responses.ALL_VULN_RESP)
const { manifest: initialManifest } = await readProjectManifest(tmp)
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
auditLevel: 'moderate',
dir: tmp,
rootProjectManifestDir: tmp,
rootProjectManifest: initialManifest,
fix: true,
userConfig: {},
rawConfig,
registries,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
overrides: {
'is-positive': '1.0.0',
},

View File

@@ -0,0 +1,83 @@
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
const registries = {
default: 'https://registry.npmjs.org/',
}
const rawConfig = {
registry: registries.default,
}
export const DEFAULT_OPTS = {
argv: {
original: [],
},
bail: true,
bin: 'node_modules/.bin',
ca: undefined,
cacheDir: '../cache',
cert: undefined,
excludeLinksFromLockfile: false,
extraEnv: {},
cliOptions: {},
fetchRetries: 2,
fetchRetryFactor: 90,
fetchRetryMaxtimeout: 90,
fetchRetryMintimeout: 10,
filter: [] as string[],
httpsProxy: undefined,
include: {
dependencies: true,
devDependencies: true,
optionalDependencies: true,
},
key: undefined,
linkWorkspacePackages: true,
localAddress: undefined,
lock: false,
lockStaleDuration: 90,
networkConcurrency: 16,
offline: false,
pending: false,
pnpmfile: ['./.pnpmfile.cjs'],
pnpmHomeDir: '',
preferWorkspacePackages: true,
proxy: undefined,
rawConfig,
rawLocalConfig: {},
registries,
rootProjectManifestDir: '',
registry: registries.default,
sort: true,
storeDir: '../store',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
peersSuffixMaxLength: 1000,
}
export const AUDIT_REGISTRY = 'http://audit.registry/'
export const AUDIT_REGISTRY_OPTS = {
...DEFAULT_OPTS,
registry: AUDIT_REGISTRY,
registries: {
default: AUDIT_REGISTRY,
},
rawConfig: {
registry: AUDIT_REGISTRY,
},
}
export const MOCK_REGISTRY = `http://localhost:${REGISTRY_MOCK_PORT}`
export const MOCK_REGISTRY_OPTS = {
...DEFAULT_OPTS,
registry: MOCK_REGISTRY,
registries: {
default: MOCK_REGISTRY,
},
rawConfig: {
registry: MOCK_REGISTRY,
},
}

View File

@@ -40,11 +40,23 @@
{
"path": "../../pkg-manifest/read-project-manifest"
},
{
"path": "../../workspace/filter-workspace-packages"
},
{
"path": "../audit"
},
{
"path": "../fs"
},
{
"path": "../types"
},
{
"path": "../utils"
},
{
"path": "../walker"
}
]
}

View File

@@ -44,3 +44,32 @@ export type PinnedVersion =
| 'major'
export type IgnoredBuilds = Set<DepPath>
export interface PackageVulnerabilityAudit {
/**
* Check if the given package version is vulnerable.
*/
isVulnerable: (packageName: string, version: string) => boolean
/**
* Get all vulnerabilities for all packages.
* @returns A map where the keys are package names and the values are arrays of vulnerabilities for those packages.
*/
getVulnerabilities: () => Map<string, PackageVulnerability[]>
}
export interface PackageVulnerability {
/**
* A semver version range that indicates which versions are vulnerable
*/
versionRange: string
/**
* The severity of the vulnerability
*/
severity: VulnerabilitySeverity
}
export type VulnerabilitySeverity =
| 'low'
| 'moderate'
| 'high'
| 'critical'

View File

@@ -18,6 +18,7 @@ import type {
ReadPackageHook,
Registries,
TrustPolicy,
PackageVulnerabilityAudit,
} from '@pnpm/types'
import type { CustomResolver, CustomFetcher, PreResolutionHookContext } from '@pnpm/hooks.types'
import { parseOverrides, type VersionOverride } from '@pnpm/parse-overrides'
@@ -174,6 +175,7 @@ export interface StrictInstallOptions {
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
trustPolicyIgnoreAfter?: number
packageVulnerabilityAudit?: PackageVulnerabilityAudit
blockExoticSubdeps?: boolean
}

View File

@@ -557,6 +557,41 @@ export async function mutateModules (
includeDirect: opts.includeDirect,
})
.map((wantedDependency) => ({ ...wantedDependency, updateSpec: true }))
if (opts.packageVulnerabilityAudit) {
for (const dep of wantedDependencies) {
let specifier: string | undefined = dep.bareSpecifier
const catalogName = specifier ? parseCatalogProtocol(specifier) : null
if (catalogName != null) {
const catalogResult = resolveFromCatalog(opts.catalogs, { alias: dep.alias, bareSpecifier: specifier! })
specifier = matchCatalogResolveResult(catalogResult, pickCatalogSpecifier)
}
const validVersion = semver.valid(specifier)
// Only proceed if the specifier is a pinned version, not a range
if (!validVersion) continue
if (opts.packageVulnerabilityAudit.isVulnerable(dep.alias, validVersion)) {
// If the current version is pinned and vulnerable, expand the specifier to a range
// that will allow updating to a non-vulnerable, semver-compatible version, if available.
if (catalogName != null && opts.catalogs?.[catalogName]) {
// If a catalog is used, update the catalog entry so the resolver can find a
// non-vulnerable version. The package.json keeps "catalog:" and the workspace manifest
// gets updated.
opts.catalogs = {
...opts.catalogs,
[catalogName]: {
...opts.catalogs[catalogName],
[dep.alias]: '^' + validVersion,
},
}
// Set prevSpecifier to the original catalog specifier so the resolver
// preserves the original pinning style (i.e. pinned stays pinned).
dep.prevSpecifier = specifier
} else {
// If no catalog is used, we directly update the specifier.
dep.bareSpecifier = '^' + validVersion
}
}
}
}
if (ctx.wantedLockfile?.importers) {
forgetResolutionsOfPrevWantedDeps(ctx.wantedLockfile.importers[project.id], wantedDependencies, _isWantedDepBareSpecifierSame)

View File

@@ -13,12 +13,20 @@ import { filterDependenciesByType } from '@pnpm/manifest-utils'
import { findWorkspacePackages } from '@pnpm/workspace.find-packages'
import type { LockfileObject } from '@pnpm/lockfile.types'
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store-connection-manager'
import type { IncludedDependencies, Project, ProjectsGraph, ProjectRootDir } from '@pnpm/types'
import type {
IncludedDependencies,
Project,
ProjectsGraph,
ProjectRootDir,
PackageVulnerabilityAudit,
VulnerabilitySeverity,
} from '@pnpm/types'
import {
IgnoredBuildsError,
install,
mutateModulesInSingleProject,
type MutateModulesOptions,
type UpdateMatchingFunction,
type WorkspacePackages,
} from '@pnpm/core'
import { globalInfo, logger } from '@pnpm/logger'
@@ -26,6 +34,7 @@ import { sequenceGraph } from '@pnpm/sort-packages'
import { updateWorkspaceManifest } from '@pnpm/workspace.manifest-writer'
import { createPkgGraph } from '@pnpm/workspace.pkgs-graph'
import { updateWorkspaceState, type WorkspaceStateSettings } from '@pnpm/workspace.state'
import type { PreferredVersions, VersionSelectors } from '@pnpm/resolver-base'
import { getPinnedVersion } from './getPinnedVersion.js'
import { getSaveType } from './getSaveType.js'
import {
@@ -127,7 +136,7 @@ export type InstallDepsOptions = Pick<Config,
lockfileCheck?: (prev: LockfileObject, next: LockfileObject) => void
update?: boolean
updateToLatest?: boolean
updateMatching?: (pkgName: string) => boolean
updateMatching?: UpdateMatchingFunction
updatePackageManifest?: boolean
useBetaCli?: boolean
recursive?: boolean
@@ -137,6 +146,7 @@ export type InstallDepsOptions = Pick<Config,
fetchFullMetadata?: boolean
pruneLockfileImporters?: boolean
pnpmfile: string[]
packageVulnerabilityAudit?: PackageVulnerabilityAudit
} & Partial<Pick<Config, 'pnpmHomeDir' | 'strictDepBuilds'>>
export async function installDeps (
@@ -224,6 +234,7 @@ when running add/update with the --workspace option')
},
forceHoistPattern,
forcePublicHoistPattern,
preferredVersions: opts.packageVulnerabilityAudit ? preferNonvulnerablePackageVersions(opts.packageVulnerabilityAudit) : undefined,
allProjectsGraph,
selectedProjectsGraph,
storeControllerAndDir: store,
@@ -272,11 +283,12 @@ when running add/update with the --workspace option')
storeController: store.ctrl,
storeDir: store.dir,
workspacePackages,
preferredVersions: opts.packageVulnerabilityAudit ? preferNonvulnerablePackageVersions(opts.packageVulnerabilityAudit) : undefined,
}
let updateMatch: UpdateDepsMatcher | null
let updatePackageManifest = opts.updatePackageManifest
let updateMatching: ((pkgName: string) => boolean) | undefined
let updateMatching: UpdateMatchingFunction | undefined
if (opts.update) {
if (params.length === 0) {
const ignoreDeps = opts.updateConfig?.ignoreDependencies
@@ -288,6 +300,11 @@ when running add/update with the --workspace option')
} else {
updateMatch = null
}
if (opts.packageVulnerabilityAudit != null) {
updateMatch = null
const { packageVulnerabilityAudit } = opts
updateMatching = (pkgName: string, version?: string) => version != null && packageVulnerabilityAudit.isVulnerable(pkgName, version)
}
if (updateMatch != null) {
params = matchDependencies(updateMatch, manifest, includeDirect)
if (params.length === 0) {
@@ -445,3 +462,55 @@ async function recursiveInstallThenUpdateWorkspaceState (
}
return recursiveResult
}
function severityStringToNumber (severity: VulnerabilitySeverity): number {
switch (severity) {
case 'low': return 0
case 'moderate': return 1
case 'high': return 2
case 'critical': return 3
default: return -1
}
}
function getVulnerabilityPenalty (severity: VulnerabilitySeverity): number {
switch (severity) {
case 'low': return -1100 // 100 more than DIRECT_DEP_SELECTOR_WEIGHT from @pnpm/resolver-base
case 'moderate': return -2000
case 'high': return -3000
case 'critical': return -4000
// Treat unrecognized severity as the lowest severity
default: return -1100
}
}
function preferNonvulnerablePackageVersions (packageVulnerabilityAudit: PackageVulnerabilityAudit): PreferredVersions {
const preferredVersions: PreferredVersions = {}
for (const [packageName, vulnerabilities] of packageVulnerabilityAudit.getVulnerabilities()) {
const vulnerableRanges = new Map<string, VulnerabilitySeverity>()
for (const vuln of vulnerabilities) {
const existingSeverity = vulnerableRanges.get(vuln.versionRange)
if (existingSeverity == null) {
vulnerableRanges.set(vuln.versionRange, vuln.severity)
continue
}
// Choose the highest severity for the same version range
if (severityStringToNumber(vuln.severity) > severityStringToNumber(existingSeverity)) {
vulnerableRanges.set(vuln.versionRange, vuln.severity)
}
}
const preferredVersionSelectors: VersionSelectors = {}
for (const [vulnRange, severity] of vulnerableRanges) {
if (vulnRange === '__proto__' || vulnRange === 'constructor' || vulnRange === 'prototype') {
// Prevent prototype pollution
continue
}
preferredVersionSelectors[vulnRange] = {
selectorType: 'range',
weight: getVulnerabilityPenalty(severity),
}
}
preferredVersions[packageName] = preferredVersionSelectors
}
return preferredVersions
}

View File

@@ -11,7 +11,8 @@ import { globalInfo } from '@pnpm/logger'
import { createMatcher } from '@pnpm/matcher'
import { outdatedDepsOfProjects } from '@pnpm/outdated'
import { PnpmError } from '@pnpm/error'
import type { IncludedDependencies, ProjectRootDir } from '@pnpm/types'
import type { IncludedDependencies, ProjectRootDir, PackageVulnerabilityAudit } from '@pnpm/types'
import type { UpdateMatchingFunction } from '@pnpm/core'
import enquirer from 'enquirer'
import chalk from 'chalk'
import { pick, pluck, unnest } from 'ramda'
@@ -163,6 +164,7 @@ dependencies is not found inside the workspace',
export type UpdateCommandOptions = InstallCommandOptions & {
interactive?: boolean
latest?: boolean
packageVulnerabilityAudit?: PackageVulnerabilityAudit
}
export async function handler (
@@ -295,6 +297,15 @@ async function update (
optionalDependencies: opts.rawConfig.optional !== false,
}
const depth = opts.depth ?? Infinity
let updateMatching: UpdateMatchingFunction | undefined
if (opts.packageVulnerabilityAudit != null) {
const { packageVulnerabilityAudit } = opts
updateMatching = (pkgName: string, version?: string) => version != null && packageVulnerabilityAudit.isVulnerable(pkgName, version)
} else if (
(dependencies.length > 0) && dependencies.every(dep => !dep.substring(1).includes('@')) && depth > 0 && !opts.latest
) {
updateMatching = createMatcher(dependencies)
}
return installDeps({
...opts,
allowNew: false,
@@ -304,9 +315,7 @@ async function update (
include,
update: true,
updateToLatest: opts.latest,
updateMatching: (dependencies.length > 0) && dependencies.every(dep => !dep.substring(1).includes('@')) && depth > 0 && !opts.latest
? createMatcher(dependencies)
: undefined,
updateMatching,
updatePackageManifest: opts.save !== false,
resolutionMode: opts.save === false ? 'highest' : opts.resolutionMode,
}, dependencies)

View File

@@ -268,7 +268,7 @@ type ParentPkg = Pick<PkgAddress, 'nodeId' | 'installable' | 'rootDir' | 'option
export type ParentPkgAliases = Record<string, PkgAddress | true>
export type UpdateMatchingFunction = (pkgName: string) => boolean
export type UpdateMatchingFunction = (pkgName: string, version?: string) => boolean
interface ResolvedDependenciesOptions {
currentDepth: number
@@ -836,7 +836,7 @@ async function resolveDependenciesOfDependency (
(options.updateMatching == null) ||
(
extendedWantedDep.infoFromLockfile?.name != null &&
options.updateMatching(extendedWantedDep.infoFromLockfile.name)
options.updateMatching(extendedWantedDep.infoFromLockfile.name, extendedWantedDep.infoFromLockfile.version)
)
)
const update = updateRequested ||

View File

@@ -94,7 +94,7 @@ export interface Importer<WantedDepExtraProps> {
export interface ImporterToResolveGeneric<WantedDepExtraProps> extends Importer<WantedDepExtraProps> {
updatePackageManifest: boolean
updateMatching?: (pkgName: string) => boolean
updateMatching?: (pkgName: string, version?: string) => boolean
updateToLatest?: boolean
hasRemovedDependencies?: boolean
preferredVersions?: PreferredVersions

27
pnpm-lock.yaml generated
View File

@@ -4047,9 +4047,21 @@ importers:
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../fs
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../types
'@pnpm/lockfile.utils':
specifier: workspace:*
version: link:../utils
'@pnpm/lockfile.walker':
specifier: workspace:*
version: link:../walker
'@pnpm/network.auth-header':
specifier: workspace:*
version: link:../../network/auth-header
'@pnpm/plugin-commands-installation':
specifier: workspace:*
version: link:../../pkg-manager/plugin-commands-installation
'@pnpm/read-project-manifest':
specifier: workspace:*
version: link:../../pkg-manifest/read-project-manifest
@@ -4071,19 +4083,28 @@ importers:
render-help:
specifier: 'catalog:'
version: 2.0.0
semver:
specifier: 'catalog:'
version: 7.7.4
devDependencies:
'@pnpm/filter-workspace-packages':
specifier: workspace:*
version: link:../../workspace/filter-workspace-packages
'@pnpm/plugin-commands-audit':
specifier: workspace:*
version: 'link:'
'@pnpm/plugin-commands-installation':
specifier: workspace:*
version: link:../../pkg-manager/plugin-commands-installation
'@pnpm/registry-mock':
specifier: 'catalog:'
version: 5.2.2(verdaccio@6.2.7(encoding@0.1.13)(typanion@3.14.0))
'@pnpm/test-fixtures':
specifier: workspace:*
version: link:../../__utils__/test-fixtures
'@types/ramda':
specifier: 'catalog:'
version: 0.29.12
'@types/semver':
specifier: 'catalog:'
version: 7.7.1
'@types/zkochan__table':
specifier: 'catalog:'
version: '@types/table@6.0.0'

View File

@@ -184,7 +184,7 @@ export async function main (inputArgv: string[]): Promise<void> {
}
if (
(cmd === 'install' || cmd === 'import' || cmd === 'dedupe' || cmd === 'patch-commit' || cmd === 'patch' || cmd === 'patch-remove' || cmd === 'approve-builds') &&
(cmd === 'install' || cmd === 'import' || cmd === 'dedupe' || cmd === 'patch-commit' || cmd === 'patch' || cmd === 'patch-remove' || cmd === 'approve-builds' || cmd === 'audit') &&
typeof workspaceDir === 'string'
) {
cliOptions['recursive'] = true

View File

@@ -188,6 +188,15 @@ function prioritizePreferredVersions (
): string[][] {
const preferredVerSelectorsArr = Object.entries(preferredVerSelectors ?? {})
const versionsPrioritizer = new PreferredVersionsPrioritizer()
// First, add all versions that satisfy versionRange with default weight 0
for (const version of Object.keys(meta.versions)) {
if (semverSatisfiesLoose(version, versionRange)) {
versionsPrioritizer.add(version, 0)
}
}
// Then apply weights from preferred selectors
for (const [preferredSelector, preferredSelectorType] of preferredVerSelectorsArr) {
const { selectorType, weight } = typeof preferredSelectorType === 'string'
? { selectorType: preferredSelectorType, weight: 1 }

View File

@@ -691,6 +691,32 @@ test('prefer the version that has bigger weight in preferred selectors', async (
expect(resolveResult!.id).toBe('is-positive@3.0.0')
})
test('versions without selector weights should have higher priority than negatively weighted versions', async () => {
nock(registries.default)
.get('/is-positive')
.reply(200, isPositiveMeta)
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir: temporaryDirectory(),
registries,
})
const resolveResult = await resolveFromNpm({
alias: 'is-positive',
bareSpecifier: '^3.0.0',
}, {
preferredVersions: {
'is-positive': {
// Penalize 3.0.0, but don't mention 3.1.0
'3.0.0': { selectorType: 'version', weight: -1 },
},
},
})
// 3.1.0 should be selected because it has default weight 0, higher than 3.0.0's -1
expect(resolveResult!.id).toBe('is-positive@3.1.0')
})
test('offline resolution fails when package meta not found in the store', async () => {
const cacheDir = temporaryDirectory()
const { resolveFromNpm } = createResolveFromNpm({