Skip to main content

Dependency Versioning and Security

From the CTO — Required reading for all product and engineering teams. Every third-party package you install is code you did not write, running with the same trust level as your own. Loose version ranges and missing lockfiles mean your production build can change between deployments without anyone approving that change. Pin everything. Lock everything. Review everything before it updates.


Table of Contents

  1. Why This Matters
  2. The Standard: Pinned Versions and Lockfiles
  3. Version Pinning by Ecosystem
  4. Lockfiles
  5. Updating Dependencies Safely
  6. Vulnerability Scanning
  7. Forbidden Patterns
  8. Quick Reference

Why This Matters

Loose ranges mean your build changes silently

A ^ prefix means you did not choose what runs in production. When package.json lists "express": "^4.18.0", npm is free to install 4.19.0, 4.20.0, or any later minor version — automatically, silently, without a code review. The patch or minor update may introduce a breaking change, a regression, or a security fix that has side effects. You did not review it. You did not test it. It is running in production.

This is not a theoretical problem. It happens regularly across the industry — a minor update to a popular package changes behaviour, breaks an integration, or disables a feature flag that downstream code relied on.

Supply chain attacks target loose dependencies

A malicious package can reach you through an indirect dependency. Supply chain attacks compromise npm, PyPI, RubyGems, or the GitHub accounts of package maintainers. The attacker publishes a new version of a widely-used package with malicious code. Any project using a loose version range (^, ~, *, latest) automatically picks up the malicious version on the next npm install or CI build.

Pinning to an exact version does not fully prevent supply chain attacks (you still need to review updates before pinning to a new version), but it eliminates the class of attacks that rely on automatic version resolution. You choose when to upgrade. No new code enters your build without someone explicitly updating the pin.

latest in Docker images is the same problem

FROM node:latest makes every new image pull a different base. The latest tag in a Dockerfile points to whatever version the registry considers latest at build time. A new Node.js major version, a different Alpine Linux base, or a changed default configuration can silently enter your build. Pin the Docker image to a specific digest or version tag.

Missing lockfiles mean different installs on different machines

If there is no lockfile, npm install on your machine and in CI may install different versions. A lockfile records the exact resolved version of every package — direct and transitive. Without it, two developers installing the same package.json may get different transitive dependency versions. One of those versions may have a vulnerability. The other may not. You have no way to know which environment is at risk.

Architectural warning: Never delete or ignore lockfiles. Never add *.lock or package-lock.json to .gitignore. Lockfiles are not generated noise — they are a precise record of what runs in every environment and the primary tool for reproducible builds.


The Standard: Pinned Versions and Lockfiles

Version pinning rules

  1. All direct dependencies must use an exact version — no ^, ~, >=, *, or latest.
  2. Lockfiles must be committed to version control in every repository, without exception.
  3. Lockfiles must not be manually edited — they are generated by the package manager.
  4. Docker base images must be pinned to a specific version tag or image digest.
  5. Dependencies are updated deliberately — via a reviewed PR, not by re-running install.

Semver range operators: what they mean

RangeExampleWhat it allowsStatus
Exact"4.18.2"Only 4.18.2Required
Patch (~)"~4.18.2"4.18.x — any patchForbidden
Minor (^)"^4.18.2"4.x.x — any minor or patchForbidden
Wildcard (*)"*"Any versionForbidden
latest tag"latest"Whatever is newest at install timeForbidden
Range">=4.0.0"Any version above the floorForbidden

Version Pinning by Ecosystem

Node.js / npm

// ✅ package.json — exact versions, no range operators
{
"dependencies": {
"express": "4.18.2",
"zod": "3.22.4",
"jsonwebtoken": "9.0.2"
},
"devDependencies": {
"typescript": "5.3.3",
"jest": "29.7.0"
}
}

// ❌ Forbidden — loose ranges
{
"dependencies": {
"express": "^4.18.2",
"zod": "~3.22.4",
"jsonwebtoken": "latest"
}
}

Use npm install --save-exact or set save-exact=true in .npmrc to make exact pinning the default for all installs.

# .npmrc — enforce exact pinning on every install
save-exact=true
# Install a package with exact pinning (no ^ or ~)
npm install --save-exact express@4.18.2

Go

Go modules use go.sum as the lockfile. The go.mod file pins the minimum version of each module. Do not use replace directives to point to unversioned local or git dependencies in production builds.

// ✅ go.mod — explicit version for every dependency
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
go.uber.org/zap v1.26.0
)

Commit both go.mod and go.sum. The go.sum file contains the cryptographic hash of every module version — it is the verification layer.

# Verify the module graph matches go.sum
go mod verify

Ruby

Use Gemfile.lock as the lockfile. Pin gem versions explicitly in Gemfile.

# ✅ Gemfile — exact versions
gem 'rails', '7.1.2'
gem 'pg', '1.5.4'
gem 'jwt', '2.7.1'

# ❌ Forbidden
gem 'rails', '>= 7.0'
gem 'pg'

Commit Gemfile.lock. Run bundle install to reproduce the exact environment. Run bundle update <gem> only when explicitly upgrading a dependency.

Python

Pin all dependencies in requirements.txt with ==. Use pip-compile (from pip-tools) to generate a fully resolved lockfile from a high-level requirements.in.

# ✅ requirements.txt — exact pins
fastapi==0.104.1
pydantic==2.5.2
httpx==0.25.2
uvicorn==0.24.0

# ❌ Forbidden
fastapi>=0.100
pydantic
# Generate a fully resolved lockfile from requirements.in
pip-compile requirements.in --output-file requirements.txt --generate-hashes

The --generate-hashes flag adds a SHA-256 hash for every package. pip install will verify the hash before installing — a tampered package will be rejected.

Docker

# ✅ Pinned to a specific version tag
FROM node:20.11.0-alpine3.19

# ✅ Even more precise — pinned to an immutable image digest
FROM node@sha256:a7f8c5e1b2d3...

# ❌ Forbidden — latest or floating tags
FROM node:latest
FROM node:20-alpine
FROM node

Image digests are immutable. A version tag (e.g. 20.11.0) can be overwritten in the registry. For the highest guarantee, pin to the digest.


Lockfiles

What lockfiles do

A lockfile records the exact resolved version of every package — including transitive dependencies (dependencies of dependencies). This guarantees that every environment — local, CI, staging, production — installs exactly the same packages.

EcosystemPackage manifestLockfile
Node.js / npmpackage.jsonpackage-lock.json
Node.js / Yarnpackage.jsonyarn.lock
Node.js / pnpmpackage.jsonpnpm-lock.yaml
Gogo.modgo.sum
RubyGemfileGemfile.lock
Pythonrequirements.inrequirements.txt (compiled)
RustCargo.tomlCargo.lock

Rules for lockfiles

  • Commit the lockfile. Always. In every repository.
  • Never add lockfiles to .gitignore. If you see this, remove it immediately.
  • Never manually edit a lockfile. Let the package manager regenerate it.
  • Review lockfile diffs in PRs. A lockfile change that adds 50 transitive packages is a signal to inspect more carefully.
  • Regenerate the lockfile in CI and fail the build if it differs from the committed version. This catches cases where a developer installed a package without committing the updated lockfile.
# npm — fail CI if lockfile is out of sync with package.json
npm ci

# Go — fail if go.sum is inconsistent
go mod verify

# Ruby — fail if Gemfile.lock is out of sync
bundle install --frozen

# Python — fail if requirements.txt is out of sync
pip install --require-hashes -r requirements.txt

npm ci (not npm install) is the correct command for CI environments. It installs exactly what is in package-lock.json and fails if package.json and the lockfile are out of sync.


Updating Dependencies Safely

Pinned dependencies must still be updated — for security patches, bug fixes, and compatibility. The difference is that updates happen deliberately and go through a review.

Automated dependency update PRs

Use Dependabot (GitHub) or Renovate to open automated PRs for dependency updates. These tools open one PR per package update, showing the exact version change, the changelog, and any CVEs addressed. The team reviews and merges each PR explicitly — nothing updates silently.

# .github/dependabot.yml — check for updates weekly
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
versioning-strategy: increase # updates to the new exact version

- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"

Update process

  1. Dependabot or Renovate opens a PR with the version bump.
  2. CI runs the full test suite against the updated dependency.
  3. A developer reviews the changelog for breaking changes or security notes.
  4. The PR is merged. The lockfile is updated as part of the merge.
  5. The new exact version is pinned in the manifest.

Do not batch many dependency updates into a single PR. One package per PR makes it easy to revert a single update if it causes a regression.

Emergency security updates

When a CVE is published for a dependency in use:

  1. Check whether your code exercises the vulnerable code path.
  2. If yes — treat as a production incident. Update and deploy within your SLA for critical vulnerabilities (target: within 24 hours for CVSS ≥ 9.0).
  3. If unsure — update anyway. The cost of a false positive is low; the cost of a real breach is not.

Vulnerability Scanning

Pinned versions give you a stable target to scan. Run vulnerability scanning in CI so every build checks the current dependency set against the known CVE database.

npm

# Run in CI — fails the build if high or critical CVEs are found
npm audit --audit-level=high

Go

# govulncheck scans for vulnerabilities in the module graph
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

Python

# pip-audit scans requirements.txt against the OSV vulnerability database
pip install pip-audit
pip-audit -r requirements.txt

Ruby

# bundler-audit checks Gemfile.lock against the ruby-advisory-db
gem install bundler-audit
bundle audit check --update

In CI (example: GitHub Actions)

- name: Audit dependencies
run: npm audit --audit-level=high
# or: govulncheck ./...
# or: pip-audit -r requirements.txt

Fail the build on high or critical severity. Do not ignore CVEs silently — if a CVE has no fix yet, document the exception with a tracking issue and a target date.


Forbidden Patterns

PatternWhy it is forbiddenUse instead
^, ~, *, >= in production depsSilent updates between installsExact version pins
"latest" as a versionAlways installs the newest version at install timeExplicit version string
FROM node:latest in DockerfileBase image changes between buildsFROM node:20.11.0-alpine3.19
Lockfile in .gitignoreEnvironments can install different packagesCommit the lockfile
Manual lockfile editsCorrupts the resolved dependency graphLet the package manager regenerate
npm install in CIAllows the lockfile to be updated silentlynpm ci
Bundling many dependency updates in one PRMakes regression identification impossibleOne package per PR
Ignoring npm audit failuresKnown CVE ships to productionFix or document with a tracking issue
Private package names that shadow public onesDependency confusion attack vectorUse scoped packages (@org/package) and a private registry

Auditing your repositories

# Find ^ or ~ ranges in package.json
grep -E '"\^|"~|">=|"latest' package.json

# Check lockfile is committed
git ls-files package-lock.json yarn.lock pnpm-lock.yaml go.sum Gemfile.lock

# Check for lockfile in .gitignore
grep -E "package-lock\.json|yarn\.lock|go\.sum|Gemfile\.lock" .gitignore

# Find FROM latest or untagged in Dockerfiles
grep -rn "FROM.*:latest\|^FROM [a-z].*[^:][^0-9]$" Dockerfile* --include="Dockerfile*"

Quick Reference

# npm — install exact version, no ^ added
npm install --save-exact express@4.18.2

# npm — set exact as default in .npmrc
echo "save-exact=true" >> .npmrc

# npm — CI install (fails if lockfile is out of sync)
npm ci

# npm — audit for vulnerabilities
npm audit --audit-level=high

# Go — verify module hashes match go.sum
go mod verify

# Python — compile a locked requirements.txt with hashes
pip-compile requirements.in --generate-hashes -o requirements.txt

# pip — install with hash verification
pip install --require-hashes -r requirements.txt
// ✅ package.json — exact versions
{ "dependencies": { "express": "4.18.2", "zod": "3.22.4" } }

// ❌ Forbidden
{ "dependencies": { "express": "^4.18.2", "zod": "latest" } }
# ✅ Dockerfile — pinned version
FROM node:20.11.0-alpine3.19

# ❌ Forbidden
FROM node:latest
TaskHow
Add a new dependency (npm)npm install --save-exact <package>@<version>
Set exact pinning as default (npm)save-exact=true in .npmrc
Install in CI (npm)npm ci — not npm install
Update a dependencyDependabot / Renovate PR, one package at a time
Scan for CVEs (npm)npm audit --audit-level=high
Scan for CVEs (Go)govulncheck ./...
Scan for CVEs (Python)pip-audit -r requirements.txt
Verify lockfile integrity (Go)go mod verify
Pin a Docker base imageUse node:20.11.0-alpine3.19; prefer digest for immutability
Handle a CVE with no fixDocument in a tracking issue with a target remediation date

Questions or edge cases not covered here? Open a discussion in the #engineering-standards channel.