feat: set minimumReleaseAge to delay new versions of dependencies from being installed (#9957)

close #9921
This commit is contained in:
Zoltan Kochan
2025-09-11 17:25:11 +02:00
committed by GitHub
parent 7be9e9d1af
commit 38e2599ecd
14 changed files with 327 additions and 40 deletions

View File

@@ -0,0 +1,22 @@
---
"@pnpm/plugin-commands-installation": minor
"@pnpm/resolve-dependencies": minor
"@pnpm/store-connection-manager": minor
"@pnpm/npm-resolver": minor
"@pnpm/core": minor
"@pnpm/config": minor
"pnpm": minor
---
There have been several incidents recently where popular packages were successfully attacked. To reduce the risk of installing a compromised version, we are introducing a new setting that delays the installation of newly released dependencies. In most cases, such attacks are discovered quickly and the malicious versions are removed from the registry within an hour.
The new setting is called `minimumReleaseAge`. It specifies the number of minutes that must pass after a version is published before pnpm will install it. For example, setting `minimumReleaseAge: 1440` ensures that only packages released at least one day ago can be installed.
If you set `minimumReleaseAge` but need to disable this restriction for certain dependencies, you can list them under the `minimumReleaseAgeExclude` setting. For instance, with the following configuration pnpm will always install the latest version of webpack, regardless of its release time:
```yaml
minimumReleaseAgeExclude:
- webpack
```
Related issue: [#9921](https://github.com/pnpm/pnpm/issues/9921).

View File

@@ -227,6 +227,8 @@ export interface Config extends OptionsFromRootManifest {
dangerouslyAllowAllBuilds: boolean
ci: boolean
preserveAbsolutePaths?: boolean
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
}
export interface ConfigWithDeprecatedSettings extends Config {

View File

@@ -63,6 +63,8 @@ export const types = Object.assign({
maxsockets: Number,
'modules-cache-max-age': Number,
'dlx-cache-max-age': Number,
'minimum-release-age': Number,
'minimum-release-age-exclude': [String, Array],
'modules-dir': String,
'network-concurrency': Number,
'node-linker': ['pnp', 'isolated', 'hoisted'],

View File

@@ -165,6 +165,8 @@ export interface StrictInstallOptions {
returnListOfDepsRequiringBuild?: boolean
injectWorkspacePackages?: boolean
ci?: boolean
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
}
export type InstallOptions =

View File

@@ -1179,6 +1179,8 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
supportedArchitectures: opts.supportedArchitectures,
peersSuffixMaxLength: opts.peersSuffixMaxLength,
injectWorkspacePackages: opts.injectWorkspacePackages,
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
}
)
if (!opts.include.optionalDependencies || !opts.include.devDependencies || !opts.include.dependencies) {

View File

@@ -0,0 +1,24 @@
import { prepareEmpty } from '@pnpm/prepare'
import { addDependenciesToPackage } from '@pnpm/core'
import { testDefaults } from '../utils/index.js'
const isOdd011ReleaseDate = new Date(2016, 11, 7 - 2) // 0.1.1 was released at 2016-12-07T07:18:01.205Z
const diff = Date.now() - isOdd011ReleaseDate.getTime()
const minimumReleaseAge = diff / (60 * 1000) // converting to minutes
test('minimumReleaseAge prevents installation of versions that do not meet the required publish date cutoff', async () => {
prepareEmpty()
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1'], testDefaults({ minimumReleaseAge }))
expect(manifest.dependencies!['is-odd']).toEqual('~0.1.0')
})
test('minimumReleaseAge is ignored for packages in the minimumReleaseAgeExclude array', async () => {
prepareEmpty()
const opts = testDefaults({ minimumReleaseAge, minimumReleaseAgeExclude: ['is-odd'] })
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1'], opts)
expect(manifest.dependencies!['is-odd']).toEqual('~0.1.2')
})

View File

@@ -400,6 +400,21 @@ test('add: fail trying to install @pnpm/exe', async () => {
expect(err.code).toBe('ERR_PNPM_GLOBAL_PNPM_INSTALL')
})
test('minimumReleaseAge makes install fail if there is no version that was published before the cutoff', async () => {
prepareEmpty()
const isOdd011ReleaseDate = new Date(2016, 11, 7 - 2) // 0.1.1 was released at 2016-12-07T07:18:01.205Z
const diff = Date.now() - isOdd011ReleaseDate.getTime()
const minimumReleaseAge = diff / (60 * 1000) // converting to minutes
await expect(add.handler({
...DEFAULT_OPTIONS,
dir: path.resolve('project'),
minimumReleaseAge,
linkWorkspacePackages: false,
}, ['is-odd@0.1.1'])).rejects.toThrow('No matching version found')
})
describeOnLinuxOnly('filters optional dependencies based on pnpm.supportedArchitectures.libc', () => {
test.each([
['glibc', '@pnpm.e2e+only-linux-x64-glibc@1.0.0', '@pnpm.e2e+only-linux-x64-musl@1.0.0'],

View File

@@ -176,6 +176,8 @@ export interface ResolutionContext {
workspacePackages?: WorkspacePackages
missingPeersOfChildrenByPkgId: Record<PkgResolutionId, { depth: number, missingPeersOfChildren: MissingPeersOfChildren }>
hoistPeers?: boolean
maximumPublishedBy?: Date
minimumReleaseAgeExclude?: string[]
}
export interface MissingPeerInfo {
@@ -485,6 +487,9 @@ async function resolveDependenciesOfImporters (
time = result.newTime
}
}
if (ctx.maximumPublishedBy && (publishedBy == null || publishedBy > ctx.maximumPublishedBy)) {
publishedBy = ctx.maximumPublishedBy
}
const pkgAddressesByImportersWithoutPeers = await Promise.all(zipWith(async (importer, { pkgAddresses, postponedResolutionsQueue, postponedPeersResolutionQueue }) => {
const newPreferredVersions = Object.create(importer.preferredVersions) as PreferredVersions
const currentParentPkgAliases: Record<string, PkgAddress | true> = {}
@@ -589,6 +594,7 @@ async function resolveDependenciesOfImporterDependency (
parentPkgAliases: importer.parentPkgAliases,
pickLowestVersion: pickLowestVersion && !importer.updatePackageManifest,
pinnedVersion: importer.pinnedVersion,
publishedBy: ctx.maximumPublishedBy,
},
extendedWantedDep
)
@@ -1299,6 +1305,17 @@ async function resolveDependency (
if (!options.updateRequested && options.preferredVersion != null) {
wantedDependency.bareSpecifier = replaceVersionInBareSpecifier(wantedDependency.bareSpecifier, options.preferredVersion)
}
let publishedBy: Date | undefined
if (
options.publishedBy &&
(
ctx.minimumReleaseAgeExclude == null ||
wantedDependency.alias == null ||
!ctx.minimumReleaseAgeExclude.includes(wantedDependency.alias)
)
) {
publishedBy = options.publishedBy
}
pkgResponse = await ctx.storeController.requestPackage(wantedDependency, {
alwaysTryWorkspacePackages: ctx.linkWorkspacePackagesDepth >= options.currentDepth,
currentPkg: currentPkg
@@ -1312,7 +1329,7 @@ async function resolveDependency (
expectedPkg: currentPkg,
defaultTag: ctx.defaultTag,
ignoreScripts: ctx.ignoreScripts,
publishedBy: options.publishedBy,
publishedBy,
pickLowestVersion: options.pickLowestVersion,
downloadPriority: -options.currentDepth,
lockfileDir: ctx.lockfileDir,

View File

@@ -133,6 +133,8 @@ export interface ResolveDependenciesOptions {
workspacePackages: WorkspacePackages
supportedArchitectures?: SupportedArchitectures
peersSuffixMaxLength: number
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
}
export interface ResolveDependencyTreeResult {
@@ -193,6 +195,8 @@ export async function resolveDependencyTree<T> (
missingPeersOfChildrenByPkgId: {},
hoistPeers: autoInstallPeers || opts.dedupePeerDependents,
allPeerDepNames: new Set(),
maximumPublishedBy: opts.minimumReleaseAge ? new Date(Date.now() - opts.minimumReleaseAge * 60 * 1000) : undefined,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
}
const resolveArgs: ImporterToResolve[] = importers.map((importer) => {

View File

@@ -75,6 +75,7 @@ export interface ResolverFactoryOptions {
registries: Registries
saveWorkspaceProtocol?: boolean | 'rolling'
preserveAbsolutePaths?: boolean
strictPublishedByCheck?: boolean
}
export interface NpmResolveResult extends ResolveResult {
@@ -132,6 +133,7 @@ export function createNpmResolver (
offline: opts.offline,
preferOffline: opts.preferOffline,
cacheDir: opts.cacheDir,
strictPublishedByCheck: opts.strictPublishedByCheck,
}),
registries: opts.registries,
saveWorkspaceProtocol: opts.saveWorkspaceProtocol,

View File

@@ -25,6 +25,10 @@ export interface PackageMeta {
cachedAt?: number
}
export interface PackageMetaWithTime extends PackageMeta {
time: PackageMetaTime
}
export type PackageMetaTime = Record<string, string> & {
unpublished?: {
time: string
@@ -90,6 +94,15 @@ export interface PickPackageOptions {
updateToLatest?: boolean
}
function pickPackageFromMetaUsingTimeStrict (
spec: RegistryPackageSpec,
preferredVersionSelectors: VersionSelectors | undefined,
meta: PackageMeta,
publishedBy?: Date
): PackageInRegistry | null {
return pickPackageFromMeta(pickVersionByVersionRange, spec, preferredVersionSelectors, meta, publishedBy)
}
function pickPackageFromMetaUsingTime (
spec: RegistryPackageSpec,
preferredVersionSelectors: VersionSelectors | undefined,
@@ -98,7 +111,7 @@ function pickPackageFromMetaUsingTime (
): PackageInRegistry | null {
const pickedPackage = pickPackageFromMeta(pickVersionByVersionRange, spec, preferredVersionSelectors, meta, publishedBy)
if (pickedPackage) return pickedPackage
return pickPackageFromMeta(pickLowestVersionByVersionRange, spec, preferredVersionSelectors, meta, publishedBy)
return pickPackageFromMeta(pickLowestVersionByVersionRange, spec, preferredVersionSelectors, meta)
}
export async function pickPackage (
@@ -110,6 +123,7 @@ export async function pickPackage (
offline?: boolean
preferOffline?: boolean
filterMetadata?: boolean
strictPublishedByCheck?: boolean
},
spec: RegistryPackageSpec,
opts: PickPackageOptions
@@ -117,7 +131,7 @@ export async function pickPackage (
opts = opts || {}
let _pickPackageFromMeta =
opts.publishedBy
? pickPackageFromMetaUsingTime
? (ctx.strictPublishedByCheck ? pickPackageFromMetaUsingTimeStrict : pickPackageFromMetaUsingTime)
: (pickPackageFromMeta.bind(null, opts.pickLowestVersion ? pickLowestVersionByVersionRange : pickVersionByVersionRange))
if (opts.updateToLatest) {
@@ -186,11 +200,17 @@ export async function pickPackage (
if (opts.publishedBy) {
metaCachedInStore = metaCachedInStore ?? await limit(async () => loadMeta(pkgMirror))
if (metaCachedInStore?.cachedAt && new Date(metaCachedInStore.cachedAt) >= opts.publishedBy) {
const pickedPackage = _pickPackageFromMeta(spec, opts.preferredVersionSelectors, metaCachedInStore, opts.publishedBy)
if (pickedPackage) {
return {
meta: metaCachedInStore,
pickedPackage,
try {
const pickedPackage = _pickPackageFromMeta(spec, opts.preferredVersionSelectors, metaCachedInStore, opts.publishedBy)
if (pickedPackage) {
return {
meta: metaCachedInStore,
pickedPackage,
}
}
} catch (err) {
if (ctx.strictPublishedByCheck) {
throw err
}
}
}

View File

@@ -1,16 +1,19 @@
import { PnpmError } from '@pnpm/error'
import { globalWarn } from '@pnpm/logger'
import { type VersionSelectors } from '@pnpm/resolver-base'
import semver from 'semver'
import util from 'util'
import { type RegistryPackageSpec } from './parseBareSpecifier.js'
import { type PackageInRegistry, type PackageMeta } from './pickPackage.js'
import { type PackageInRegistry, type PackageMeta, type PackageMetaWithTime } from './pickPackage.js'
export type PickVersionByVersionRange = (
meta: PackageMeta,
versionRange: string,
preferredVerSels?: VersionSelectors,
export interface PickVersionByVersionRangeOptions {
meta: PackageMeta
versionRange: string
preferredVersionSelectors?: VersionSelectors
publishedBy?: Date
) => string | null
}
export type PickVersionByVersionRange = (options: PickVersionByVersionRangeOptions) => string | null
export function pickPackageFromMeta (
pickVersionByVersionRangeFn: PickVersionByVersionRange,
@@ -19,6 +22,10 @@ export function pickPackageFromMeta (
meta: PackageMeta,
publishedBy?: Date
): PackageInRegistry | null {
if (publishedBy) {
assertMetaHasTime(meta)
meta = filterMetaByPublishedDate(meta, publishedBy)
}
if ((!meta.versions || Object.keys(meta.versions).length === 0) && !publishedBy) {
// Unfortunately, the npm registry doesn't return the time field in the abbreviated metadata.
// So we won't always know if the package was unpublished.
@@ -37,7 +44,12 @@ export function pickPackageFromMeta (
version = meta['dist-tags'][spec.fetchSpec]
break
case 'range':
version = pickVersionByVersionRangeFn(meta, spec.fetchSpec, preferredVersionSelectors, publishedBy)
version = pickVersionByVersionRangeFn({
meta,
versionRange: spec.fetchSpec,
preferredVersionSelectors,
publishedBy,
})
break
}
if (!version) return null
@@ -67,6 +79,12 @@ export function pickPackageFromMeta (
}
}
function assertMetaHasTime (meta: PackageMeta): asserts meta is PackageMetaWithTime {
if (meta.time == null) {
throw new PnpmError('MISSING_TIME', `The metadata of ${meta.name} is missing the "time" field`)
}
}
const semverRangeCache = new Map<string, semver.Range | null>()
// This is a performance optimization; working with string-ish semver
@@ -95,12 +113,10 @@ function semverSatisfiesLoose (version: string, range: string): boolean {
}
export function pickLowestVersionByVersionRange (
meta: PackageMeta,
versionRange: string,
preferredVerSels?: VersionSelectors
{ meta, versionRange, preferredVersionSelectors }: PickVersionByVersionRangeOptions
): string | null {
if (preferredVerSels != null && Object.keys(preferredVerSels).length > 0) {
const prioritizedPreferredVersions = prioritizePreferredVersions(meta, versionRange, preferredVerSels)
if (preferredVersionSelectors != null && Object.keys(preferredVersionSelectors).length > 0) {
const prioritizedPreferredVersions = prioritizePreferredVersions(meta, versionRange, preferredVersionSelectors)
for (const preferredVersions of prioritizedPreferredVersions) {
const preferredVersion = semver.minSatisfying(preferredVersions, versionRange, true)
if (preferredVersion) {
@@ -114,16 +130,11 @@ export function pickLowestVersionByVersionRange (
return semver.minSatisfying(Object.keys(meta.versions), versionRange, true)
}
export function pickVersionByVersionRange (
meta: PackageMeta,
versionRange: string,
preferredVerSels?: VersionSelectors,
publishedBy?: Date
): string | null {
let latest: string | undefined = meta['dist-tags'].latest
export function pickVersionByVersionRange ({ meta, versionRange, preferredVersionSelectors }: PickVersionByVersionRangeOptions): string | null {
const latest: string | undefined = meta['dist-tags'].latest
if (preferredVerSels != null && Object.keys(preferredVerSels).length > 0) {
const prioritizedPreferredVersions = prioritizePreferredVersions(meta, versionRange, preferredVerSels)
if (preferredVersionSelectors != null && Object.keys(preferredVersionSelectors).length > 0) {
const prioritizedPreferredVersions = prioritizePreferredVersions(meta, versionRange, preferredVersionSelectors)
for (const preferredVersions of prioritizedPreferredVersions) {
if (preferredVersions.includes(latest) && semverSatisfiesLoose(latest, versionRange)) {
return latest
@@ -135,16 +146,7 @@ export function pickVersionByVersionRange (
}
}
let versions = Object.keys(meta.versions)
if (publishedBy) {
if (meta.time == null) {
throw new PnpmError('MISSING_TIME', `The metadata of ${meta.name} is missing the "time" field`)
}
versions = versions.filter(version => new Date(meta.time![version]) <= publishedBy)
if (!versions.includes(latest)) {
latest = undefined
}
}
const versions = Object.keys(meta.versions)
if (latest && (versionRange === '*' || semverSatisfiesLoose(latest, versionRange))) {
// Not using semver.satisfies in case of * because it does not select beta versions.
// E.g.: 1.0.0-beta.1. See issue: https://github.com/pnpm/pnpm/issues/865
@@ -225,3 +227,59 @@ class PreferredVersionsPrioritizer {
.map((weight) => versionsByWeight[parseInt(weight, 10)])
}
}
function filterMetaByPublishedDate (meta: PackageMetaWithTime, publishedBy: Date): PackageMeta {
const versionsWithinDate: PackageMeta['versions'] = {}
for (const version in meta.versions) {
if (!Object.prototype.hasOwnProperty.call(meta.versions, version)) continue
const timeStr = meta.time[version]
if (timeStr && new Date(timeStr) <= publishedBy) {
versionsWithinDate[version] = meta.versions[version]
}
}
const distTagsWithinDate: PackageMeta['dist-tags'] = {}
const allDistTags = meta['dist-tags'] ?? {}
for (const tag in allDistTags) {
if (!Object.prototype.hasOwnProperty.call(allDistTags, tag)) continue
const distTagVersion = allDistTags[tag]
if (versionsWithinDate[distTagVersion]) {
distTagsWithinDate[tag] = distTagVersion
continue
}
// Repopulate the tag to the highest version available within date that has the same major as the original tag's version
let originalSemVer: semver.SemVer | null = null
try {
originalSemVer = new semver.SemVer(distTagVersion, true)
} catch {
continue
}
const originalMajor = originalSemVer.major
let bestVersion: string | undefined
const originalMajorPrefix = `${originalMajor}.`
for (const candidate in versionsWithinDate) {
if (!Object.prototype.hasOwnProperty.call(versionsWithinDate, candidate)) continue
if (!candidate.startsWith(originalMajorPrefix)) continue
if (!bestVersion) {
bestVersion = candidate
} else {
try {
if (semver.gt(candidate, bestVersion, true)) {
bestVersion = candidate
}
} catch (err) {
globalWarn(`Failed to compare semver versions ${candidate} and ${bestVersion} from packument of ${meta.name}, skipping candidate version.`)
}
}
}
if (bestVersion) {
distTagsWithinDate[tag] = bestVersion
}
}
return {
...meta,
versions: versionsWithinDate,
'dist-tags': distTagsWithinDate,
}
}

View File

@@ -0,0 +1,115 @@
import { createFetchFromRegistry } from '@pnpm/fetch'
import { createNpmResolver } from '@pnpm/npm-resolver'
import { type Registries } from '@pnpm/types'
import nock from 'nock'
import tempy from 'tempy'
const registries: Registries = {
default: 'https://registry.npmjs.org/',
}
const fetch = createFetchFromRegistry({})
const getAuthHeader = () => undefined
const createResolveFromNpm = createNpmResolver.bind(null, fetch, getAuthHeader)
afterEach(() => {
nock.cleanAll()
nock.disableNetConnect()
})
beforeEach(() => {
nock.enableNetConnect()
})
test('repopulate dist-tag to highest same-major version within the date cutoff', async () => {
const name = 'dist-tag-date'
const meta = {
name,
versions: {
'3.0.0': {
name,
version: '3.0.0',
dist: { tarball: `https://registry.npmjs.org/${name}/-/${name}-3.0.0.tgz` },
},
'3.1.0': {
name,
version: '3.1.0',
dist: { tarball: `https://registry.npmjs.org/${name}/-/${name}-3.1.0.tgz` },
},
'3.2.0': {
name,
version: '3.2.0',
dist: { tarball: `https://registry.npmjs.org/${name}/-/${name}-3.2.0.tgz` },
},
'2.9.9': {
name,
version: '2.9.9',
dist: { tarball: `https://registry.npmjs.org/${name}/-/${name}-2.9.9.tgz` },
},
},
'dist-tags': {
latest: '3.2.0',
},
time: {
'2.9.9': '2020-01-01T00:00:00.000Z',
'3.0.0': '2020-02-01T00:00:00.000Z',
'3.1.0': '2020-03-01T00:00:00.000Z',
'3.2.0': '2020-05-01T00:00:00.000Z',
},
}
// Cutoff before 3.2.0, so latest must be remapped to 3.1.0 (same major 3)
const cutoff = new Date('2020-04-01T00:00:00.000Z')
nock(registries.default)
.get(`/${name}`)
.reply(200, meta)
const cacheDir = tempy.directory()
const { resolveFromNpm } = createResolveFromNpm({
cacheDir,
fullMetadata: true,
registries,
})
const res = await resolveFromNpm({ alias: name, bareSpecifier: 'latest' }, {
publishedBy: cutoff,
})
expect(res!.id).toBe(`${name}@3.1.0`)
})
test('keep dist-tag if original version is within the date cutoff', async () => {
const name = 'dist-tag-date-keep'
const meta = {
name,
versions: {
'1.0.0': {
name,
version: '1.0.0',
dist: { tarball: `https://registry.npmjs.org/${name}/-/${name}-1.0.0.tgz` },
},
},
'dist-tags': { latest: '1.0.0' },
time: { '1.0.0': '2020-01-01T00:00:00.000Z' },
}
const cutoff = new Date('2020-02-01T00:00:00.000Z')
nock(registries.default)
.get(`/${name}`)
.reply(200, meta)
const cacheDir = tempy.directory()
const { resolveFromNpm } = createResolveFromNpm({
cacheDir,
fullMetadata: true,
registries,
})
const res = await resolveFromNpm({ alias: name, bareSpecifier: 'latest' }, {
publishedBy: cutoff,
})
expect(res!.id).toBe(`${name}@1.0.0`)
})

View File

@@ -29,6 +29,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick<Confi
| 'key'
| 'localAddress'
| 'maxSockets'
| 'minimumReleaseAge'
| 'networkConcurrency'
| 'noProxy'
| 'offline'
@@ -53,7 +54,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick<Confi
export async function createNewStoreController (
opts: CreateNewStoreControllerOptions
): Promise<{ ctrl: StoreController, dir: string }> {
const fullMetadata = opts.fetchFullMetadata ?? (opts.resolutionMode === 'time-based' && !opts.registrySupportsTimeField)
const fullMetadata = opts.fetchFullMetadata ?? ((opts.resolutionMode === 'time-based' || Boolean(opts.minimumReleaseAge)) && !opts.registrySupportsTimeField)
const { resolve, fetchers, clearResolutionCache } = createClient({
customFetchers: opts.hooks?.fetchers,
userConfig: opts.userConfig,
@@ -94,6 +95,7 @@ export async function createNewStoreController (
includeOnlyPackageFiles: !opts.deployAllFiles,
saveWorkspaceProtocol: opts.saveWorkspaceProtocol,
preserveAbsolutePaths: opts.preserveAbsolutePaths,
strictPublishedByCheck: Boolean(opts.minimumReleaseAge),
})
await fs.mkdir(opts.storeDir, { recursive: true })
return {