Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Floating Versions Are an Open Door: Pin Your Actions and Dependencies

The problem Look at almost any CI workflow and you'll find a line like this: - uses: actions/checkout@v4 Open almost any package.json and you'll see this: "dependencies": { "some-sdk": "^0.5.0" } And in

Floating Versions Are an Open Door: Pin Your Actions and Dependencies

The problem

Look at almost any CI workflow and you'll find a line like this:

- uses: actions/checkout@v4

Open almost any package.json and you'll see this:

"dependencies": { "some-sdk": "^0.5.0" }

And in a Python project, often just this:

litellm

All three say: give me whatever the latest acceptable version is right now. It feels responsible, since you get patches automatically. But it also means the code you run today is not the code you ran yesterday, and nobody reviewed the difference.

In 2026 that trade-off no longer works: the fastest way into thousands of companies is not to break their code, but to change the code they download.

Why "latest" is now a liability

The past few months have been a showcase of what floating versions cost.

On October 21, a compromised release of the popular LLM proxy LiteLLM was published to PyPI and Docker Hub. It was live for about two hours. Anyone who ran pip install litellm without a version, or whose auto-updater followed latest, got a credential stealer that ran on every Python start on the machine.

On October 8, version 0.5.144 of the Tensorlake npm SDK shipped a new variant of the Shai-Hulud worm. A dependency declared as ^0.5.0 would happily resolve to it. The worm stole tokens and republished the victim's other packages.

Earlier this year the same worm family hit TanStack: 84 malicious versions of 42 packages, all with valid provenance. A downstream project merged a routine Dependabot bump 24 minutes after it opened, and the worm used its token to publish 110 more.

GitHub Actions has the same weakness, in a sneakier form. A tag like @v4 is not a version at all. It's a movable pointer that the action's maintainer, or anyone who steals their credentials, can retarget to a different commit at any time. That's exactly what happened in the 2025 tj-actions/changed-files incident (CVE-2025-30066): attackers moved existing version tags to a malicious commit, and every workflow that referenced those tags started dumping its CI secrets into public build logs.

The pattern is always the same. Pinning doesn't make malicious code safe, but floating versions let malicious code reach you automatically, immediately and silently.

Why an exact version isn't enough: pin by SHA

The usual advice is "pin exact versions". It's a good start, but it isn't enough, because a version number is just a name, and names can be moved.

A Git tag like v4.2.2 is exactly as movable as v4: whoever controls the repository can point it at a different commit. Docker tags are mutable too, even precise ones. In the LiteLLM incident, the real v1.107.0 tag pointed at the attacker's image. On PyPI, maintainers can add new files to a release that already exists. And none of this matters if your manifest still accepts the next version that a hijacked account publishes.

The only identifier that can't be reassigned is a cryptographic hash of the content: a commit SHA, an image digest, a package integrity hash. Change one byte and the hash changes, so the install fails loudly instead of succeeding silently. That's the real meaning of pinning, and it has three parts.

1. Pin by hash, not by name. Your manifest can express intent (^4.2.0), but what you install must come from a lockfile that records, for every package (direct and transitive), the exact version and its integrity hash. For Actions and containers, the reference itself should be the SHA or digest. Commit the lockfile and install from it, never around it.

2. Enforce the hash at install time. A hash in a file is useless if nothing checks it. Use the installer modes that verify every artifact against the lockfile and refuse anything that doesn't match: npm ci, pip install --require-hashes, uv sync --locked, cargo build --locked.

3. Update deliberately, with a delay. Pinning is not "never update". Replace silent drift with automated update pull requests that wait a few days before proposing a new release. Most malicious versions are pulled within hours, so a short cooldown filters out the bulk of them.

The fix, ecosystem by ecosystem

GitHub Actions. Reference actions by full 40-character commit SHA, not by tag. That includes exact tags like @v4.2.2, which can be retargeted just like @v4. Keep the human-readable tag as a comment:

- uses: actions/checkout@<full-40-character-commit-sha> # v4.2.2

Dependabot and Renovate bump the SHA and the comment together. Even better, workflow lockfiles are now generally available on every GitHub plan: gh actions-lock writes .github/workflows/actions.lock with the exact commit of every action, transitive ones included, and admins can enforce it with the Require lockfile policy.

npm (and pnpm, Yarn). Commit package-lock.json (or pnpm-lock.yaml / yarn.lock) and use npm ci in CI, never npm install. npm ci installs exactly what the lockfile says and verifies every sha512 integrity hash. Add ignore-scripts=true to .npmrc, since install-time scripts are how Shai-Hulud runs, and allow-list the few packages that need them.

Python. Unpinned requirements.txt files are why LiteLLM spread so fast. Use a real lockfile: uv lock with uv sync --locked, poetry.lock, or pip-tools with pip-compile --generate-hashes, then install with pip install --require-hashes -r requirements.txt. pip then refuses any file whose hash doesn't match, even under the same version number.

Containers. latest is the ^ of Docker, and even :1.107.0 is only a tag. Pin images by digest:

FROM python:3.13-slim@sha256:<digest>

And don't let auto-updaters pull production images straight from a public registry. Let a bot propose the new digest in a pull request instead.

Everything else. Commit Cargo.lock and build with --locked, rely on go.sum and the Go checksum database, and commit Gemfile.lock or composer.lock. Every mature ecosystem already has a hash-carrying lockfile; the work is to enforce it in CI.

A Monday checklist

  1. Commit a lockfile with integrity hashes in every repository, and make CI fail when it's missing or stale.
  2. Switch CI installs to hash-verifying modes: npm ci, uv sync --locked, pip install --require-hashes and cargo build --locked.
  3. Pin every GitHub Action by full commit SHA (a version tag is not enough).
  4. Pin container images by @sha256 digest, never by tag, and turn off blind auto-updates in production.

Conclusion

Floating versions turn every upstream compromise into your compromise, automatically, and even exact version names can be moved. Pin by SHA and hash, enforce them at install time. You'll still get every security patch, just a few days later and with your eyes open.

You can follow me on GitHub, where I'm creating cool projects.

I hope you enjoyed this article, until next time 👋

gioboa image

Giorgio BoaFollow

Giorgio Boa is a full stack developer and the front-end ecosystem is his passion. He is also international public speaker, active in open source ecosystem, he loves learn and studies new things.
📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.