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
- Why This Matters
- The Standard: Pinned Versions and Lockfiles
- Version Pinning by Ecosystem
- Lockfiles
- Updating Dependencies Safely
- Vulnerability Scanning
- Forbidden Patterns
- 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
*.lockorpackage-lock.jsonto.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
- All direct dependencies must use an exact version — no
^,~,>=,*, orlatest. - Lockfiles must be committed to version control in every repository, without exception.
- Lockfiles must not be manually edited — they are generated by the package manager.
- Docker base images must be pinned to a specific version tag or image digest.
- Dependencies are updated deliberately — via a reviewed PR, not by re-running install.
Semver range operators: what they mean
| Range | Example | What it allows | Status |
|---|---|---|---|
| Exact | "4.18.2" | Only 4.18.2 | Required |
Patch (~) | "~4.18.2" | 4.18.x — any patch | Forbidden |
Minor (^) | "^4.18.2" | 4.x.x — any minor or patch | Forbidden |
Wildcard (*) | "*" | Any version | Forbidden |
latest tag | "latest" | Whatever is newest at install time | Forbidden |
| Range | ">=4.0.0" | Any version above the floor | Forbidden |
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.
| Ecosystem | Package manifest | Lockfile |
|---|---|---|
| Node.js / npm | package.json | package-lock.json |
| Node.js / Yarn | package.json | yarn.lock |
| Node.js / pnpm | package.json | pnpm-lock.yaml |
| Go | go.mod | go.sum |
| Ruby | Gemfile | Gemfile.lock |
| Python | requirements.in | requirements.txt (compiled) |
| Rust | Cargo.toml | Cargo.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
- Dependabot or Renovate opens a PR with the version bump.
- CI runs the full test suite against the updated dependency.
- A developer reviews the changelog for breaking changes or security notes.
- The PR is merged. The lockfile is updated as part of the merge.
- 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:
- Check whether your code exercises the vulnerable code path.
- If yes — treat as a production incident. Update and deploy within your SLA for critical vulnerabilities (target: within 24 hours for CVSS ≥ 9.0).
- 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
| Pattern | Why it is forbidden | Use instead |
|---|---|---|
^, ~, *, >= in production deps | Silent updates between installs | Exact version pins |
"latest" as a version | Always installs the newest version at install time | Explicit version string |
FROM node:latest in Dockerfile | Base image changes between builds | FROM node:20.11.0-alpine3.19 |
Lockfile in .gitignore | Environments can install different packages | Commit the lockfile |
| Manual lockfile edits | Corrupts the resolved dependency graph | Let the package manager regenerate |
npm install in CI | Allows the lockfile to be updated silently | npm ci |
| Bundling many dependency updates in one PR | Makes regression identification impossible | One package per PR |
Ignoring npm audit failures | Known CVE ships to production | Fix or document with a tracking issue |
| Private package names that shadow public ones | Dependency confusion attack vector | Use 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
| Task | How |
|---|---|
| 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 dependency | Dependabot / 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 image | Use node:20.11.0-alpine3.19; prefer digest for immutability |
| Handle a CVE with no fix | Document in a tracking issue with a target remediation date |
Questions or edge cases not covered here? Open a discussion in the #engineering-standards channel.