BLOG
NPM Implements Pre-Publication Malware Scanning, But Will It Work?
GitHub's existing tech let's hundreds of malicious packages stay published per day. How will moving the scan to pre-publishing change that?
By c0a15726-c5b1-4b0d-85e6-fe15553df9e2 ·
On July 28, npm announced publish-time malware scanning. New packages get scanned before they become installable, and depending on what the scanner finds, a package is published normally, held for manual review, or blocked outright. I want to say up front that I’m genuinely happy about this. Stopping malware before it reaches a developer’s machine is better than pulling it down afterwards, and it’s the change I’ve wanted from npm for years.
Then I read the announcement again. It’s 447 words. It doesn’t say what the scanner is. It doesn’t say when this starts. It doesn’t say whether they’ve partnered with anyone. It doesn’t give a single number about how well any of it works. And most importantly, it doesn’t mention that npm has been scanning every package version on the registry this entire time — which is a fact npm published themselves, four months ago.
What NPM actually announced
The mechanics are straightforward. Publishing now introduces a delay of about five minutes before your package is installable, stretching to 15 minutes or more at peak times or for larger packages. NPM is careful to say those numbers “aren’t a service guarantee.” While a publish is pending, npm dist-tag still works, but npm deprecate and npm unpublish don’t. If your CI assumes a package is installable the instant npm publish returns, that’s now broken and you need to fix it.
If a package gets blocked, the publisher “may receive a notification with the option to appeal.” Depending on the severity and confidence of the finding, NPM may also take action against the maintainer account.
The second half of the announcement introduces something new: a contentPolicy field in package.json for declaring what NPM calls dual-use content. Packages that declare it must also ship a text-only DISCLOSURE file in the package root explaining the functionality and its legitimate use. Dual-use packages have to be published through a method that enforces 2FA, and once you’ve declared it you can never take it back — new versions that drop the field or the file get rejected. NPM says this will be “progressively enforced” and that they’re emailing maintainers of dual-use packages directly so they don’t get blocked by default.
Gating on publish is the correct architecture, full stop.
For as long as I’ve been doing this, unfortunately the model has been publish-first, catch-later. A malicious package goes live, it gets installed by real people for somewhere between six minutes and six weeks, someone reports it, and eventually it comes down. Every incident we document at OpenSourceMalware lives inside that window. The window is the damage. A malicious package that never becomes installable has a blast radius of zero, and no amount of downstream tooling gets you that.
This is also the one piece of the puzzle that only the registry can do. Cooldowns, package firewalls, and lockfile discipline are all attempts by the rest of us to build a filter in front of a registry that ships whatever it’s handed. npm putting the filter where it belongs — at the front door, on their side of it — is a structurally better answer than any of them. I don’t want to be cynical about that. It’s the right change.
NPM has been scanning since at least April 2026
But here's what the announcement leaves out. On April 1, 2026 in a post about securing the open source supply chain, GitHub Principal Engineer Zachary Steindler wrote:
“We scan every npm package version for malware, and our detections are constantly updated and improved as attacks evolve. Hundreds of newly published packages contain malicious code daily, so when detected, a human reviews to confirm it’s a true positive before we take action. At this scale, even a 1% false-positive rate would disrupt hundreds of legitimate publishes daily.”
Read that next to the July announcement and the shape of this thing changes completely. This is not npm starting to scan for malware. This is npm moving a scan it was already running to the other side of the publish gate. The detection engine is the same engine. The rules are the same rules. The only thing that moved is when the answer gets applied.
That matters because we already know how well that engine performs. Sonatype has logged roughly 1.8 million malicious packages, and npm accounted for 96.6% of the malicious package count in Q2 2026 alone — somewhere around 46 malicious npm packages a day earlier in the year. The IndonesianFoods campaign pushed more than 150,000 malicious packages into the registry over a couple of days in 2025. Shai-Hulud landed. Mini Shai-Hulud landed. Miasma landed. Every one of those happened on a registry that was, by its own account, scanning every package version for malware. And today we know that there are literally of thousands of known malicious packages sitting in npm that credible researchers reported, yet still haven't been taken down.
So when npm says a package will now be “scanned before it becomes available for install,” the honest question isn’t whether scanning is a good idea. It’s Scanned by what? Because the thing that’s been scanning has a track record, and the track record is the reason my inbox is full.
Five minutes is the tell
Put the two statements side by side and there’s a contradiction sitting in the middle of them.
The April blog says when malware is detected, a human reviews it to confirm it’s a true positive before we take action. They explained why in the same breath: at their volume, a 1% false-positive rate would disrupt hundreds of legitimate publishes every day. That’s not a throwaway line, that’s a design constraint. They were telling us that automated blocking was too dangerous to ship, so they put a person in the loop.
In July, they’re promising a decision in about five minutes.
Those two things cannot both be true in the way they’re written. Either there’s a human reviewing detections at hundreds-per-day volume inside a five-minute SLA — which nobody who has staffed a triage queue believes — or the block decision is now automated. And if the block decision is automated, npm has shipped precisely the thing they told us in April they couldn’t ship, against precisely the false-positive math they used to explain why.
The dual-use metadata is the evidence that they know it. You don’t build an escape hatch for legitimate packages that look malicious to automated scanning unless you’ve concluded that your automated scanning is about to start blocking legitimate packages. contentPolicy and the DISCLOSURE file aren’t a policy about attackers. They’re a mitigation for false positives that NPM has already forecast, written into the announcement of the system that will generate them.
I’d have a lot more confidence in this rollout if npm had just said that. “We’re automating the block decision, here’s our measured false-positive rate, here’s the appeal turnaround, here’s the escape hatch” is a defensible engineering position. Instead we get five minutes and a new field in package.json, and we’re left to work out the rest.
What’s not in the announcement
I went looking for the detail elsewhere, on the assumption the changelog was just a summary. It isn’t a summary. There’s nothing behind it.
No vendor or partner is named anywhere. There are several companies whose entire business is detecting malicious packages, and some of them are demonstrably good at it, so whether npm built this or bought it is a fairly load-bearing question. No answer. No start date either — the announcement is written in a tense that could mean it shipped on the 28th or ships next quarter. No description of the detection method: static analysis, behavioural, ML classification, IOC matching, all of the above, unclear. No baseline and no catch rate, so there’s no way to tell whether this is an improvement on the engine that’s been running all year. No false-positive rate, despite npm having previously done the arithmetic on exactly that in public. No appeal SLA, which for a maintainer whose release is blocked on a Friday is the number that actually matters.
Also on July 28, 2026, GitHub published a piece on disrupting supply chain attacks on npm and GitHub Actions by Greg Ose and Zachary Steindler. I read it hoping for the specifics. It’s a roadmap recap — staged publishing, account protection, checkout defaults, Dependabot cooldowns — and it names no scanning engine and gives no detection metrics either. The detail isn’t hiding somewhere else. It doesn’t exist yet.
Compare that to how npm handled the v12 breaking changes a couple of months ago. Exact flags. Exact commands. A version number you could prepare against. Somebody clearly sat down and wrote out what developers would need to do. This announcement, about a control that can silently block your release, got a fraction of that care. That asymmetry tells you something about how far along this actually is.
My prediction: low-hanging fruit
I spend most of my week looking at malicious npm packages, so let me be specific about what I think this will and won’t catch.
It’ll catch the obvious stuff, and there’s a lot of obvious stuff. Known IOC strings and known C2 domains. Plaintext exfiltration of ~/.npmrc, environment variables, or wallet files. Unobfuscated postinstall hooks that curl a payload. Lazy typosquats. Anything reusing a Shai-Hulud pattern verbatim, because those signatures are well known by now. That’s a genuinely meaningful share of daily volume and blocking it pre-publish is a real win.
It won’t catch the stuff that’s actually costing people money. Multi-stage loaders where version one is clean and the payload arrives in a patch release three weeks later — the tarball being scanned is benign, because it is benign. Dependency confusion, where the malice is in the name and the version number relative to something in a private registry, and no amount of reading the code tells you that. Anything with a runtime trigger, geofencing, or a delay. And the DPRK tradecraft we track in campaigns like PolinRider, where the malicious artifact frequently isn’t in the npm tarball at all — it’s in the repo, in a tasks.json, in a VS Code settings file. Publish-time tarball scanning does not see any of that.
The five-minute budget is the constraint that decides all of this. Whatever npm has built has to reach a verdict on every publish in roughly five minutes at registry scale. That rules out meaningful dynamic analysis. It rules out sandboxed detonation with any patience. It rules out cross-referencing behaviour across versions over time. What fits in that budget is fast static analysis and signature matching — which is to say, exactly the class of detection that catches yesterday’s attack and misses tomorrow’s.
None of that makes it worthless. It raises the floor, and the floor needed raising. But raising the floor moves the low-effort actors along and leaves the ones we actually write reports about entirely untouched.
The dual-use requirement is a confession
The announcement also included dual-use content policy for legitimate packages that “include security-relevant capabilities that can resemble malicious software to automated scanning.” The examples given are penetration-testing tools, security research utilities, and code-obfuscation tools. If you ship one, you add "contentPolicy": {"class": "dual-use"} to your package.json, drop a DISCLOSURE file in the package root explaining yourself, and publish through a 2FA-enforced method.
Think about who actually complies with this.
No attacker will ever file a DISCLOSURE. Not one. The entire mechanism depends on voluntary self-identification by people publishing security-relevant code, which selects perfectly for the honest ones. The compliance burden lands squarely on researchers, red teamers, and defensive tooling authors — the people who are not the problem — while the actual malicious publisher simply doesn’t tick the box. We publish tooling at OpenSourceMalware. This is now our paperwork.
And it’s a one-way door. Once a package carries the dual-use declaration, every future version must carry it or be rejected. There is no un-declaring. If you strip the risky capability out of your library in v3, you’re still flagged, still routed into “additional automated scanning suited to dual-use content,” still subject to case-by-case Trust and Safety review, forever. A permanent label with no path back is a strange thing to attach to legitimate software on a self-service basis.
Then there’s the list. npm says enforcement will be progressive and that they’re “reaching out directly by email to maintainers of dual-use packages that need to add this metadata to avoid being blocked by default.” So a list exists, it was built by some process nobody outside npm has seen, and if you’re on it and don’t hear about it, your next publish fails. There’s no way to query whether you’re on it. There’s no published criteria for how you got there.
Are dependency confusion PoCs dual-use or not?
Here’s the question I actually can’t answer, and neither can you.
GitHub never explicitly says whether dependency confusion proof-of-concept packages fall under the dual-use category. Their policy lists pentest tools, research utilities, and obfuscators — and then says, in the same document, that “the examples in this Policy are illustrative, not exhaustive.” So dependency confusion PoCs are neither in nor out. They’re just unaddressed.
This is not a small gap. Dependency confusion PoCs get published to npm every single day by bug bounty hunters probing for internal package names, and they’ve been a running sore for years — they’re not obviously malware and they’re not obviously legitimate. GitHub has reason to care, too: in November 2025 an npm package targeting GitHub-owned repositories turned out to be a red team exercise. They’ve been on the receiving end of exactly this.
Given that history, I’d bet this is a big part of what the dual-use policy is aimed at. But I’m guessing, and I shouldn’t have to guess. There isn’t enough detail in this announcement to responsibly speculate further, so I’ll leave it here: GitHub needs to explain, in far more detail, what they expect and what this policy actually covers. Until they do, a researcher publishing a PoC can’t tell whether they need a DISCLOSURE file or whether they’re about to get their account actioned. That’s a gaping hole in understanding, and it’s entirely theirs to close.
What would actually convince me
I want this to work. So here’s what would move me from skeptical to convinced, and none of it is unreasonable:
Name the engine or the partner.
Publish a baseline and a catch rate, so we can tell whether this beats the scanner that’s been running all year.
Publish the false-positive rate and the appeal turnaround time — NPM already did this arithmetic publicly in April, so the numbers exist.
State the start date plainly.
Define the dual-use categories properly instead of leaving maintainers to guess whether they’re covered. Give researchers a sanctioned way to test detections.
And publish a transparency report of what got blocked, the way every other platform operating a content gate at scale eventually has to.
That’s a list npm can knock over in one blog post, and it would do more for trust in this feature than the feature itself.
Publish-time gating is the right architecture and I’m glad it’s happening. But npm has been scanning every package version this whole time, and they told us so themselves. Moving that same scanner in front of the gate changes when it fails, not whether it fails.
The five-minute promise doesn’t reconcile with the human review they described in April. The dual-use metadata exists because they know the automation will hit legitimate packages. And the announcement gives us no engine, no date, no numbers, and no definition of the one policy that carries a permanent label and a publishing ban.
I hope I’m wrong about all of it. I’ve been doing this long enough to doubt it. Ask me again when I see what gets through.