Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions workspaces/arborist/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## [Unreleased]

### Features
* **security**: Add minimum package age policy to prevent supply chain attacks
- New `minimum-release-age` config option to enforce waiting period before installing newly published versions
- New `minimum-release-age-exclude` config option to exempt specific packages from the policy
- Helps mitigate attacks where malicious versions are published and quickly removed
- Inspired by pnpm's minimumReleaseAge feature

## [9.1.8](https://github.com/npm/cli/compare/arborist-v9.1.7...arborist-v9.1.8) (2025-11-25)
### Bug Fixes
* [`b118364`](https://github.com/npm/cli/commit/b1183644faea618ee36af513c5bfc3387ada0f7e) [#8760](https://github.com/npm/cli/pull/8760) undefined override set conflicts shouldn't error (@owlstronaut)
Expand Down
53 changes: 52 additions & 1 deletion workspaces/arborist/lib/arborist/build-ideal-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -1200,11 +1200,62 @@ This is a one-time fix-up, please be patient...
}

async #fetchManifest (spec) {
const {
minimumReleaseAge = 0,
minimumReleaseAgeExclude = [],
} = this.options

let avoidRange = this.#avoidRange(spec.name)

const shouldApplyPolicy =
minimumReleaseAge > 0 &&
!minimumReleaseAgeExclude.includes(spec.name)

if (shouldApplyPolicy) {
try {
// get the full packument
const packument = await pacote.packument(spec, {
...this.options,
fullMetadata: true,
})

const now = new Date()
const cutoff = new Date(now.getTime() - (minimumReleaseAge * 60 * 1000))
const avoidVersions = []

for (const [version, time] of Object.entries(packument.time || {})) {
// filter 'created' and 'modifed' and validate that is semver
if (!semver.valid(version)) {
continue
}

const releaseDate = new Date(time)
if (releaseDate > cutoff) {
avoidVersions.push(version)
}
}

// convert each recent version into a range that avoids it
if (avoidVersions.length > 0) {
const avoidPolicyRange = avoidVersions.join(' || ')

if (avoidRange) {
avoidRange = `${avoidRange} || ${avoidPolicyRange}`
} else {
avoidRange = avoidPolicyRange
}
}
} catch (err) {
// Ignore error getting packument (e.g: if doesn't exist or network failure)
// for not breaking the installation, we dont apply the policy
}
}
const options = {
...this.options,
avoid: this.#avoidRange(spec.name),
avoid: avoidRange,
fullMetadata: true,
}

// get the intended spec and stored metadata from yarn.lock file,
// if available and valid.
spec = this.idealTree.meta.checkYarnLock(spec, options)
Expand Down
185 changes: 185 additions & 0 deletions workspaces/arborist/test/arborist/minimum-release-age.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
const t = require('tap')
const Arborist = require('../../lib/arborist/index.js')
const pacote = require('pacote')

// mock pacote response
const packumentResponse = {
name: 'foo',
versions: {
'1.0.0': {},
'1.0.1': {},
'1.0.2': {},
},
time: {
'1.0.0': new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), // 1 day ago
'1.0.1': new Date(Date.now() - 1000 * 60 * 5).toISOString(), // 5 minutes ago
'1.0.2': new Date(Date.now() - 1000 * 60 * 1).toISOString(), // 1 minute ago
},
}

t.test('minimum-release-age policy', async t => {
const originalPackument = pacote.packument
const originalManifest = pacote.manifest

t.teardown(() => {
pacote.packument = originalPackument
pacote.manifest = originalManifest
})

pacote.packument = async () => {
return packumentResponse
}

let capturedOptions = null
pacote.manifest = async (spec, opts) => {
// capture options if 'avoid' is present, which indicates our logic ran
if (opts.avoid) {
capturedOptions = opts
}
return { name: 'foo', version: '1.0.0' }
}

const path = t.testdir({})
const arb = new Arborist({
path,
minimumReleaseAge: 10, // 10 minutes
cache: path + '/cache',
})

// we try to add 'foo'
// this should trigger buildIdealTree -> #add -> #fetchManifest
try {
await arb.buildIdealTree({ add: ['foo'] })
} catch (e) {
// it might fail later because we are mocking things partially,
// but we just want to check if fetchManifest was called with avoid
}

t.ok(capturedOptions, 'pacote.manifest was called with options')

if (capturedOptions) {
const avoid = capturedOptions.avoid || ''
// 1.0.1 is 5 mins old (should be avoided, limit is 10 mins)
t.match(avoid, '1.0.1', 'should avoid 1.0.1')
// 1.0.2 is 1 minute old (should be avoided)
t.match(avoid, '1.0.2', 'should avoid 1.0.2')
// 1.0.0 is 1 day old (should NOT be avoided)
t.notMatch(avoid, '1.0.0', 'should not avoid 1.0.0')
}
})

t.test('minimum-release-age-exclude bypasses policy', async t => {
const originalPackument = pacote.packument
const originalManifest = pacote.manifest

t.teardown(() => {
pacote.packument = originalPackument
pacote.manifest = originalManifest
})

pacote.packument = async () => {
return packumentResponse
}

let capturedOptions = null
pacote.manifest = async (spec, opts) => {
capturedOptions = opts
return { name: 'foo', version: '1.0.2' }
}

const path = t.testdir({})
const arb = new Arborist({
path,
minimumReleaseAge: 10,
minimumReleaseAgeExclude: ['foo'], // exclude 'foo' from policy
cache: path + '/cache',
})

try {
await arb.buildIdealTree({ add: ['foo'] })
} catch (e) {
// ignore errors
}

t.ok(capturedOptions, 'pacote.manifest was called')

if (capturedOptions) {
const avoid = capturedOptions.avoid || ''
// since 'foo' is excluded, recent versions should NOT be avoided
t.notMatch(avoid, '1.0.1', 'should not avoid 1.0.1 (excluded)')
t.notMatch(avoid, '1.0.2', 'should not avoid 1.0.2 (excluded)')
}
})

t.test('minimum-release-age=0 disables policy', async t => {
const originalPackument = pacote.packument
const originalManifest = pacote.manifest

t.teardown(() => {
pacote.packument = originalPackument
pacote.manifest = originalManifest
})

let packumentCalled = false
pacote.packument = async () => {
packumentCalled = true
return packumentResponse
}

pacote.manifest = async () => {
return { name: 'foo', version: '1.0.2' }
}

const path = t.testdir({})
const arb = new Arborist({
path,
minimumReleaseAge: 0, // disabled
cache: path + '/cache',
})

try {
await arb.buildIdealTree({ add: ['foo'] })
} catch (e) {
// ignore errors
}

// when policy is disabled, packument should not be fetched for this purpose
t.notOk(packumentCalled, 'packument should not be called when policy is disabled')
})

t.test('handles packument fetch errors gracefully', async t => {
const originalPackument = pacote.packument
const originalManifest = pacote.manifest

t.teardown(() => {
pacote.packument = originalPackument
pacote.manifest = originalManifest
})

pacote.packument = async () => {
throw new Error('Network error')
}

let manifestCalled = false
pacote.manifest = async () => {
manifestCalled = true
return { name: 'foo', version: '1.0.0' }
}

const path = t.testdir({})
const arb = new Arborist({
path,
minimumReleaseAge: 10,
cache: path + '/cache',
})

try {
await arb.buildIdealTree({ add: ['foo'] })
} catch (e) {
// ignore errors from buildIdealTree
}

// even if packument fails, manifest should still be called
// (policy is just skipped on error)
t.ok(manifestCalled, 'manifest should still be called even if packument fails')
})
39 changes: 39 additions & 0 deletions workspaces/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,42 @@ This method can be used for avoiding or tweaking default values, e.g:
Save the config file specified by the `where` param.
Must be one of
`project`, `user`, `global`, `builtin`.

## Configuration Options

This package defines configuration options for npm. Below are some notable security-related options:

### `minimum-release-age`

* Default: `0` (disabled)
* Type: Number

The minimum age (in minutes) that a package version must have before it can be installed. This helps protect against supply chain attacks where malicious versions are published and then quickly removed.

When set to a value greater than 0, npm will avoid installing package versions that were published within the specified time window.

Example:
```ini
minimum-release-age=10
```

This will only install package versions that were published at least 10 minutes ago.

### `minimum-release-age-exclude`

* Default: `[]`
* Type: Array

A list of package names that should be excluded from the `minimum-release-age` policy. This is useful for packages where you need immediate access to new versions.

Example:
```ini
minimum-release-age-exclude[]=critical-package
minimum-release-age-exclude[]=@scope/another-package
```

Or via command line:
```bash
npm install --minimum-release-age=10 --minimum-release-age-exclude=trusted-package
```

16 changes: 16 additions & 0 deletions workspaces/config/lib/definitions/definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,22 @@ const definitions = {
`,
flatten,
}),
'minimum-release-age': new Definition('minimum-release-age', {
default: 0,
type: Number,
description: `
The minimum release age of the packages that are going to be installed
when using \`npm install\`.
`,
}),
'minimum-release-age-exclude': new Definition('minimum-release-age-exclude', {
default: [],
type: [String],
description: `
Excluded packages when using \`minimum-release-age\` (bypasses the
policy for the specified packages).
`,
}),
'node-gyp': new Definition('node-gyp', {
default: (() => {
try {
Expand Down