Every article on supply chain security eventually lands on the same three words: pin your dependencies. It is good advice that has become slightly dangerous because most teams now treat it as a complete answer. "We pin everything" is the answer I hear most often when I ask engineering leaders what they do about software supply chain risk, and it is the answer that lets everyone move on to the next agenda item. The problem is that pinning a version number — lodash: "4.17.21" in your package.json — is a much weaker control than most people think. It prevents one narrow failure mode (a version range resolving to a different release than you expected) and does nothing about several others that are at least as common. The March 2022 node-ipc protestware incident, the ongoing wave of confused-dependency attacks following the 2021 Birsan research, and the routine cadence of npm token compromises have all demonstrated that pinned projects still get hit. This post is about why, and what the actual full control set looks like.
What exactly does a pinned version number buy you?
A pinned version number buys you reproducibility of the resolution step — the answer to the question "which version of this package am I supposed to install." If your package.json says "lodash": "4.17.21" instead of "lodash": "^4.17.0", you have eliminated the risk that tomorrow's install picks up a newly published 4.17.22 that contains a regression or a compromised release.
That is genuinely useful, but it is a small slice of the threat model. It does not tell you anything about the contents of lodash@4.17.21 as served by the registry at install time. It does not protect against a registry that serves different bytes for the same version to different clients. It does not prevent transitive dependencies — which your lockfile may or may not pin, depending on the tool — from moving underneath you. And it does not defend against the package author's account being compromised and a new version being cut that keeps the old version as a target for anyone rolling back.
What does a lockfile-level integrity hash add?
A lockfile-level integrity hash — the integrity field in npm's package-lock.json, the hash in poetry.lock, the sha256 in a requirements.txt with --hash entries — adds verification that the bytes delivered match the bytes that were seen when the lockfile was generated. This is the control that actually defends against registry tampering and man-in-the-middle attacks on the install path.
The gap is that most lockfile workflows regenerate the lockfile whenever anyone adds a dependency, and the regeneration fetches fresh content from the registry. If the registry serves a compromised release at regeneration time, the new integrity hash is the compromised hash, and the lockfile is now vouching for the bad content. This is the failure mode behind multiple Python package takeover incidents in 2021 and 2022: the attacker did not need to break the integrity system, they needed the victim to regenerate against a compromised registry state.
# This is the minimum — pinned version + integrity hash
django==4.0.4 \
--hash=sha256:39cd2bcd96b7fae17 \
--hash=sha256:c0c8ec39c6a6d9c08
# Pip refuses the install if the downloaded wheel does not match.
How do typosquats and dependency confusion defeat pinning?
Typosquats and dependency confusion defeat pinning because they exploit the name-resolution step that happens before the pin even applies. If your package.json adds "requsts": "2.28.0" — a transposed typo — the lockfile will faithfully pin the malicious requsts package and its integrity hash, exactly as it would a legitimate dependency. The pin is doing its job. The problem is upstream of the pin.
Dependency confusion, the attack class documented by Alex Birsan in 2021 and still generating CVEs in 2022, works similarly. An internal package acme-shared-utils that is published only to a private registry can be shadowed by a public package of the same name with a higher version number. Package managers configured to prefer the highest version across all configured registries will pull the public, malicious one. The public version's pin is whatever the attacker chose, and it will be faithfully honored.
Neither attack requires breaking any integrity control. They both work by getting into the dependency graph in the first place.
What about transitive dependencies the lockfile does not actually pin?
Transitive dependencies that the lockfile does not actually pin are a surprisingly common gap. For many years, Python's pip freeze output pinned only top-level dependencies, leaving transitive packages to be re-resolved on every install. This was finally addressed by pip-tools, poetry, and pdm, but requirements.txt files in the wild still routinely pin only the packages the developer added directly.
Ruby's Bundler has always pinned transitives in Gemfile.lock. npm and Yarn pin transitives, with some nuance: npm's package-lock.json pins by URL and integrity, which covers registry-level tampering, but npm install with a non-exact top-level range can legitimately bump transitives without changing the top-level pin. Go modules pin via go.sum, which is strongly verified and close to the gold standard. Maven's dependencyManagement section is optional and frequently omitted. A 2021 Sonatype study found that 68% of Maven projects on Maven Central had at least one transitive dependency whose version was not pinned.
What does the full integrity stack look like in 2022?
The full integrity stack in 2022 has five layers, and pinning is only the first: pin the version, verify the integrity hash, verify the publisher's signature, verify the source-to-binary provenance, and enforce the policy downstream. Each layer defends a different attack.
Pinning defends against silent range drift. Integrity hashes defend against registry-level tampering. Publisher signatures — npm's package signing via Sigstore (rolling out through 2022), PyPI's forthcoming signature support, Maven Central's GPG signatures — defend against account compromise by requiring a second factor. Source-to-binary provenance, via SLSA attestations, defends against compromised build environments even with valid publisher signatures. And downstream enforcement — policy gates in CI, admission controllers in Kubernetes — makes all of the above actually binding rather than aspirational.
A project running all five layers can still be attacked, but the attacker needs a coordinated compromise across multiple independent systems. A project relying on pinning alone needs an attacker to get into exactly one system: the registry serving their next install.
How Safeguard Helps
Safeguard evaluates the full five-layer integrity stack for every component in every tracked project, not just the pinning layer. When Safeguard ingests a lockfile or SBOM, it cross-references each component against its Sigstore transparency log entry, its SLSA provenance claim, and the registry-side signing metadata, and surfaces components that are pinned but not signed, signed but not built with provenance, or built with provenance that does not match the declared source repo. The policy engine lets you express rules like "block any direct dependency added this quarter that does not have publisher signatures and SLSA level 2 or higher." For the typosquat and confusion classes specifically, Safeguard's package security checks flag new dependencies whose names closely resemble popular packages, or whose version jumps suggest namespace-capture attacks, before the first build even runs.