Nobody Chose Most of Your Dependencies

A short list of declared packages fanning out into a much larger resolved graph

Open a manifest and you will find a short list. A dozen entries, maybe thirty. Somebody argued about each of them at some point. Open the lockfile and you will find hundreds — occasionally more packages than the application has source files.

That ratio is the whole subject. The short list is a set of decisions. The long list is what those decisions dragged in behind them, and it is where almost all of the code you ship comes from. Nobody evaluated it. Nobody could have; the graph was assembled by a resolver, at install time, from constraints written by strangers.

The interesting question is not whether this is bad. It is obviously unavoidable, and the alternative — writing everything yourself — has a far worse security record than reusing well-maintained code. The interesting question is what the arrangement actually commits you to, because most teams have not worked it out.

What you are committing to

Every publisher in your resolved graph has two capabilities.

The first is the obvious one: their code runs inside your application, with whatever privileges your application has. A logging helper six levels deep in the graph shares an address space with your credential handling.

The second is less obvious and usually more dangerous. In most ecosystems, installing a package can execute code — install hooks, build scripts, setup steps, native compilation. That code runs on the machine doing the install, which is a developer laptop or, far more interestingly, a build agent that holds signing keys and registry credentials. The package does not need to be imported to reach that machine. It only needs to be resolved.

So the trust set is not “the libraries we call”. It is “every account that can publish anything the resolver will reach, plus every account that can take over one of those accounts”. That set is not visible in the manifest and not enumerated anywhere by default.

The count is the wrong number

The standard artefact from a dependency scan is a list of packages with known vulnerabilities, and the standard response is to ask how long the list is. It is a poor measure in both directions.

It over-reports because most of the listed flaws are not reachable from your code. A parsing bug in a code path your application never enters is real in the library and inert in your deployment. Treating it identically to a flaw in the function you call on every request means the queue is sorted by publication date rather than by exposure.

It under-reports because it only knows about disclosed vulnerabilities in packages someone has catalogued. It says nothing about the package added last week by a maintainer with a two-week-old account, nothing about the dependency whose sole author has not responded to an issue in three years, and nothing about the one that quietly changed hands. None of those produce a finding. They are the ones that matter.

Maintainers are single points of failure

A package is not a thing. It is a name, controlled by an account, resolving to whatever that account last published.

Three failure modes follow directly. An account is compromised and a malicious release goes out under a trusted name. A maintainer, exhausted, hands publishing rights to a volunteer nobody vetted — a completely normal and generous act that also transfers the ability to run code on thousands of build machines. Or nobody publishes anything at all, and the package sits at its last release while the ecosystem moves past it, accumulating incompatibilities that eventually force a migration under time pressure.

The first two are usually caught, if at all, by someone noticing that a patch release did something surprising.

What a lockfile does and does not do

A lockfile records the exact resolved graph — every package, every version, usually a content hash. Reinstalling reproduces that graph rather than re-resolving it. This is genuinely valuable and it is routinely oversold.

What it gives you: the graph stops moving on its own. Two developers and the build agent get identical trees. If the registry serves different bytes for a version you have already seen, the integrity hash catches it.

What it does not give you: any judgement about the contents. A locked dependency is a dependency you have pinned, not one you have reviewed. And the lock only holds until someone updates it — at which point a routine “bump dependencies” change can move several hundred packages, arriving as a pull request that is physically unreviewable and gets approved on the strength of the tests passing.

That review moment is where the control has to live, and a diff of the lockfile is a bad place to put it. What helps is much duller: knowing which of those several hundred changes crossed a major boundary, which introduced packages not previously in the graph at all, and which came from publishers new to your tree. Those three questions are answerable mechanically and they narrow an unreviewable diff to a handful of lines worth a human’s attention.

Ranges delegate review authority

A version range is a standing instruction to the resolver: accept anything in this window without asking me. Writing a caret range means the next release the publisher chooses to call a minor is one you have pre-approved, on the basis of a version number the publisher assigned themselves.

Nothing verifies that claim. No registry checks that a minor release is backward compatible, and nothing at all checks that it is not hostile. The range enforces arithmetic; the compatibility promise is entirely social. That is a reasonable bargain when the alternative is never receiving security fixes, and it is worth being explicit that it is a bargain rather than a safety property.

Shrinking the graph is a security control

The unglamorous mitigation is to have less of it.

Every dependency removed removes a publisher from the trust set, a package from the update treadmill and a set of transitive edges you never chose. A three-line utility that arrives with four transitive dependencies is a worse deal than three lines of your own code, and the calculation is not close. A framework that brings sixty packages may still be worth it, but the sixty should be on the ledger when the decision is made.

Mirroring through an internal registry is the other lever, and it changes a different thing. It does not make packages safer. It puts a point of control between the public registry and your build agents, so that “which versions may enter” becomes a decision your organisation makes rather than one the resolver makes at install time — worth the operational cost mainly where a bad package would otherwise reach many teams before anyone noticed.

The question to ask

Not “how many vulnerable packages do we have”. That number goes up and down with disclosure schedules and tells you little about your exposure.

Ask instead: who can change what we ship without our involvement, how many of them are there, and by what mechanism would we find out? For most teams the honest answers are several hundred accounts, we do not know, and we would read about it. Improving any one of those three is worth more than clearing a scanner queue.