Supply Chain · Case Study · 8 min read
The library you shipped without reading
Most of a modern application is code the team never wrote. A dependency scan on this one returned sixty advisories. One of them was exploitable, it was rated moderate, and it was buried four levels down in a package nobody remembered adding.
A dependency is a decision to ship someone else’s code under your name. On the application in this engagement, that someone else wrote most of it: the first-party code was around forty thousand lines, and the installed dependency tree was over nine hundred packages. The team had read and reviewed the forty thousand. Nobody had read the rest, because reading the rest is not a thing anyone has time to do.
The client had already run a dependency scanner in their pipeline. It reported sixty advisories across the tree and had been reporting roughly that number, give or take, for a year. The build did not fail on it, because a build that fails sixty times cannot ship, so the number had become weather — something you note and walk past. They asked us to tell them which of the sixty actually mattered, which is the right question and not the one the scanner answers.
What the audit actually asks
A dependency scanner answers one question: is a package with a known advisory present in your tree. That is a question about presence. The question that decides risk is whether your own code can reach the vulnerable function with input an attacker controls, and presence is a poor proxy for it in both directions.
A critical advisory on a function your application never calls is not a vulnerability in your product. A moderate advisory on a parser that sits on your login path, run against the body of every unauthenticated request, is not moderate in your product. The scanner cannot tell these apart because it does not read your code. It reads your lockfile.
Building the inventory
We started where the scanner stops, by building the dependency tree out properly: direct dependencies against transitive ones, production against development, and for each package the path through which it had been pulled in. Most teams know their direct dependencies and are surprised by their transitive ones, and it is the transitive layer where the interesting findings live, because nobody chose those packages deliberately and nobody is watching them.
The exercise also surfaced the dead weight. Fourteen packages in the production tree were not reached by any code path we could find — left behind by a refactor, or pulled in for one utility function that had since been rewritten inline. Unused code you still ship is still attack surface, and it is the cheapest surface to remove, because removing it breaks nothing by definition.
The finding
One transitive package was a small object-merge utility, four levels down in the tree, pulled in by a logging adapter that was itself pulled in by the web framework. Its advisory described a prototype pollution flaw and was rated moderate. It had been sitting in the scanner’s list of sixty for the entire year.
The reason it mattered was a single line in the first-party code. When the application normalised incoming webhook payloads, it passed the parsed JSON body of the request straight into that merge utility to fold it onto a defaults object. The body was attacker-controlled and unauthenticated. That is the whole gadget: a user-supplied key of __proto__ in the webhook body reached a merge function that walked it without guarding the prototype chain.
We confirmed it by sending a crafted payload that set a property on Object.prototype and observing a downstream request handler read the polluted value back as if it were configuration. From there the impact was not theoretical — one of the properties we could reach governed whether a server-side authorisation check ran at all, and setting it flipped the default from deny to allow for the duration of the worker process.
Reachability is the whole game
Of the sixty advisories, this was one. We walked all sixty, and the table below is the shape of what we found: most were real advisories against code the application could not reach, or against development dependencies that never ship to production.
| Advisory | Scanner rating | Reachable from app? | Verdict |
|---|---|---|---|
| Prototype pollution in merge utility | Moderate | Yes — unauthenticated webhook body | Critical in context |
| ReDoS in a Markdown parser | High | Yes — only in the admin preview pane | Medium, authenticated and rate-limited |
| Command injection in a build tool | Critical | No — development dependency, not shipped | Not applicable to production |
| SSRF in an HTTP client option | High | No — the vulnerable option is never set | Informational |
| Deprecated crypto in a test helper | Moderate | No — test scope only | Not applicable |
The dependency nobody maintains
The merge utility had one more property that made it worse than the finding alone. Its last release was more than three years old, the repository was archived, and the maintainer had posted a note that they were no longer supporting it. There was no patched version to upgrade to, because there was no longer anyone to publish one.
This is the part of open-source risk that dependency scanning handles least well. A known advisory with a fix is a ticket. A known advisory in an abandoned package is a decision: replace the package, fork and patch it yourself, or vendor it in and own it. All three cost more than a version bump, which is exactly why abandoned transitive dependencies accumulate quietly until one of them turns out to be reachable.
What we asked the client to fix
Not only the one package, though that one we fixed first by replacing the merge call with a guarded alternative that refuses __proto__, constructor and prototype keys, which closed the reachable path immediately regardless of what the dependency did.
The durable recommendation was about the process that let a reachable finding hide in a list of sixty for a year. Gate the pipeline on reachability, not on count: an advisory on a code path the application actually exercises blocks the build, while an unreachable one files a tracked ticket and does not. Remove the fourteen unused packages. Pin the tree with lockfile integrity so a dependency cannot change underneath a passing build. And give the dependency budget an owner, because a surface nobody is accountable for is a surface nobody prunes.
The part that generalises
Your dependency tree is part of your attack surface, and your inventory of it is probably a lockfile nobody reads. The number of advisories in it is not the measure that matters; most of them describe code your application will never run, and treating them all as equal is how the one that it does run gets lost in the noise.
Before your next release, take one reachable dependency and trace it by hand: which of your own code paths call into it, with what input, and from whom. It is slow, and it is the thing the scanner cannot do for you, and it is where the finding that actually threatens you is going to be.
In short
- Point 1
- Presence is not reachability. A critical advisory on code you never call is not your vulnerability; a moderate one on your login path is.
- Point 2
- The dangerous dependency is usually transitive — chosen by nobody, watched by nobody, often abandoned upstream.
- Point 3
- Gate your pipeline on whether a finding is reachable, not on how many findings there are. A count of sixty hides the one that matters.
- Point 4
- Unused dependencies you still ship are attack surface you can remove for free. Prune the tree before you patch it.