← All posts

Pin Everything: Why "Latest" Is a Time Bomb

15 min read
devopssecuritysupply-chain

Nearly every software outage has a story that begins the same way: But nobody changed anything.

Very often that is technically true. Nobody on the team edited the code. But the code depended on something described by a label — latest, v3, ^4.17.0, main — and somebody, somewhere, moved that label. The line of configuration stayed the same, but the software it pointed to changed.

To pin a dependency means to name it so precisely that it cannot quietly turn into something else. This post covers why floating labels are so dangerous, what a real pin looks like, why a pin on its own still isn’t enough, and the eight rules that tie it all together.

What “Latest” Actually Means

In the container world, latest sounds like a promise: give me the newest version. It is not. In Docker, latest is simply the default tag name used when you don’t specify one. It points at whatever the publisher last tagged that way, which might be the newest release, an old one, or a development build.

Kubernetes, the most widely used system for running containers, makes this worse. If a container image uses the latest tag (or no tag at all) and no pull policy is set, Kubernetes defaults to downloading the image again every time a container starts. That means two copies of the “same” service, started an hour apart on different machines, can be running different software.

Package managers have the same problem under a different name. A version range such as ^4.17.0 in a JavaScript project means “any compatible version from 4.17.0 up to (but not including) 5.0.0.” The day a new version is published, every fresh install picks it up automatically, whether or not anyone reviewed it.

Every version reference falls into one of three categories:

Kind of Reference Examples Who Can Change What It Points To What It Protects Against
Floating label latest, main, v3, ^4.17.0 The publisher, every time they release Nothing; it is designed to move
Version tag 15.4.0, v35.1.2 Anyone who controls the repository, at any time Accidental upgrades, if nobody moves or deletes the tag
Content fingerprint sha256:9f8e… image digest, 40-character commit SHA (Secure Hash Algorithm) Nobody; it is a mathematical hash of the exact bytes Silent changes of any kind, malicious or accidental

The middle row is the one that catches most people out. A version number looks permanent, but in Git, container registries, and most package registries, a tag is just a movable pointer. The Open Container Initiative (OCI) specification that every container registry follows treats tags as mutable and makes no promise that they stay put. Immutability is an optional policy that some registries let you switch on.

A version tag named v35 first points at a reviewed commit, then an attacker moves it to a malicious commit. A reference pinned to the reviewed commit's SHA keeps pointing at the reviewed code. your config says uses: action@v35 yesterday today: tag moved! A reviewed code B malicious code uses: action@a1b2c3d… pinned to a fingerprint: cannot move
A version tag is a sticky note that anyone with access can peel off and stick somewhere else. A commit SHA or image digest is a fingerprint of the exact contents, so it can only ever match the code you reviewed.

The Day a Version Tag Pointed 23,000 Repositories at Malicious Code

On 14 March 2025, attackers compromised tj-actions/changed-files, a popular add-on (“Action”) for GitHub’s automated build system, GitHub Actions. Security firm StepSecurity, which detected the attack, reported that more than 23,000 repositories used it.

The attackers did not need anyone to install anything new. They moved the Action’s existing version tags to point at a malicious commit that printed the build system’s secrets (cloud access keys, GitHub tokens, package-publishing tokens, private keys) into the build logs. For public repositories, those logs could be read by anyone.

Every project that referenced the Action by a version tag ran the attacker’s code on its next build. Projects that referenced it by the full commit SHA of an earlier release kept running the code they had already trusted. The U.S. Cybersecurity and Infrastructure Security Agency (CISA) issued an alert (CVE-2025-30066) that also covered a second compromised Action, reviewdog/action-setup@v1, in the same campaign. Its guidance included pinning third-party Actions to commit SHAs.

GitHub’s own documentation says the same thing plainly: Using the commit SHA is the safest option for stability and security.

The same failure shows up in package registries, where version ranges do the attacker’s distribution for them:

Incident What Happened Why Floating Versions Made It Worse
event-stream (npm, 2018) A new maintainer was given publishing rights and added a malicious dependency aimed at a cryptocurrency wallet Projects using a version range pulled the poisoned release automatically
tj-actions/changed-files (GitHub Actions, 2025) Version tags were moved to a commit that dumped build secrets into logs Any workflow referencing a tag ran the new code on its next build
Self-replicating npm worm (npm, September 2025) Stolen developer credentials were used to publish new, infected versions of packages in bulk Each new version was picked up by every install that allowed a range

A Pin Is Not a Backup

So pin everything to a fingerprint and you’re safe? Not quite. A pin guarantees that if you get the software, it is exactly the software you expected. It does not guarantee that you can still get it.

The clearest recent example is Bitnami, a widely used source of free, ready-made container images and Helm charts (installation packages for Kubernetes). In July 2025, Bitnami’s owner Broadcom announced that from 28 August 2025 the free public catalog would shrink to a limited set of images available only on the latest tag. All versioned tags moved to a separate bitnamilegacy archive that receives no further updates, and production users were directed to a paid subscription.

Read that again: the free tier now offers only the floating label, and teams that did the responsible thing and pinned a version were the ones whose configuration stopped resolving.

The same configuration line, image bitnami/postgresql 15.4.0, returned the image and a running service before the change. After the catalog change it returned HTTP 404 and the container could not start. before August 2025 image: bitnami/postgresql:15.4.0 200 OK running ✓ after (probed July 2026), same line image: bitnami/postgresql:15.4.0 404 can't start
The same unchanged line of configuration, before and after the Bitnami catalog change. A registry probe on 13 July 2026 found the versioned tag returned "not found" on the main catalog, while the same tag still resolved in the frozen bitnamilegacy archive.

What makes this kind of break so nasty is when it fires. Running services keep working, because they already have the image. The failure only appears when something needs to download it fresh:

  1. A new deployment or upgrade. The least harmful case: you find out during a planned change, with a rollback available.
  2. A build or security-scan job. The pipeline turns red. Annoying, but not dangerous.
  3. Automatic scaling. A busy service adds a new machine, the machine cannot download the image, and the extra capacity silently never arrives, right when you need it.
  4. Disaster recovery. You rebuild in a new region with an empty cache, and discover the recovery plan cannot work on the very day you are carrying it out.

The lesson is that pinning controls drift, while keeping your own copies controls availability. You need both. A pull-through cache or mirror (a registry you run that saves a copy of everything you download) protects you, but only if it was already in place before the upstream copy disappeared.

A Pin Without Updates Is Just Slower Decay

The other common objection to pinning is fair: If nothing ever changes, I’ll never get security fixes.

The United States National Institute of Standards and Technology (NIST) puts it well in its container security guide (SP 800-190): an image built from current components is free of known vulnerabilities for days or weeks, and then it starts aging as new vulnerabilities are discovered. A large study of 356,218 Docker Hub images (the DIVA study) found many images that had gone unupdated for hundreds of days, with vulnerabilities passed down from parent images to the images built on top of them.

The answer is not to float. It is to make updates a deliberate, reviewed change. Tools such as Renovate and Dependabot watch your pinned versions and open a pull request when something new is published, so an upgrade arrives as a proposal you can test, not a surprise you discover in production.

The pinning loop: pin by fingerprint, an update bot proposes a new version, tests run, the same tested bytes are promoted, and copies are kept for rollback. Then the loop repeats with the next update. pin by fingerprint bot proposes a pull request test and review promote the same bytes keep copies for rollback the next update starts the loop again, as a reviewed change
Pinning is not "never update." It turns every update into a step you choose, test, and can undo.

Pin Everything: Eight Rules

A good house rule fits in one sentence: Production always references a specific version, never latest, never “always download,” and the last two deployed versions stay available for rollback. The eight rules below put that sentence into practice.

  1. Never deploy a floating label to production. No latest, no branch names like main, no open version ranges. If a vendor only offers latest, resolve it to a digest yourself and pin that.
  2. Pin container images by digest, and keep the tag for humans. Write postgres:16.4@sha256:… so people can read the version and machines verify the bytes.
  3. Pin third-party build steps to a full commit SHA. Add a comment with the version number (# v4.2.0) so the line stays readable.
  4. Commit your lockfile, and make the build fail if it is out of date. A lockfile records the exact version of every dependency, including the dependencies of your dependencies. Build systems should install from it in strict mode and refuse to silently rewrite it.
  5. Pin the tools, not just the libraries. The package manager, compiler, infrastructure tool, and Helm chart versions all change your output. Upgrading one of them is a change and deserves review.
  6. Build once, promote the same bytes. Test an artifact, then move that exact artifact (by digest) to staging and production. Rebuilding for each environment reintroduces drift.
  7. Keep your own copies. Put a cache or mirror in front of public registries before you need it, and set retention so the versions you might roll back to are never cleaned up.
  8. Automate the updates. Let a bot propose new versions on a schedule, and let your tests decide. Pins that nobody updates turn into a backlog of known vulnerabilities.

Here is what that looks like across the most common kinds of dependency:

What You Depend On Floating (Avoid) Pinned (Prefer) What Keeps It Current
Container image nginx:latest nginx:1.27.2@sha256:… Renovate or Dependabot digest updates
GitHub Actions step actions/checkout@v4 actions/checkout@<40-character SHA> # v4.x.y Renovate or Dependabot Actions updates
JavaScript packages "^4.17.0" with no lockfile Committed package-lock.json, installed with npm ci Scheduled dependency pull requests
Python packages requests>=2 A lockfile with hashes (for example uv.lock or the new standard pylock.toml) Scheduled lockfile refresh
Helm chart Newest chart from the repository Exact version, recorded in Chart.lock Chart update pull requests
Infrastructure module ref=main An exact release version Module version pull requests

Common Objections

Digests are unreadable. That’s why you keep the tag next to them. The tag is for people; the digest is for the machine. Both fit on one line.

This is a lot of overhead for a small project. Most of it is a one-time setup: commit the lockfile, switch on an update bot, and replace latest once. After that, the bot does the remembering.

Our registry makes tags immutable, so we’re fine. For your registry, partly. It does nothing for the public images, packages, and Actions you pull from someone else’s registry, which is exactly where the incidents above happened.

The Bottom Line

A floating label means whatever someone else decided most recently. A pin means you decide. That one difference is what separates an upgrade you planned from an outage you have to explain.

Pin everything to a fingerprint, keep copies of what you pin, and let automation propose the updates. Your configuration will finally mean the same thing tomorrow that it means today.

Sources and References