Skip to content

Add cooldown policy config to filter newly released packages#12692

Open
crocodele wants to merge 16 commits into
composer:mainfrom
crocodele:feature/MinimumReleaseAge
Open

Add cooldown policy config to filter newly released packages#12692
crocodele wants to merge 16 commits into
composer:mainfrom
crocodele:feature/MinimumReleaseAge

Conversation

@crocodele

@crocodele crocodele commented Dec 27, 2025

Copy link
Copy Markdown

Introduces a configurable cooldown: a waiting period before a newly published package version becomes installable, to reduce exposure to supply-chain attacks where a compromised maintainer pushes a malicious release (many such versions are caught within hours/days of publication).

Configuration

Lives under config.policy.cooldown:

{
    "config": {
        "policy": {
            "cooldown": {
                "age": "7 days",
                "block": true,
                "audit": "report",
                "ignore": {
                    "vendor/pkg": "reason / pinned for now"
                }
            }
        }
    }
}
  • age: cooldown duration. Integer seconds, or "<n> <unit>" where unit is second/minute/hour/day/week (singular or plural). null/0 disables. Relative phrases like "tomorrow" are rejected rather than silently misinterpreted.
  • block (default true): withhold too-new versions during update/require.
  • audit (ignore|report|fail, default ignore): how composer audit treats within-cooldown installed/locked packages.
  • ignore: package/constraint patterns that bypass the cooldown.
  • Env overrides: COMPOSER_POLICY_COOLDOWN_AGE, COMPOSER_POLICY_COOLDOWN_BLOCK (and the global COMPOSER_POLICY switch).
  • Settable via composer config policy.cooldown.age "7 days" etc.

Trustworthy timestamp (published-time)

The cooldown measures against a new repository-owned field, published-time, which the package author cannot influence – falling back to the existing author-controlled time only when a repository does not provide it. When the fallback is used, the cooldown message says so explicitly. This closes the "attacker back-dates time to skip the cooldown" hole. published-time is added to the repository JSON schema and is persisted in composer.lock/installed.json so composer audit can use the authoritative date offline. (Server-side population on Packagist is already in place.)

Behaviour

  • Security-fix bypass: a version released after a matching security advisory (and not affected by it), within 2x the cooldown window, bypasses the cooldown so fixes aren't withheld. Only the oldest such fix per constraint part bypasses. Works even when advisory blocking is disabled.
  • Never filtered: root package, platform packages, locked/installed packages, dev versions, and versions with no usable date (conservative).
  • Audit integration: composer audit can report or fail on within-cooldown packages.
  • Clear errors: when a required version is withheld, the message states how long until it clears and whether the authoritative or fallback timestamp was used, and points at policy.cooldown.ignore / policy.cooldown.block.

Backwards compatibility

Opt-in: no effect until an age is set. Repositories and older clients that don't know published-time are unaffected (unknown field ignored / graceful fallback to time).

Related to #12633.

Developed with assistance from Claude Code (Opus 4.x).

Introduces a configurable waiting period before new package versions can
be installed, reducing the risk of exposure to supply chain attacks. Many
vulnerable package versions are identified within hours/days of
publication.

Configuration:
- config.minimum-release-age.minimum-age: duration (e.g., "7 days")
- config.minimum-release-age.exceptions: list of package patterns to bypass
- COMPOSER_MINIMUM_RELEASE_AGE env var override

Key features:
- Security fixes automatically bypass cooldown when released after a
  security advisory (within 2x cooldown window)
- Dev versions, locked packages, and platform packages are never filtered
- Packages without release dates are allowed through conservatively
- Clear error messages show when packages are filtered and time remaining
@Seldaek Seldaek added this to the 2.10 milestone Dec 29, 2025
Comment thread src/Composer/Config.php Outdated
'client-certificate' => [],
'forgejo-domains' => ['codeberg.org'],
'forgejo-token' => [],
'minimum-release-age' => ['minimum-age' => null, 'exceptions' => []],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It'd be great to add support in ConfigCommand as well for setting the age and exceptions

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in 5e094e3. I added test cases only for happy paths.

@Seldaek

Seldaek commented Dec 29, 2025

Copy link
Copy Markdown
Member

Thanks a bunch, looks quite solid at first sight but I'll have to review more in depth for sure.

@crocodele crocodele requested a review from Seldaek December 29, 2025 18:34
@Seldaek

Seldaek commented Mar 20, 2026

Copy link
Copy Markdown
Member

So thinking some more about this feature, I realized that an attacked could right now easily fake the release date to put it in the past, as the release is under control of the package authors..

Thus I think if we want to do this, we should have a new date like publish_date or something that is purely owned by the package repositories and cannot be set by the packages. And the minimum-release-age would apply to that date (if available). Just writing this down for future reference.. not really expecting you to act on this right now.

@stof

stof commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@Seldaek isn't the release date the date of the git commit for VCS-based repositories ?

foreach ($constraintParts as $index => $part) {
if ($part->matches($packageConstraint)) {
$key = $name . ':' . $index;
if (isset($oldestSecurityFixDate[$key]) && $releaseDate === $oldestSecurityFixDate[$key]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It'd be safer here to avoid relying on having the same object instance I think

Suggested change
if (isset($oldestSecurityFixDate[$key]) && $releaseDate === $oldestSecurityFixDate[$key]) {
if (isset($oldestSecurityFixDate[$key]) && $releaseDate->getTimestamp() === $oldestSecurityFixDate[$key]->getTimestamp()) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in one of the recent commits.

$diff = $this->now->diff($availableAt);

$parts = [];
if ($diff->d > 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You need to support $diff->m and $diff->y too here for completeness to avoid showing incorrect diffs.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Or maybe rather use ->days which is the full amount of days diff.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Using ->days now, in CooldownPolicyConfig.php.

}

// Parse strings like "7 days", "1 week", "24 hours"
$timestamp = strtotime($duration, 0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe we should validate expected formats with a regex here to avoid any random config?

@crocodele crocodele Jun 20, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ultimately I ended up doing exactly this, i.e. limiting the value to strings like "1 week", "3 days", "60 hours" etc., or an integer value which is interpreted as number of seconds. Ambiguity around valid strtotime() strings like "last week" and "next week" might be problematic.

$releaseAgeInfo = $pool->getReleaseAgeInfoForPackageVersion($packageName, $constraint);
$timeInfo = $releaseAgeInfo !== null ? ' (available in '.$releaseAgeInfo['availableIn'].')' : '';

// Don't pass pool to getPackageList to avoid adding all removed versions - we only want versions specific to each filter

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This means versions are being hidden though, right? It'd be great to have some .test files to test all the problem output here.

@Seldaek

Seldaek commented Mar 20, 2026

Copy link
Copy Markdown
Member

@Seldaek isn't the release date the date of the git commit for VCS-based repositories ?

Yes but that is completely under the control of the committer.

And on top of that if you set the 'time' property in the composer.json you can just set whatever date and that will take precedence over the VCS inferred date.

@Seldaek Seldaek modified the milestones: 2.10, 2.11 Mar 31, 2026
@Inscure

Inscure commented Mar 31, 2026

Copy link
Copy Markdown

The recent "axios" incident is a perfect example of why we need this feature asap.

A native quarantine period (24–72h) would provide a vital proactive buffer, shielding PHP projects from similar supply chain attacks. It allows the community to flag malicious releases before they are automatically pulled into production via automated updates.

Implementing this now would be a massive win for PHP security and ecosystem stability.

@arabcoders

Copy link
Copy Markdown

And on top of that if you set the 'time' property in the composer.json you can just set whatever date and that will take precedence over the VCS inferred date.

In my opinion this need to be addressed, as this work around rely solely on the time, if attacker is able to change the release date to say month or something then this mitigation is useless,

Recent attacks started to appear in composer registry for ref

I think this option should be deprecated and removed, or at least should be turned off and rely on date from the registry first and fallback to vcs when this option turned on

@CybotTM

CybotTM commented May 7, 2026

Copy link
Copy Markdown

Security fixes automatically bypass cooldown when released after a security advisory (within 2x cooldown window)

Security advisories usually mention the fixed versions, only those fixed versions may bypass cooldown - but this should be configurable.

@jdsdev

jdsdev commented May 12, 2026

Copy link
Copy Markdown

If security advisories were highlighted during an update, that could also give users the option of researching the advisory, and deciding whether to bypass the cooldown manually.

Recently there was a compromise of the intercom/intercom-php package, so this is no longer theoretical.

@crocodele

Copy link
Copy Markdown
Author

So thinking some more about this feature, I realized that an attacked could right now easily fake the release date to put it in the past, as the release is under control of the package authors..

Thus I think if we want to do this, we should have a new date like publish_date or something that is purely owned by the package repositories and cannot be set by the packages. And the minimum-release-age would apply to that date (if available). Just writing this down for future reference.. not really expecting you to act on this right now.

@Seldaek, do you think we can pursue this publish timestamp that is owned by the package repositories, starting with Packagist? Or what would be the best way forward? I suppose the whole minimum-release-age feature is useful only when the release date for package versions can't be faked – otherwise it brings a false sense of security.

@Seldaek

Seldaek commented May 24, 2026

Copy link
Copy Markdown
Member

I don't think it's such a hard problem and I'll get on it soon but this had to take a backseat for higher prio things (also security related.. Zero chill at the moment with all the attacks and AI driven security audits)

@crocodele

Copy link
Copy Markdown
Author

I don't think it's such a hard problem and I'll get on it soon but this had to take a backseat for higher prio things (also security related.. Zero chill at the moment with all the attacks and AI driven security audits)

Thanks! If there's something I can do to help, let me know. And once there's a clear path, I'm more than happy to clean up this PR and make the required changes here.

@MarkusJLechner

MarkusJLechner commented May 27, 2026

Copy link
Copy Markdown

Came here by because of laravel-lang supply chain attack (https://snyk.io/de/blog/laravel-lang-supply-chain-advisory/). We tried hacking together our own fix, but this really needs to be in composer itself.

looking at the thread, it seems like any custom approach would be easily bypassed by the "backdating the release date" trick that's already been mentioned.

please prioritize merging this, it solves a real problem that we currently have no clean way to fix. thanks for the great work!

Edit: k, #12633 (comment)
Came along that comment, would leave this comment here for future readers that +1 should be avoided as this a a change of something bigger

@Seldaek

Seldaek commented Jun 12, 2026

Copy link
Copy Markdown
Member

@crocodele composer/packagist#1765 is now live so IF metadata has published-time it should be preferred over time as the release date. This field needs to be added to composer-schema and parsed by ArrayLoader and added to PackageInterface too.

However a few other things since the 2.10 release need to be changed here:

  • I've thought about the name and IMO cooldown might be a better name, as it is much shorter and generally how people refer to this as dependency cooldown. Not sure if we need to rename or not, but worth considering.
  • This should be configured within the new framework of dependency policies i.e. config: policy: cooldown: { ... }. We need to review the whole config format in depth and make sure names are unified and follow the existing policy stuff tho, I will try and do that next week.
  • This cooldown announcement from rubygems made us realize we probably should support cooldown in the outdated command as well, but maybe that should be kept for a follow-up PR as this one is already big enough.

crocodele and others added 12 commits June 13, 2026 00:51
Catches the branch up with ~6 months / 214 commits of upstream, which
landed the config.policy.* version-blocking framework (Composer\Policy\*,
FilterListPoolFilter, reworked SecurityAdvisoryPoolFilter, Pool policy
buckets, unified Problem messaging, ConfigCommand wiring).

The branch's standalone minimum-release-age was built before that
framework existed and collided with it on 12 files. Per the agreed plan,
those conflicts are resolved by taking upstream wholesale; the branch's
reusable assets (ReleaseAgeConfig, ReleaseAgePoolFilter, their tests,
docs) are kept but left unwired here and rebuilt as config.policy.cooldown
in the following commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Integrates the cooldown into the dependency-policy framework that landed
on main, per Seldaek's review (PR composer#12692):

- New Composer\Policy\CooldownPolicyConfig (extends ListPolicyConfig),
  registered in PolicyConfig as the built-in "cooldown" list. Carries an
  `age` duration on top of the shared block/audit/ignore skeleton.
  Excluded from filterableLists() — it has its own dedicated filter.
- Rename ReleaseAgePoolFilter -> CooldownPoolFilter; drive it from
  CooldownPolicyConfig, honour policy.cooldown.ignore rules (name +
  constraint), keep the security-fix bypass (fed by
  SecurityAdvisoryPoolFilter::getAdvisoryMap()).
- Pool gains a cooldownRemovedVersions bucket (+ accessors), threaded
  through PoolOptimizer; PoolBuilder runs the cooldown filter after the
  filter-list filter; Installer wires it in the update path only.
- config.policy.cooldown schema (age/block/audit/ignore); ConfigCommand
  policy.cooldown.{block,audit,age}; Problem reports cooldown exclusions
  with policy.cooldown.* guidance and "available in" timing.
- COMPOSER_POLICY_COOLDOWN_AGE / _BLOCK env overrides.
- Drop the standalone config.minimum-release-age (config key, filter,
  config object, docs). Docs moved under config.policy.cooldown.

Tests: CooldownPolicyConfigTest, CooldownPoolFilterTest, Problem +
ConfigCommand cooldown cases; existing PolicyConfig consumers updated for
the new constructor arg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Packagist now exposes a server-set published-time per version that the
package author cannot influence (unlike the existing `time` field). The
cooldown policy prefers it over `time`, closing the hole where an attacker
could backdate a release to skip the cooldown.

- ArrayLoader reads `published-time` (ISO 8601 or unix timestamp, UTC)
  alongside `time`; ArrayDumper round-trips it.
- New getPublishedDate()/setPublishedDate() on PackageInterface + Package,
  proxied by AliasPackage.
- CooldownPoolFilter measures age against getPublishedDate() ?? getReleaseDate()
  and records which source drove the decision; Problem adds a caveat when the
  cooldown fell back to the author-controlled `time`.

Tests: ArrayLoader published-time parsing (ISO/unix/missing/malformed),
filter precedence + fallback, Problem source attribution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the 2026-03-20 PR review (carried over from the old ReleaseAgePoolFilter):

- Compare oldest-security-fix dates by getTimestamp() instead of relying on
  DateTimeInterface object identity (===), which was fragile.
- formatTimeUntilAvailable now uses DateInterval::$days (total days) rather
  than ->d, which resets each month and under-reported waits longer than a
  month. Adds a regression test for a multi-month wait.

The duration-format validation suggestion (parseDuration) is left pending
Seldaek's input on the preferred approach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment wording/punctuation, a clearer test method name, and dropping a
few now-redundant explanatory comments. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address two issues in the cooldown policy:

- policy.cooldown.age: a malformed COMPOSER_POLICY_COOLDOWN_AGE env value
  threw an error referencing the config key instead of the env var. The env
  parse is now wrapped to report "Invalid value for
  COMPOSER_POLICY_COOLDOWN_AGE: ...", matching the existing getBoolEnv /
  COMPOSER_AUDIT_ABANDONED precedent.

- policy.cooldown.audit was parsed and documented but never evaluated, since
  cooldown is excluded from the filter-list audit path. It is now wired into
  composer audit via a dedicated CooldownAuditor (the audit-time counterpart
  to CooldownPoolFilter), reporting/failing on installed packages still within
  the cooldown age and honoring policy.cooldown.ignore. The shared per-package
  date/ignore logic is extracted onto CooldownPolicyConfig so the block and
  audit paths share one implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cooldown security-fix bypass needs the advisory map, but the map was only
resolved when policy.advisories.block was true: Installer skipped constructing
the SecurityAdvisoryPoolFilter, and the filter itself early-returned before
resolving advisories. So with policy.advisories.block=false but cooldown enabled,
a genuine security release was withheld for the full cooldown window.

SecurityAdvisoryPoolFilter now takes a resolveWithoutBlocking flag: when set it
resolves and stores the advisory map but removes no packages. Installer constructs
the filter when advisories OR cooldown blocks, passing the flag for the
cooldown-only case. PoolBuilder then feeds the map to the cooldown filter as before.

Note: this incurs an advisory fetch in the cooldown-only case (none happened
before) - inherent to making the bypass work, as it requires advisory data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getCooldownInfoForPackageVersion() picked the soonest-available version by
comparing the stored ATOM release-date strings lexically. When two versions
carry different UTC offsets (e.g. +02:00 vs +00:00) the lexical order differs
from the chronological order, so the wrong version's "available in" hint could
be shown. Parse the stored strings into DateTimeImmutable and compare instants.

Cosmetic (error message only); the common path is unaffected since ArrayLoader
normalizes time/published-time to UTC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getEffectiveDate() (getPublishedDate() ?? getReleaseDate()) was resolved 3+ times
per package across the skip check, age check, and the two security-fix passes
(isSecurityFix runs once per package in each pass). Cache it per filter() run,
keyed by spl_object_hash to match the solver's per-package map idiom. Pure
cleanup, behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
isSecurityFix() looked up advisories under getName() only, while the advisory
block side (SecurityAdvisoryPoolFilter::getMatchingAdvisories) and the cooldown
removal tracking iterate getNames(false). A fork that replaces another package,
with the advisory recorded under the replaced name, was correctly blocked but its
fresh fixing release was invisible to the bypass and stayed in cooldown.

Iterate getNames(false) (canonical + replaced names) when matching advisories,
mirroring the block side. Provides (virtual names) stay excluded, keeping the
bypass symmetric with what the block side removes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ie, document published-time

- parseDuration: drop strtotime, accept only integer seconds or explicit
  second/minute/hour/day/week durations. Relative phrases ("tomorrow",
  "next week") and unsupported units now throw instead of silently applying
  a surprising or zero (feature-disabling) cooldown.
- CooldownPoolFilter: compare security-fix release instants with the same
  precision as the oldest-selection pass (<= vs whole-second ===), so a fix
  released a fraction later in the same second no longer also bypasses the
  cooldown.
- Document the repository-owned `published-time` field in the repository
  JSON schema, and cover the ArrayDumper output for it.
- Align the cooldown age documentation and config schema with the supported
  duration units.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

API Surface Changes

If any of the additions below are not intended as public API, mark them with @internal in the docblock.

New API Surface

Methods

Properties

Modified API Surface

Methods

  • Composer\DependencyResolver\Pool::__construct
    - public function __construct(array $packages = [], array $unacceptableFixedOrLockedPackages = [], array $removedVersions = [], array $removedVersionsByPackage = [], array $securityRemovedVersions = [], array $abandonedRemovedVersions = [], array $filterListRemovedVersions = [])
    + public function __construct(array $packages = [], array $unacceptableFixedOrLockedPackages = [], array $removedVersions = [], array $removedVersionsByPackage = [], array $securityRemovedVersions = [], array $abandonedRemovedVersions = [], array $filterListRemovedVersions = [], array $cooldownRemovedVersions = [])
  • Composer\DependencyResolver\PoolBuilder::__construct
    - public function __construct(array $acceptableStabilities, array $stabilityFlags, array $rootAliases, array $rootReferences, IOInterface $io, ?EventDispatcher $eventDispatcher = null, ?PoolOptimizer $poolOptimizer = null, array $temporaryConstraints = [], ?SecurityAdvisoryPoolFilter $securityAdvisoryPoolFilter = null, ?FilterListPoolFilter $filterListPoolFilter = null)
    + public function __construct(array $acceptableStabilities, array $stabilityFlags, array $rootAliases, array $rootReferences, IOInterface $io, ?EventDispatcher $eventDispatcher = null, ?PoolOptimizer $poolOptimizer = null, array $temporaryConstraints = [], ?SecurityAdvisoryPoolFilter $securityAdvisoryPoolFilter = null, ?FilterListPoolFilter $filterListPoolFilter = null, ?CooldownPoolFilter $cooldownPoolFilter = null)
  • Composer\Repository\RepositorySet::createPool
    - public function createPool(Request $request, IOInterface $io, ?EventDispatcher $eventDispatcher = null, ?PoolOptimizer $poolOptimizer = null, array $ignoredTypes = [], ?array $allowedTypes = null, ?SecurityAdvisoryPoolFilter $securityAdvisoryPoolFilter = null, ?FilterListPoolFilter $filterListPoolFilter = null): Pool
    + public function createPool(Request $request, IOInterface $io, ?EventDispatcher $eventDispatcher = null, ?PoolOptimizer $poolOptimizer = null, array $ignoredTypes = [], ?array $allowedTypes = null, ?SecurityAdvisoryPoolFilter $securityAdvisoryPoolFilter = null, ?FilterListPoolFilter $filterListPoolFilter = null, ?CooldownPoolFilter $cooldownPoolFilter = null): Pool

@crocodele

Copy link
Copy Markdown
Author

@crocodele composer/packagist#1765 is now live so IF metadata has published-time it should be preferred over time as the release date. This field needs to be added to composer-schema and parsed by ArrayLoader and added to PackageInterface too.

Using published-time now when available, falling back to the less-trustworthy time.

However a few other things since the 2.10 release need to be changed here:

  • I've thought about the name and IMO cooldown might be a better name, as it is much shorter and generally how people refer to this as dependency cooldown. Not sure if we need to rename or not, but worth considering.

I went with minimum-release-age originally to align with other (non-PHP) tools such as Renovate, npm, and pnpm. Renamed everything from minimum-release-age to cooldown now.

  • This should be configured within the new framework of dependency policies i.e. config: policy: cooldown: { ... }. We need to review the whole config format in depth and make sure names are unified and follow the existing policy stuff tho, I will try and do that next week.

Changed the config format.

  • This cooldown announcement from rubygems made us realize we probably should support cooldown in the outdated command as well, but maybe that should be kept for a follow-up PR as this one is already big enough.

Yeah, this is almost 3k lines already, so I'll leave this out for now. 😅

@crocodele crocodele changed the title Add minimum-release-age config to filter newly released packages Add cooldown policy config to filter newly released packages Jun 20, 2026
@crocodele crocodele requested a review from Seldaek June 25, 2026 00:08
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.

8 participants