Back to glossary

Manifest Confusion

Manifest confusion is a mismatch between an npm package's registry manifest and the package.json in its tarball, two copies that tools trust inconsistently.

Also known as npm manifest confusion, manifest confusion attack, manifest-tarball mismatch, packument mismatch

Manifest confusion is a disagreement between the two copies of an npm package's metadata. npm publish sends the registry one request with two payloads: a JSON manifest describing the version, and the tarball, which carries its own package.json. The registry stores them separately and serves them to different consumers. The only consistency check GitHub has announced compares their name and version. Every other field, including dependencies and scripts, is the publisher's word, given twice.

That splits a simple question, what will this package do when I install it, into several. The npm website, dependency resolvers, install-script runners, Node.js, and security scanners each read one copy or the other, and they do not all read the same one. In our tests on October 15, 2026, the same package version ran one install script under npm install and a different one under npm ci.

One publish, two documents

#

We pointed npm 12.2.0 at a local registry and captured what npm publish sends. It is a single PUT /<name> whose body, trimmed, looks like this:

JSON
{
  "_id": "mc-publish-demo",
  "name": "mc-publish-demo",
  "dist-tags": { "latest": "1.0.1" },
  "versions": {
    "1.0.1": {
      "name": "mc-publish-demo",
      "version": "1.0.1",
      "main": "index.js",
      "scripts": { "test": "node -e 0", "install": "node-gyp rebuild" },
      "gypfile": true,
      "_npmVersion": "12.2.0",
      "dist": {
        "integrity": "sha512-spxdxPhcwmwgkpsc5r1VS1zhdCsmPzSgsgNI9rC1WHiWtAUyniqJw8bAefzxojvUq+eS4UkFay+AGsEeUpmMXg==",
        "shasum": "5e1e7cc49a2a782ab2f0a3ae4b27b2c5466825c1",
        "tarball": "http://127.0.0.1:4873/mc-publish-demo/-/mc-publish-demo-1.0.1.tgz"
      }
    }
  },
  "_attachments": {
    "mc-publish-demo-1.0.1.tgz": {
      "content_type": "application/octet-stream",
      "data": "H4sIAAAAAAAC/+2Xv2...",
      "length": 393
    }
  }
}

versions["1.0.1"] is the manifest: npm's normalized rendering of package.json, plus bookkeeping. _attachments is the tarball, base64-encoded, with a second package.json inside it. The npm registry serves the manifest back inside the packument, the per-package document at GET /<name>, and serves the tarball from dist.tarball. The one cryptographic link between them is dist.integrity, a SHA-512 of the tarball's bytes that the manifest carries. No integrity mechanism covers the manifest itself.

The registry also serves an abbreviated packument to installers that ask for it with Accept: application/vnd.npm.install-v1+json. It drops scripts entirely and adds a boolean, hasInstallScript, that the registry computes from the manifest. For esbuild 0.20.0, the full document carries "postinstall": "node install.js" and the abbreviated one carries only "hasInstallScript": true. Either way, every field an installer reads before it downloads the tarball came from the publisher's manifest.

Publisher
npm publish
Packs the files into a tarball and writes a normalized copy of package.json as a separate manifest.
PUT /<name> · one request, two payloads
versions[v]_attachments
Registry
Manifest, kept in the packument
dependencies, scripts, bin, engines, os, cpu, stored as the publisher sent them.
Derived from it: the abbreviated document installers fetch, including hasInstallScript.
compared only on name and version, since Sep 2023
Tarball, served at dist.tarball
The packed files, including a second package/package.json.
Derived from it: dist.integrity (SHA-512) and the registry signature over name@version:integrity.

One npm publish request carries a manifest and a tarball. The registry stores them apart, signs the name, version, and tarball hash, and compares the two documents only on name and version.

The registry trusts the client

#

From the start, the registry trusted clients to keep the two documents consistent. Darcy Clarke, who managed the npm CLI team at GitHub until December 2022, made this public on June 27, 2023 and gave it its name. By his account, GitHub knew of the issue by early November 2022 and closed his HackerOne report on March 21, 2023, saying it was being handled internally. He classified it, at minimum, as CWE-602: client-side enforcement of server-side security.

His proof of concept is still published. Ask the registry about it:

Bash
$ npm view darcyclarke-manifest-pkg@2.1.15 name version scripts dependencies
name = 'darcyclarke-manifest-pkg'
version = '2.1.15'
scripts = {}
dependencies = {}

Then ask the tarball:

Bash
$ curl -s https://registry.npmjs.org/darcyclarke-manifest-pkg/-/darcyclarke-manifest-pkg-2.1.15.tgz \
    | tar -xzO package/package.json
{
  "name": "express",
  "version": "3.0.0",
  "main": "index.js",
  "scripts": {
    "install": "touch ./bad-pkg-write && echo \"bad pkg exec!\"\n"
  },
  "license": "ISC",
  "dependencies": {
    "sleepover": "*"
  }
}

The manifest describes a package with no install script and no dependencies. The package.json inside it claims to be express@3.0.0, depends on sleepover, and runs a shell command at install time. The registry's signature on this version still verifies:

JavaScript
// node verify.mjs darcyclarke-manifest-pkg 2.1.15
import crypto from 'node:crypto'
const [name, version] = process.argv.slice(2)
const m = await (await fetch(`https://registry.npmjs.org/${name}/${version}`)).json()
const { keys } = await (await fetch('https://registry.npmjs.org/-/npm/v1/keys')).json()
const msg = `${m.name}@${m.version}:${m.dist.integrity}`
for (const { keyid, sig } of m.dist.signatures) {
  const { key } = keys.find((k) => k.keyid === keyid)
  const pub = crypto.createPublicKey({ key: Buffer.from(key, 'base64'), format: 'der', type: 'spki' })
  console.log(msg, crypto.verify('sha256', Buffer.from(msg), pub, Buffer.from(sig, 'base64')))
}
// darcyclarke-manifest-pkg@2.1.15:sha512-+4iSJV5s...5S9A== true

It verifies because the signature covers exactly what npm says it covers, and no more. We come back to that below.

On September 27, 2023, GitHub began rejecting publishes whose manifest name or version differs from the tarball's package.json, and shipped npm pkg fix to help publishers comply. That closes the identity half of Clarke's demo for new versions. We found no public announcement extending the check to any other field, and versions published before it, like this one, still resolve as their manifests say.

The weakness lives in the API, not in one server. Any registry that implements npm's publish endpoint inherits it unless it adds the comparison itself. GitLab's npm registry accepted uploads with conflicting manifest and tarball data until fixes shipped in July 2024 (CVE-2024-6595).

The npm CLI publishes mismatches itself

#

A registry that rejected every disagreement outright would reject the official client. Here is the package.json we packed for the capture above, field by field, against the manifest npm 12.2.0 sent:

FieldIn the tarball's package.jsonIn the manifest npm sent
scripts{"test": "node -e 0"}adds "install": "node-gyp rebuild"
gypfileabsenttrue
bin"./cli.js"{"mc-publish-demo": "cli.js"}
repository"github:example/mc-publish-demo"{"type": "git", "url": "git+https://github.com/example/mc-publish-demo.git"}
bugs, homepageabsentderived from repository

The install row is the one that matters. The package ships a binding.gyp, so npm 10.9.4 and 12.2.0 both inject node-gyp rebuild into the manifest and leave the tarball's package.json untouched. Clarke flagged this behavior in 2023, and it still ships. A package these versions publish with a binding.gyp and no install script of its own gets a manifest that disagrees with its tarball about whether it has an install script at all.

Some disagreement is the norm. A package with a GitHub repository field gets bugs and homepage added to its manifest and not to its tarball. Security-relevant disagreement is rarer: when JFrog's researchers scanned the registry in March 2024, they found more than 800 packages whose manifest and tarball disagreed and judged 18 of them deliberate, mostly copies of Clarke's proof of concept. Two things follow. A mismatch on its own is weak evidence of malice. And the obvious server-side fix, rejecting any difference, would have broken the reference client on day one.

Each consumer trusts a different copy

#

Every tool that touches a package has to pick one copy for each field it uses. To see which, we installed Clarke's package with eight client versions, then repeated the tests against a local registry that serves a manifest and a tarball disagreeing on scripts, dependencies, and main as two separate documents, the way registry.npmjs.org serves them.

ManifestGET /darcyclarke-manifest-pkg/2.1.15
{ "name": "darcyclarke-manifest-pkg", "version": "2.1.15", "scripts": {}, "dependencies": {}, "dist": { "integrity": "sha512-+4iS…5S9A==", "signatures": [ … ] // signature verifies }}
Read by
npmjs.com, npm viewdependency resolution, every client testednpm’s script runner, when resolving from the registrynpm’s hasInstallScript gatescanners that read the packument
Tarballpackage/package.json in the .tgz
{ "name": "express", "version": "3.0.0", "main": "index.js", "scripts": { "install": "touch ./bad-pkg-write && echo \"bad pkg exec!\"\n" }, "license": "ISC", "dependencies": { "sleepover": "*" }}
Read by
Node.js require and importnpm’s script runner, when installing from a lockfilepnpm, Bun, Yarn script runnersnpm query, npm sbomYarn 1 lockfile

The two documents for darcyclarke-manifest-pkg@2.1.15, with the fields that disagree highlighted and the consumers that read each copy in our tests.

Client, tested Oct 15, 2026Dependencies installed fromInstall script that runsScripts by default
npm 10.9.4, 11.21.0ManifestManifest's when resolving from the registry, tarball's when installing from the lockfileRun
npm 12.2.0ManifestSame split as npm 10 and 11Blocked until approved
pnpm 10.34.6, 12.11.2ManifestTarball'sBlocked until approved
Bun 1.4.2ManifestTarball'sBlocked until trusted
Yarn 4.18.1ManifestTarball'sOff
Yarn 1.22.22Manifest, then the tarball's on a clean checkoutTarball'sRun

Resolution is unanimous: every client resolves the dependencies the manifest lists. A dependency that exists only in the tarball's package.json is ignored by resolvers and visible to anything that reads the extracted files, which is why Clarke's sleepover shows up in npm query output without being installed. Yarn 1 adds a twist: it resolves from the manifest, then writes the tarball's dependencies into yarn.lock and installs those on the next yarn install --frozen-lockfile. With Clarke's package it also saved the tarball's version, ^3.0.0, into our package.json, which then failed to resolve.

Runtime is unanimous the other way. Node.js has never heard of the registry. It reads main and exports from the package.json on disk, which came from the tarball. When our manifest said "main": "index.js" and the tarball said "main": "other.js", require() loaded other.js no matter which client installed it. Any analysis that derives a package's entry points, and therefore reachability, from registry metadata is analyzing a program that never runs.

npm does not agree with itself. After installing Clarke's package with npm 12.2.0:

Plain text
$ npm ls
app@1.0.0
`-- darcyclarke-manifest-pkg@2.1.15

$ npm query '#darcyclarke-manifest-pkg' | jq -c '.[] | {name, version}'
{"name":"express","version":"3.0.0"}

$ npm sbom --sbom-format spdx
npm error code ESBOMPROBLEMS
npm error invalid: darcyclarke-manifest-pkg@npm:express@3.0.0, ^2.1.15 required by app@1.0.0
npm error missing: sleepover@*, required by darcyclarke-manifest-pkg@npm:express@3.0.0

npm ls answered from npm's hidden lockfile, node_modules/.package-lock.json, which recorded the manifest. When that cache is missing or stale, it reads disk instead and reports the package as invalid. npm query and npm sbom read the package.json on disk. To its credit, npm sbom refuses to emit a document it knows is inconsistent.

npm install and npm ci run different scripts

#

npm decides which install scripts to run in arborist's rebuild.js. It starts from node.package, the metadata arborist built the node from, and reads package.json from disk only when that metadata says a script exists without saying what it is:

JavaScript
const { package: pkg, hasInstallScript } = node.target
const { gypfile, bin, scripts = {} } = pkg
const { preinstall, install, postinstall, prepare } = scripts
const anyScript = preinstall || install || postinstall || prepare
if (!refreshed && !anyScript && (hasInstallScript || this.#oldMeta)) {
  // ...read package.json from disk and use its scripts

When npm resolves a package from the registry, by adding it or by installing without a lockfile, it fetches the full manifest with Accept: application/json. That manifest has scripts, so npm runs the manifest's command without reading the tarball's. When npm installs from package-lock.json, the lockfile records "hasInstallScript": true but not the command, so npm reads the tarball's package.json and runs that one. Here is our local registry package, pre-approved in allowScripts, under npm 12.2.0:

Plain text
$ npm install mc-scripts
$ ls node_modules/mc-scripts
MANIFEST_SCRIPT_RAN  index.js  package.json

$ rm -rf node_modules && npm ci
$ ls node_modules/mc-scripts
TARBALL_SCRIPT_RAN  index.js  package.json

The developer who adds a dependency runs one command. CI, and every teammate who installs from the lockfile afterward, runs another. npm 10 and 11 behave the same way, with scripts on by default.

Same package version, same lockfile entry, same approval: "mc-scripts@1.0.0": true
Resolving from the registry
npm install mc-scripts
manifest
Fetch the full packument: GET /mc-scripts, Accept: application/json
node.package is the manifest, so scripts.install is already known. The tarball’s package.json is not consulted for scripts.
Runs the manifest’s install command
The developer who adds the package
Installing from the lockfile
npm ci
manifest, via lockfile
package-lock.json says "hasInstallScript": true but stores no command.
tarball
Read node_modules/mc-scripts/package.json from the extracted tarball.
Runs the tarball’s install command
CI, and every teammate after the first install

The same approved package version takes two code paths in npm. Resolving from the registry runs the manifest's install command; installing from the lockfile runs the tarball's.

npm 12's script review shows both halves without saying so. The warning printed at install time quotes the manifest's command. The review command it points to quotes the tarball's:

Plain text
$ npm install mc-scripts
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   mc-scripts@1.0.0 (install: node -e "require('fs').writeFileSync('MANIFEST_SCRIPT_RAN', '')")

$ npm install-scripts ls
1 package has install scripts blocked because they are not covered by allowScripts:
  mc-scripts@1.0.0 (install: node -e "require('fs').writeFileSync('TARBALL_SCRIPT_RAN', '')")

The approval npm writes, "mc-scripts@1.0.0": true, binds neither command. Approvals are keyed by package name and version, not by script content, and pnpm's allowBuilds and Bun's trustedDependencies are keyed the same way. If you approve scripts in npm 12, approve what npm install-scripts ls prints, because that is what npm ci will run.

Clarke's package takes a different branch. Its manifest declares no scripts, so hasInstallScript is absent and npm never runs, warns about, or lists the tarball's install command on any registry install path we tried. pnpm, Bun, and Yarn read scripts from the tarball, so they detect it and block it by default. Approving the package by name in pnpm or Bun runs it. Yarn 1 runs it without asking. Installing the same tarball by URL takes the tarball's word for everything: npm 10.9.4 installed it as node_modules/express, added sleepover, and ran the script. npm 12 refuses remote tarball URLs unless you allow them.

Running dependency install scripts by default was a design mistake, and install time is where most supply chain attacks execute. pnpm fixed the default in version 10 and npm in version 12. That is the most effective change the ecosystem has made against this class of attack: a human now decides before anything runs. It does not tell the human which copy they are looking at.

Signatures and provenance cover only the tarball

#

Every cryptographic statement npm makes about a version names the tarball. The registry signature covers the string ${name}@${version}:${dist.integrity}. The publish attestation and the SLSA (Supply-chain Levels for Software Artifacts) provenance statement each have one subject, pkg:npm/<name>@<version>, whose digest is the tarball's SHA-512. We decoded both for sigstore@5.0.0, and the digest equals its dist.integrity.

None of them covers dependencies, scripts, hasInstallScript, bin, os, or cpu, the manifest fields that resolvers and npm's script runner act on. That is why Clarke's package verifies. Provenance tells you which workflow produced the tarball's bytes. It says nothing about the metadata npm installs from, and a compromised maintainer publishing through a legitimate pipeline gets valid provenance either way. Signatures and provenance are necessary. They are not sufficient.

Lockfiles inherit the split. Here is the entry npm 12 wrote for Clarke's package:

JSON
{
  "node_modules/darcyclarke-manifest-pkg": {
    "version": "2.1.15",
    "resolved": "https://registry.npmjs.org/darcyclarke-manifest-pkg/-/darcyclarke-manifest-pkg-2.1.15.tgz",
    "integrity": "sha512-+4iSJV5ssylm+upnjrhuNoUqCY/aJlscRcGG/b+XeVhQujfsvGDlRjQUVv9tYSA1y1uvKfAhJ6TKHwtfXT5S9A=="
  }
}

The entry has no dependencies and no hasInstallScript, because the manifest had neither, and an integrity that pins a tarball claiming both. A lockfile is a frozen merge of the two documents, not an arbiter between them.

Fixes, and what each one leaves open

#
FixWhat it closesWhat it leaves open
The registry rejects name and version mismatches (npm, since September 2023)Publishing a tarball under another package's identityEvery other field, versions published before the change, and other registries
Install scripts off by default (npm 12, pnpm 10 and later, Bun, Yarn 4)Scripts executing before a human decidesApprovals bind a package name and version, not a script; npm prints different commands in different places; hidden dependencies and runtime entry points are untouched
Lockfiles and npm ciTarball bytes changing after resolutionThe dependency list and script flag come from one of the two documents, and npm and Yarn 1 copy from different ones
Scanning the tarball and diffing it against the manifestHidden scripts, hidden dependencies, false identitiesMost mismatches are npm's own normalization, and a package that is malicious in both copies needs behavioral analysis, not a diff

We think the registry should stop accepting a manifest from the publisher. The tarball is the artifact: it is hashed, signed, attested, extracted, and executed. The manifest should be an index the registry computes from it, applying the normalization the CLI applies today, so that every field a resolver acts on is a deterministic function of signed bytes. The cost is real and bounded: unpacking one tarball per publish, moving the CLI's binding.gyp and repository rewriting server-side, and deciding what to do with old mismatched versions. Rewriting their manifests changes what fresh installs resolve to. Leaving them keeps the hole open.

Clients do not need to wait for the registry. After extraction an installer holds both documents, and comparing dependencies, scripts, bin, and engines between them costs less than any network request it makes. pnpm already compares name and version when it reuses a package from its store, which is why our second pnpm install of Clarke's package failed while the first succeeded. npm should take script commands from one source on every code path, and print that source in its warning.

We have used the package.json inside the tarball as the source of truth for our analysis since September 5, 2022, when our research team found the discrepancy independently, and we raise a Manifest Confusion alert when a package's registry metadata and its tarball disagree. The tests above are a reminder that the tarball is not the whole story either: when npm resolves from the registry, the manifest's script is the one that runs. The disagreement itself is the finding, and it has to be caught before install, because the first install is where npm runs the manifest's command.

The registry signs one document and resolves from the other. Until it derives the second from the first, every installer, website, and scanner is choosing which half of a package to believe, and they do not all choose the same half.