Index

A Lockfile Gives Repeatability, Not Provenance

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.shasum for the exercised archive;
  • package metadata used for installation and autoloading; and
  • a root content-hash connecting 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:

PathDefault or approved resultSuppression control
New Composer pluginNon-interactive install refuses an unlisted pluginExplicit allow-plugins decision
Approved pluginPlugin activates and writes its marker--no-plugins prevents activation
Root post-install-cmdScript runs and writes its marker--no-scripts prevents the script
Library formatterDoes not run as a root install script; behavior appears when the application calls itApplication 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:

PropertyUseful evidenceWhat can still be wrong
Version selectionLocked package graphUpdate authorization
Manifest agreementcontent-hash and strict validationMalicious agreement
Archive integritydist.shasum verificationPublisher identity
Repository identityRoot repository configurationCompromised approved repository
Update authorizationLock diff and review evidenceMisjudged package behavior
Plugin permissionallow-plugins policyApproved plugin intent
Root-script executionEvent and suppression resultTrusted script behavior
Application behaviorTests after autoloadUntested malicious behavior
Advisory freshnessSource, time, response, policyUnknown vulnerability
Installed-tree integrityRecursive file manifestAuthorized unwanted output
Final-artifact provenanceBuild-output digestCompromised builder or policy
Consumption verificationReceived digest before useCompromised 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.