To respond to critical CVE alerts in your dependencies, do not start by upgrading. Start by confirming you ship the affected package and version, then ask whether the vulnerable code path is even reachable in how you use it. Last quarter a CVSS 9.8 advisory landed for a parsing library we pulled in transitively. The Slack channel lit up. My first action was not `composer update`. It was `composer audit --locked` to confirm the version, followed by ten minutes reading the advisory to figure out if our usage ever touched the bug. That ordering is the whole game. Below is the exact process I run, framed around that incident.
Do I even ship the affected package and version?
Panic spends the first hour arguing about a package you might not be exposed to. The lockfile answers it in seconds. Audit against the lockfile, not the installed tree, so the result matches what actually deploys. A direct dependency in `composer.json` is obvious; the dangerous ones are transitive, three levels deep, pinned by some other library.
# Audit exactly what the lockfile pins (matches CI/prod)
composer audit --locked --format=json > audit.json
# Is the named package present at all, and at what version?
composer show vendor/affected-package
# Who pulled it in? (the transitive case)
composer depends vendor/affected-package --treeIf the advisory says "affected: < 3.4.2" and `composer show` reports 3.4.5, you are clear on that line item. Write that down and move on. If you are on 3.1.0, you fall inside the affected range and you are vulnerable, so you continue to the harder question. For a deeper walkthrough of parsing the audit output and the GHSA/CVE fields it returns, I wrote that up separately in reading and fixing a composer audit report. This post is about what you do after the tool has spoken.
Is the vulnerable code path actually reachable?
This is the step most teams skip, and it is the one that decides whether you wake people up at 2am or schedule a fix for Tuesday. A CVE is a statement about the library. Your exposure is a statement about your code. In our incident the vulnerability was in a function that deserialized untrusted XML with external entities enabled. We used the library, yes, but only to render trusted templates we authored ourselves. No attacker input ever reached the vulnerable parser.
- Find the vulnerable function or class named in the advisory, then grep your codebase for every call site.
- Trace the input. Does attacker-controlled data ever reach that call, directly or through a queue, webhook, or upload?
- Check the trigger conditions. Many CVEs need a non-default config flag, a specific feature enabled, or a particular file type.
- Decide reachability: unreachable lowers urgency, but never to zero. Reachable through internet-facing input is patch-now.
# Find every call site of the vulnerable symbol
grep -rn "loadXmlEntities\|->parseUntrusted(" app/ src/
# Trace back to entry points: do any controllers/jobs feed it user input?
grep -rln "VulnerableClass" app/Http app/Jobs app/ConsoleA CVE describes the library's weakness. Reachability describes your blast radius. Never confuse the two, and never let the CVSS number alone set your response time.
How urgent is this really: severity vs exploitability vs exposure
CVSS base score is the headline, not the verdict. I weigh three things before committing to a timeline. Severity is the advisory's score and impact. Exploitability is whether a public PoC exists and how much access it needs (pre-auth RCE is a different animal from an authenticated, rate-limited edge case). Exposure is whether the affected feature is internet-facing or sits behind a VPN on an internal admin tool.
A 9.8 on an internal-only, authenticated, unreachable path can be a scheduled patch. A 7.5 on a public endpoint with a working exploit in the wild is patch-now, drop everything. This is the same prioritization muscle I lean on when a scanner dumps fifty findings at once; I broke that scoring down in reading a security vulnerability report and prioritizing it. The output of this step is one decision: patch-now or scheduled, with a written reason.
Act: patch if you can, mitigate if you can't
If a patched release exists, the fix is usually a tight, targeted bump rather than a full `composer update` that drags in a hundred unrelated changes mid-incident. Constrain the upgrade to the affected package and its minimum safe version, then run the audit again to prove it is gone.
# Bump just the affected package to the patched version, with its deps
composer require vendor/affected-package:^3.4.2 --update-with-dependencies
# Prove the advisory no longer matches the lockfile
composer audit --locked
# Run the suite before this leaves your machine
php artisan testWhen no patch exists yet (it happens, especially on the first day), you buy time with an interim mitigation: disable the affected feature, change a config flag that closes the vulnerable path, or block the exploit pattern at the edge. For our XML CVE the upstream fix lagged by a day, so the stopgap was disabling external entity loading in our own bootstrap before any patch shipped. Mitigations are temporary by definition: ticket the removal so the WAF rule or the disabled feature comes back out once the real patch lands.
public function boot(): void
{
// INTERIM MITIGATION for CVE-2026-XXXXX (remove after upgrade to >= 3.4.2)
// Refuse to resolve any external entity until the patched release ships.
// The callback returns null for every PUBLIC/SYSTEM id, so XXE cannot load.
libxml_set_external_entity_loader(static fn (?string $publicId, ?string $systemId, array $context) => null);
}Verify the fix, then communicate and record
Deploy, then verify in the running environment, not just on your laptop. Re-run `composer audit --locked` against what is actually deployed, exercise the previously vulnerable feature, and if a PoC was public, confirm it now fails. A green test suite is necessary, not sufficient. Once the patch is live, go back and remove any interim mitigation you put in, then audit one more time.
The last step is the one people resent and later thank themselves for: write it down. What was the CVE, when did the advisory land, was the path reachable, what did we change, when did the mitigation go in and come out. That record is your audit trail for the next assessment and the next teammate. Tightening the surrounding controls afterward (rate limits, headers, dependency hygiene) belongs in your standing Laravel security checklist so the next CVE meets a smaller blast radius. For the exact advisory fields and the canonical workflow, the official guidance lives at https://getcomposer.org/doc/03-cli.md#audit.
A critical CVE is not an emergency by default; it becomes one based on reachability and exposure, and your job is to measure those before you touch the lockfile. Confirm the version, trace the path, score the real risk, patch or mitigate, verify in production, and leave a record. Run that loop calmly enough times and the next 9.8 advisory stops being a fire drill and becomes a checklist.

