Two licence stories made the rounds this week. Bitwarden announced a dual-licence model for its published apps, and Cloudflare completed its acquisition of Deno. Both produced the usual cycle: a long thread, a fork proposal, a dozen posts explaining what BUSL means. That cycle only ever fires when a vendor announces something.
We wanted to know what happens when nobody announces anything. So we took the dependency closure of a fairly ordinary stack, 492 packages reached from twenty common seeds including Next, Express, Vite, Prisma, Jest and the Sentry SDK, pulled every published version record for all of them, and walked the declared licence string release by release. That is 35,319 stable releases of legal text, tracked over sixteen years.
The headline relicensings are not the problem. The ones that arrive in a patch bump are.
TL;DR
- Across 492 packages and 35,319 stable releases, 78 licence changes landed in a release a caret range would install for you, 45 in a patch bump and 33 in a minor. 65 distinct packages have done it at least once.
- It happened two days ago.
fb-watchmansat on Apache-2.0 for nine years, then shipped 2.0.3 on 9 October 2026 under MIT. We ran the upgrade. npm’s complete output was “changed 2 packages in 611ms”. - A plain
npm install @sentry/nodepulls 24 packages. One of them,sentry@0.45.0, is published under the Functional Source License, which is not open source and not a valid SPDX identifier. It arrives two hops down, in production dependencies. - Both of npm’s own SBOM formats get that package wrong, in opposite directions. CycloneDX silently drops its licence block entirely. SPDX emits an identifier that is not in the SPDX list. The only package in the tree that is not open source is the only one the SBOM cannot describe.
- Nothing in the standard toolchain flags any of this.
npm auditreported “found 0 vulnerabilities” every time, because licences are not vulnerabilities and no one ever claimed they were.
How we measured it
The method is deliberately boring and anyone can repeat it. Start from twenty seed packages, resolve the dependencies of each latest version to a depth of four, and collect the union. That gives 492 package names: not a curated list of famous projects, just what a few normal installs drag in behind them.
For each of those, pull the full registry document, which carries every version ever published along with its license field and its publish timestamp. Group the stable releases by major line, sort by semantic version inside each line, and look for the point where the string changes.
That grouping matters. If you simply sort every version by publish date you get nonsense, because maintainers back-publish old majors: socket.io appears to flip from MIT to nothing in 2017 purely because a 0.9.x patch went out after a 2.0.x release. We also respected caret semantics properly. A ^1.2.3 range spans minors and patches, but a ^0.9.0 range only spans patches, so minor-level changes inside a 0.x line were excluded. That discipline cost us 15 of the 93 in-line changes we found. The remaining 78 are genuinely reachable by a range already sitting in somebody’s package.json.
Finding one: 78 silent changes, and the tooling is silent too
Most of the 78 are harmless in direction. The great majority are permissive-to-permissive tidying: BSD to BSD-2-Clause, MIT/X11 to MIT, Apache License 2.0 spelled out to the proper Apache-2.0 identifier. Twenty-four packages in the Jest family all moved from BSD-3-Clause to MIT on the same day in 2017, in a minor release, when Facebook relicensed.
Direction is not the point. Mechanism is. We proved the mechanism rather than asserting it, by installing the package and running the upgrade.
Take fb-watchman, a dependency of Jest. Version 2.0.0 shipped in January 2017 under Apache-2.0. So did 2.0.1 in 2019 and 2.0.2 in 2022. On 9 October 2026, two days before this post, 2.0.3 and then 2.0.4 arrived under MIT. We installed fb-watchman@2.0.2, which wrote the range ^2.0.2 into package.json, confirmed Apache-2.0 on disk, then ran npm update fb-watchman:
$ npm update fb-watchman
changed 2 packages in 611ms
$ cat node_modules/fb-watchman/package.json | grep license
"license": "MIT"
The legal document governing a package in production changed, and the tool that changed it told us how many milliseconds it took. Apache-2.0 to MIT is a loosening, so nobody is harmed here. But an identical command with an identical output is what a tightening would look like too.
Finding two: the Blue Oak wave is in progress right now
The clearest live example is a maintainer migration that is still running. Five packages in our closure now declare BlueOak-1.0.0, and every one of them switched inside an existing major line:
minimatch10.0.3 to 10.1.0, a minor, 28 October 2025glob11.0.3 to 11.1.0, a minor, 17 November 2025isexe3.1.1 to 3.1.2, a patch, 5 February 2026minipass7.1.2 to 7.1.3, a patch, 19 February 2026path-scurry, now on Blue Oak at its latest release
The Blue Oak Model License 1.0.0 is OSI-approved, so this is a sane and arguably improved choice by the maintainer. It is also the exact event that breaks a naive compliance pipeline. Plenty of teams run a licence allowlist in CI that permits MIT, ISC, Apache-2.0 and the BSD variants, written years ago and never revisited. Those four packages are in almost every Node install on earth. A patch-level dependency refresh now introduces a string that allowlist has never seen, and the build either fails for a reason nobody can explain or, far more commonly, there is no allowlist at all and the string lands unnoticed.
Finding three: one package in the tree is not open source
Of the 492 packages, 487 declare one of the five ordinary permissive licences at their latest version: 363 MIT, 75 Apache-2.0, 25 ISC, 12 BSD-3-Clause, 7 BSD-2-Clause. The remaining five are where it gets interesting. One is caniuse-lite, under CC-BY-4.0, a Creative Commons licence that is not OSI-approved and that reaches virtually every frontend build in the world through Browserslist. One is tslib under 0BSD, one is lightningcss under the weak copyleft MPL-2.0, one is nodemailer, which moved from MIT to MIT-0 in a patch release in 2023.
And one is not open source at all.
$ npm install @sentry/node
added 24 packages in 7s
$ npm ls sentry
└─┬ @sentry/node@11.6.0
└─┬ @sentry/bundler-plugins@11.6.0
└── sentry@0.45.0
$ grep '"license"' node_modules/sentry/package.json
"license": "FSL-1.1-Apache-2.0"
The sentry package is the Sentry CLI, and @sentry/bundler-plugins declares it as a runtime dependency, which @sentry/node in turn declares as a runtime dependency. It is not a devDependency. It installs in production. Its LICENSE.md opens: “A Permitted Purpose is any purpose other than a Competing Use.” The Functional Source License converts to Apache-2.0 after two years, which is a genuinely thoughtful design, and for the overwhelming majority of teams a non-compete restriction on an error-reporting CLI is a complete non-issue.
It is an issue if you are building something Sentry might reasonably consider competing, or if you are an agency shipping that tree into a client’s product and you have signed a contract warranting that all third-party components are OSI-approved. We have seen that clause in procurement paperwork more than once. The point is not that Sentry has done anything wrong. The point is that you would not know.
Finding four: your SBOM cannot describe the one package that matters
This is the finding we did not expect. npm 10 ships SBOM generation built in. We ran both supported formats against that same 24-package tree, same machine, same lockfile, one minute apart.
The SPDX output records "licenseDeclared": "FSL-1.1-Apache-2.0". That is the honest string, but it is not a valid SPDX licence identifier. We checked it against the official SPDX licence list, version 3.29.0, all 740 entries. FSL is not among them. So the SPDX document is technically invalid at precisely that field, and a downstream consumer validating identifiers will reject or discard it.
The CycloneDX output is worse. It drops the licence information entirely:
components: 24
components with NO licence recorded: 1
- sentry@0.45.0
Twenty-three of twenty-four components carry a licence. The single one that does not is the single one that is not open source. We confirmed the mechanism with a synthetic package declaring "license": "BUSL-1.1-ish": CycloneDX emitted no licences block, SPDX passed the raw string straight through. Any licence string that cannot be mapped to an SPDX identifier is silently omitted from one format and silently invalidated in the other, and non-SPDX strings are overwhelmingly the source-available ones. The filter removes exactly the signal.
If your compliance process is “we generate an SBOM at build time and archive it”, you now have an artefact that formally attests your dependency tree while being blind to the only entry in it a lawyer would care about.
Finding five: aliases make the name and the identifier disagree
One more, found by accident when our own fetcher threw two 404s. pretty-format@30.5.1, a Jest dependency, declares "@jest/react-is-18": "npm:react-is@^18.3.1". That is an alias. The name on the left does not exist in the public registry.
The CycloneDX SBOM records that component with name: "@jest/react-is-18" and purl: "pkg:npm/react-is@18.3.1". Those two fields name different packages in the same record. A scanner keyed on the component name queries a package that 404s. A scanner keyed on the purl gets the right answer. Both behaviours are defensible, which is why you need to know which one your tool does.
What we would actually do about it
None of this warrants panic. It warrants about half a day of work, once.
- Put a licence allowlist in CI, and make it fail the build. Not a report nobody reads. If a new string appears, a human reads it before the merge. This is the single control that would have caught every one of the 78 changes.
- Do not trust a CycloneDX SBOM’s silence. An empty licence field means “npm could not map this string”, which is a stronger signal than any string it did map. Treat missing as an alert, not a blank.
- Audit the transitive tree, not the direct dependencies. The FSL package was two hops down, and nothing at the top of that tree hints at it.
- Review on
npm update, not just on major upgrades. Every one of the 78 changes we found was reachable without a major version bump. - Check your procurement warranties against reality. If you have told a client that every component is OSI-approved, go and verify it today rather than on the day they ask.
Licence drift is not a security problem, which is exactly why it has no owner. It never shows up in npm audit, it never files a CVE, and it never triggers a Dependabot alert. It surfaces during due diligence, or in a procurement questionnaire, or in the one meeting where someone finally reads the SBOM. By then the commit that introduced it is eighteen months old.
At REPTILEHAUS we do dependency and supply chain audits as a standard part of our engagements, including licence allowlisting in CI and SBOM generation that actually tells you something. If you are shipping a product into an enterprise procurement process and you are not certain what is in the tree, that is a conversation worth having early. Get in touch.
All figures in this post were measured on 11 October 2026 against the live npm registry and reproduced locally with npm 10.9.4 on Node 22.22.0. The population of 492 packages is the depth-four dependency closure of twenty seed packages.
📷 Photo by Markus Spiske on Unsplash


