A version range lets every fresh install pull whatever a maintainer, or an attacker holding the maintainer's token, published a few minutes ago, while an exact pin freezes the code you tested and also freezes the vulnerabilities in it. The argument is usually framed as pin or float, but the useful question is where each decision is made: in the manifest, in the lockfile, in the install command, or in the update bot. This guide compares exact pinning, hash pinning and semantic version ranges as security controls: the threat each one addresses, how each one fails, how lockfiles and hashes change the picture, and how to combine them with cooldowns and update automation so you get reproducible builds without slow patching. It is for application security engineers, build teams and library maintainers. Bot configuration itself is covered in the Dependency Update Automation Guide; this page does not repeat it.
Key takeaways
- Applications and libraries need different answers. An application should resolve to exact, hash-verified versions at build time; a library should declare the honest range it works with, because only its manifest, not its lockfile, reaches consumers.
- The lockfile is the real pin. A caret range in package.json is harmless if CI installs strictly from a committed lockfile with integrity hashes; an exact pin in the manifest is weak if the lockfile is missing or ignored.
- Hash pinning is what stops tampering. A version number names a release; a hash identifies the bytes. pip hash-checking mode, npm integrity fields and Go's go.sum all refuse content that does not match what was recorded.
- Pinning without updating is a vulnerability policy. OpenSSF Scorecard itself warns that pinning can inhibit updates, so every pin needs an owner and an update path with a defined remediation time frame.
- A cooldown is the bridge between the two. Pin exactly, let a bot propose new versions only after they have aged a few days, and fast-track advisory fixes. Most malicious releases are removed within hours.
The short answer
For anything you deploy (services, containers, CLI tools, CI workflows), resolve every direct and transitive dependency to an exact version and a content hash, commit that record, install only from it in CI, and move pins forward through reviewed pull requests from an update bot with a release-age delay. For a library others consume, publish a version range that reflects real compatibility, never a wildcard, and keep a committed lockfile only for your own reproducible test runs. Treat third-party CI actions and container base images as dependencies that must be pinned by commit SHA or digest. The rest of this page explains why, and where each option breaks.
Pinning, ranges and lockfiles side by side
Three distinct controls hide behind the word "pinning". The table separates them from ranges so the comparison is like for like.
| Dimension | Version range in manifest | Exact version pin in manifest | Lockfile or hash pin (resolved record) |
|---|---|---|---|
| What it fixes | An allowed set, for example npm ^1.2.3 meaning >=1.2.3 <2.0.0-0 | One version string per direct dependency | Exact version and content hash for every direct and transitive dependency |
| Transitive dependencies | Float within their own ranges | Still float unless a lockfile exists | Fixed |
| Protects against a malicious new release | No: a fresh resolve can select it immediately | Partly: direct dependency fixed, transitive ones not | Yes, until someone regenerates the lock |
| Protects against a tampered artefact at the same version | No | No: same version string, different bytes still installs | Yes, when hashes are checked (integrity, --hash, go.sum) |
| Patch latency | Low for new installs; existing builds unchanged until reinstalled | High unless a bot raises updates | High unless a bot regenerates the lock |
| Reproducibility | Poor: two builds a day apart can differ | Partial | High |
| Consumer impact (libraries) | Lets consumers deduplicate and patch transitive versions | Forces duplicate copies or resolution conflicts downstream | None: lockfiles are not used by consumers |
| Main failure mode | Silent adoption of a compromised or breaking release | Stale, vulnerable versions and false confidence about transitive code | Lock drift, regenerated without review, or ignored by the install command |
How each option fails: threat model
Version ranges
The attacker's goal is to get code into your build without touching your repository. A range is a standing invitation: any release inside it is accepted on the next resolve. The common route is a stolen maintainer or publishing token used to push a new patch release, which every caret and tilde consumer is eligible to receive. In the Nx s1ngularity attack, a token taken through a GitHub Actions workflow was used to publish infected versions that were live for about four to five hours and ran a post-install script on every machine and pipeline that installed them. Ranges decide whether your next install during that window picks it up. Self-propagating worms such as the one described in the Shai-Hulud npm Worm Guide multiply this: each stolen token publishes more poisoned patch releases into more ranges.
Ranges also carry quieter risks. Semantic version promises are made by humans and broken by accident, so a minor release can change behaviour. In npm semantics a caret on a 0.x version is narrower than many developers assume (^0.2.3 allows only <0.3.0-0, and ^0.0.3 allows nothing beyond 0.0.3), which leads teams to loosen ranges to * or latest to get updates, removing every limit.
Exact pins without hashes
An exact version in the manifest stops a new release of that direct dependency from being adopted, which is why many teams believe they are protected. Two gaps remain. First, the dependency's own dependencies still resolve from ranges unless a lockfile exists, and a poisoned release of a transitive dependency is adopted just as easily as one of a direct dependency. Second, a version string is a name, not content: if a registry, mirror or proxy serves different bytes under the same version, a pin without a hash installs them. The longer-term failure is staleness: pins that nobody owns age into known-vulnerable versions, and OWASP ASVS 5.0 requirement V15.2.1 (components within documented remediation time frames) is failed quietly.
Lockfiles and hash pins
A lockfile with hashes closes both gaps, but it fails in operational ways. It can be regenerated wholesale by a developer running an update command, pulling in hundreds of changes in one unreviewed diff. It can be ignored if CI uses a command that rewrites it rather than one that enforces it. It can be bypassed for private modules, as Go documents for modules matched by GOPRIVATE or GONOSUMDB, or when GOSUMDB=off. And it does nothing if the first resolution already captured a malicious version: the hash then faithfully protects the attacker's bytes.
How the main ecosystems implement pinning
Each package manager puts the controls in different places. The table lists what the official documentation describes, so you can tell which file is the real pin in your stack.
| Ecosystem | Range syntax lives in | Resolved record | Content verification | Strict install for CI |
|---|---|---|---|---|
| Go modules | go.mod (minimum versions) | None needed: minimal version selection is deterministic and "the build list is not saved in a 'lock' file" | go.sum SHA-256 (h1:) hashes, checked against the transparent checksum database at sum.golang.org (GOSUMDB) | A hash mismatch is reported as a security error and the download is deleted |
| npm | package.json (^, ~, ranges, tags, URLs) | package-lock.json (lockfileVersion 3 for npm v9 and later); npm-shrinkwrap.json if published | integrity field: Subresource Integrity string, typically sha512 | npm ci: requires a lockfile, exits with an error if it does not match package.json, never writes either file |
| Python (pip) | requirements files and project metadata (>=, ~=, ==) | A fully pinned requirements file, or a tool lockfile | --hash=sha256:... per requirement; hash-checking mode | --require-hashes: every requirement, including dependencies, must be pinned with == and hashed or the install fails |
| Rust (Cargo) | Cargo.toml version requirements | Cargo.lock, tracked by default by cargo new | Checksums recorded in Cargo.lock | --locked style flags fail if the lock would change |
Three details matter for security reviews. Go's minimal version selection never picks up a new upstream release until something requires it. npm's lockfile cannot be published, so a library's lockfile never protects its consumers; npm-shrinkwrap.json can be published but npm advises it mainly for CLI tools and deployed packages. And pip ignores index-supplied hashes in download URLs for --require-hashes: the docs explain those guard against corruption, not tampering, so the hashes must come from your own resolved record.
The same logic applies outside language package managers. GitHub's hardening guidance tells users to pin third-party actions to a full-length commit SHA because "a tag can be moved or deleted if a bad actor gains access to the repository", and now describes workflow dependency lockfiles as superseding SHA pinning for repositories that adopt them. OpenSSF Scorecard's Pinned-Dependencies check asks for Dockerfile base images and GitHub workflow dependencies to be pinned by hash rather than by a mutable tag or version range. See the GitHub and GitHub Actions Security Guide and the Container Image Security Guide.
Architecture: where each decision should live
The disagreement between "pin everything" and "use ranges" mostly disappears once the decision points are separated. A sound design has four layers, each with one job.
- Manifest (intent): declares the versions the code is compatible with. Libraries use honest ranges (Cargo's FAQ: libraries "should always specify the range that they do work with", never
*). Applications may use ranges or exact versions; it matters less because the next layer decides. - Lockfile (decision): records the exact resolution and content hashes for the whole tree. It is committed, code-reviewed like source, and changed only by a deliberate update.
- Install (enforcement): CI and production builds install only from the lockfile in strict mode (
npm ci,pip install --require-hashes, Go's go.sum verification, Cargo's locked mode), and fail rather than resolve fresh versions. - Update (change): a bot or scheduled job proposes lockfile changes as small pull requests, after a release-age cooldown, with tests and review, and with a separate fast path for security advisories.
A registry proxy can add a fifth layer: a release-age rule and block list applied to every fetch. The Package Registry Security Comparison Guide covers registry-side controls, and the Malicious Package and Dependency Confusion Guide covers resolution order between internal and public sources, which pinning alone does not fix.
Patch speed: measuring the real cost of pinning
The case against pinning is patch latency: a pinned tree only moves when someone changes it. The question is whether the delay is controlled.
| Model | When the fix reaches production | When a malicious release would reach production | Who decides |
|---|---|---|---|
| Ranges, no lockfile | Next build after the fix is published, if it is inside the range | Next build after publication: minutes | Nobody: the resolver |
| Lockfile, no update automation | When a developer notices and regenerates: often weeks or months | When a developer regenerates during the malicious window | Whoever runs the update command |
| Lockfile plus bot with cooldown and security fast path | Advisory-driven PR within hours to days, merged under the remediation SLA | Only after the cooldown, by which point most malicious releases have been removed | Policy, reviewed per change |
The third row is the target: faster than the second for security fixes and safer than the first for new releases, at the cost of reviewing a steady flow of update pull requests. Cooldown values per update class are in the bot guide linked above.
Controls per option
If you keep ranges in manifests
- No wildcards or dist-tags: ban
*, empty strings andlatestin dependency declarations; a linter or policy check in CI can enforce it. - Commit the lockfile and enforce it: ranges are acceptable only when the build installs strictly from the committed lock.
- Override known-bad transitive versions: npm
overridesin the root package.json can force a specific version anywhere in the tree while upstream catches up. - Disable install scripts where possible: most malicious releases execute at install time; see the npm Install-Time Security Guide.
If you pin exactly
- Pin by hash, not only by version: generate hashes for every requirement and run installs in hash-checking mode.
- Give every pin an update path: an update bot covering every ecosystem in the repository, including CI actions and base images. Scorecard's Dependency-Update-Tool check notes it only confirms a tool is enabled, not that its pull requests are merged, so measure merges.
- Record why a pin is held back: a pin kept below the latest version needs an issue reference and an expiry date, or it becomes permanent.
For lockfiles and hash pins
- Review lockfile diffs: require code-owner review for lockfile changes and flag new package names, new install scripts and changed resolved URLs or registries.
- Fail on drift: strict install commands in every pipeline, plus a check that the lockfile is consistent with the manifest.
- Keep checksum verification on: do not set
GOSUMDB=off; scopeGOPRIVATEto your own module paths only. - Verify provenance at the first pin: hashes protect what you first captured, so check publisher provenance when a new package or version enters the lock. See the npm Trusted Publishing and Provenance Guide and the SLSA and Build Provenance Guide.
Decision guidance
| Situation | Recommended approach | Why |
|---|---|---|
| Deployed service or container | Lockfile with hashes, strict install, bot with cooldown | Reproducibility and tamper resistance outweigh resolver convenience |
| Published library | Honest ranges in the manifest; lockfile for your own CI only | Consumers resolve from your manifest; exact pins create duplicates and conflicts downstream |
| CLI tool installed by users | Consider a published lock (for npm, npm-shrinkwrap.json) or a single bundled artefact | Users get the tree you tested |
| CI workflows and build tools | Pin actions by full commit SHA or workflow lockfile; base images by digest | These run with pipeline secrets and tags can be moved |
| Internal packages you publish | Ranges or exact versions with no cooldown | You control the publisher; delay adds nothing |
| Regulated or high-assurance systems | Hash pins, internal mirror with release-age rule, human approval for every lock change | Change control (NIST SP 800-53 CM-3) and integrity (SI-7) evidence |
Small teams: commit lockfiles, switch CI to strict install, and enable an update bot with its default cooldown. That alone delivers most of the benefit. Large organisations: publish shared bot presets, run a registry proxy that enforces release age and block lists for every build, report per-team lock freshness, and treat the update bot as a privileged non-human identity with scoped, short-lived credentials.
Worked scenario: a payments service and a shared client library
A platform team owns a Node.js payments service and publishes an internal client library used by 40 other services. Before the change, the service declares caret ranges, has a lockfile, but its pipeline runs npm install, which can update the lock. The library pins every dependency exactly to "be safe".
- Exposure found: two builds a week apart shipped different transitive versions, and the library's exact pins forced three copies of one HTTP client into consumers, one with a known advisory.
- Service fix: CI moves to
npm ci; lockfile changes require code-owner review; the update bot raises grouped patch and minor updates after a cooldown and security fixes immediately. - Library fix: exact pins are replaced with honest caret ranges that the test suite covers at both the lowest and highest allowed versions, so consumers can deduplicate and patch transitive versions themselves.
- CI fix: third-party actions are pinned to full commit SHAs and the base image to a digest, with the bot updating both.
- Result: when a compromised patch release of a logging dependency appears and is removed by the registry within hours, the service never sees it (cooldown and strict install), while the next advisory fix in the HTTP client reaches production within the remediation SLA through a single reviewed pull request.
Implementation roadmap
- Inventory: list repositories by ecosystem, whether a lockfile is committed, which install command CI uses, and whether actions and base images are pinned. Scorecard's Pinned-Dependencies check gives a quick first pass for workflows, Dockerfiles and scripts.
- Enforce lockfiles in audit mode: add a CI job that reports when the install would change the lock, without failing builds, for two weeks.
- Switch to strict install: change pipelines to
npm ci, hash-checking pip installs, locked Cargo builds; keep Go checksum verification on. Fail on drift. - Add hashes where missing: generate hashed requirement files for Python services and confirm npm lockfiles carry integrity fields.
- Turn on update automation with cooldowns: follow the bot guide; start with one pilot ring of well-tested repositories.
- Pin CI and images: convert action tags to commit SHAs or adopt workflow lockfiles; pin base images by digest.
- Roll back: if a lockfile update breaks production, revert the lockfile commit and redeploy; the previous resolved tree and hashes return exactly. If the update was malicious, follow the Malicious Open Source Package Response Playbook and rotate any secrets reachable from affected builds.
Detection and measurement
- Unlocked builds: pipeline logs showing
npm installrather thannpm ci, pip installs without hash-checking, orGOSUMDB=off. - Lockfile changes outside the bot: human-authored lockfile commits with large diffs, new registries in resolved URLs, or new install scripts.
- Mutable references: actions referenced by tag or branch, and images referenced by tag, in any workflow or Dockerfile.
- Yanked or flagged versions in locks: alert when a locked version is removed or flagged malicious.
Metrics that show the approach is working:
- Percentage of deployable repositories with a committed lockfile and strict install in CI.
- Percentage of dependencies, actions and images pinned by hash, SHA or digest.
- Median and 90th percentile time from advisory publication to merged fix, against the remediation time frames ASVS V15.1.1 asks you to document.
- Median age of locked versions behind latest, per team, as a staleness indicator.
- Count of versions adopted inside the cooldown window, which should be zero except for security fixes.
Framework mapping
| Framework | Reference | How pinning and ranges relate |
|---|---|---|
| NIST SP 800-53 Rev. 5 | CM-2 Baseline Configuration, CM-3 Configuration Change Control | The lockfile is the dependency baseline; lock changes go through change control |
| NIST SP 800-53 Rev. 5 | SI-7 Software, Firmware, and Information Integrity; SI-7(6) Cryptographic Protection | Hash verification of every artefact at install |
| NIST SP 800-53 Rev. 5 | SI-2 Flaw Remediation; SR-3 Supply Chain Controls and Processes; SR-4 Provenance | Update path for pins; supplier and provenance checks when versions enter the lock |
| NIST CSF 2.0 | PR.PS-02, ID.RA-01 | Software maintained commensurate with risk; vulnerabilities in pinned components identified |
| OWASP ASVS 5.0 | V15.1.1, V15.2.1, V15.2.4 | Remediation time frames; components within them; dependencies from the expected repository |
| MITRE ATT&CK | T1195.001 Compromise Software Dependencies and Development Tools | The technique that ranges expose and hash pins plus cooldowns reduce |
Common failure patterns
- Lockfile committed, never enforced: CI runs a command that re-resolves, so the lock records history rather than controlling builds.
- Exact pins in a library: consumers inherit duplicate and stale copies they cannot patch without forking.
- Pin and forget: no bot, no owner, no expiry; the tree ages into known-vulnerable versions while dashboards show "100% pinned".
- Wholesale lock regeneration: a developer runs a full update to fix one package and a reviewer approves a 3,000-line lockfile diff without reading it; any malicious version published that morning comes along.
- Tags treated as pins: CI actions and base images referenced by mutable tag while application code is hash-pinned, leaving the pipeline, which holds the secrets, as the weakest link.
- Checksum verification switched off for convenience:
GOSUMDB=offor broadGONOSUMDBpatterns set once to fix a private module error and never narrowed.
Practitioner checklist
- Commit a lockfile with content hashes for every deployable project.
- Use strict install commands in CI and fail on lockfile drift.
- Ban wildcards and dist-tags in manifests; libraries declare honest ranges.
- Run pip in hash-checking mode with hashes from your own resolved record.
- Keep Go checksum database verification on; scope GOPRIVATE narrowly.
- Pin CI actions by full commit SHA or workflow lockfile and base images by digest.
- Run an update bot with a release-age cooldown and a fast path for advisories.
- Require code-owner review of lockfile diffs and flag new packages and install scripts.
- Track time to remediate and the age of locked versions.
Standards and references
- Go: Go Modules Reference (minimal version selection, go.sum, checksum database)
- npm Docs: package-lock.json
- npm Docs: npm ci
- pip: Secure installs (hash-checking mode)
- The Cargo Book: Frequently Asked Questions
- GitHub Docs: Security hardening for GitHub Actions
- OpenSSF Scorecard: Check documentation (Pinned-Dependencies, Dependency-Update-Tool)
- MITRE ATT&CK: Compromise Software Dependencies and Development Tools
Related NHI Mgmt Group resources: Dependency Update Automation Guide · Software Supply Chain Security Guide · Open Source Security Guide · Reproducible Builds Guide