From da8b51239b8034fd0ca4846f6329090a93a2a291 Mon Sep 17 00:00:00 2001 From: PR3C14D0 Date: Wed, 3 Dec 2025 23:15:53 +0100 Subject: [PATCH 1/3] feat: add minimum package age policy to prevent supply chain attacks Add support for minimum-release-age configuration option to enforce a waiting period before installing newly published package versions. This helps mitigate supply chain attacks where malicious versions are published and quickly removed. Changes: - Add minimum-release-age config option (default: 0, disabled) - Add minimum-release-age-exclude config option for exemptions - Implement version filtering in Arborist's #fetchManifest method - Add comprehensive test coverage (4 tests, 9 assertions) The policy works by: 1. Fetching the full packument for each package 2. Calculating a cutoff time based on the configured age 3. Identifying versions released after the cutoff 4. Adding them to the 'avoid' range passed to pacote Users can configure via .npmrc, CLI flags, or environment variables: minimum-release-age=10 minimum-release-age-exclude[]=critical-package Files modified: - workspaces/config/lib/definitions/definitions.js - workspaces/arborist/lib/arborist/build-ideal-tree.js - workspaces/arborist/test/arborist/minimum-release-age.js (new) Inspired by pnpm's minimumReleaseAge feature (pnpm/pnpm#9921) --- .../arborist/lib/arborist/build-ideal-tree.js | 53 ++++- .../test/arborist/minimum-release-age.js | 185 ++++++++++++++++++ .../config/lib/definitions/definitions.js | 16 ++ 3 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 workspaces/arborist/test/arborist/minimum-release-age.js 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/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 { From 559dddd5b8172968b162dd41470a39561dcd78e4 Mon Sep 17 00:00:00 2001 From: PR3C14D0 Date: Wed, 3 Dec 2025 23:19:36 +0100 Subject: [PATCH 2/3] docs(arborist): update CHANGELOG for minimum-release-age feature --- workspaces/arborist/CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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) From 0eb8a32c8ef97a4c5a7328cf8afa8e440e19408b Mon Sep 17 00:00:00 2001 From: PR3C14D0 Date: Wed, 3 Dec 2025 23:22:03 +0100 Subject: [PATCH 3/3] docs(config): document minimum-release-age options Add documentation for: - minimum-release-age configuration - minimum-release-age-exclude configuration - Usage examples and security benefits" --- workspaces/config/README.md | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) 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 +``` +