Dependency risk, and lockfiles as a control
A lockfile does not make a dependency safe. It makes the dependency the same every time, which is a different and narrower promise. If a maintainer publishes a malicious version and you commit the lockfile update, the lockfile faithfully guarantees that every developer and every build server installs exactly that malicious version. Understanding precisely what the file guarantees is what turns it from a build convenience into a security control.
What does a lockfile not protect you from?
A compromised or hostile package that you legitimately resolved. Lockfiles record decisions; they do not evaluate them. The three most damaging supply chain patterns in recent years all survive a lockfile intact: a maintainer account taken over and a new version published, a package deliberately named to be mistaken for a popular one, and an internal package name that resolves from a public registry because nobody scoped the private one.
It also does nothing about the code that runs during installation. Package managers in several ecosystems execute arbitrary scripts as part of installing a dependency, and that execution happens on developer laptops and build agents holding cloud credentials, source access and signing keys. The version was pinned. The behaviour was not.
The useful mental model is that a lockfile converts an unbounded trust problem into a bounded review problem. You still have to review. What changes is that a change in third-party code now arrives as a diff in a file in your repository, which means it can be gated, and gating a diff is something engineering organisations already know how to do.
What does a lockfile actually guarantee?
Two things: identical resolution and content integrity. Resolution means that a version range such as a caret or tilde constraint gets flattened to one exact version per package, including transitive dependencies, so the graph installed today is the graph installed in six months when a rebuild is triggered for an unrelated reason. Integrity means the file records a cryptographic hash of the archive, so a package republished under the same version number with different content fails the install rather than silently substituting.
That integrity check is the part most teams underuse. It only fires if you install from the lockfile rather than resolving fresh, and the commands differ: in the npm ecosystem the strict install is a distinct command from the ordinary one, and only the strict form fails when the manifest and lockfile disagree. Continuous integration should always use the strict form, and a pipeline that runs the permissive command is quietly reintroducing the risk the file exists to remove.
The test is quick. Edit a dependency version in your manifest without regenerating the lockfile, and push. If the build succeeds, your pipeline is resolving rather than installing, and the lockfile is documentation rather than a control.
Why do install scripts matter more than versions?
Because they are the mechanism by which a dependency problem becomes a credential problem. When a package manager runs a post-install hook, that code executes with the privileges of whoever ran the install. On a build agent that commonly means access to registry tokens, a cloud role, environment variables holding deployment credentials and a checkout of your source. Nothing needs to reach production for the incident to be serious.
In JavaScript the disabling is no longer yours to do, and the remaining work is the inverse of the old advice. npm 12, pnpm 10 and Yarn 4.14 all ship with dependency lifecycle scripts off by default, so the task is reviewing and committing the allowlist that lets the handful of legitimate native builds run instead of failing: npm records approvals in package.json via approve-scripts, pnpm uses allowBuilds and fails the install on anything unreviewed, Yarn pairs enableScripts with a per-package dependenciesMeta.built entry. Check which default you are actually on before assuming you are covered, because Yarn 4.14 migrates an existing project by writing enableScripts: true, and anything still on an older major runs everything.
Switching execution off during installation is still the change to make in the ecosystems that have not moved. pip source distributions, Cargo build scripts, and Maven and Gradle build plugins all execute code with no allowlist in front of them, so that is where a continuous integration flag earns its place. In every ecosystem, run installs in a container with no ambient cloud credentials, obtaining any needed tokens in a later step, so a hostile hook finds nothing worth stealing.
Dependency confusion deserves its own paragraph because the fix is configuration rather than vigilance. If your internal packages share a naming pattern with anything a stranger could register publicly, scope them to a private registry and configure the resolver so that names under that scope are never fetched from a public source. A resolver that falls back to the public registry on a miss is the entire vulnerability.
How do the ecosystems compare?
Not evenly. The strength of the control depends on whether the ecosystem has a real lockfile, whether integrity hashes are mandatory rather than optional, and whether installing runs code. The gaps below are the ones worth checking in your own repositories rather than assuming.
| Ecosystem | Lock mechanism | Integrity hashes | Runs code on install | Gap to check |
|---|---|---|---|---|
| npm, pnpm, yarn | Lockfile covering the full transitive graph | Yes, per archive | No, blocked by default since npm 12, pnpm 10 and Yarn 4.14, then allowlisted per package | Pipelines using the permissive install command, and projects on older majors or migrated with scripts re-enabled |
| Python with pip | PEP 751 pylock.toml is the standard format, still experimental on both pip sides; otherwise compiled pinned requirements | Intrinsic to pylock.toml, optional in requirement files and only enforced when present | Yes, for source distributions | Plain requirement files with no hashes, and a pylock.toml valid only for the Python version and platform that produced it |
| Go modules | Module file plus checksum file | Yes, backed by a public checksum database | No install-time hooks | Checksum verification disabled by environment variables |
| Maven and Gradle | No lockfile by default; Gradle offers dependency locking | Available via checksum verification, rarely switched on | Build plugins execute freely | Version ranges and snapshot dependencies |
| Cargo | Lockfile, tracked by default for every crate since the library exception was withdrawn | Yes | Build scripts compile and run | Assuming a committed lockfile constrains consumers, when only Cargo.toml does |
| Container base images | Only if referenced by digest, not by tag | Digest is the hash | Build stages run arbitrary commands | Mutable tags such as latest or a major version |
How should container images be pinned?
By digest, in both directions. A tag is a mutable pointer: the same tag can resolve to different content tomorrow, which means a build that succeeded last week is not reproducible and a scan result from last week describes an image you may no longer be running. Referencing a base image by its content digest gives you the same guarantee a lockfile gives your libraries, and it is one line in a Dockerfile.
The obvious objection is that digest pinning stops you receiving security patches, and it does, which is exactly why the pinning has to be paired with automation. An update tool that raises a pull request moving the digest forward on a schedule gives you both properties: builds are reproducible between updates, and updates arrive as reviewable changes with a date attached. Pinning without that automation trades one risk for another and is worse than a tag.
Downstream, pin what you deploy. Deployment manifests that reference a tag rather than a digest mean the image running in production may not be the image you tested, particularly with mutable tags and pull policies that refresh. Resolving to a digest at deploy time is what makes the sentence "we scanned what we shipped" true rather than approximate.
What is worth doing this week?
Four changes, none of which needs a project. Switch every pipeline to the strict, lockfile-respecting install command and confirm it fails on a deliberately mismatched manifest. Read the install script allowlist your JavaScript package manager now requires and commit it deliberately, and disable script execution outright in the ecosystems that still run it by default. Scope internal package names to a private registry with no public fallback. Pin base images by digest with an automated bump.
Then decide what an SBOM is for before generating one. A software bill of materials in SPDX or CycloneDX format is genuinely useful for answering "are we affected" during a widely publicised vulnerability, and increasingly it is requested in procurement. It is not useful if it is generated at build time, stored nowhere queryable, and never diffed. Produce it per release, keep it addressable by the artefact digest, and make sure someone can search across all of them in one command.
Signing and provenance are the next tier rather than the starting point. Verifying that an artefact was built by your pipeline from your source, using a signing tool and a provenance attestation, closes a real gap between build and deploy. It is worth doing after resolution, integrity and install-time execution are handled, because those three are where actual incidents keep occurring.
Common questions
- Does a lockfile make dependencies secure?
- No. It makes them identical across installs and verifies that the archive content matches a recorded hash. If you resolve and commit a malicious version, the lockfile guarantees everyone installs exactly that version. Its security value is procedural: third-party changes now arrive as a reviewable diff in your repository, which means they can be gated, rather than appearing silently at the next build.
- Should lockfiles be committed to version control?
- Yes for applications and services, in every ecosystem that supports them. The whole purpose is that the build server, the developer laptop and the release pipeline resolve the same graph, which only works if the file is shared. Libraries are no longer the exception they were once documented to be: Cargo now tracks Cargo.lock for every crate it creates and frames the decision around deterministic builds, bisecting and continuous integration rather than around whether the crate is a library. What still holds is the consequence. A published library's lockfile is ignored by its consumers, who resolve against the manifest, so committing it buys reproducibility inside your own repository and nothing beyond it.
- What is dependency confusion?
- An attack where an internal package name is also registered on a public registry, and your resolver fetches the public one because it falls back on a miss or prefers a higher version number. The fix is configuration rather than vigilance: scope internal packages to a private registry and configure the resolver so names in that scope are never fetched from a public source under any circumstances.
- Why are package install scripts a security risk?
- Because they execute arbitrary code with the privileges of whoever ran the install, which on a build agent commonly includes registry tokens, a cloud role, deployment credentials and a source checkout. Nothing has to reach production for the incident to be serious. In JavaScript the current majors already block them: npm 12, pnpm 10 and Yarn 4.14 run dependency scripts only for packages you allowlist, so the work is reviewing that list and confirming an older major or a migrated Yarn config has not left execution on. Elsewhere, pip source distributions, Cargo build scripts and Maven or Gradle plugins still run code, so switch execution off in continuous integration where the package manager will let you. Run installs without ambient cloud credentials either way.
- Should you pin container base images by digest or tag?
- By digest, paired with automation that moves the digest forward on a schedule. A tag is a mutable pointer, so the same reference can resolve to different content later, which breaks reproducibility and invalidates any scan result from last week. Digest pinning without an automated update mechanism blocks security patches, so the two changes belong together rather than separately. Pin deployment manifests by digest as well, otherwise the image running in production may not be the image you tested and scanned.
- Is an SBOM worth generating?
- Only if it is queryable. An SBOM in SPDX or CycloneDX format earns its place when a widely publicised vulnerability appears and you need to answer whether you are affected across every deployed artefact in minutes. That requires generating one per release, addressing it by the artefact digest, and being able to search all of them at once. A file produced at build time and stored nowhere answers nothing.