Go Fixed It. esbuild Shipped It. drizzle-kit Pinned It Back.
A fixed vulnerability does not stay fixed if a dependency in the middle of the chain stays pinned to an old version. The forty-one CVEs from the last post were never esbuild being slow. They were a stale transitive pin reaching back in time to re-instantiate a binary the whole upstream world had already moved past, with no malice anywhere in the line. Where npm's defaults let fixed vulnerabilities slip back through, why security has to be shared across the whole chain, and an honest accounting of where the argument breaks.
In the last post I showed how a project named hexmetrics scanned clean against the lockfile scanners and lit up forty-one findings under grype, and I explained why both results were correct. Then I closed with a line I now have to walk back.
I said the vulnerability persisted because esbuild’s maintainers had not shipped a build with a newer Go yet.
That was wrong. esbuild had already shipped the fix. The reason it never reached hexmetrics is the actual subject of this post, and it is more interesting than a slow maintainer: a fixed vulnerability does not stay fixed if a dependency in the middle of the chain refuses to let you move forward.
What I got wrong
The forty-one findings were Go standard-library CVEs compiled into the esbuild binary. esbuild is written in Go and ships a prebuilt Go binary in its platform packages. grype reads the Go toolchain version embedded in that binary and matches it against Go stdlib advisories. So the “esbuild CVEs” were really the net/http, crypto/x509, archive-parser cluster baked into the compiler that built esbuild.
Here is the part I missed. esbuild had already rebuilt on a patched toolchain:
| esbuild | Go build | grype findings |
|---|---|---|
| 0.25.12 | go1.23.12 | forty-one |
| 0.28.0 | go1.26.1 | zero |
esbuild did its job. It picked up the patched Go and shipped a clean binary. hexmetrics was still sitting on 0.25.12 for a reason that has nothing to do with esbuild’s release cadence and everything to do with what sits above it in the tree.
The chain that pins the bug in place
drizzle-kit → @esbuild-kit/esm-loader → @esbuild-kit/core-utils → esbuild ^0.25.x
Two things about that chain matter.
First, @esbuild-kit/* is deprecated. Its author folded the work into tsx and told everyone to migrate. It is a dead package still riding along in the closure.
Second, the version range is a caret on a 0.x version. A caret on a 0.x release locks the minor, not just the patch. ^0.25.x can float up to 0.25.99 and never to 0.26.0. So this chain can never reach esbuild 0.28 on its own. It is structurally pinned to the line that ships the vulnerable binary.
I checked whether I could upgrade my way out. No published drizzle-kit depends on esbuild at or above 0.26. Not the latest stable, not the 1.0 release candidate. They all pin ^0.25.x. There is no version of the direct dependency I can install that dislodges the old binary.
So the fix is a floor override, and it is permanent until upstream moves:
// pnpm-workspace.yaml / package.json
"pnpm": { "overrides": { "esbuild": ">=0.28.0" } }
Two traps on the way there, both of which cost me time:
- Target the binary-bearing package, not its wrapper. My first instinct was to bump
@esbuild-kit/core-utils. Bumping a wrapper does nothing to the nested binary. The vulnerable bytes are insideesbuilditself, so that is where the override has to land. - Clean-reinstall before you re-scan. After writing the override, the package manager left the stale 0.25.12 binaries on disk, both the hoisted copy and the nested ones. grype scans the filesystem, so it kept reporting the old findings until
rm -rf node_modules && pnpm installpruned them. Skip that step and you get a false “still vulnerable” and chase a ghost.
That override is exactly the kind of long-lived, load-bearing entry that override-audit-cli exists to keep honest. It is permanent today, but the day drizzle-kit moves its floor, this override goes from necessary to redundant, and nothing will tell you that happened unless something is watching.
How can a fixed vulnerability come back in your node_modules?
A fixed vulnerability, resurrected
This is not an unfixed CVE. It is a re-fixed-then-resurrected one, and it travels through three hands:
- Go patched the standard library and published the advisories.
- esbuild rebuilt on the patched toolchain and shipped a clean binary.
- drizzle-kit still pins
esbuild ^0.25.xthrough a dependency it has had no reason to revisit, and that range reaches back in time and re-instantiates the exact binary everyone already moved past.
Nobody in that chain did anything unreasonable. drizzle-kit pinned a working version of a tool that builds correctly, which is the normal, conservative thing to do. The problem is not a bad actor anywhere in the line. It is that the chain has no mechanism to notice when a once-current floor quietly goes stale, and npm gives off no signal that it happened.
That is what makes it arguably worse than a bug nobody ever fixed. The entire upstream world has marked this one resolved and stopped watching it. Go closed the advisories. esbuild moved on. The attention that surrounds a live, unpatched vulnerability has already dissipated. A live finding that nobody is tracking is the definition of “goes undetected,” and a single unrevisited version range is enough to resurrect it on every machine that installs the chain.
Security starts with everyone
Two of the three links here did their part, and the chain still shipped a resurrected vulnerability. That is the actual lesson, and it is not “find the link that failed.” It is that no single link can see the whole picture, so security only holds when every link is paying a little attention to its own footprint.
Walk the checkpoints. Go can patch, but Go cannot make every downstream binary rebuild. esbuild can ship a clean build, but esbuild cannot make a consumer’s version range float up to it. drizzle-kit can pick a version that builds, but a version that builds is not the same as a version that is current, and nothing in npm tells it the difference. The consumer can install the chain, but npm does not show them which binaries came along for the ride. The scanner can read the disk, but it cannot tell you which of the findings are actually reachable.
Every one of those is a real checkpoint, and every one of them is partial by nature. The vulnerability slipped through not because a checkpoint was negligent, but because npm’s defaults let a miss at any single checkpoint stay invisible to all the others. The closure is deep, disclosure is optional, and the version semantics quietly favor staying put. That is a structure where things slip through on their own, with no malice required anywhere in the line.
“Security is everyone’s job” usually reads like a poster on a breakroom wall. Here it is a literal description of the only arrangement that works. Universal attention is not redundant. It is load-bearing, because the seams between links are exactly where a re-fixed CVE goes to hide. The fix for that is not to lean harder on one maintainer. It is for each link to keep an eye on the one thing it can actually see clearly, which is its own dependencies.
You ship everything inside you
If you publish a package, you are publishing everything inside it. Its entire transitive closure, including compiled binaries, lands on every machine that installs you. That is easy to forget, because npm makes the contents of your own closure nearly invisible to you too. “I only required esbuild, what is inside it is someone else’s problem” feels reasonable right up until you remember that every consumer downstream is making the same assumption about you. The convenience of a dependency and the contents of a dependency arrive together, whether or not anyone looked at the second part.
The structural reality is that npm makes the closure deep and invisible. You pull thousands of transitive packages and their binaries with no default signal about what is actually on disk, and maintainers never have to disclose it. The trust is implicit and unverifiable. When people get uneasy about the npm model, this is usually what they are pointing at, and it is not any single CVE. It is that the accountability gap is the default state rather than a worst case. Nobody in the chain is required to look down, so by default nobody does.
But this one is build-time, so who cares?
This is the honest objection, and it is where the nuance lives, so it is worth answering directly instead of hand-waving past it.
This specific chain is build-time. esbuild and drizzle-kit run during the build, not in the deployed request path. The Go stdlib bugs need attacker-controlled input flowing through the vulnerable code path. During a build, esbuild processes your own source. So the live runtime exposure on the running site is close to nil. If you are looking at these forty-one findings on a deployed app and panicking, calm down. They are almost certainly not reachable.
But build-time is not a free pass. Often it is the worse target. The machine that runs your build holds deploy keys, signing secrets, and source. The supply-chain breaches everyone remembers, SolarWinds, Codecov, xz, live exactly there, at build and distribution time, not in the request handler.
And the common mistake makes the build-time-is-harmless framing concrete. Ship a Docker image without pruning devDependencies and that go1.23.12 binary rides straight into your production image. It is not in the request path, but it is now lighting up your image scanner with forty-one CVEs and enlarging your in-image attack surface for no reason at all.
Then generalize it, and the danger stops being hypothetical. Swap “esbuild, a build tool” for any runtime native dependency. A bundled libvips behind your image uploads. An embedded TLS library. A native parser sitting in the request path. The exact same maintainer behavior, a stale floor pinning an old binary in place, now puts a reachable, attacker-facing vulnerability live on a public site. The behavior is identical. Only the blast radius changed, and nobody in the chain chose the blast radius on purpose. This instance is safe because of where the dependency happens to sit, not because anyone checked. Move the same pattern into the request path and the same quiet inattention becomes load-bearing in a much worse way.
The honest counterpoint
A lot of the upstream non-fixing is correct, and a post that pretends otherwise is not worth reading. So here is the strongest version of the other side.
Reachability versus presence. Those forty-one are almost certainly unreachable in this use. A maintainer who ignores them is making a silent, correct reachability judgment, not being negligent. Part of the defect is on the scanner side: reporting presence instead of exploitability generates noise that maintainers rationally refuse to chase. “There is a vulnerable binary on disk” and “you are exploitable” are different claims, and most tools only know how to make the first one.
Why upstream legitimately does not backport. Open source has no backporting culture. Fixes go forward in latest, not sideways into old lines. Semver conservatism makes dependency bumps slow on purpose. Exact-pinning, like Next pinning its postcss, is deliberate, reproducible engineering, not laziness. And your green scan is simply not the maintainer’s KPI. They owe you a working build, not a clean grype report against your compliance framework.
But presence still has a cost, which is why “it is just noise” is not a universal out. In a FedRAMP or otherwise regulated system, RA-5 and SI-2 require you to remediate or formally document every finding, regardless of exploitability. You inherited that paperwork cost from a maintainer who did not look, and “it is unreachable” does not make the documentation requirement disappear. It just changes which box you check.
So the move is to retarget the critique. Not “drizzle-kit introduced forty-one CVEs,” which is true but misleading, because they are unreachable noise here. The accurate charge is: “drizzle-kit ships a deprecated, unmaintained dependency and a stale binary, and never had to tell you.” That version survives contact with a competent engineer. The reachability lens cuts both ways. It makes this instance low-risk, and it is exactly the mechanism by which genuinely dangerous exposures stay invisible, because the same “probably unreachable” shrug is applied whether or not anyone actually checked the reachability.
What to actually do
For consumers:
- Run binary-aware SCA. grype and Syft read the filesystem and the binaries on it.
npm auditreads names in a lockfile against an advisory database, and it is structurally blind to a CVE living inside a compiled blob in the tarball. The tool almost everyone runs cannot see this entire class. - When you remediate, target the binary-bearing package, not its wrapper, and clean-reinstall before you re-scan, because stale binaries linger and the scanner reads the disk.
- Triage by reachability. Separate “present but dead code” from “actually exploitable.” That judgment, which of the forty-one matter, is the thing upstream will not give you and most scanners cannot.
For maintainers, and that includes me:
This is not a call to chase every scanner finding. It is a smaller and more reasonable ask: treat dependency freshness and transitive footprint as part of product quality. Periodically revisit floors so a downstream caret can reach a current build instead of being held on an old one. Drop dependencies that have been deprecated with a migration path published, the way @esbuild-kit was. None of that is heroic, and none of it is a verdict on anyone who has not gotten to it yet. It is simply the one checkpoint a maintainer is uniquely positioned to cover, because you are the only one who can see your own direct dependencies clearly. The whole resurrection here traces back to a single floor that no longer needed to be where it was, and that nobody had a reason to look at. Looking is cheap. The gap persists only because, by default, nobody is asked to look at all.
The one-liner to end on:
A green
npm auditmeans “no one filed paperwork about your dependency names.” It does not mean “nothing dangerous is on your disk.” Those are very different claims, and the gap between them is where the interesting breaches live.
This is the third post in the thread that started when one project disagreed with itself about whether it was secure. The first was how three scanners can all be right. The second was why an Apply can succeed while the CVEs persist. This one is about the link in the chain that made the whole thing necessary, and the version range nobody looked at twice.
Appendix: reproduction notes
# Find the real binary (esbuild ships it in the platform package, not the JS shim)
find node_modules -name esbuild -type f -size +1M
# Read the Go toolchain it was built with
strings <binary> | grep -oE 'go1\.[0-9.]+'
# Count the CVEs grype attributes to that one binary
grype file:<binary>
- Vulnerable:
esbuild@0.25.12built ongo1.23.12, forty-one findings. - Clean:
esbuild@0.28.0built ongo1.26.1, zero findings. - Fix: a global
esbuildoverride to>=0.28.0, followed byrm -rf node_modules && pnpm install.
HexOps is open source. Source at github.com/Hexaxia-Labs/hexops. MIT license.
Aaron Lamb Co-Founder, Hexaxia Technologies