diff --git a/workspaces/arborist/CHANGELOG.md b/workspaces/arborist/CHANGELOG.md index 3bc39a81061bb..b055342b56eab 100644 --- a/workspaces/arborist/CHANGELOG.md +++ b/workspaces/arborist/CHANGELOG.md @@ -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) diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 699735f349826..cb26cac25d3cf 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -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) diff --git a/workspaces/arborist/test/arborist/minimum-release-age.js b/workspaces/arborist/test/arborist/minimum-release-age.js new file mode 100644 index 0000000000000..56eae0f9fe5ee --- /dev/null +++ b/workspaces/arborist/test/arborist/minimum-release-age.js @@ -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') +}) diff --git a/workspaces/config/README.md b/workspaces/config/README.md index 6a948d9b11a91..5761c2bc723a5 100644 --- a/workspaces/config/README.md +++ b/workspaces/config/README.md @@ -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 +``` + diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 570abecdb4484..9b0ec5de6fc4f 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -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 {