diff --git a/common/changes/@microsoft/rush/feature-add-minimum-release-age-support_2025-10-07-10-49.json b/common/changes/@microsoft/rush/feature-add-minimum-release-age-support_2025-10-07-10-49.json new file mode 100644 index 00000000000..addb3ae3ac1 --- /dev/null +++ b/common/changes/@microsoft/rush/feature-add-minimum-release-age-support_2025-10-07-10-49.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add support for PNPM's minimumReleaseAge setting to help mitigate supply chain attacks", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index cdad7dd3b7a..3fa9d1b178b 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -773,6 +773,8 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { globalPackageExtensions?: Record; globalPatchedDependencies?: Record; globalPeerDependencyRules?: IPnpmPeerDependencyRules; + minimumReleaseAge?: number; + minimumReleaseAgeExclude?: string[]; pnpmLockfilePolicies?: IPnpmLockfilePolicies; pnpmStore?: PnpmStoreLocation; preventManualShrinkwrapChanges?: boolean; @@ -1188,6 +1190,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration static loadFromJsonFileOrThrow(jsonFilePath: string, commonTempFolder: string): PnpmOptionsConfiguration; // @internal (undocumented) static loadFromJsonObject(json: _IPnpmOptionsJson, commonTempFolder: string): PnpmOptionsConfiguration; + readonly minimumReleaseAge: number | undefined; + readonly minimumReleaseAgeExclude: string[] | undefined; readonly pnpmLockfilePolicies: IPnpmLockfilePolicies | undefined; readonly pnpmStore: PnpmStoreLocation; readonly pnpmStorePath: string; diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json index 1f9e1ab6065..d677fa2179f 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json @@ -67,6 +67,38 @@ */ /*[LINE "DEMO"]*/ "autoInstallPeers": false, + /** + * The minimum number of minutes that must pass after a version is published before pnpm will install it. + * This setting helps reduce the risk of installing compromised packages, as malicious releases are typically + * discovered and removed within a short time frame. + * + * For example, the following setting ensures that only packages released at least one day ago can be installed: + * + * "minimumReleaseAge": 1440 + * + * (SUPPORTED ONLY IN PNPM 10.16.0 AND NEWER) + * + * PNPM documentation: https://pnpm.io/settings#minimumreleaseage + * + * The default value is 0 (disabled). + */ + /*[LINE "HYPOTHETICAL"]*/ "minimumReleaseAge": 1440, + + /** + * An array of package names or patterns to exclude from the minimumReleaseAge check. + * This allows certain trusted packages to be installed immediately after publication. + * Patterns are supported using glob syntax (e.g., "@myorg/*" to exclude all packages from an organization). + * + * For example: + * + * "minimumReleaseAgeExclude": ["webpack", "react", "@myorg/*"] + * + * (SUPPORTED ONLY IN PNPM 10.16.0 AND NEWER) + * + * PNPM documentation: https://pnpm.io/settings#minimumreleaseageexclude + */ + /*[LINE "HYPOTHETICAL"]*/ "minimumReleaseAgeExclude": ["@myorg/*"], + /** * If true, then Rush will add the `--strict-peer-dependencies` command-line parameter when * invoking PNPM. This causes `rush update` to fail if there are unsatisfied peer dependencies, diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 65a640c69db..8f850b4e73d 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -34,6 +34,8 @@ interface ICommonPackageJson extends IPackageJson { ignoredOptionalDependencies?: typeof PnpmOptionsConfiguration.prototype.globalIgnoredOptionalDependencies; allowedDeprecatedVersions?: typeof PnpmOptionsConfiguration.prototype.globalAllowedDeprecatedVersions; patchedDependencies?: typeof PnpmOptionsConfiguration.prototype.globalPatchedDependencies; + minimumReleaseAge?: typeof PnpmOptionsConfiguration.prototype.minimumReleaseAge; + minimumReleaseAgeExclude?: typeof PnpmOptionsConfiguration.prototype.minimumReleaseAgeExclude; }; } @@ -100,6 +102,30 @@ export class InstallHelpers { commonPackageJson.pnpm.patchedDependencies = pnpmOptions.globalPatchedDependencies; } + if (pnpmOptions.minimumReleaseAge !== undefined || pnpmOptions.minimumReleaseAgeExclude) { + if ( + rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined && + semver.lt(rushConfiguration.rushConfigurationJson.pnpmVersion, '10.16.0') + ) { + terminal.writeWarningLine( + Colorize.yellow( + `Your version of pnpm (${rushConfiguration.rushConfigurationJson.pnpmVersion}) ` + + `doesn't support the "minimumReleaseAge" or "minimumReleaseAgeExclude" fields in ` + + `${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + + 'Remove these fields or upgrade to pnpm 10.16.0 or newer.' + ) + ); + } + + if (pnpmOptions.minimumReleaseAge !== undefined) { + commonPackageJson.pnpm.minimumReleaseAge = pnpmOptions.minimumReleaseAge; + } + + if (pnpmOptions.minimumReleaseAgeExclude) { + commonPackageJson.pnpm.minimumReleaseAgeExclude = pnpmOptions.minimumReleaseAgeExclude; + } + } + if (pnpmOptions.unsupportedPackageJsonSettings) { merge(commonPackageJson, pnpmOptions.unsupportedPackageJsonSettings); } diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index 571a7855b3c..4b249e7a883 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -138,6 +138,14 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { * {@inheritDoc PnpmOptionsConfiguration.autoInstallPeers} */ autoInstallPeers?: boolean; + /** + * {@inheritDoc PnpmOptionsConfiguration.minimumReleaseAge} + */ + minimumReleaseAge?: number; + /** + * {@inheritDoc PnpmOptionsConfiguration.minimumReleaseAgeExclude} + */ + minimumReleaseAgeExclude?: string[]; /** * {@inheritDoc PnpmOptionsConfiguration.alwaysInjectDependenciesFromOtherSubspaces} */ @@ -258,6 +266,33 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration */ public readonly autoInstallPeers: boolean | undefined; + /** + * The minimum number of minutes that must pass after a version is published before pnpm will install it. + * This setting helps reduce the risk of installing compromised packages, as malicious releases are typically + * discovered and removed within a short time frame. + * + * @remarks + * (SUPPORTED ONLY IN PNPM 10.16.0 AND NEWER) + * + * PNPM documentation: https://pnpm.io/settings#minimumreleaseage + * + * The default value is 0 (disabled). + */ + public readonly minimumReleaseAge: number | undefined; + + /** + * List of package names or patterns that are excluded from the minimumReleaseAge check. + * These packages will always install the newest version immediately, even if minimumReleaseAge is set. + * + * @remarks + * (SUPPORTED ONLY IN PNPM 10.16.0 AND NEWER) + * + * PNPM documentation: https://pnpm.io/settings#minimumreleaseageexclude + * + * Example: ["webpack", "react", "\@myorg/*"] + */ + public readonly minimumReleaseAgeExclude: string[] | undefined; + /** * If true, then `rush update` add injected install options for all cross-subspace * workspace dependencies, to avoid subspace doppelganger issue. @@ -425,6 +460,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration this._globalPatchedDependencies = json.globalPatchedDependencies; this.resolutionMode = json.resolutionMode; this.autoInstallPeers = json.autoInstallPeers; + this.minimumReleaseAge = json.minimumReleaseAge; + this.minimumReleaseAgeExclude = json.minimumReleaseAgeExclude; this.alwaysInjectDependenciesFromOtherSubspaces = json.alwaysInjectDependenciesFromOtherSubspaces; this.alwaysFullInstall = json.alwaysFullInstall; this.pnpmLockfilePolicies = json.pnpmLockfilePolicies; diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts index 4d4ab4a7bb2..75b9e3e874f 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts @@ -73,4 +73,17 @@ describe(PnpmOptionsConfiguration.name, () => { 'level' ]); }); + + it('loads minimumReleaseAge', () => { + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + `${__dirname}/jsonFiles/pnpm-config-minimumReleaseAge.json`, + fakeCommonTempFolder + ); + + expect(pnpmConfiguration.minimumReleaseAge).toEqual(1440); + expect(TestUtilities.stripAnnotations(pnpmConfiguration.minimumReleaseAgeExclude)).toEqual([ + 'webpack', + '@myorg/*' + ]); + }); }); diff --git a/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-minimumReleaseAge.json b/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-minimumReleaseAge.json new file mode 100644 index 00000000000..54efb4b31ff --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-minimumReleaseAge.json @@ -0,0 +1,4 @@ +{ + "minimumReleaseAge": 1440, + "minimumReleaseAgeExclude": ["webpack", "@myorg/*"] +} diff --git a/libraries/rush-lib/src/schemas/pnpm-config.schema.json b/libraries/rush-lib/src/schemas/pnpm-config.schema.json index a5256c98228..0501173b754 100644 --- a/libraries/rush-lib/src/schemas/pnpm-config.schema.json +++ b/libraries/rush-lib/src/schemas/pnpm-config.schema.json @@ -191,6 +191,20 @@ "type": "boolean" }, + "minimumReleaseAge": { + "description": "The minimum number of minutes that must pass after a version is published before pnpm will install it. This setting helps reduce the risk of installing compromised packages, as malicious releases are typically discovered and removed within a short time frame.\n\n(SUPPORTED ONLY IN PNPM 10.16.0 AND NEWER)\n\nPNPM documentation: https://pnpm.io/settings#minimumreleaseage\n\nThe default value is 0 (disabled).", + "type": "number" + }, + + "minimumReleaseAgeExclude": { + "description": "List of package names or patterns that are excluded from the minimumReleaseAge check. These packages will always install the newest version immediately, even if minimumReleaseAge is set. Supports glob patterns (e.g., \"@myorg/*\").\n\n(SUPPORTED ONLY IN PNPM 10.16.0 AND NEWER)\n\nPNPM documentation: https://pnpm.io/settings#minimumreleaseageexclude\n\nExample: [\"webpack\", \"react\", \"@myorg/*\"]", + "type": "array", + "items": { + "description": "Package name or pattern", + "type": "string" + } + }, + "alwaysFullInstall": { "description": "(EXPERIMENTAL) If 'true', then filtered installs ('rush install --to my-project') * will be disregarded, instead always performing a full installation of the lockfile.", "type": "boolean"