composer install exits successfully. It selects the version in the committed
lockfile, downloads an archive whose checksum matches, generates the autoloader,
and produces the same output on a second clean install.
The output is EXFILTRATED.
Nothing about that result means Composer ignored the lock. The lockfile was updated to select the malicious package and its correct checksum. Composer then repeated that decision exactly.
This is the useful boundary of a dependency lock. It records a resolution. It can make later installations select the same versions, references, metadata, and declared distribution bytes. It cannot prove who deserved to publish those bytes, why the update was approved, what package code will do, whether new advisories exist, or which artifact a consumer eventually executes.
Treating all of those questions as “dependency locking” makes a precise control sound stronger than it is. The better question is smaller: which property does this lockfile enforce, and which authority can still change the evidence it contains?
The lockfile repeats a selected graph
The SEC-02 fixture uses Composer 2.10.2, PHP 8.5.8, and a local
repository containing a synthetic quote formatter. The first repository state
offers only version 1.0.0. Composer resolves it and writes a lockfile.
That package entry records:
- the package name and version;
- the distribution type and URL;
- a 40-character
dist.shasumfor the exercised archive; - package metadata used for installation and autoloading; and
- a root
content-hashconnecting the lock to relevant project configuration.
The root application allows ^1.0. After version 1.1.0 appears in the
repository, a clean composer install still selects 1.0.0 and returns
EUR 12.34. The newer allowed version does not silently move a locked install.
Composer’s command documentation describes this directly:
when composer.lock exists, install uses the exact versions recorded there.
Its basic-usage guide recommends committing the application
lock so a team receives the same dependency versions.
That is already valuable. It turns an unconstrained question—“what satisfies these requirements today?”—into a reviewable graph. A lock diff can show that a package, distribution URL, checksum, reference, or transitive dependency moved.
It is still a selected graph. The lock does not contain evidence about the person or automation which proposed the update, the review which accepted it, or the publisher authority behind the repository metadata.
A correct lockfile can preserve a bad update
The fixture then runs an explicit update. The new lock selects version 1.1.0,
its new archive URL, and its matching checksum. Installation succeeds and the
application returns EXFILTRATED. Deleting vendor and the Composer cache does
not improve the result. The next locked install returns the same output.
Repeatability and authorization have disagreed.
The update command owns resolution. Source review owns whether the resulting change is acceptable. That review has more work than confirming that the JSON is syntactically valid:
- which direct and transitive packages moved;
- which repository supplied their metadata;
- whether source or distribution references changed;
- whether install-time execution authority changed;
- what application behavior changed; and
- which advisories, licenses, or support constraints now apply.
As A Pull Request Is a Review Narrative argues for source changes, the revision and the reason it became acceptable are different artifacts. A lockfile is evidence of the dependency decision. It is not the review record for that decision.
A checksum proves agreement with the recorded checksum
The clean lock records the distribution archive’s checksum. The fixture keeps that lock unchanged and replaces the archive at the same path. Composer exits with a checksum-verification failure before extracting it.
That catches a real distribution failure: the received bytes differ from the bytes named by the lock. A proxy, cache, repository, storage layer, or download path cannot substitute an arbitrary archive without crossing this check.
The next case changes both the package archive and the metadata which supplies its correct checksum. An authorized lock update records that new value. Composer accepts the archive because the bytes and recorded checksum agree.
Integrity and provenance have disagreed.
Composer’s repository documentation separates package metadata from the source or distribution locations which provide package contents. The root project also chooses repository order and authority. If the same compromised path can publish the archive, publish its metadata, and authorize the lock change, a matching checksum is not independent publisher identity.
The right claim is deliberately plain: for this distribution path, Composer
rejects bytes which do not match dist.shasum. The checksum does not identify
who built the archive, prove that its package name is deserved, or approve its
behavior.
The content hash catches disagreement, not malicious agreement
The lockfile’s root content-hash solves another narrow problem. The fixture
changes the application’s requirement from ^1.0 to ^2.0 without updating
the lock. composer validate --strict exits 2 and reports both an outdated
lock and the version-constraint disagreement.
That is useful protection against a damaged merge or a hand-edited manifest. Composer’s merge-conflict guidance recommends validation to find required packages missing from, or inconsistent with, the lock.
A matching content hash does not inspect package intent. An attacker or reviewer with authority to update both files can produce a completely consistent malicious resolution. Internal consistency is not independent approval.
This is why “the lockfile changed in the same pull request” is a starting point for review, not its conclusion.
A targeted update has a transitive boundary
Partial updates need the same precision. The fixture locks
fixture/order-pricing at 1.0.0 and its transitive fixture/rate-data at
1.0.0. A new pricing release requires rate data ^1.1.
Running a targeted update for only fixture/order-pricing exits successfully
but changes nothing. Composer reports that there is nothing to modify because
the required transitive movement was not allowed. Running the same update with
--with-dependencies changes both packages to 1.1.0.
The command’s success does not mean an update occurred. The package argument does not mean transitives are immutable. The command, flags, lock diff, and resulting versions together define the update boundary.
This matters in automation. A green dependency-update job can be a no-op. A seemingly narrow update can intentionally include a transitive graph. Review the graph which changed rather than inferring it from the command’s name.
Installation can execute code before the application starts
Archive selection is not the only dependency boundary. Composer plugins can execute inside the Composer process. Root scripts can execute commands on install and update events. Ordinary package code runs later when the application loads or calls it.
The fixture keeps those paths separate:
| Path | Default or approved result | Suppression control |
|---|---|---|
| New Composer plugin | Non-interactive install refuses an unlisted plugin | Explicit allow-plugins decision |
| Approved plugin | Plugin activates and writes its marker | --no-plugins prevents activation |
Root post-install-cmd | Script runs and writes its marker | --no-scripts prevents the script |
| Library formatter | Does not run as a root install script; behavior appears when the application calls it | Application test and runtime boundary |
Composer’s allow-plugins configuration exists so a project
can decide which packages may execute plugin code. The official plugin
documentation notes that non-interactive installation fails
for a new plugin which has not been allowed. Permission is not a safety verdict.
An approved plugin receives the Composer process’s filesystem, environment,
network, and credential capabilities.
The untrusted-package guidance is blunter: plugins and
scripts have full access to the account running Composer, and --no-plugins --no-scripts disables those two execution mechanisms. It also recommends a
sandbox for untrusted dependencies.
The flags are not interchangeable. --no-scripts does not disable plugins.
--no-plugins does not prevent ordinary package code from running after the
application requires vendor/autoload.php. Some applications also need trusted
plugins or root scripts to produce a working installation. Suppression is a
boundary choice, not a universal deployment recipe.
Advisory knowledge can change while the lock stays still
The fixture audits the same fixture/quote-format lock against two repository
knowledge states. K0 contains no advisory and composer audit --locked exits
0. K1 adds synthetic advisory SEC-02-0001 for the locked version. The
lockfile SHA-256 remains identical; audit now reports the advisory and exits
1.
The package did not become vulnerable when the metadata changed. The available knowledge changed.
An ignored-advisory control exits 0 but keeps the advisory, the reason,
owner=security-team, and an expiry in the JSON result. Ignoring is an owned
policy decision. It is not remediation and should not erase the evidence which
must be revisited.
The pinned Composer binary also exposes a version-specific discrepancy. The
current configuration documentation says advisory audit mode
report should report findings without a non-zero exit. Composer 2.10.2 still
exits 1 for the fixture’s advisory under that setting. The retained command,
configuration, JSON output, source response, and binary version establish the
observation. They do not justify a claim about every Composer version.
A clean audit is bounded in the other direction. It means the configured sources returned no matching known advisory at that query time. It does not prove the package has no vulnerability, that every advisory source is complete, or that the response will remain current.
The lockfile is an input, not the final artifact
After a clean install, the fixture hashes the lockfile, generated autoloader, installed package tree, and synthetic application artifact. It then changes the installed formatter before packaging.
The lockfile hash remains identical. The autoloader hash remains identical. The installed-tree and final-artifact hashes change, so consumer policy rejects the artifact.
The lock cannot see that mutation because it has already done its job. Build scripts, generated files, patches, plugins, container layers, publication, and consumer downloads can all change what eventually runs.
This is the dependency seam inside the larger supply-chain authority map. A lock constrains one build input. Artifact provenance must bind the output to the complete build decision, and the consumer must verify the bytes it actually receives.
Keep dependency properties separate enough to disagree
The fixture retains 12 decisions rather than one dependency-safety score:
| Property | Useful evidence | What can still be wrong |
|---|---|---|
| Version selection | Locked package graph | Update authorization |
| Manifest agreement | content-hash and strict validation | Malicious agreement |
| Archive integrity | dist.shasum verification | Publisher identity |
| Repository identity | Root repository configuration | Compromised approved repository |
| Update authorization | Lock diff and review evidence | Misjudged package behavior |
| Plugin permission | allow-plugins policy | Approved plugin intent |
| Root-script execution | Event and suppression result | Trusted script behavior |
| Application behavior | Tests after autoload | Untested malicious behavior |
| Advisory freshness | Source, time, response, policy | Unknown vulnerability |
| Installed-tree integrity | Recursive file manifest | Authorized unwanted output |
| Final-artifact provenance | Build-output digest | Compromised builder or policy |
| Consumption verification | Received digest before use | Compromised trust root |
Each row has a different enforcement point, owner, failure action, recovery, and residual risk. A weighted total would hide the important cases: exact selection with a malicious update, valid integrity with compromised metadata, or a clean lock beside a modified artifact.
Vendoring or an internal mirror remains a credible alternative when external availability or provenance is unacceptable and the update cadence is low enough to own. That choice moves authority. It does not remove the obligations to review changes, retain upstream provenance, track advisories, and verify the resulting artifact.
The reproduction is synthetic. It does not represent Brian’s dependency policy,
a real Laravel graph, Packagist ownership, GitHub release provenance, CI
credentials, or production consumers. It retains Composer 2.10.2 and PHP 8.5.8,
the generated locks and command outputs, 12 decision properties, 26 rejected
counterfactuals, 11 official source responses, and a clean export of commit
08498cdbdb9e1c452531b66fab3d56a007ae1a54.
When composer.lock changes, ask it for the evidence it can provide: the exact
graph, references, URLs, checksums, and root agreement. Then ask the remaining
questions at their real boundaries: who approved the update, which repository
and publisher supplied it, what code can execute, which advisories are current,
what the build produced, and what the consumer received.
The lockfile should be trusted to repeat the dependency decision—not promoted into proof that every decision around it was trustworthy.