Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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"
}
4 changes: 4 additions & 0 deletions common/reviews/api/rush-lib.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,8 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase {
globalPackageExtensions?: Record<string, IPnpmPackageExtension>;
globalPatchedDependencies?: Record<string, string>;
globalPeerDependencyRules?: IPnpmPeerDependencyRules;
minimumReleaseAge?: number;
minimumReleaseAgeExclude?: string[];
pnpmLockfilePolicies?: IPnpmLockfilePolicies;
pnpmStore?: PnpmStoreLocation;
preventManualShrinkwrapChanges?: boolean;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions libraries/rush-lib/src/logic/installManager/InstallHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

Expand Down Expand Up @@ -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);
}
Expand Down
37 changes: 37 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/*'
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"minimumReleaseAge": 1440,
"minimumReleaseAgeExclude": ["webpack", "@myorg/*"]
}
14 changes: 14 additions & 0 deletions libraries/rush-lib/src/schemas/pnpm-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down