Close-up of a red fire extinguisher's valve: the pressure gauge needle sits in the green zone, a yellow plastic seal secures the safety pin, and a yellow tag hangs from the neck. The tag has fields for model number and manufacturer, owner ID, and a monthly inspection record with columns for date, inspector, and comments. Every field is still blank. A visual metaphor for a component record: the form exists, but it only proves something once someone fills it in.

Before this article begins: I am not a lawyer, and this article is not legal advice. I find the topic interesting and important enough to work my way into it. This article reflects my current understanding, to the best of my knowledge and belief. If you find a mistake, I would be happy to hear from you.

Composer produces a large part of the evidence that ISO/IEC 18974 asks for, as a by-product of every build. What it does not produce is an archive of the code you shipped, a second look at releases that are already in your customers' hands, and a name next to every responsibility. For a PHP team, that is where the effort of conformance lies.

Since September 11, 2026, the reporting obligations of Article 14 of the Cyber Resilience Act have applied, and reports go through ENISA's Single Reporting Platform. Most other obligations follow on December 11, 2027. The OpenChain FAQ says that the primary way to find out whether an organisation conforms to ISO/IEC 18974 is to discuss it “in sales communications or procurement negotiations”. The question is therefore likely to reach a PHP agency or product vendor through a customer's purchasing department before it reaches them through an auditor.

Frédéric Noppe has written a German-language introduction that walks through the standard clause by clause (part 1, part 2). I do not repeat it here. I read the standard from the perspective of a Composer project instead, and at one point I read it more strictly than he does.

The standard applies to supplied software, which it defines as software that an organisation “distributes or makes available to third parties”. For an agency that hands a shop over to its client, and for a vendor whose customers install its product or plugin, that is clear. For a web application that you operate yourself and that your customers only use through a browser, it is not, and I know of no authoritative answer. This article is written for the clear case.

A standard that is narrow on purpose

ISO/IEC 18974 started as the OpenChain Security Assurance Specification 1.1 and was published as an ISO/IEC standard in December 2023. It has eight pages. The OpenChain Project publishes a functionally identical text under CC-BY 4.0, so you can read the requirements without buying the ISO document. That text numbers the requirements in clause 4, as the ISO version does, and I use these numbers throughout. The older specification 1.1 numbers them in section 3 and staggers the validity of a conformance at 18, 24 and 36 months, where the ISO version says 18 months. If you work from material that is based on 1.1, keep both differences in mind.

In its own words, the standard focuses on the “what” and “why” of a security assurance program and deliberately leaves out the “how” and “when”. Every requirement comes with verification materials, and these are records: a documented procedure, a list of names, evidence of a review. What conforms is always a program, never a package. A Composer package cannot conform to ISO/IEC 18974, and neither can a PHAR.

Two definitions carry the rest of this article. A known vulnerability is a vulnerability “previously discovered in open source software components that are publicly available”, and the definition lists “CVEs, GitHub/GitLab vulnerability alerts, and package manager alerts”. Package manager alerts are named explicitly. What composer audit reports is therefore exactly the kind of input the standard was written for, and you do not have to argue for it in front of an auditor. Supplied software is what you distribute or make available to others. As a rule, the require-dev section of your composer.json is not part of it.

The introduction of the standard calls its subject “a narrow subset of primary concern”: checking open source software against publicly known security vulnerabilities. Malware in a dependency, a maintainer account that was taken over, a tag that was moved to a different commit: none of these is a known vulnerability at the moment it happens. The XZ Utils backdoor, which is often cited in this context, was not one either until it was disclosed. The processes of the standard only start to apply after the disclosure. The build environment is outside the standard, too. What I wrote about hardening GitHub Actions workflows belongs to the domain of SLSA, not to that of ISO/IEC 18974.

Composer already writes most of the records

Requirement 4.1.5 asks for a documented procedure for each of eight methods, from identifying threats to exporting information about risks to third parties. Requirement 4.3.2 asks that security assurance activities are applied to every component in the bill of materials: detect known vulnerabilities, assign a risk score, decide and document what to do, and keep a record “of the identified known vulnerabilities and action(s) taken (including even if no action was required)”.

Since Composer 2.9, the resolver refuses versions that are affected by a known advisory during update, require and remove. I described how that works in “The Bouncer in the Dependency Resolver”. Read against the standard, that bouncer implements the second method of 4.1.5, detecting known vulnerabilities in the supplied software, and it does so whether or not anybody remembers to run it. composer audit is the same method on demand.

What interests me more is where the decisions end up. Composer's dependency policies are configured under config.policy in composer.json, and composer.json is under version control. When an advisory does not affect you, you record that as an ignored advisory with a reason:

{
    "config": {
        "policy": {
            "advisories": {
                "ignore-id": {
                    "GHSA-xxxx-xxxx-xxxx": "Affects the SOAP client only, which we do not use. Re-evaluate before enabling the SOAP integration.",
                    "CVE-2026-xxxxx": "Exploitable on Windows only. The supplied software runs in Linux containers (see scope statement)."
                }
            }
        }
    }
}

Each entry is a record of an identified known vulnerability and the action taken, which is what 4.3.2.2 asks for, including the case in which the action is “none”. The reason says why. The commit that added the entry says who and when. The standard defines documented evidence as “explicitly stored data that outlines, explains or records information related to activities and actions”, and the output of git log -p composer.json fits that definition.

The other half of 4.3.2.2 is the case in which you did act. An update that replaces a vulnerable version is only a record if the commit message names the advisory. Composer does not write that commit message for you.

4.3.2 also asks for a risk or impact score for every identified vulnerability. composer audit shows the severity from the advisory. That is the assessment of whoever published the advisory, made for the general case. The standard asks for remediation or mitigation “suitable for the use-case of the software”, and that assessment is yours to make.

The seventh method of 4.1.5 asks for a way to verify that identified risks have been addressed before the supplied software is released. In a Composer project, that is one line in the release pipeline:

composer audit --locked --no-dev

--locked audits what is in composer.lock, regardless of what happens to be in vendor/. --no-dev limits the audit to what you ship, which is the scope of the standard. policy.advisories.audit defaults to fail, so an advisory that you have not handled makes the command exit with a non-zero code and stops the release. The exit code is the release criterion. The same command also fails for abandoned packages and for versions that are flagged as malware, because these policies default to fail as well. Neither is a known vulnerability in the sense of the standard, and I keep both in the gate anyway.

The following table maps every requirement of the standard to what Composer and the PHP toolchain contribute, and to what remains open. The rows for 4.1.5 are where Composer is strong. The rows above and below them are where it has nothing to offer.

Requirements of ISO/IEC 18974, what Composer and PHP contribute, and what remains open
Requirement Composer and PHP What remains open
4.1.1 Policy None. config.policy is technical configuration, not a written policy Policy and how it is communicated
4.1.2 Competence None Roles, competence profiles, evidence
4.1.3 Awareness None Evidence of assessed awareness
4.1.4 Program scope --no-dev as the boundary of the scope, one composer.json per product Scope statement, metrics, evidence of reviews
4.1.5 (1) Structural and technical threats PHPStan or Psalm, allow-plugins against code execution during installation Threat modelling
4.1.5 (2) Detecting known vulnerabilities composer audit, blocking of advisories during update, require and remove Operating system and runtime
4.1.5 (3) Following up policy.advisories.ignore-id with a reason, under version control Deadlines, responsibility
4.1.5 (4) Communicating to customers GitHub Security Advisories for libraries VEX or CSAF for applications
4.1.5 (5) Analysis after release composer audit --locked against archived lock files, Dependency-Track Somebody has to schedule it; install does not block advisories
4.1.5 (6) Repeated security testing before release Audit in CI, PHPUnit regression tests for fixed vulnerabilities None
4.1.5 (7) Risks addressed before release Exit code of composer audit as the release gate None
4.1.5 (8) Risk information for third parties SBOM from the CycloneDX plugin, --sbom of the PHPUnit PHAR VEX
4.2.1 Access SECURITY.md, support.security in composer.json, private vulnerability reporting on GitHub, security.txt Internal procedure for responding
4.2.2 Effectively resourced Ecosystem Security Team of the PHP Foundation (for maintainers) People, funding, time
4.3.1 Software bill of materials composer.lock, CycloneDX plugin Archive of the software itself; author and timestamp
4.3.2 Security assurance Audit, severities from the advisories, ignore-id with a reason Customer agreement; a record even when no action was required
4.4.1 Completeness None Affirmation that all requirements are met
4.4.2 Duration None Renewed affirmation after 18 months at the latest

security.txt as defined by RFC 9116 is a recommendation in this table, not a requirement of the standard. 4.2.1 only asks for a publicly visible way to inquire about vulnerabilities and an internal procedure for answering.

Three gaps that are specific to PHP

Nobody looks at a release after it has shipped

composer install from a lock file does not block advisories. That is deliberate: a lock file exists so that the same thing is installed twice, and an install that resolves the graph again whenever an advisory is published would break that promise. The consequence is that a release you shipped in 2024 keeps its vulnerable dependencies, and nothing in your daily work tells you about it. The bouncer only stands at the door of composer update, and nobody runs composer update on a release that has shipped.

The fifth method of 4.1.5 asks for exactly this second look: “analyzing supplied software for newly published known vulnerabilities post release”. Picture the question as it reaches an agency: a vulnerability in a widely used library has been published, and a client wants to know whether the shop that was delivered in 2024 is affected. That is the kind of inquiry that 4.2.1 expects you to be able to answer.

If you keep composer.json and composer.lock of every release that you still support, the answer is a loop:

for release in releases/*/; do
    composer audit --locked --no-dev --format=summary --working-dir="$release" || echo "$release needs attention"
done

Run it on a schedule, from cron or from a scheduled CI job. The ignored advisories that applied at the time of the release are archived with it, because they live in its composer.json. Dependency-Track does the same for SBOMs and keeps doing it without a cron job. Either way, the analysis only happens if somebody has planned it, and the plan is what 4.1.5 asks you to document.

Packagist is not an archive

4.3.1.1 asks for a documented procedure that records all open source software in the supplied software across its lifecycle, and it adds: “This includes an archive of all open source software used in the supplied software.” Frédéric Noppe reads this as archiving the SBOM. The wording asks for the software itself. For PHP, the stricter reading is worth taking seriously, because nobody else keeps that archive for you.

Packagist.org does not store code. It stores metadata that points to a Git repository, and for most packages Composer downloads a zip archive that GitHub generates on demand. GitHub does not guarantee that the archive for the same commit stays byte-identical over time. Since July 2026, the commit that a stable version points to can no longer be changed on Packagist, as I described in “Composer and Packagist under supply chain stress”. A version can still be soft-deleted, after which Composer no longer resolves it, and a repository can disappear altogether.

The archive therefore has to be yours. The simplest form is the artefact you deliver, with vendor/ included: the tarball that you hand over to your client, or the container image that you push. If you would rather archive packages than builds, Private Packagist can mirror third-party repositories and keep copies of the packages you used. An archive that consists of links does not count.

What composer.lock cannot see

composer.lock knows what Composer installed. Not everything that ends up in a PHP deployment came through Composer.

A PHAR bundles its dependencies, and tools such as PHPUnit prefix the namespaces of these dependencies so that they cannot collide with yours. I explained why PHPUnit does this in “One version per process”. The price is that composer.lock lists the package that contains the PHAR, if the PHAR was installed with Composer at all, but not what is inside it. composer audit does not look inside, and to a scanner the PHAR is a single file whose bundled code no longer carries its original namespace. Tools that you install with PHIVE do not appear in composer.lock at all. Neither does a library that somebody copied into the repository by hand.

Then there is the platform. composer.lock records the PHP version and the extensions that your packages require as constraints, not as installed versions. Which PHP actually runs your code is decided by the server or the container image. If you deliver a container image, you need a scanner for that layer and for the operating system packages beneath it. For that layer, a scanner such as Trivy or Syft is the right tool, as the L3montree articles recommend. For the Composer layer, the declared list is better than anything a scanner can reconstruct.

composer.lock is not yet a component record

4.3.1.2 asks for component records, and the standard defines a component record by the NTIA minimum elements: supplier name, component name, version, other unique identifiers, dependency relationship, author of the SBOM data, and timestamp. composer.lock has the first five. It names neither an author of its data nor the time at which it was produced.

The plugin cyclonedx/cyclonedx-php-composer turns the Composer metadata into a CycloneDX document that has the missing elements:

composer CycloneDX:make-sbom --spec-version=1.6 --omit=dev --output-format=JSON --output-file=sbom.cdx.json

Pass the version of the specification explicitly. The plugin defaults to CycloneDX 1.5, and version 2.1.0 of the technical guideline TR-03183-2 of Germany's Federal Office for Information Security (BSI) asks for CycloneDX 1.6 or later, or SPDX 3.0.1 or later. The guideline is explicitly not binding and does not confer a presumption of conformity with the CRA. It is still the most concrete description of an SBOM for the CRA that I know of, and I used it as the yardstick for PHPUnit.

One tension remains, and the plugin does not resolve it. A reproducible build produces the same bytes from the same input. --output-reproducible achieves this by leaving out every value that depends on time or chance, and that includes the timestamp and the serial number. The NTIA minimum elements require a timestamp, and so does section 5.2.1 of the TR. With the plugin, you choose between an SBOM that is reproducible and one that is complete. The way out is a deterministic timestamp: the time of the commit that the release tag points to, passed in as SOURCE_DATE_EPOCH. The plugin does not read that variable. The script that builds the SBOM for PHPUnit does.

From December 11, 2027, the CRA itself asks for an SBOM “in a commonly used and machine-readable format” that covers at least the top-level dependencies of the product (Annex I, Part II, point 1). A Composer project covers more than that without trying, because composer.lock lists the transitive dependencies as well. What it lacks is a suitable format and the two missing elements.

What I changed in the PHPUnit SBOM

While researching this article, I measured PHPUnit against the standard. PHPUnit belongs in require-dev and is therefore normally not part of anybody's supplied software. Its PHAR, however, is software that I distribute, and in August I described the SBOM embedded in it as something that supply chain tools can process directly.

Measured against the definition of a component record, it fell short. As of October 3, 2026, the script that generated it wrote the namespace of CycloneDX 1.4 and, for each component, group, name, version, description, licences and package URL. Three of the seven minimum elements were missing: the dependency relationships, the author of the SBOM data, and the timestamp.

On October 4, 2026, I changed this on all branches from 8.5 to main. The embedded SBOM now uses CycloneDX 1.7, takes its timestamp from SOURCE_DATE_EPOCH or from the release tag, names its author and the tool that produced it, and contains the complete dependency graph. The tests of the PHAR validate it against the CycloneDX 1.7 schema. A third commit adds the fields of TR-03183-2: the creator of each component, licences separated into declared and concluded ones as well as the effective licence, the URI of the source code, the location of the security.txt, and a statement about the completeness of the dependency information. PHPUnit 8.5.56, 9.6.38, 10.5.66, 11.5.57, 12.5.38 and 13.4.1, all released on October 5, 2026, ship with it.

Every field comes from composer.json, composer.lock or Git, and nothing is scanned. The nine external components are PHP and the extensions that PHPUnit and its bundled packages require. They are the first components in the SBOM that I do not deliver: the PHAR needs them but does not contain them. CycloneDX 1.7 marks such components with isExternal, which matches what ISO/IEC 18974 calls supplied software.

One element could not go into the PHAR. An SBOM inside a PHAR cannot contain the hash of that PHAR, because writing the SBOM into the PHAR changes the hash. That is why every new PHAR on phar.phpunit.de now comes with a second SBOM next to it, phpunit-X.Y.Z.phar.cdx.xml. It contains the embedded SBOM plus the filename and SHA-512 hash of the PHAR, the properties that the TR asks for to describe the file as executable, archive and structured, and a serial number that is derived from its download URL as a UUIDv5, so that a rebuild produces the same document. It is signed with GPG, like the PHAR itself, and phpunit.de/verify.html describes how to check it.

I checked the published SBOM for PHPUnit 13.4.1 the way a customer would: the signature is valid, the SHA-512 hash matches the PHAR, and the document validates against the CycloneDX 1.7 schema. That last step is harder than it should be. The official schema imports spdx.xsd from a URL that xmllint does not load, so I had to rewrite the import in a local copy of the schema first.

Because of the tension described above, PHPUnit does not use the CycloneDX plugin. Its own scripts take the timestamp from SOURCE_DATE_EPOCH or from the release tag, so their output is reproducible and complete at the same time. phar-site-generator 5.3.0 builds its own PHAR the same way, so the approach does not depend on PHPUnit.

Two things remain open, and the ChangeLog entry claims neither. First, the TR knows components at the level of files, where every executable file is a component of its own. The PHAR of PHPUnit 13.4 contains about 1,850 PHP files, and the roughly 650 of them that come from bundled packages were modified by php-scoper and are no longer identical to their upstream versions. The ChangeLog therefore only claims the fields that the TR requires for logical and identified components. Second, the TR asks for a CPE, and for PHP the only version the SBOM can name is the minimum version from composer.json, because the PHAR does not know which PHP it will run on. A scanner that matches CPEs against the NVD reads that as the installed version and reports the vulnerabilities of PHP 8.4.1 for PHPUnit 13.4 and those of PHP 7.2.0 for PHPUnit 8.5. I have not found a good answer to that yet.

Whether PHPUnit belongs in anybody's supplied software at all is a question of scope, and composer install --no-dev is how that scope is enforced. In December 2017, a hosting provider took a jeweller's online shop offline because a scan had found PHPUnit's eval-stdin.php (CVE-2017-9841) on the server: one of the components the shop was built from had shipped an outdated PHPUnit to production. I told that story in “PHPUnit: A Security Risk?”. A scope statement that excludes development dependencies does not help if a release process ignores it.

A name next to every responsibility

The requirements 4.1.1 to 4.1.4 and 4.2.2 have no Composer equivalent. They ask for:

  • A written policy for the security assurance of the supplied software, communicated internally and reviewed
  • A list of roles with their responsibilities, the competence that each role needs, and evidence that the people in these roles have it
  • Evidence that these people are aware of the policy, of their contribution, and of what happens when the program is not followed
  • A written statement of the scope, metrics, and evidence of every review
  • Named persons, groups or functions for every role, adequate staffing and funding, allocated time, and access to expertise on known vulnerabilities

The last item is 4.2.2, “effectively resourced”, and it is the requirement that a small team cannot satisfy by writing a document. The record it asks for is a name, and the name has to belong to somebody who has the time. In a team of five that ships a product, “everybody keeps an eye on the advisories” may be an honest description of how things work, but it does not meet the requirement.

4.1.4.2 asks for “a set of metrics the program shall achieve to improve”. A Composer project can produce two of them from data it already has. The first is the time from the publication of an advisory to the deployment of the fix. The second is the number of ignore-id entries that have no reason or that are older than a limit you set; git blame composer.json tells you how old each one is.

One more remark, which is my opinion and not a requirement of the standard. Whoever bases the second method of 4.1.5 on the advisory data of Packagist should consider supporting it through the sponsoring program that Packagist announced in May 2026. 4.2.2 is about your own resources. The advisory data your audit depends on is provided by a small company whose security work is partly funded from outside.

Where Composer is ahead of the standard

The PHP incidents of 2026 were not known vulnerabilities. In April, malicious releases of intercom/intercom-php were published through a compromised GitHub repository. In May, a credential stealer reached the laravel-lang packages through stolen access tokens. ISO/IEC 18974 has nothing to say about either of these until an advisory exists. Composer and Packagist do: the malware policy blocks flagged versions during composer install as well, Packagist no longer lets a stable version point to a different commit, and the transparency log records what happened to a package. None of this is required by the standard, and all of it addresses threats that the standard does not cover.

The cooldown policy, which is in development for Composer 2.11, shows that the two can pull in opposite directions. It withholds newly released versions for a configurable period, because many malicious releases are discovered within hours or days. There is deliberately no exception for releases that fix a vulnerability. The pull request that introduced the policy explains why: the metadata on Packagist only carries part of the advisories and not the date on which they were reported, and anyone who can tag a release can also file an advisory. A cooldown therefore also delays the fix for a known vulnerability, and known vulnerabilities are what ISO/IEC 18974 is about.

When every version past the waiting period is blocked by an advisory or a filter list, Composer lists each version together with the policy that removed it and names the ways out: an entry under policy.cooldown.ignore, or a one-off COMPOSER_POLICY_COOLDOWN_PERIOD=0. The documentation shows the conflict in its own example, with the reason “Security fixes need to be applied immediately” next to symfony/security-bundle. Once Composer 2.11 is released, I would turn the cooldown on and treat the fix for a known vulnerability like any other exception: an entry under policy.cooldown.ignore with a reason, committed, and removed again once the waiting period is over. That puts the decision into the same record as every ignored advisory.

The CRA adds an obligation that neither Composer nor the standard covers. A manufacturer who identifies a vulnerability in an integrated component, open source components included, has to report it to whoever maintains that component (Article 13(6)). For a PHP supplier, that is the other end of the report that the PHP Foundation's guide “So you received a security report. Now what?” describes from the maintainer's side.

Conclusion

OpenChain recommends starting with self-certification and a narrowly scoped program, a single product rather than the whole company. Self-certification means answering every question of a checklist with yes, and the organisation alone vouches for those answers. I would use that checklist as a gap analysis first: put the table from this article next to it and mark the rows for which you have a record today.

In a Composer project, most rows of 4.1.5 are marked before you start, because the resolver, the audit and the lock file have been writing records all along. The rows for 4.1.1 to 4.1.4 and for 4.2.2 are usually empty. They are the ones that someone has to fill in again when the 18 months are over and the next customer asks.