When the xz backdoor landed in 2024, it was a wake-up call for every package
ecosystem: the code you install is only as trustworthy as the pipeline that built
it. PHP is no exception. Run composer install and you pull down code from dozens
of repositories — but nothing checks who built each package, or whether the
archive you received is the one its maintainer actually published.
composer.lock doesn't solve this. It pins a dist hash, so it verifies you got
the same bytes every time — but it says nothing about where those bytes came
from. If an attacker publishes a malicious release, the lock file faithfully pins
the malicious hash. Integrity is not provenance.
The rest of the software world has an answer to this now: Sigstore and
build-provenance attestations. npm ships Sigstore-signed provenance. GitHub
Actions can attest any build artifact, recording a signed statement — "this
artifact was built by this workflow in this repository" — in a public
transparency log. Until recently, PHP had no way to produce or consume any of it.
This post shows the whole loop working end to end, on real packages.
The one subtlety that matters: attest the zipball
Here's the trap that makes naïve provenance for Composer silently useless.
Most "sign your release" setups attest a release tarball — the output of
git archive, uploaded as a GitHub release asset. But Composer doesn't install
that. It installs the dist zipball: api.github.com/repos/{owner}/{repo}/zipball/{commit}
— a different artifact with a different digest. Attest the tarball and your
attestation covers a file nobody installs. A verifier checking what Composer
actually downloaded finds nothing.
The fix is to attest the exact zipball Composer fetches. Its digest is
reproducible for a given commit, and — crucially — the commit is the same
reference Packagist records as the package's dist. Attest that, and the
attestation covers the bytes that land in vendor/.
Signing: one line in your release workflow
is a Composer
plugin that verifies these attestations as packages are downloaded:
composer require --dev k2gl/composer-attest
As Composer downloads each package, the plugin hashes the dist, asks GitHub for an
attestation bound to that digest, and verifies the Sigstore bundle — checking the
certificate chain, the transparency-log inclusion, and that the signing identity is
a GitHub Actions workflow of the package's own repository. Configure how strict it
is in composer.json:
{
"extra": {
"k2gl-attest": {
"mode": "enforce",
"require-attestation": false
}
}
}
In warn mode it reports and continues; in enforce it fails the install on a
bad attestation. Under the hood it reuses
SOCIAL SHARE CARD GENERATOR