Version exposure gets the attention — PHP 8.1 out of security support, MySQL 5.7 past its date — because the dates are public and the fix is a project. Dependency exposure is quieter. The application runs on a supported PHP, the server is patched, and there are still forty known-vulnerable libraries sitting in vendor/ because nobody has run composer update since 2019. This is the cheapest part of a security review and the one most often skipped.
Here is how we work through it.
Ten seconds of setup, then the list
Composer has shipped an audit command since 2.4. It checks your installed versions against the Packagist security advisories database (which aggregates the FriendsOfPHP advisories and GitHub's data) and prints what matches:
composer --version # need 2.4+; upgrade with composer self-update
composer audit
On a server you do not want to touch, audit the lock file without installing anything:
composer audit --locked --format=json > audit.json
You can run that against a copy of composer.lock on your laptop. No deployment, no risk, and it works even if the production box cannot reach the internet.
The first run on a neglected codebase typically returns somewhere between five and fifty advisories. Do not panic and do not run composer update with no arguments. That command updates everything to the newest versions its constraints allow, in one unreviewable change, and it is how a security task turns into a week of debugging.
Triage: reachable, present, or noise
An advisory count is not a risk number. Sort each finding into one of three buckets before deciding anything.
Reachable. The vulnerable code path is exposed to input you do not control. An XSS or deserialization bug in a templating or serializer library the app uses on every request; an SQL injection in a database layer; a file-upload bug in an image library. These are real. Fix them this week.
Present but not reachable. A CVE in a CLI component you do not run, in a PDF generator invoked only by an internal admin behind VPN, or in a dev-only dependency that never ships. Still worth fixing, but scheduled — not an interruption. Check which it is:
composer why monolog/monolog # who pulled this in
grep -rn "ClassName" app/ src/ # is the affected class actually used
Noise. The advisory applies to a configuration you do not have, or the package is dev-only and composer audit --no-dev drops it. Record why you dismissed it, with a date, in the same place you keep the audit output. Undocumented dismissals get re-litigated every quarter.
What comes out of that sort is a short list of things that matter, which is a much easier conversation with a business owner than "forty vulnerabilities".
Fix the reachable ones narrowly
Update one package at a time, with its dependencies, and nothing else:
composer update vendor/package --with-dependencies
git diff composer.lock
Read that diff. If a patch-level bump is all it took, you are done after a smoke test. If the fix only exists in a new major version, you have a real piece of work — and now it is a scoped, named piece of work rather than a vague fear.
Two situations come up repeatedly on older estates:
- The fix requires a newer PHP than production runs. This is common and it is the honest argument for doing the PHP upgrade: dependency security and runtime security are the same project. In the meantime, pin
config.platform.phpincomposer.jsonto the PHP version you actually run, so Composer stops resolving to packages the server cannot execute. - The package is abandoned.
composer audit --abandoned=reportlists them. Abandoned means nobody will ever publish a fix. Options, in order of preference: move to the community fork Packagist names as the replacement; remove the dependency if it does five things and you use one; or vendor and patch it yourself and accept that you now maintain it. Guzzle 5, old Swiftmailer, and a long tail of PSR-era helpers are the usual suspects.
Where an upstream fix exists but the upgrade is genuinely blocked, a maintained patch file applied through cweagans/composer-patches is a legitimate stopgap — as long as it comes with a ticket and an expiry date, not a permanent arrangement nobody remembers.
The code Composer cannot see
On applications that predate Composer — or that were built by five different contractors — a clean audit means less than it looks. Check the parts outside the dependency manager:
- Libraries copied into the repository.
lib/,includes/, a PHPMailer from 2016 with the version constant still in the header. Grep for version strings and check them by hand against the advisory feeds. - JavaScript in the web root. A jQuery 1.x, a TinyMCE, a bundled charting library. They run in your users' browsers and no PHP tool audits them.
- CMS plugins. WordPress, Magento, and Drupal extensions usually live outside Composer's view. Use the platform's own audit tooling as well.
vendor/committed to git and out of sync withcomposer.lock. It happens more than you would think. If the deployed tree was never rebuilt, you are auditing a file that does not describe the running code. Compare a checksum of the deployedvendor/against a freshcomposer installfrom the same lock file before trusting any of this.
Make it stay fixed
A one-time cleanup decays in about six months. Two small guardrails keep the count near zero:
- Fail the build on new advisories.
composer audit --lockedexits non-zero when it finds something. Put it in CI. Every pull request that adds a vulnerable package gets caught at the point where changing course is free. - Add
roave/security-advisoriesas a dev dependency. It is a conflict-only package with no code: it makes Composer refuse to install a known-vulnerable version in the first place. Cheap insurance, occasionally inconvenient, and the inconvenience is the feature.
Then put a recurring half hour on the calendar — monthly is plenty — to run composer outdated --direct, take the patch releases, and keep the gap small enough that any single update is boring.
What this is worth
Most of the audits we run end the same way: a couple of hours of triage, three or four narrow updates, one abandoned package that needs a real decision, and a CI gate. The application does not get rewritten and the stack does not change. It just stops carrying a list of publicly documented holes that anyone can check from the outside.
If your list comes back long, or the only fix for a reachable CVE is a PHP version you do not run yet, send us the audit output and the versions — that is enough for a straight answer about how much work it is.