Add cooldown policy config to filter newly released packages#12692
Add cooldown policy config to filter newly released packages#12692crocodele wants to merge 16 commits into
Conversation
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
| 'client-certificate' => [], | ||
| 'forgejo-domains' => ['codeberg.org'], | ||
| 'forgejo-token' => [], | ||
| 'minimum-release-age' => ['minimum-age' => null, 'exceptions' => []], |
There was a problem hiding this comment.
It'd be great to add support in ConfigCommand as well for setting the age and exceptions
There was a problem hiding this comment.
Added in 5e094e3. I added test cases only for happy paths.
|
Thanks a bunch, looks quite solid at first sight but I'll have to review more in depth for sure. |
|
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 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]) { |
There was a problem hiding this comment.
It'd be safer here to avoid relying on having the same object instance I think
| if (isset($oldestSecurityFixDate[$key]) && $releaseDate === $oldestSecurityFixDate[$key]) { | |
| if (isset($oldestSecurityFixDate[$key]) && $releaseDate->getTimestamp() === $oldestSecurityFixDate[$key]->getTimestamp()) { |
There was a problem hiding this comment.
Addressed in one of the recent commits.
| $diff = $this->now->diff($availableAt); | ||
|
|
||
| $parts = []; | ||
| if ($diff->d > 0) { |
There was a problem hiding this comment.
You need to support $diff->m and $diff->y too here for completeness to avoid showing incorrect diffs.
There was a problem hiding this comment.
Or maybe rather use ->days which is the full amount of days diff.
There was a problem hiding this comment.
Using ->days now, in CooldownPolicyConfig.php.
| } | ||
|
|
||
| // Parse strings like "7 days", "1 week", "24 hours" | ||
| $timestamp = strtotime($duration, 0); |
There was a problem hiding this comment.
Maybe we should validate expected formats with a regex here to avoid any random config?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
This means versions are being hidden though, right? It'd be great to have some .test files to test all the problem output here.
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. |
|
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. |
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 |
Security advisories usually mention the fixed versions, only those fixed versions may bypass cooldown - but this should be configurable. |
|
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 |
@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 |
|
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. |
|
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) |
|
@crocodele composer/packagist#1765 is now live so IF metadata has However a few other things since the 2.10 release need to be changed here:
|
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>
API Surface ChangesIf any of the additions below are not intended as public API, mark them with New API SurfaceMethods
Properties
Modified API SurfaceMethods
|
Using
I went with
Changed the config format.
Yeah, this is almost 3k lines already, so I'll leave this out for now. 😅 |
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 issecond/minute/hour/day/week(singular or plural).null/0disables. Relative phrases like"tomorrow"are rejected rather than silently misinterpreted.block(defaulttrue): withhold too-new versions duringupdate/require.audit(ignore|report|fail, defaultignore): howcomposer audittreats within-cooldown installed/locked packages.ignore: package/constraint patterns that bypass the cooldown.COMPOSER_POLICY_COOLDOWN_AGE,COMPOSER_POLICY_COOLDOWN_BLOCK(and the globalCOMPOSER_POLICYswitch).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-controlledtimeonly when a repository does not provide it. When the fallback is used, the cooldown message says so explicitly. This closes the "attacker back-datestimeto skip the cooldown" hole.published-timeis added to the repository JSON schema and is persisted incomposer.lock/installed.jsonsocomposer auditcan use the authoritative date offline. (Server-side population on Packagist is already in place.)Behaviour
composer auditcan report or fail on within-cooldown packages.policy.cooldown.ignore/policy.cooldown.block.Backwards compatibility
Opt-in: no effect until an
ageis set. Repositories and older clients that don't knowpublished-timeare unaffected (unknown field ignored / graceful fallback totime).Related to #12633.
Developed with assistance from Claude Code (Opus 4.x).