//nefariousplan

Mutable Reference As Immutable

Git tags, date-labeled artifacts, the latest tag. Treated as pinned. Not.

Most software supply chain security has a one-sentence assumption at the bottom: if I pin to a specific reference, I am building the same thing every time.

The reference is a tag, or a version number, or a hash-looking string that the tooling treats as authoritative. The build pulls that reference, caches it, and moves on. Next build, same reference, same result. This is supposed to be the guarantee that lets reproducible builds exist, that lets CI pipelines be trusted, that lets teams publish artifacts and say what is in them.

The pattern is what happens when the reference is not what you thought. When the thing you pinned turns out to be rewritable at the protocol level, by anyone with the right credentials, at any time. The pin was a label, not an address. Every build that trusted the label got whatever the label was pointing at on that day.

Mechanism

Digital references fall into two families. Content-addressed references are computed from the content itself: a SHA hash of a git commit, an npm integrity hash, an OCI digest. These are unforgeable within the collision resistance of the hash function. If the content changes, the reference changes. Pinning to a content-addressed reference is structurally safe.

Name-addressed references are labels assigned by whoever owns the namespace. Git tags, npm versions, docker image tags, the literal string latest, a date-labeled artifact directory. These are mutable by design. The namespace owner (the repo maintainer, the package publisher, the registry admin) can, at any time, point the same label at different content. The pointer is metadata, not identity.

The pattern: a downstream user pins to a name-addressed reference, treating it as if it were content-addressed. The tooling does nothing to enforce the distinction. The build caches the label and the associated content together. On the next build, the label is resolved again. The resolution returns whatever the current pointer is. If the pointer has moved, the build silently picks up new code. If it has not, the build is reproducible. You cannot tell the difference from inside the build.

The attacker's position is to move the pointer. Often this requires compromising the namespace owner, which is its own pattern (maintainer-account-compromise). Sometimes it does not, because the namespace protocol allows the tag to be re-pointed without compromise (force-push, tag delete plus recreate, republish over a "yanked" version in registries that permit it). In either case, the downstream does not know. The label resolves. The build succeeds. The behavior changed.

Exhibits

tj-actions: Mutable Tags Were Always a Lie. GitHub Actions users reference third-party actions by tag: uses: tj-actions/changed-files@v45. The tag looks like a pinned version. It is a pointer at whatever commit the tag currently points to. An attacker who gets the ability to move the tag gets to substitute their code for everyone pinned to the tag. In this incident the attacker rewrote the tag to point at a commit that exfiltrated secrets. Every pipeline with @v45 in its workflow file pulled the new commit the next time it ran. The pin was the lie. The action's tag history had been a lie for as long as the ecosystem had treated mutable tags as pinned.

Axios, Sapphire Sleet, and 70 Million Weekly Installs. The axios compromise moved malicious code into a new version number. Consumers on floating ranges (^1.x, ~1.7.0, or direct tracking of latest) resolved to the new version automatically. The version number is name-addressed: npm assigns it, the maintainer publishes it, the registry serves it. The integrity hash that accompanies the version attests to what the maintainer published, not to what the maintainer intended. Consumers who believed they were pinned because their lockfile contained a version string learned that the version string was the pointer, not the content.

Boundaries

Not every mutable reference is the pattern. If you deliberately track latest because you want automatic updates, you accepted the mutability as a feature. The pattern describes the MISTAKE, the treatment of a mutable reference AS IF it were immutable. A conscious decision to ride the tip is not the pattern.

Not every package version bump. A maintainer who legitimately ships a new version that breaks a consumer is not this pattern, that is regular dependency management. Mutable-reference-as-immutable specifically describes the case where a downstream thought they were pinned and were not.

Not every yanked-and-republished package. Yanking a compromised version and republishing under a new number is the RESPONSE, not the pattern. The pattern is the original confusion that made the attack work. Republishing under a new number with a new hash restores the property the downstream assumed they had.

Defender playbook

Audit what your build pins to. For every external reference (actions, libraries, images, tarballs), determine whether the reference is content-addressed or name-addressed. Content-addressed pins look like SHA hashes, integrity strings, or digests. Name-addressed pins look like versions, tags, branch names. For anything that matters, move the name-addressed pins to content-addressed pins.

On GitHub Actions specifically, pin by commit SHA: uses: org/action@<40-char-sha>. The shas are content-addressed within git. The tag syntax that looks similar is not. The friction of updating is the cost of not being the next tj-actions incident.

On npm, pin the lockfile and enforce npm ci (which refuses to modify the lockfile) in CI. The lockfile contains integrity hashes that are content-addressed. A floating range in the lockfile defeats the purpose, treat floating ranges as a deliberate choice to accept mutability.

On container registries, pull by digest. The image:tag syntax is a name-addressed alias the registry can repoint. The image@sha256:... syntax is content-addressed. Production deploys should never resolve tags at deploy time.

Watch for hash drift against pinned references. If a reference you believed was content-addressed changes hash between builds, investigate. The mechanism of drift is often a misconfigured pin, sometimes a tooling bug, occasionally the pattern itself manifesting.

Kinship

Maintainer Account Compromise. The common attack path that turns this pattern into an incident. A maintainer account compromise gives the attacker the namespace authority to move the label. Mutable-reference-as-immutable is the downstream vulnerability that lets the move become a breach.

Trust Inversion. A mutable reference treated as immutable is a trust inversion at the reference layer: the label you trusted to mean a specific content is now authorizing attacker content. Every instance of this pattern composes with trust-inversion at the moment of the push.

Revocation Gap. When a moved reference produces an incident, the revocation gap is the window between "label points at malicious content" and "downstream stops resolving the label." For cached build systems the gap can extend past the fix, because the cache still serves the bad content until manually purged.

The pin is only as strong as what is being pinned to. If the reference can change, the pin is a promise someone else gets to break.