DepWall

Home / Blog

  • npm
  • bugs
  • supply chain
  • osv

The string "constructor" was a valid npm integrity hash

Our exact-hash denylist is the one signal with no false-positive rate to trade off. Then we probed it with six inherited property names and it blocked on every one of them.

DepWall's exact-tarball denylist is the one signal we describe as having no false-positive rate. Every other npm check infers something — install scripts, name distance, package age, download counts. This one compares dist.integrity, the subresource-integrity string npm publishes for a version's tarball, against digests from advisories somebody has already confirmed malicious. A hit is not a judgement. It means the registry is serving the same bytes the advisory is about.

That claim is the entire reason the signal is allowed to return critical on its own. So it is worth saying plainly that for a while it would block a package whose integrity string was the word constructor.

The lookup#

The denylist is a plain object: digest in, advisory id out. The lookup was the obvious one.

for (const token of rec.integrity.split(/\s+/)) {
  const advisory = hashes[token];
  if (!advisory) continue;
  return [{ signal: "known-malicious", severity: "critical", message: `… ${advisory} …` }];
}

hashes[token] walks the prototype chain. A plain object in JavaScript inherits from Object.prototype, so a lookup for a key that was never in the list still finds something:

const hashes = { "sha512-real": "MAL-2026-1" };

hashes["constructor"]     // ƒ Object() { [native code] }
hashes["toString"]        // ƒ toString() { [native code] }
hashes["valueOf"]         // ƒ valueOf() { [native code] }
hashes["hasOwnProperty"]  // ƒ hasOwnProperty() { [native code] }
hashes["isPrototypeOf"]   // ƒ isPrototypeOf() { [native code] }
hashes["__proto__"]       // Object.prototype

Six inherited names, every one of them truthy. Five are functions. The guard was if (!advisory) continue, and a function is not falsy, so the loop did not continue — it returned a critical finding and the gate blocked the install. The message interpolated the "advisory id" it had found, which is how you get a security tool telling you a package matches ƒ Object() { [native code] }.

Why this is worse than an ordinary bug#

A false BLOCK is not a small defect here. It is the specific failure this signal is supposed to be incapable of, and the reason it is trusted enough to act alone. Every other check we run is allowed to be wrong sometimes, which is why they mostly escalate to ASK and ask a human. This one was granted the authority to stop an install by itself because the comparison is exact — and it was not exact.

It also matters where the input comes from. rec.integrity is whatever the registry endpoint answered with. On the public registry that is npm. Behind a corporate mirror, or a proxy, or anything else a client can be pointed at, it is whoever runs that. So this was untrusted input reaching a property lookup — a small, old, well-documented category of bug that we shipped anyway.

The fix#

Two lines. Ask whether the list owns the key, and check the value is the kind of thing an advisory id is.

if (!Object.hasOwn(hashes, token)) continue;
const advisory = hashes[token];
if (typeof advisory !== "string" || advisory.length === 0) continue;

Object.create(null) for the list would also have worked and is arguably tidier. We chose the explicit check because the list is JSON parsed at start-up and travels through a few hands before it gets here, and a guard at the point of use survives a refactor that swaps how the object is built.

How it was found#

Not by a user, and not by the test suite as it stood. It was found by sitting down and asking what an attacker controls about this signal's input, then feeding those things in one at a time. The answer was: they control the whole string, because the string comes from the registry.

The regression test is now a loop over all six inherited names, asserting the signal returns nothing for each. It is short and it would have caught this on day one:

for (const probe of ["constructor", "toString", "__proto__",
                     "valueOf", "hasOwnProperty", "isPrototypeOf"]) {
  expect(knownMaliciousSignal({ ...rec, integrity: probe },
                              { "sha512-real": "MAL-1" })).toEqual([]);
}

What we are keeping#

The general lesson is not "use Object.hasOwn". It is that a claim of zero false positives has to be probed rather than reasoned about. We had reasoned: an exact comparison cannot be wrong. That was true of the comparison and false of the lookup that fed it, and no amount of re-reading the sentence would have surfaced the difference.

The narrower lesson we are keeping: every signal that takes a value from the registry and uses it as a key gets this treatment. There is exactly one such lookup in the engine today, and it is now guarded and tested. If a second one appears, this post is why it will be written differently.

About DepWall

An install-time firewall for AI coding agents. It sits in front of npm, pip, cargo, go and the rest, and decides whether a package is safe to pull before any of its code runs. Free and open source.

Install it · What it does · Red-team corpus

Related