PHPUnit and Composer Audit: Detecting Vulnerabilities in Test Dependencies
AI generated
@test
assert
PHPUnit · Security · Supply Chain
Composer Audit for Test Dependencies
Why dev dependencies must not be a security blind spot

Vulnerabilities in production dependencies tend to get taken seriously, while test and dev dependencies like PHPUnit plugins or coverage tools are often ignored. Yet exactly these packages run with full privileges in the CI/CD pipeline and form a real entry point for supply chain attacks, one composer audit can uncover deliberately.

14 min read Composer audit Supply chain security CI/CD

1. Why dev dependencies often get overlooked

In many security processes, the focus lands almost exclusively on require dependencies, the packages actually shipped to production. That makes sense at first glance, since only that code ultimately runs on the live server and is directly reachable from the outside. Test and dev dependencies under require-dev, such as PHPUnit itself, mocking libraries, coverage tools, or static analysis tools, mostly fall through the cracks entirely under this view.

This blind spot is dangerous because dev dependencies are far from harmless just because they do not run in production. They execute on every composer install, on developer machines and in every CI pipeline, often with the very same privileges as the actual build process, including access to environment variables, SSH keys for deployments, and sometimes even production credentials accidentally available in the CI environment.

2. Composer audit: how it works and how to run it

Since Composer 2.4, the built in composer audit command checks the composer.lock file against a security database, by default the FriendsOfPHP Security Advisories database. The command checks every package in the lock file, regardless of whether it is declared under require or require-dev, unless explicitly filtered. That means a plain invocation with no extra options already covers test and dev packages too.

The command outputs, for every vulnerability found, the affected package, the installed version, the CVE identifier, and a short advisory title. It matters to evaluate the command's return value in scripts: composer audit returns a non zero exit code when vulnerabilities are found, which can be used directly as a failure condition in a CI pipeline.


# Full audit including require-dev (default behavior)
composer audit

# Check production dependencies only (exclude require-dev)
composer audit --no-dev

# Machine readable output for further processing in a CI script
composer audit --format=json > audit-report.json

3. Why vulnerabilities in test tooling are dangerous anyway

The thought 'PHPUnit only runs locally and in the pipeline, not on the live server' underestimates two attack vectors. First, PHPUnit runs with all the privileges of the executing process, and in many CI setups that process has access to deployment secrets, database credentials for test environments resembling production data, or even cloud provider credentials for automated deployments. A compromised test dependency capable of code execution can exfiltrate these secrets without production code ever being touched.

Second, test and analysis tools in particular are a frequent target of supply chain attacks, because compared to large frameworks they receive less attention from security researchers while still being installed as a dev dependency in thousands of projects. One known case involved a compromised package that executed malicious code via a post install script at installation time, long before a single test ever ran. Attacks like that hit every developer who runs composer install, regardless of whether a test is ever actually started.

4. Integrating composer audit into the CI pipeline

A composer audit step should sit early in the pipeline, ideally before the actual PHPUnit run, so that a found vulnerability stops the build before potentially compromised code ever executes. For Magento projects, it makes sense to run the audit both against the root composer.json and, where present, against separate composer.json files of individual modules, provided they declare their own dependencies.

A pragmatic approach is to define the audit step as its own, fast CI job, independent of the longer test run, so it remains separately visible even when the PHPUnit run fails for unrelated reasons. This makes it clear whether a build failed due to a test failure or due to a security vulnerability, which matters for prioritizing the fix.


# .gitlab-ci.yml (excerpt)
composer-audit:
  stage: security
  script:
    - composer audit --format=json --no-interaction | tee audit-report.json
  artifacts:
    paths:
      - audit-report.json
    when: always
  allow_failure: false

5. Tiered handling by severity

Not every reported vulnerability justifies an immediate build failure. composer audit's JSON output also includes the severity of each advisory, allowing a differentiated policy: vulnerabilities with high or critical severity block the merge immediately, while low or medium severity ones initially only raise a warning and get turned into a ticket to be resolved within a defined deadline.

This tiering prevents teams from ignoring composer audit entirely after repeated false alarms over minor issues, or lazily disarming the check with allow_failure: true. A realistic policy tiered by risk noticeably increases team buy in compared to a rigid all or nothing rule.


<?php
// tools/audit-policy.php
declare(strict_types=1);

$report = json_decode((string) file_get_contents(__DIR__ . '/../audit-report.json'), true);
$blockingSeverities = ['critical', 'high'];
$blocked = false;

foreach ($report['advisories'] ?? [] as $package => $advisories) {
    foreach ($advisories as $advisory) {
        $severity = $advisory['severity'] ?? 'unknown';
        $isDev = str_contains($package, 'phpunit') || str_contains($package, 'mockery');

        if (in_array($severity, $blockingSeverities, true)) {
            fwrite(STDERR, sprintf(
                "BLOCKING: %s (%s, dev package: %s)\n",
                $package, $severity, $isDev ? 'yes' : 'no'
            ));
            $blocked = true;
        } else {
            fwrite(STDOUT, sprintf("WARNING: %s (%s)\n", $package, $severity));
        }
    }
}

exit($blocked ? 1 : 0);

6. The risk of post install scripts and plugins

Beyond known CVEs, Composer itself carries a structural risk: packages can execute arbitrary PHP code at install time via the scripts section in their composer.json, and Composer plugins can even hook into the installation process itself. A malicious or compromised test utility package can therefore execute code on a plain composer install alone, entirely independent of whether it is ever actually referenced in a test.

Since version 2.2, Composer offers the allow-plugins configuration option, which explicitly defines which packages are permitted to run Composer plugin code. For a deliberate security strategy, it pays to keep this list deliberately short and only allow new entries after briefly reviewing the respective package, instead of blanket approving everything Composer suggests on first install.


{
    "config": {
        "allow-plugins": {
            "composer/installers": true,
            "phpstan/extension-installer": true,
            "dealerdirect/phpcodesniffer-composer-installer": false
        }
    }
}

7. Automated updates for test dependencies

A single composer audit run only protects against vulnerabilities known at the time of the check, not against ones discovered later. In addition, a tool like Renovate or Dependabot should be set up to automatically open pull requests for outdated dependencies, including test dependencies under require-dev. It matters to configure these tools not just for require, but explicitly for require-dev as well, since some default configurations deprioritize or entirely skip dev dependencies.

A sensible compromise is auto merging high priority security updates for dev dependencies as long as the CI pipeline stays green, while pure feature updates with no security relevance get reviewed manually. That keeps maintenance overhead manageable without leaving known vulnerabilities open unnecessarily long.

8. Continuous monitoring beyond individual CI runs

A composer audit step in the CI pipeline only ever checks the state at the moment of a specific merge or pull request. If a new vulnerability in an already installed package becomes known afterward, it only surfaces at the next commit that triggers some CI pipeline, which can take weeks during quiet project phases. An additional, scheduled job that runs daily or weekly against the current composer.lock, independent of code changes, closes exactly this gap.

Such a scheduled job should not just turn the pipeline red on a finding, it should actively trigger a notification, for example to a Slack channel or by email to the responsible team, since nobody manually checks an unused, purely informational pipeline every day. Only this active alerting ensures a newly discovered vulnerability in an already installed test dependency gets noticed promptly, even when no one happens to be working on the affected module at that moment.

9. Special considerations in a Magento project context

Magento projects carry an especially broad attack surface due to the large number of third party modules and their own separate composer.json dependencies. Many Magento extensions declare their own test dependencies or developer tools, rarely reviewed by anyone outside the original development team. A project wide composer audit run covers this entire transitive dependency chain, while an isolated check of only your own app/code modules leaves blind spots.

For Mark Shust Docker setups or similar development environments, it is advisable to establish composer audit as part of the bin/composer wrapper workflow, for example as a standalone command run against the full lock file before every composer update, so new vulnerabilities are caught immediately instead of being discovered only weeks later at the next scheduled security review.

Measure Tool Timing
Full audit including dev deps composer audit (default invocation) On every CI run, before the test run
Tiered severity policy Custom script over audit --format=json As a blocking pipeline step
Restrict plugin execution config.allow-plugins in composer.json Once, reviewed for every new package
Automated update PRs Renovate / Dependabot, including require-dev Continuously in the background
Check transitive module dependencies composer audit on the project root Before extension updates and releases

Mironsoft

Test automation, Magento quality assurance, and CI integration

Tests that catch real bugs instead of just turning green?

We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.

Test Audit

Reviewing existing suites for mocking antipatterns and blind spots.

Test Strategy

Meaningfully combining unit, integration, and MFTF tests for Magento projects.

CI Integration

Setting up fast, reliable test runs in GitLab CI or GitHub Actions.

10. Summary

Composer Audit for Dev Deps: The Essentials at a Glance

Blind spot

Dev dependencies like PHPUnit plugins are often ignored in security reviews.

Risk

Test tools run with full CI privileges and access to deployment secrets.

Tool

composer audit checks require-dev against known CVEs by default.

Enforcement

An early, blocking CI step with a severity tiered policy.

11. FAQ: Composer Audit for Dev Deps: The Essentials at a Glance

1Does composer audit check dev dependencies by default?
Yes, a plain invocation with no extra options checks every package in the composer.lock file, regardless of whether it is declared under require or require-dev. Only the --no-dev option explicitly excludes dev dependencies.
2Why do vulnerabilities in PHPUnit extensions matter if they never run in production?
Because dev dependencies execute on every composer install and in every CI pipeline, often with access to deployment secrets, database credentials, or cloud credentials. A compromised test dependency can exfiltrate that data without production code ever being touched.
3How do I integrate composer audit sensibly into a CI pipeline?
As its own, early pipeline step before the actual test run, so a found vulnerability stops the build before potentially compromised code executes. Its JSON output can additionally be evaluated for a severity tiered policy.
4Should every reported vulnerability block the build?
Not necessarily. A differentiated policy blocks immediately on critical or high severity, while lower severities initially just raise a warning and get handled as a ticket with a defined deadline, to avoid alert fatigue within the team.
5What is the risk of post install scripts in Composer packages?
Packages can execute arbitrary PHP code at install time via the scripts section of their composer.json, and Composer plugins can hook directly into the installation process itself. A compromised package can execute malicious code on plain composer install alone, regardless of whether it is ever referenced.
6How does the allow-plugins option in composer.json protect against this?
It explicitly defines which packages are allowed to execute Composer plugin code. A deliberately short list, where new entries are only allowed after a brief review, reduces the risk of unknowingly running malicious plugin code.
7Is a single composer audit run enough?
No, a single run only covers vulnerabilities known at the time of the check. A tool like Renovate or Dependabot should additionally open automated update pull requests, explicitly including require-dev dependencies.
8Why are test and analysis tools a popular target for supply chain attacks?
Because compared to large frameworks they receive less attention from security researchers, while still being installed as a dev dependency in thousands of projects, offering a broad, under scrutinized attack surface.
9What deserves special attention with composer audit in Magento projects?
The large number of third party modules with their own composer.json dependencies significantly widens the attack surface. A project wide audit run that also covers transitive dependencies of individual extensions is therefore more important than an isolated check of only your own app/code modules.
10Where in a Docker workflow should composer audit run?
Ideally via the bin/composer wrapper as a standalone command before every composer update, as well as a fixed, early step in the CI pipeline, so new vulnerabilities are caught immediately rather than only at a later, manually triggered security review.