Dependency scanning had been a third-party service that read the lock file from a webhook, reported findings a day later by email, and had a false positive rate high enough that the emails were filtered into a folder. Composer 2.4 in August added an audit command that reads the same advisory database in about two seconds.
The symptom
$ ls ~/mail/dependency-alerts | wc -l
412
$ grep -l 'CRITICAL' ~/mail/dependency-alerts/* | wc -l
88
# 88 critical alerts in 14 months. actioned: 3.
# of the remaining 85:
# 61 the advisory was for a version we do not run
# 19 dev dependencies, not deployed
# 4 already fixed, reported anyway
# 1 genuine, and found by somebody reading a blog postThree actioned out of eighty-eight is an alert channel that has been trained into invisibility, and the one genuine finding arriving via a blog post rather than the tool is the outcome that follows. The false positives were mostly the scanner not understanding platform constraints and dev-only requirements.
Why it happens
An external scanner reads a lock file without the resolver that produced it, so it cannot always tell which version is actually installed, whether a package is a dev dependency, or whether a platform constraint excludes the affected branch. Composer knows all three because it did the resolution.
The fix
The command
$ composer audit
Found 2 security vulnerability advisories affecting 1 package:
+-------------------+------------------------------------------+
| Package | guzzlehttp/guzzle |
| CVE | CVE-2022-31090 |
| Title | CURLOPT_HTTPAUTH option leakage |
| URL | https://github.com/.../GHSA-25mq-v84q |
| Affected versions | >=7.0.0,<7.4.5 |
| Reported at | 2022-06-27T22:24:00+00:00 |
+-------------------+------------------------------------------+
$ echo $?
1 # so it can gate a build
$ composer audit --locked --no-dev --format=json--locked reads the lock file without requiring an install, which is what makes this cheap enough to run on every push — two seconds and no vendor directory. --no-dev removes the nineteen findings that were never deployed, which was a fifth of the noise on its own.
Gating a build, and the exception process that has to exist
- name: Audit dependencies
run: |
composer audit --locked --no-dev --format=json > audit.json || true
php bin/audit-gate.php audit.json .audit-exceptions.yml
- if: failure()
run: |
echo '### Security advisories' >> "$GITHUB_STEP_SUMMARY"
php bin/audit-summary.php audit.json >> "$GITHUB_STEP_SUMMARY"
# .audit-exceptions.yml
- advisory: GHSA-25mq-v84q-hvxx
package: guzzlehttp/guzzle
reason: |
The affected option is not used. Verified by grepping
for CURLOPT_HTTPAUTH across the tree; zero uses.
expires: 2022-10-01
approved_by: a.yildirim
An expiry date on every exception is what stops the file becoming a permanent suppression list, and the build failing on an expired entry is what enforces it. Requiring a reason and a name is the part that makes the file readable a year later — an advisory identifier with no explanation is indistinguishable from somebody silencing a build.
The transitive advisory with no fixed version
the hard case, which will arrive:
a package you do not require directly
pulled in by one you do
with an advisory
and no fixed release
the options, in order of preference:
1 the direct dependency releases a version pinning a
safe transitive → wait, and chase
2 a Composer conflict entry, forcing resolution away
from the affected range → only works if a safe
version exists
3 an inline patch, applied at install time
4 an exception with a date and a mitigation
we used (2) once and (4) twice.{
"conflict": {
"some/transitive": "<2.4.1"
}
}
// which makes the resolver refuse any version below 2.4.1,
// and fails loudly if no compatible version exists —
// rather than silently installing the vulnerable one.
A conflict entry is the cleanest of the four because it is declarative and the resolver enforces it, and it only works when a safe version exists that the direct dependency tolerates. When it does not, the honest position is an exception with a documented mitigation rather than a patch nobody will maintain.
What the advisory database does and does not cover
covered:
packages on Packagist with a published advisory
not covered:
the PHP runtime itself
the extensions
the base image's OS packages
npm — a separate command, a separate database
anything vendored, copied in, or fetched by a script
a package whose maintainer never filed an advisory
which means composer audit is one of four checks, and
presenting it as "dependencies are clean" is wrong.The runtime and the base image are the two largest gaps and they need an image scanner, which is a different tool with a different failure mode. Listing what each check covers, next to where its output appears, is what stops a green build being read as a claim it is not making.
Verifying it worked
$ composer audit --locked --no-dev
No security vulnerability advisories found
# the deliberate check, run once:
$ composer require guzzlehttp/guzzle:7.4.0 --no-update
$ composer audit --locked
Found 2 security vulnerability advisories
$ echo $?
1 # the gate works
# and the exception expiry
$ php bin/audit-gate.php audit.json .audit-exceptions.yml
exception for GHSA-25mq-v84q expired on 2022-10-01
exit 1Installing a known-vulnerable version deliberately to confirm the gate fires is worth the ten minutes, because a check that silently passes everything is indistinguishable from one that works. Testing the expiry path separately is the other half and is the one nobody thinks to test until an exception has been in the file for a year.
What this costs
A build that fails for reasons outside your control, on a Tuesday, when an advisory is published for a package you do not maintain. That is the correct behaviour and it will interrupt somebody who was doing something else, which is why the exception process has to be quick enough to use — a documented reason, a date, a name, and a merged pull request.
It also replaces a scanner that covered slightly more with one that covers slightly less, because the external service also read the npm lock file and the base image. Running npm audit and an image scan alongside is three checks where there was one, and being explicit about which covers what is the only thing that stops the gaps being assumed away.