Vivian Voss

Your npm Tree Is Inside the CRA

npm supply chain cra dependencies

Seventeen lines. That is what an experienced developer puts into package.json for an ordinary commercial web application in 2026: a framework, a rendering library, a type checker, a linter, something for dates, something for forms, something for charts. Every one of those lines is a decision that could be defended in a review, and most of them save a fortnight of work that would otherwise be done badly.

I typed those seventeen lines into a jail on my own machine last night and went to make tea whilst the install ran.

What came back was 383 distinct packages across 33,715 files and 168 megabytes. The lock file that describes them runs to 463 entries. Three hundred and sixty-six of those packages arrived without anybody asking, because the seventeen bring their own furniture, and the furniture brings its own.

Seventeen lines, and what came with them 17 of 383 packages 17 chosen, one line each in package.json every one a defensible decision 366 arrived with them chosen by nobody in particular 285 accounts can publish into the whole counted against the registry in 5.45 seconds

None of that is news. Anybody who has watched the counter climb during an install made their peace with it years ago. What changed is that on Friday somebody attached a stopwatch to it.

What happened on Friday

The reporting obligations of the EU Cyber Resilience Act came into force on 11 September 2026. A manufacturer who learns that a vulnerability in their product is being actively exploited files an early warning within 24 hours. The fuller notification follows within 72, and the final report within a fortnight of the fix.

Two details decide who this catches, and the assumption in the room is usually wrong about both.

It reaches whoever places a product on the Union market, wherever that company happens to sit. A firm in California with one customer in Dublin is inside the scope and needs somebody established in the Union to carry the obligations. And it leaves the volunteer alone: non-commercial free software is excluded outright, whilst the new category of open-source steward carries lighter duties and no administrative fines at all.

There is a way out, and it deserves naming because most people assume they are already standing on it. The regulation follows the market, so a service that is genuinely not offered in the Union is not caught. That takes a decision, and it has to be a real one: a geographic block, terms that exclude Union customers, a payment route that refuses their cards, a storefront that sends them elsewhere. A service that takes a card from Dublin without asking where it came from is inside, whether anybody in the company has read a line of the regulation or not. Nobody has to opt in. Staying inside is what happens when nobody decides, and of the two options it is the only one with an invoice attached.

The second deadline is where this stops being a compliance matter and becomes a question about how software is built. The obligation to know what is in the product, the machine-readable bill of materials, does not bind until 11 December 2027. The duty to report therefore runs fifteen months ahead of the duty to be able to answer.

The question the lock file cannot answer

Suppose the call comes. Something in that tree is being exploited in the wild, and there are 24 hours.

The first question is which version shipped, and the lock file handles it beautifully. Every entry carries a resolved URL and an integrity hash. That is a solved problem and has been for years.

The second question is who maintains it, and here the file simply has nothing to say. The fields it carries per entry are version, resolved, integrity, dev, license, dependencies, engines and funding. I checked. There is no maintainer field in the format, and there never was, because knowing who may change a package was not what anybody built the file for.

So I asked the registry instead. I walked the tree for package names, all 383 of them, and queried each one.

285 distinct npm accounts hold publish rights on that tree.

Two hundred and eighty-five people the developer has never met, each able to publish a version that lands in the product on the next install. Every one of them may well be excellent at the job; some of them certainly are. The number describes an arrangement rather than an accusation, and the arrangement is what the seventeen lines bought.

The run took 5.45 seconds, which sounds perfectly acceptable until you notice what it rests on: a third-party service that has to be reachable, has to be honest, has to still hold the record for a package that may have been transferred or unpublished since, and has to answer several hundred questions in a row at three in the morning. Nobody wrote that service into the architecture diagram. It is there anyway, and now it is on the critical path of a regulatory deadline.

The call at three in the morning An exploit in the tree is reported Which version shipped? package-lock.json: URL and hash local, instant, answered Who maintains it? no maintainer field in the format 383 requests across the network Early warning due within 24 hours

The same application, built three ways

This is the part I actually wanted to know, so I built the thing three times in the same jail and counted.

The first build is the one above: the framework, plus the six libraries that turn up in nearly every commercial codebase I have been shown in the last three years. HTTP client, date handling, schema validation, forms, server state, charts.

The second build drops all six and keeps the framework.

The third swaps the framework itself for a lighter one.

                     direct   packages   accounts    bytes    files
as usually built         17        383        285   168 MB   33,715
framework only            6         28         36    96 MB   10,864
lighter framework         4         20         26    27 MB      877

Look at the first two rows for a moment, because they behave in a way I did not expect.

Dropping those six libraries saves 72 megabytes, which is pleasant and hardly dramatic. It removes 249 accounts, and that is an entirely different order of thing. Six lines of package.json, six convenient decisions made on six ordinary Tuesdays, and behind them sit seven eighths of everyone with a key to the building.

Same application, three builds: disk against people as usually built 168 MB 285 accounts framework only 96 MB 36 accounts lighter framework 27 MB 26 accounts megabytes on disk accounts with publish rights Dropping six libraries removes 43 per cent of the disk and 87 per cent of the accounts.

Bytes and parties do not move together. That is the whole finding, and I would not have believed it without the table.

The inconvenient credit

Which brings me to something I did not enjoy measuring.

Next.js comes out of this rather well on the number under discussion. Ninety-six megabytes is a great deal of disk for a starting point, and it holds 28 packages with 36 accounts behind them, because the project compiles its dependencies in rather than shipping them loose. Whatever else that decision costs, it means a shorter list of people who can change what you ship.

This sits awkwardly beside what I have written about that framework before, so let me be precise about what has changed and what has not. The objections in the archive were about weight in the browser, about build times, about the quiet assumption that you will deploy on one particular platform, and about the work involved in declining to. Every one of those still stands, and the last one gains a new edge in this context: a hosting dependency is another party in the chain you have to speak for. What I am crediting here is narrower than that and quite specific. On the question of how many strangers hold keys, a framework that compiles its dependencies beats a framework that scatters them.

There is a larger thing behind that, and measuring for this piece is what turned it up. The figure that has circulated for a project of this kind, and that I have quoted myself, was 1,400 packages. Last night it came to 383.

That fall was deliberate work. Vite replaced its bundler with a Rust implementation and the count came down with it. Next.js compiles its dependencies in rather than scattering them loose across the tree. Both decisions were taken by people who could see the problem at least as clearly as anybody complaining about it from the outside, and both cost years of unglamorous effort that nobody writes conference talks about.

The ecosystem is trying to climb out of its own hole, and it is getting somewhere. Anybody still telling the four-thousand-package horror story is quoting 2021 at a room that has moved on.

What to do with this on a Tuesday

None of the above is an argument for writing everything yourself. That argument exists and has been made here before. It loses in most rooms for the same reason it always has: a date library that gets time zones right represents years of somebody's careful work, and a team that reimplements it will be slower and probably wrong, whatever it chooses to call that.

What is new is the unit of measurement.

Until Friday, the case against a dependency was speed or cost or elegance, and all three can be waved away by anybody with a deadline. The question now is how many parties you can name inside a day, and that has an answer which does not care about anybody's preferences.

So there are two things worth carrying into the next review, and the first is a question rather than a rule.

Is this product on the Union market, and did anybody actually decide that?

If the answer to the first is yes and to the second is no, then the 24-hour question above is already yours, and it has been since Friday. Most teams I have asked this week could not say which of the two answers applied to them, which is itself the answer.

The second thing is small enough to say out loud. Before adding a dependency, count the accounts it brings as well as the megabytes.

There is no tidy command for it, which is itself worth noticing. The tooling will tell you the size, the licence, the install time and the vulnerability count, because those are the questions somebody built it to answer. For the count of people I had to write sixteen lines of script against the registry, and it is in the notes below for anyone who wants it. Run it before the decision rather than during the incident. A library bringing four accounts and a library bringing sixty are two quite different purchases, and the invoice is now written in hours.

The tree that already knew

The jail those measurements ran in is itself a machine that answers to somebody, so I put the same question to it.

With npm and nginx installed on top of the base system it held 29 packages, maintained by 15 distinct addresses, twelve of the packages sharing one of them. Almost every address ends in @FreeBSD.org, which puts something behind the name that can be written to.

Asking took no measurable time:

# pkg query "%n %v %m" nginx
nginx 1.30.4,3 joneum@FreeBSD.org
        0.00s real

A local database, no network, no third party, and the same answer at three in the morning as at noon. Nobody produced that bill of materials for an auditor. It fell out of the way the packages were installed, and it was there before anybody asked.

The counts are not comparable and I will not pretend they are; a web application framework does more than a web server. What compares is the second question, and one of these two trees answers it without a network connection.

Who profits, who pays

The profit here is real and widely spread. Developers ship in a fortnight what used to take a quarter. The registry sits at the centre of an ecosystem it made possible. An entire audit industry sells maps of a territory that would need no map if it were smaller.

The bill arrives elsewhere, as it does every Wednesday in this series. It arrives at the manufacturer, who inherited 366 decisions nobody made deliberately and must now speak for all of them within a day. It arrives at whoever happens to be on call. And it arrives on a schedule set by somebody else's exploit, which is the one kind of deadline you cannot plan around.

There is a third party who pays and is almost never counted. Those 285 accounts now sit inside a supply chain with a regulatory clock attached, and most of them belong to people who published something useful in their own time and never agreed to be part of anybody's compliance posture.

The limit

Three deductions, and they are not small.

The comparison across the three builds is not perfectly fair. The lighter framework does less, and some of what it does not do would have to be written by hand, at which point the accounts come down and the defects go up. Server state caching is the honest example: dropping the library for it means building request deduplication yourself, and doing that badly is worse than importing it.

The regulation is a sensible answer to a real problem, and the timings were derived from incidents in which months passed whilst a known exploit ran. Anybody calling this bureaucracy did not spend the December of Log4Shell on a bridge call.

Whether a particular service counts as a product in the regulation's sense is a question for somebody with a law degree, and this is a Wednesday column about dependency trees. What it can say with confidence is which side of the line the default puts you on, and that nobody moves off it by accident.

And the curated tree has its own exposure. Fifteen maintainers are a smaller attack surface and also a smaller pool of eyes, and a chain of trust is a single point of failure viewed from the other end. I would still take the chain, and I would not dress it up as armour.

The point

What the regulation asks is an old question wearing a new hat: what is in this, and who put it there.

It happens to be the question this series has asked every Wednesday since it started, which is either a pleasing coincidence or a sign that the question was always the right one. For fifteen months it now has a legal answer date. For a great deal of the software written this week the honest response is that nobody can say. The record was never lost; being able to answer was simply never among the things the format was built to do.

Seventeen lines went in. Two hundred and eighty-five people came with them, and the file that describes the result has a field for the checksum and none for the person.

The script, for anyone who wants the count on their own project. Sixteen lines of Node, run from the project directory after an install. Checked on a second machine against the lighter build: 20 packages and 26 accounts, the same as in the jail.

const fs = require("fs"), names = new Set(), people = new Set();
const walk = d => fs.existsSync(d) && fs.readdirSync(d).forEach(e => {
  const p = `${d}/${e}`;
  if (e.startsWith("@")) return walk(p);
  try { names.add(JSON.parse(fs.readFileSync(`${p}/package.json`)).name); } catch {}
  walk(`${p}/node_modules`);
});
walk("node_modules");
(async () => {
  const list = [...names];
  for (let i = 0; i < list.length; i += 20) await Promise.all(list.slice(i, i + 20).map(async n => {
    const j = await (await fetch(`https://registry.npmjs.org/${n.replace("/", "%2f")}`)).json();
    (j.versions?.[j["dist-tags"]?.latest]?.maintainers ?? []).forEach(m => people.add(m.name));
  }));
  console.log(`${names.size} packages, ${people.size} accounts`);
})();