Skip to content

Conversation

@bienzaaron
Copy link
Contributor

@bienzaaron bienzaaron commented Sep 17, 2025

What's the problem this PR addresses?

closes #6899. See rationale in pnpm/pnpm#9921 and pnpm/pnpm#9957, but the tl;dr is that with the recent uptick in compromised npm packages, this can offer some level of protection to prevent end-users from installing malware prior to detection and removal from registries.

There are a few differences from the pnpm implementation. I felt these differences made sense with some of the other features yarn supports, but I also understand the desire for parity between package managers, so open to thoughts there.

  1. npm added in the option names (npmMinimumReleaseAge versus minimumReleaseAge). Since yarn implements many resolvers and this is only implemented in the case of the npm resolver, I felt that this made the behavior of the options more clear.

  2. npmMinimumReleaseAgeExclude supports not only package names like pnpm's implementation, but it also supports:

    • exact match on package locators (i.e. exact package resolutions -- like @aws-sdk/[email protected] or @aws-sdk/types@npm:3.877.0)
    • micromatch glob patterns on package descriptors (i.e. semver descriptors -- like @aws-sdk/types@^3.0.0, @aws-sdk/types@npm:^3.0.0, @aws-sdk/types@* or @aws-sdk/*)

    The rationale here is mostly in the case that you know certain package versions that are affected (e.g. [email protected]) or if you need to upgrade to an excluded version, but its part of a monorepo -- that's where the micromatch glob comes in handy.

How did you fix it?

I added the options and checked for them within the NPM semver resolver when evaluating candidates. I'm new to this codebase -- I think I've made all the updates needed and was able to test some scenarios successfully (see below).

Testing manually

I used `@aws-sdk/[email protected]` to test. At the time of writing, this package was published 7742 minutes ago, which is less than 10000 minutes (used in configurations below).

yarn add @aws-sdk/[email protected] fails ✅

.yarnrc.yml

npmMinimumReleaseAge: 10000

install command

❯ yarn add @aws-sdk/[email protected]
➤ YN0000: · Yarn 4.9.4-git.20250917.hash-a05df867e
➤ YN0000: ┌ Resolution step
➤ YN0082: │ @aws-sdk/types@npm:3.887.0: No candidates found
➤ YN0000: └ Completed
➤ YN0000: · Failed with errors in 0s 11ms

yarn add @aws-sdk/types@^3.0.0 resolves prior version ✅

.yarnrc.yml

npmMinimumReleaseAge: 10000

install command

❯ yarn add @aws-sdk/types@^3.0.0
➤ YN0000: · Yarn 4.9.4-git.20250917.hash-a05df867e
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + @aws-sdk/types@npm:3.862.0, @smithy/types@npm:4.5.0, tslib@npm:2.8.1
➤ YN0000: └ Completed in 0s 237ms
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed
➤ YN0000: · Done in 0s 271ms
❯ yarn why @aws-sdk/types
└─ test-yarn-project@workspace:.
   └─ @aws-sdk/types@npm:3.862.0 (via npm:^3.0.0)

yarn add @aws-sdk/types@^3.0.0 resolves most recent version with package exclude = @aws-sdk/types

.yarnrc.yml

npmMinimumReleaseAge: 10000
npmMinimumReleaseAgeExclude:
  - "@aws-sdk/types"

install command - installs @aws-sdk/[email protected]

❯ yarn add @aws-sdk/types@^3.0.0
➤ YN0000: · Yarn 4.9.4-git.20250917.hash-a05df867e
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + @aws-sdk/types@npm:3.887.0, @smithy/types@npm:4.5.0, tslib@npm:2.8.1
➤ YN0000: └ Completed in 0s 237ms
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed
➤ YN0000: · Done in 0s 265ms
❯ yarn why @aws-sdk/types
└─ test-yarn-project@workspace:.
   └─ @aws-sdk/types@npm:3.887.0 (via npm:^3.0.0)

yarn add @aws-sdk/types@^3.0.0 resolves most recent version with package exclude = @aws-sdk/*

.yarnrc.yml

npmMinimumReleaseAge: 10000
npmMinimumReleaseAgeExclude:
  - "@aws-sdk/*"

install command - installs @aws-sdk/[email protected]

❯ yarn add @aws-sdk/types@^3.0.0
➤ YN0000: · Yarn 4.9.4-git.20250917.hash-a05df867e
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + @aws-sdk/types@npm:3.887.0, @smithy/types@npm:4.5.0, tslib@npm:2.8.1
➤ YN0000: └ Completed in 0s 205ms
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed
➤ YN0000: · Done in 0s 232ms
❯ yarn why @aws-sdk/types
└─ test-yarn-project@workspace:.
   └─ @aws-sdk/types@npm:3.887.0 (via npm:^3.0.0)

yarn add @aws-sdk/types@^3.0.0 resolves most recent version with package exclude = @aws-sdk/[email protected]

.yarnrc.yml

npmMinimumReleaseAge: 10000
npmMinimumReleaseAgeExclude:
  - "@aws-sdk/[email protected]"

install command - installs @aws-sdk/[email protected]

❯ yarn add @aws-sdk/types@^3.0.0
➤ YN0000: · Yarn 4.9.4-git.20250917.hash-a05df867e
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + @aws-sdk/types@npm:3.887.0, @smithy/types@npm:4.5.0, tslib@npm:2.8.1
➤ YN0000: └ Completed in 0s 292ms
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed
➤ YN0000: · Done in 0s 324ms
❯ yarn why @aws-sdk/types
└─ test-yarn-project@workspace:.
   └─ @aws-sdk/types@npm:3.887.0 (via npm:^3.0.0)

yarn add @aws-sdk/types@^3.0.0 resolves most recent version with package exclude = @aws-sdk/types@^3.0.0

.yarnrc.yml

npmMinimumReleaseAge: 10000
npmMinimumReleaseAgeExclude:
  - "@aws-sdk/types@^3.0.0"

install command - installs @aws-sdk/[email protected]

❯ yarn add @aws-sdk/types@^3.0.0
➤ YN0000: · Yarn 4.9.4-git.20250917.hash-a05df867e
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + @aws-sdk/types@npm:3.887.0, @smithy/types@npm:4.5.0, tslib@npm:2.8.1
➤ YN0000: └ Completed in 0s 248ms
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed
➤ YN0000: · Done in 0s 278ms
❯ yarn why @aws-sdk/types
└─ test-yarn-project@workspace:.
   └─ @aws-sdk/types@npm:3.887.0 (via npm:^3.0.0)

yarn add @aws-sdk/types@^3.5.0 resolves prior version with package exclude = @aws-sdk/types@^3.0.0

.yarnrc.yml

npmMinimumReleaseAge: 10000
npmMinimumReleaseAgeExclude:
  - "@aws-sdk/types@^3.0.0"

install command - installs @aws-sdk/[email protected]

❯ yarn add @aws-sdk/types@^3.5.0
➤ YN0000: · Yarn 4.9.4-git.20250917.hash-a05df867e
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + @aws-sdk/types@npm:3.862.0, @smithy/types@npm:4.5.0, tslib@npm:2.8.1
➤ YN0000: └ Completed in 0s 219ms
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed
➤ YN0000: · Done in 0s 256ms
❯ yarn why @aws-sdk/types
└─ test-yarn-project@workspace:.
   └─ @aws-sdk/types@npm:3.862.0 (via npm:^3.5.0)

yarn add @aws-sdk/types@^3.5.0 resolves prior version with package exclude = @aws-sdk/types@npm:^3.0.0

.yarnrc.yml

npmMinimumReleaseAge: 10000
npmMinimumReleaseAgeExclude:
  - "@aws-sdk/types@npm:^3.0.0"

install command - installs @aws-sdk/[email protected]

❯ yarn add @aws-sdk/types@^3.5.0
➤ YN0000: · Yarn 4.9.4-git.20250917.hash-a05df867e
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + @aws-sdk/types@npm:3.862.0, @smithy/types@npm:4.5.0, tslib@npm:2.8.1
➤ YN0000: └ Completed
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed
➤ YN0000: · Done in 0s 223ms
❯ yarn why @aws-sdk/types
└─ test-yarn-project@workspace:.
   └─ @aws-sdk/types@npm:3.862.0 (via npm:^3.5.0)

yarn add @aws-sdk/types@^3.0.0 resolves most recent version with package exclude = @aws-sdk/types@npm:^3.0.0

.yarnrc.yml

npmMinimumReleaseAge: 10000
npmMinimumReleaseAgeExclude:
  - "@aws-sdk/types@npm:^3.0.0"

install command - installs @aws-sdk/[email protected]

❯ yarn add @aws-sdk/types@^3.0.0
➤ YN0000: · Yarn 4.9.4-git.20250917.hash-a05df867e
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + @aws-sdk/types@npm:3.887.0, @smithy/types@npm:4.5.0, tslib@npm:2.8.1
➤ YN0000: └ Completed in 0s 239ms
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed
➤ YN0000: · Done in 0s 269ms
❯ yarn why @aws-sdk/types
└─ test-yarn-project@workspace:.
   └─ @aws-sdk/types@npm:3.887.0 (via npm:^3.0.0)

Checklist

  • I have set the packages that need to be released for my changes to be effective.
  • I will check that all automated PR checks pass before the PR gets reviewed.

@MarshallOfSound
Copy link

This is so good, I just started working on this but thought I'd double check no one else had. This feature will move the needle substantially for folks that care 👍 Would be great to see this land

const rcEnv: Record<string, any> = {};
for (const [key, value] of Object.entries(config))
rcEnv[`YARN_${key.replace(/([A-Z])/g, `_$1`).toUpperCase()}`] = Array.isArray(value) ? value.join(`;`) : value;
rcEnv[`YARN_${key.replace(/([A-Z])/g, `_$1`).toUpperCase()}`] = Array.isArray(value) ? value.join(`,`) : value;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like an a bug leftover from a while ago or something. My tests were failing because the array was not being parsed correctly -- they are comma-delimited when passed as YARN_ - see

if (definition.isArray || (definition.type === SettingsType.ANY && Array.isArray(value))) {
if (!Array.isArray(value)) {
return String(value).split(/,/).map(segment => {
return parseSingleValue(configuration, path, segment, definition, folder);
});
} else {

Comment on lines 180 to 197
const RELEASE_DATE_PACKAGES: Record<string, Record<string, number | string>> = {
"release-date": {
"1.0.0": new Date(new Date().getTime() - /* 10 days */ 1000 * 60 * 60 * 24 * 10).toISOString(),
"1.1.0": new Date(new Date().getTime() - /* 5 days */ 1000 * 60 * 60 * 24 * 5).toISOString(),
"1.1.1": new Date().toISOString(),
},
"release-date-transitive": {
"1.0.0": new Date(new Date().getTime() - /* 10 days */ 1000 * 60 * 60 * 24 * 10).toISOString(),
"1.1.0": new Date(new Date().getTime() - /* 5 days */ 1000 * 60 * 60 * 24 * 5).toISOString(),
"1.1.1": new Date().toISOString(),
},
"@scoped/release-date": {
"1.0.0": new Date(new Date().getTime() - /* 10 days */ 1000 * 60 * 60 * 24 * 10).toISOString(),
"1.1.0": new Date(new Date().getTime() - /* 5 days */ 1000 * 60 * 60 * 24 * 5).toISOString(),
"1.1.1": new Date().toISOString(),
},
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

open to a better way of storing/computing this metadata.

The only other thought I had was to store a delta (e.g. 5 days) in the fixture package.json metadata and calculate the registry response as now - delta from that. Less hardcoding that way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way you implemented it seems good to me 👍

function extractInferenceParametersFromRequest(request: Descriptor): InferenceParameters {
if (request.range === `unknown`)
return {type: `resolve`, range: `latest`};
return {type: `resolve`, range: `*`};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See eccba6c for context on this. Without this, add on new packages or up wouldn't resolve correctly if the latest package was newer than the minimum age.

Not sure what kind of implications (performance or otherwise) this might have on those operations or others.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm this is a little problematic - some packages like to release new versions without immediately turning them latest.

Could we rather have a check in the tag resolver so that if the tag version is too new it picks the highest version that's lower than the tag and matches the gates?

Comment on lines 66 to 70
structUtils.stringifyIdent(descriptor) === exclude
|| structUtils.stringifyLocator(structUtils.makeLocator(descriptor, version)) === exclude
|| structUtils.stringifyLocator(structUtils.makeLocator(descriptor, `${PROTOCOL}:${version}`)) === exclude
|| micromatch.isMatch(structUtils.stringifyDescriptor({...descriptor, range: rawRange}), exclude)
|| micromatch.isMatch(structUtils.stringifyDescriptor(descriptor), exclude),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I covered the bases here, but let me know if there are other cases to be considered, or any comparison functions that might already exist.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we just do the glob matching, perhaps after normalizing the glob to turn foo into foo@*?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure. I was thinking users could specify:

  1. scope + name - @aws-sdk/types
  2. locator, with or without the protocol prefix - @aws-sdk/[email protected] OR @aws-sdk/types@npm:1.2.3
  3. descriptor, again, with our without the protocol prefix - @aws-sdk/types@^1.2.3 OR @aws-sdk/types@^npm:1.2.3
  4. any glob that would match on the descriptor. so @aws-sdk/*, @aws-sdk/types@* or even aws*.

I think it would be more complicated to condense these down into a narrower set of checks that it would be to just enumerate them.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd prefer to start with those two:

  • An ident
  • A descriptor with a semver range (and only that; no npm: at all), just like peer dependency ranges

I suspect checking on arbitrary descriptors / locators will be more confusing than not, based on the experience we had with the resolutions field.

@bienzaaron bienzaaron changed the title feat: implement minimumNpmReleaseAge and minimumNpmReleaseAgeExclude config options feat: implement npmMinimumReleaseAge and npmMinimumReleaseAgeExclude config options Sep 17, 2025
Copy link
Member

@arcanis arcanis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outstanding work, thanks! I left a couple of requests, let me know what you think.

Comment on lines 580 to 590
npmMinimumReleaseAge: {
description: `Minimum age of a package version according to the publish date on the npm registry in minutes to be considered for installation`,
type: SettingsType.NUMBER,
default: 0,
},
npmMinimumReleaseAgeExclude: {
description: `Array of package name glob patterns to exclude from the minimum release age check`,
type: SettingsType.STRING,
isArray: true,
default: [],
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move these settings into plugin-npm?

type: SettingsType.STRING,
default: `throw`,
},
npmMinimumReleaseAge: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
npmMinimumReleaseAge: {
npmMinimalAgeGate: {

I could envision multiple types of gates.

type: SettingsType.NUMBER,
default: 0,
},
npmMinimumReleaseAgeExclude: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
npmMinimumReleaseAgeExclude: {
npmPreapprovedPackages: {

Same reason, if there were multiple gates we'd want a single setting to bypass them all.

Comment on lines 180 to 197
const RELEASE_DATE_PACKAGES: Record<string, Record<string, number | string>> = {
"release-date": {
"1.0.0": new Date(new Date().getTime() - /* 10 days */ 1000 * 60 * 60 * 24 * 10).toISOString(),
"1.1.0": new Date(new Date().getTime() - /* 5 days */ 1000 * 60 * 60 * 24 * 5).toISOString(),
"1.1.1": new Date().toISOString(),
},
"release-date-transitive": {
"1.0.0": new Date(new Date().getTime() - /* 10 days */ 1000 * 60 * 60 * 24 * 10).toISOString(),
"1.1.0": new Date(new Date().getTime() - /* 5 days */ 1000 * 60 * 60 * 24 * 5).toISOString(),
"1.1.1": new Date().toISOString(),
},
"@scoped/release-date": {
"1.0.0": new Date(new Date().getTime() - /* 10 days */ 1000 * 60 * 60 * 24 * 10).toISOString(),
"1.1.0": new Date(new Date().getTime() - /* 5 days */ 1000 * 60 * 60 * 24 * 5).toISOString(),
"1.1.1": new Date().toISOString(),
},
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way you implemented it seems good to me 👍

function extractInferenceParametersFromRequest(request: Descriptor): InferenceParameters {
if (request.range === `unknown`)
return {type: `resolve`, range: `latest`};
return {type: `resolve`, range: `*`};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm this is a little problematic - some packages like to release new versions without immediately turning them latest.

Could we rather have a check in the tag resolver so that if the tag version is too new it picks the highest version that's lower than the tag and matches the gates?

Comment on lines 65 to 79
const shouldExclude = minimumReleaseAgeExclude.some(exclude =>
structUtils.stringifyIdent(descriptor) === exclude
|| structUtils.stringifyLocator(structUtils.makeLocator(descriptor, version)) === exclude
|| structUtils.stringifyLocator(structUtils.makeLocator(descriptor, `${PROTOCOL}:${version}`)) === exclude
|| micromatch.isMatch(structUtils.stringifyDescriptor({...descriptor, range: rawRange}), exclude)
|| micromatch.isMatch(structUtils.stringifyDescriptor(descriptor), exclude),
);
if (!shouldExclude) {
const versionTime = new Date(registryData.time[version]);
const ageMinutes = (new Date().getTime() - versionTime.getTime()) / 60 / 1000;
if (ageMinutes < minimumReleaseAge) {
return miscUtils.mapAndFilter.skip;
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move that logic into an npmConfigUtils helper?

Comment on lines 66 to 70
structUtils.stringifyIdent(descriptor) === exclude
|| structUtils.stringifyLocator(structUtils.makeLocator(descriptor, version)) === exclude
|| structUtils.stringifyLocator(structUtils.makeLocator(descriptor, `${PROTOCOL}:${version}`)) === exclude
|| micromatch.isMatch(structUtils.stringifyDescriptor({...descriptor, range: rawRange}), exclude)
|| micromatch.isMatch(structUtils.stringifyDescriptor(descriptor), exclude),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we just do the glob matching, perhaps after normalizing the glob to turn foo into foo@*?

@arcanis
Copy link
Member

arcanis commented Sep 17, 2025

I slightly tweaked the code; instead of checking the original descriptor (ie whatever was in the dependencies field) we now just checked the ident + the proposed version. I don't think it changes the intent of the code: adding foo@^1.2.0 in the allowed range will allow any package 1.2.1, 1.3.0 etc to be used, but not 2.0 and higher.

Waiting for the tests to run one last time then it'll be good to merge and you'll be able to run it by using yarn set version from sources until it gets released (I'd like to also release #6898 if possible).

@arcanis arcanis merged commit 8e84598 into yarnpkg:master Sep 18, 2025
25 of 26 checks passed
@arcanis
Copy link
Member

arcanis commented Sep 18, 2025

Released in 4.10 - thanks a lot !

@kaszperro
Copy link

Does it also support transitive dependencies?

@clemyan clemyan changed the title feat: implement npmMinimumReleaseAge and npmMinimumReleaseAgeExclude config options feat: implement npmMinimalAgeGate and npmPreapprovedPackages config options Oct 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Add minimumReleaseAge option similar to pnpm

4 participants