If you find this add-on useful, please star it on GitHub — stars show appreciation and help maintainers know their work matters.
Pre-install CVE supplement to Composer’s native
config.policy. Blocks packages before install-from-lock can load them.
Status — pre-stable (0.x). API and exit codes are stable; defaults, detection heuristics, and recommendation logic may shift in minor versions while we iterate on real-world feedback.
Note on versioning. v1.0.0 through v1.1.4 were a versioning mistake — v1.0 was declared before the project met the stability gate (v1.0 must be earned). The pre-stable line continues at v0.2.x. The v1.x tags remain published for archeology; future work happens on the v0.x line. If you have v1.x installed, run
composer require sharkyger/composer-cve-gate:^0.2to migrate.
The freshness-hold feature (gap §2 below) is time-boxed: when Composer
ships minimum-release-age (a reserved name in their policy roadmap), that
specific feature is retired. The install-from-lockfile advisory gap
(§1 below) is the durable reason for this tool to exist, so the tool
itself remains as long as that gap is open — and will be archived only
if Composer closes it upstream too.
Composer 2.10+ ships config.policy.advisories.block (default true) for
advisory blocking during composer update / require / remove, and
config.policy.malware.block (default true) for malware blocking via the
Aikido feed during composer install. composer-cve-gate fills three gaps
that composer.policy doesn’t cover:
composer install time — When a lockfile was
clean at commit but a vulnerability is published for a locked version
afterward, a subsequent composer install (the typical CI deploy) loads
the vulnerable version with no block. composer audit can be opted in
post-install but doesn’t prevent it.minimum-release-age, a reserved name in their
roadmap — we archive when that ships).safe-scan walks vendor/ looking for
known compromise indicators (C2 domains, exfil URLs, attacker-injected file
paths) after a supply-chain incident surfaces in the news.The scanner queries multiple CVE databases and applies time-based filtering. Each signal covers different scope — see below for accuracy:
config.policy.advisories.block primarily pulls
from GHSA, so this is partially redundant with stock Composer. We query
it so the install-from-lock gate has GHSA coverage on the locked set.--min-age 0 when needed.minimum-release-age
as a native policy, we will archive this tool.ossf/malicious-packages
registry (local snapshot).
composer.policy.malware, which uses the Aikido
feed. We include OSSF for breadth.Two package shapes have no advisory data to query, so the gate skips them
before any scan runs — silently in the install gate, with one
informational line in safe-scan. Neither blocks the install:
composer.json repositories of "type": "path",
or any package whose lockfile entry has dist.type: "path") — your
project’s own bespoke clientname/site-package, a Drupal custom module,
an in-house Laravel package. Not on Packagist, not in any vulnerability
database.dev-main, dev-feature/x,
1.x-dev, etc.) — typically a private / in-development extension
installed from a branch. Composer advisories are keyed to released
versions and tags, not arbitrary branches.If you want to keep an eye on what got skipped, composer safe-scan
lists each skipped package with the reason in its report and counts
them in the summary line as N skipped.
Composer dependency code can execute on the next autoload bootstrap or when
loading a composer-plugin type package — both happen during composer
install itself, before composer audit gets to inspect anything. If a
vulnerable (or malicious) version isn’t blocked at install time from the
lockfile, the code runs before you have a chance to audit it.
Pre-install and install-time gating are the only points in the lifecycle
where blocking is still useful. composer audit post-install is a useful
backstop, but too late if the malicious code already executed.
The plugin adds three commands: safe-install, safe-upgrade (aliased as
safe-update), and safe-scan.
composer safe-install monolog/monolog
The plugin resolves monolog/monolog plus its full transitive tree,
queries every package against OSV / GHSA / NVD plus the freshness
hold, and only proceeds with the actual install if everything is
clean. Output on a clean scan:
safe-install: scanning monolog/monolog
[standard composer require output follows]
If something is blocked, you’ll see a structured report and nothing installs:
safe-install: scanning evil/pkg
BLOCKED: evil/[email protected] — status=vulnerable
[CRITICAL] CVE-2026-XXXX — info-stealer in post-install script
safe-install: blocked 1 of 1 package(s). Nothing installed.
Exit code is 1. Your project is untouched — no download, no
vendor/ write, no post-install scripts run.
composer safe-install --dev phpstan/phpstan
--dev is forwarded to composer require, so the package lands in
require-dev as expected.
composer safe-upgrade
(Also available as composer safe-update — alias for discoverability.)
Scans every direct dependency from your composer.json, then
delegates to composer update with no package args — composer
resolves the full graph (including transitive-only updates).
composer safe-upgrade vendor/pkg
Scans then runs composer update vendor/pkg. Works the same with
safe-update.
The 3-day freshness hold blocks installs of packages published less than 72 hours ago — that’s the window where a compromised version is most often up on Packagist but not yet in any CVE database. If you know a particular fresh release is fine (e.g. a patch you’ve been waiting for from a maintainer you trust), pin to that version and disable the hold:
composer safe-install --min-age 0 vendor/just-released:1.2.3
composer safe-scan
Reads composer.lock to enumerate every installed dependency, runs
the full pre-install scan against each, and additionally walks
vendor/<package>/ looking for indicator-of-compromise strings or
marker files from any known-malicious finding (C2 domains, exfil URLs,
attacker-injected file paths). Output categorises packages as:
=== safe-scan report ===
INFECTED — 1 package(s):
evil/[email protected]
[url] https://evil.test/exfil → vendor/evil/pkg/src/payload.php
safe-scan — 12 clean, 0 suspicious, 1 infected (of 13 scanned).
| Status | Meaning |
|---|---|
CLEAN |
No findings, no IoC matches. |
SUSPICIOUS |
Vulnerability database hit, but no IoC strings on disk. |
INFECTED |
IoC strings or marker files found inside the installed package. |
Read-only — safe-scan never executes, modifies, or downloads
anything. It’s the answer to “am I already infected?” after a
supply-chain incident hits the news.
safe-install / safe-upgrade:
| Exit code | Meaning |
|---|---|
0 |
Scan clean, install proceeded |
10 |
At least one package blocked, nothing installed |
1 |
Scanner errored (network, missing Python, etc.) |
safe-scan:
| Exit code | Meaning |
|---|---|
0 |
Clean |
1 |
Infected (IoC matches found on disk) |
2 |
Suspicious (vulnerability findings but no IoCs on disk) |
3 |
Scanner error (lockfile missing, malformed, etc.) |
When you see a BLOCKED line, the next step is to look up the CVE
or advisory ID it cites and decide whether the issue actually
applies to your usage. If it doesn’t, you have two paths:
composer safe-install vendor/pkg:^2.1.4FRESH-HOLD, not from a CVE):
composer safe-install --min-age 0 vendor/pkgcomposer require sharkyger/composer-cve-gate --dev
That’s it — the plugin self-registers and all three subcommands appear in
composer list immediately. No config file, no per-project setup.
| Component | Version | Why |
|---|---|---|
| Composer | ^2.0 |
Plugin uses the modern composer-plugin-api v2 hook |
| PHP | ^8.2 |
Modern constructor promotion, readonly, enum |
| Python | ≥ 3.11 |
Scanner uses datetime.UTC (Python 3.11+) |
The bundled scanner (bin/dependency_security_check.py) is invoked as
a subprocess — python3 must be on PATH. The scanner has zero
third-party Python dependencies (only stdlib + the optional certifi
bundle on macOS for SSL trust). If Python is missing at activation,
the plugin fails loud immediately rather than disabling itself
silently.
The install-from-lock gate is on by default in advisory mode — plain
composer install loads the lockfile, scans the locked set, warns on any
findings, and proceeds. To fail the build on findings, switch to block
mode via the root composer.json:
{
"extra": {
"composer-cve-gate": {
"install-gate": "advisory",
"install-gate-min-age": 3,
"install-gate-cache-ttl": 21600
}
}
}
Any subset of these keys works — unspecified keys keep their defaults.
| Key | Type | Default | Behaviour |
|---|---|---|---|
install-gate |
string | advisory |
advisory warns and proceeds · block aborts the install with a non-zero exit before any download or post-install script runs · off is a silent permanent disable |
install-gate-min-age |
int (days) | 3 |
Freshness hold applied to every locked package’s publish date. 0 disables the hold (re-introduces the zero-hour-publish gap). |
install-gate-cache-ttl |
int (seconds) | 21600 (6h) |
Per-package clean-verdict cache under ~/.cache/composer-cve-gate/install-gate/ (or %USERPROFILE%\.cache\… on Windows). 0 disables caching. Only clean verdicts are cached; flagged or errored verdicts are always re-scanned. |
Invalid or malformed values fall back to these defaults silently — the
gate prefers a slightly-too-strict configuration over a silently
disabled one. The flip side: a typo like "install-gate": "blok"
silently degrades to advisory, so if block mode is critical, sanity-check
your config by triggering a known finding once after editing it and
confirming the gate’s output names the mode you expect.
COMPOSER_CVE_GATE_DISABLE (emergency bypass)To skip the gate for a single command without editing composer.json,
set COMPOSER_CVE_GATE_DISABLE=1 in the environment:
COMPOSER_CVE_GATE_DISABLE=1 composer install
Any non-empty value other than the string 0 enables the bypass — so
1, true and yes all work, and so does the string false, which
is not falsy here. To re-enable the gate, unset the variable or set it
to 0. It is loud by design — a warning is printed to the build log so
a disabled gate stays visible — and it works in all modes, including
block. Use it as an emergency lever for a single command (e.g. an
urgent hotfix deploy where you’ve already verified the finding); for a
permanent silent disable in composer.json, use install-gate: off
instead.
Precedence: install-gate: off short-circuits first (no scan, no log
line). Otherwise, COMPOSER_CVE_GATE_DISABLE (when set to a non-0
value) takes priority over the install-gate mode in composer.json.
The core claim — blocking a flagged package at composer install time,
before any download or post-install script runs — ships as an end-to-end
test you can reproduce yourself in a throwaway container. The test builds a
project whose composer.lock pins a flagged package (an inert, fixture-only
stub — no real package or malware), runs a real composer install, and
asserts the gate proceeds-with-a-warning in advisory mode and
aborts before the operation runs in block mode (the package is never
written to vendor/). The verdict is driven by a local advisory fixture, so
it is deterministic and needs no live network lookup.
Any Linux base with PHP 8.2+, Python 3.11+, git and Composer 2 works. Start a
disposable container (--rm auto-removes it on exit):
docker run --rm -it debian:trixie bash # Debian / Ubuntu (apt)
# or: docker run --rm -it almalinux:10 bash # RHEL / AlmaLinux / UBI (dnf)
Then, inside it:
# 1) dependencies — Debian/Ubuntu (apt):
apt update && apt install -y php-cli php-mbstring php-xml php-zip git unzip python3 python3-venv python3-pip
# RHEL/AlmaLinux/UBI instead (dnf):
# dnf install -y php-cli php-mbstring php-xml git unzip python3 python3-pip
# 2) Composer — hash-verified official installer:
php -r "copy('https://getcomposer.org/installer','composer-setup.php');"
php -r "if (hash_file('sha384','composer-setup.php') === trim(file_get_contents('https://composer.github.io/installer.sig'))) { echo 'verified'.PHP_EOL; } else { unlink('composer-setup.php'); exit(1); }"
php composer-setup.php --install-dir=/usr/local/bin --filename=composer
# 3) run the proof:
git clone https://github.com/sharkyger/composer-cve-gate.git && cd composer-cve-gate
python3 -m venv .venv && . .venv/bin/activate
pip install pytest -r requirements.txt
pytest tests/integration/ -v # -> 2 passed
2 passed confirms the gate fired on a real composer install on that
platform. It has been reproduced this way across both major packaging
families — Debian/Ubuntu (apt) and RHEL/AlmaLinux including unregistered UBI
(dnf) — on PHP 8.3–8.5 and Python 3.12–3.14. The same end-to-end test runs in
CI on every change.
Full step-by-step — per-distro start commands, the dnf variant, and Docker start/stop/cleanup tips — is in docs/verifying-in-docker.md.
composer-cve-gate is a supplement to config.policy, not a replacement.
It does not replace:
composer audit — post-install lockfile scanning, included in every
Composer project by default. Run it regularly.config.policy.advisories.block — native advisory blocking during
composer update / require / remove (Composer 2.10+, default true).
We run in parallel for the install-from-lock gap it doesn’t cover.config.policy.malware.block — native malware blocking via Aikido
during composer install (Composer 2.10+, default true). We provide
additional OSSF ingestion.When Composer ships minimum-release-age (a reserved name in their policy
roadmap), the freshness-hold differentiator disappears and we will archive.
We’re a stopgap for a known gap, not a permanent product. Maintain without
long-term lock-in fear.
If your project uses DDEV (TYPO3, Drupal, Laravel, Symfony, Magento, …), install the addon instead of the composer plugin directly. The addon runs the scanner inside the web container against the container’s PHP version — which is the version your application actually runs — rather than whatever PHP happens to be on your host.
ddev add-on get sharkyger/composer-cve-gate
That registers three custom commands and auto-installs the composer
plugin into your project (if composer.json exists):
ddev safe-install monolog/monolog
ddev safe-upgrade
ddev safe-scan
Each one runs in the web container and applies the same 5-signal gate the plain-composer commands do. No host shim — your host PHP version is irrelevant.
Remove the addon with ddev add-on remove composer-cve-gate, which
also removes the composer plugin from your project.
composer-cve-gate is part of the safe-install family:
Shipped:
homebrew-safe-upgrade — brew safe-install / brew safe-upgradeclaude-code-cve-gate — Claude Code hook (intercepts AI installs)mistral-code-cve-gate — Mistral Code hookcomposer safe-install, composer safe-upgrade, and the install-from-lock gate (plain composer install from a lockfile)Roadmap:
pip-cve-gate — pip safe-install / pip safe-upgradenpm-cve-gate — npm safe-install / npm safe-upgradeAll share the OSV + GHSA + NVD + freshness-hold pattern. Composer has a native plugin API, so we use it here. pip and npm will use prefixed binaries instead.
MIT. See LICENSE.
Report vulnerabilities privately to [email protected]. See SECURITY.md. This repo does not accept public bug reports for security topics.
If you find this add-on useful, please star it on GitHub — stars show appreciation and help maintainers know their work matters.