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.