innobrain / soak-time
Protects against supply chain attacks by filtering recently published packages.
Package info
github.com/innobraingmbh/composer-soak-time
Type:composer-plugin
pkg:composer/innobrain/soak-time
Requires
- php: ^8.1
- composer-plugin-api: ^2.0
Requires (Dev)
- composer/composer: ^2.2
- phpunit/phpunit: ^10.5
README
Innobrain Soak Time 🛡️
A Composer plugin that enforces a soak time — a minimum age — on every package version before install. New releases stay out of the solver pool until they age past the threshold, blocking zero-day malicious releases (typosquats, account takeovers, malicious co-maintainer pushes).
A date filter alone is defeatable: an attacker can force-push an old tag at a malicious commit with a backdated GIT_COMMITTER_DATE, and Packagist serves that timestamp. Packagist.org locks the source and dist reference of stable versions and refuses moved tags (composer/packagist#1742, docs) — but only for stable versions on packagist.org. The plugin pins each version's git SHA, source URL, dist URL, and dist sha256 in composer-integrity.lock, extending that protection to dev versions, the local download cache, and non-packagist sources, and hard-fails on any later drift. See SECURITY_MODEL.md.
🧭 How it works
Four checks run on every install/update:
| Check | Hook | Catches |
|---|---|---|
Timestamp filter (PackageFilter) |
PRE_POOL_CREATE |
Fresh malicious releases — drops versions younger than the soak time from the solver pool. |
Reference drift (ReferenceDriftCheck) |
PRE_POOL_CREATE |
Altered historical releases — a backdated GIT_COMMITTER_DATE still changes the content-addressed SHA, which can't be forged. |
Hash pinning (HashVerifier) |
POST_FILE_DOWNLOAD |
Cache poisoning at ~/.composer/cache/files/ — re-hashes the downloaded archive (Composer's native sha1 is empty for GitHub zips). |
Source pinning (PackageIntegrityRecorder) |
POST_PACKAGE_INSTALL / POST_PACKAGE_UPDATE |
--prefer-source installs; fails closed if a dist install never exposes its archive. |
Pins are written to composer-integrity.lock when a version is first seen (trust-on-first-use) and verified on every later run.
📦 Installation
composer require --dev innobrain/soak-time # project composer global require innobrain/soak-time # all local projects
Upgrading from ≤ v1.3.0?
composer updatefails because the oldSoakTimeConfigis still in PHP memory. Reinstall instead:composer global remove innobrain/soak-time && composer global require innobrain/soak-time(or the--devequivalents).
⚙️ Configuration
Default soak time is 168h (7 days). Configure via extra in composer.json:
{
"extra": {
"soak-time-hours": 168,
"soak-time-whitelist": ["roave/security-advisories", "your-company/*"],
"soak-time-dev-branches": ["your-company/my-lib"]
}
}
- Per-run override:
SOAK_TIME_HOURS=336 composer update(takes precedence; ignored with a warning if not a non-negative integer). - Whitelist bypasses the soak filter for trusted packages that update constantly.
*is allowed in the name half only — the vendor must be a literal (your-company/*,your-company/lib-*). Vendor-side wildcards (*/x,*/*,*) are rejected.SOAK_TIME_SKIPaccepts the same patterns. - Versions with no release date are filtered unless whitelisted. Whitelist path/internal repos only if you trust their metadata.
Windows PowerShell sets env vars as $env:SOAK_TIME_HOURS=336; composer update.
Dev branches (soak-time-dev-branches)
Dev versions like dev-main or 1.x-dev are mutable — their sourceReference (git SHA) legitimately changes every time the branch advances. By default the plugin treats every version as immutable and hard-fails if a pinned reference drifts. That would make composer update permanently broken for any dev-branch dependency once the branch advances.
Declare the packages whose dev versions are intentionally mutable:
{
"extra": {
"soak-time-dev-branches": ["your-company/my-lib", "your-company/*"]
}
}
Or pass the list as a comma-separated env var for a one-run override:
SOAK_TIME_DEV_BRANCHES=your-company/my-lib composer update
Patterns follow the same rules as the whitelist — vendor must be a literal, * is allowed only in the name half.
Security trade-off: for a declared dev package, the source reference is allowed to advance when isDev() is true. However, if the reference is unchanged but the downloaded archive's sha256 differs, the plugin still hard-fails — that is cache poisoning of a fixed SHA, not legitimate branch movement. Stable versions are never treated as mutable regardless of this list.
Undeclared dev versions whose reference changed are blocked with an error that names soak-time-dev-branches so you know how to unblock them after investigation.
🔐 Integrity lock file
composer-integrity.lock records each version's sha256 (when Composer exposes the archive), sourceReference, sourceUrl, distUrl, and firstSeenAt. Commit it alongside composer.lock — later installs verify against it and hard-fail on drift.
Packages from path repositories are exempt from integrity pinning entirely: they are local code in the same trust domain as the root project, have no archive hash or source reference to pin, and would otherwise fail every install.
Some paths (including plugin self-update) install from dist without exposing the archive; the plugin then fails closed — fix with composer global reinstall innobrain/soak-time --prefer-source. Opt out (not recommended) with soak-time-integrity: false, or relocate via soak-time-integrity-lock:
{
"extra": {
"soak-time-integrity": true,
"soak-time-integrity-lock": "composer-integrity.lock"
}
}
🚨 Emergency skip
Install a fresh security patch by skipping the freshness filter for one package (integrity checks still run):
SOAK_TIME_SKIP=vendor/package composer update vendor/package
SOAK_TIME_SKIP=1 skips freshness for the whole run.
🔍 Troubleshooting
Run composer update -v to see dropped versions. If the soak time hides every version of a required package, resolution fails — the plugin names the package and its newest version's age up front. Fix by lowering SOAK_TIME_HOURS, whitelisting it, or a one-run SOAK_TIME_SKIP.
🙏 Credits & License
Fork of cotonet/soak-time by Cotonet - Resiliência Digital. MIT License — see LICENSE. Copyright Cotonet - Resiliência Digital (original) and Innobrain GmbH (fork).