Your scanner and npm may not be reading the same package
A tar archive can hold two entries at one path, and the tools that read one don't agree about which wins. An adversarial review found our own tool gave three different answers to that question — and why the fix was to refuse the input rather than pick a side.
A tar archive can contain two entries with the same path. Nothing in the format forbids it, and the tools that read one do not agree about what it means.
tar and npm extract last-wins: the second entry overwrites the first, and what
lands on disk is the last one in the stream. A reader that walks the entries and
stops at the first match gets the other file. Both are defensible readings of a
format that simply does not say.
For most software this is a curiosity. For anything that reports on an artifact someone else will install, it is the whole ballgame — because the report describes one file and the installation runs the other.
The shape of it
Build a tarball containing, in order:
package/package.json ← ordinary. no scripts, no unusual dependencies
package/index.js
package/package.json ← same path again. an install script this time
A scanner that resolves the path first-wins reads entry one. It reports a package with no install hooks, ordinary dependencies, and — if it checks — an integrity digest that verifies correctly, because the digest covers the whole archive and the archive is exactly what was published.
npm install writes entry three over entry one and runs what is in it.
Every individual statement the scanner made is true of a file that exists in the package. None of them is true of the package that ran.
We found this in our own tool
Sentinel reads published npm artifacts and reports what they declare, without executing them. An adversarial review spent three days on it and found this. It was the only High.
The detail that made it worse than the general case, and the reason it is worth writing up rather than just fixing: our tool did not have one wrong answer, it had three.
| where | how it resolved a duplicate path |
|---|---|
| metadata extraction | first — .find() over the entry list |
| file inventory | both — mapped over every entry, no de-duplication |
| the diff between two reports | last — Map.set() in a loop, so the later write won |
So the report was internally inconsistent before anything downstream read it. The metadata described the benign file. The inventory listed both. And the diff, which is the part that decides whether a change gets flagged, silently collapsed them to the hostile one — meaning a genuine "an install script appeared" signal could degrade into a routine "some file contents changed."
Three answers to one question, inside a tool whose entire purpose is answering that question.
Why we did not simply match npm
The obvious fix is to resolve last-wins everywhere and agree with the installer. We rejected it.
Matching npm makes the report correct for npm, today, on the assumption that
every consumer of the artifact extracts the same way. tar, bsdtar, pnpm,
yarn, a language-specific reimplementation, a CI cache layer and a container
build step do not all guarantee that, and the ones that differ will not announce
it. The bug would come back the first time any of them disagreed, and it would come
back silently.
More importantly, it leaves two independent parts of our own code each deciding a question they should never have been deciding separately — which is precisely how we got three answers instead of one.
So the reader now refuses. An archive with two entries at one path is rejected before anything else looks at it:
Archive contains a duplicate entry path "package/package.json"; a package with two
entries at one path is ambiguous (extractors resolve it differently) and cannot be
analyzed as a single artifact.
Two properties made fail-closed the right call rather than a cop-out. A duplicate path has no legitimate use in an npm package — we re-ran a pinned corpus of 50 real published MCP servers and not one contains a duplicate, so refusing costs no coverage. And the ambiguity is the signal: a package that ships two files at one path is either malformed or deliberate, and neither is something to report a clean result about.
Refusing also fixes it once, for every consumer, instead of fixing it three times and hoping the fourth consumer remembers.
How to check your own
If you maintain something that reads archives and reports on them — a scanner, an SBOM generator, a license checker, a policy gate — the test takes ten minutes and needs no payload.
Build a tarball with two entries at one path, where both are harmless but
distinguishable: {"name":"x","version":"1.0.0"} and
{"name":"x","version":"9.9.9"}. Then ask your tool what version the package is.
- Reports
1.0.0— you are first-wins, and disagree with the installer. - Reports
9.9.9— you are last-wins, and agree with npm for now. - Reports both, or refuses — you already handle it.
Then ask the second question, which is the one that actually caught us: does every part of your tool answer the same way? Ours did not, and no test noticed, because each part was correct in isolation and nothing compared them.
What we did not establish
Two limits worth stating plainly, because a writeup that overstates its reach is the same failure in a different costume.
We did not verify that npm's registry preserves duplicate entries through
publish. The direct-tarball vector needs no registry at all — npm install ./package.tgz, a vendored artifact, an internal mirror, an air-gapped
transfer — and that vector is real. Whether the public registry re-serves such a
tarball unchanged is untested. We deliberately did not test it by publishing a
malformed package to npmjs.com; if you want that answer, a local registry is the
place to get it.
We did not exhaustively characterise the wider class. Duplicate paths are one instance of a general problem: pax extended headers can override an entry's size and path, GNU long-name entries carry the real path out-of-band, and any hand-written reader may resolve those differently than the reference implementation. We flagged that class and deliberately did not change our parser to chase it, because rewriting a parser to fix a hypothesis is how you introduce the bug you were avoiding. The right order is: build the fixture, demonstrate two extractors disagreeing, then change code.
This is a fixed bug in our own tool, so there is no disclosure window to observe on our account. Other tools plausibly share it — we have not tested any of them, and this piece does not name or evaluate one. The point above is what makes that useful rather than merely interesting: it tells you how to check your own without needing us to check it for you.
The general form
The class is parser differentials in supply-chain tooling, and it is not specific to tar or npm. Anywhere one program decides what an artifact contains and a different program acts on it, the two must agree — and formats that permit ambiguity guarantee that eventually they will not.
The defence that generalises is not "resolve it the way the installer does." It is resolve it once, or refuse. A producer that hands down a single settled answer cannot disagree with itself, and an input that admits two answers is an input worth declining.
Published on Virasai AI. Corrections and disagreement are welcome — the issue tracker is the fastest route, and a reproduction beats a description.