
Testing npm Provenance and ignore-scripts Against a Real Install-Time RAT Payload
Introduction
Eight malicious npm packages, 40,767 downloads, an Overlord RAT plus a stealer. That's what the reporting says, and the reporting is the only source I have on this campaign — I haven't independently confirmed the download count.
What I can test is the response. This post runs the two defenses platform teams reach for first — npm provenance attestations and --ignore-scripts — against a benign stand-in for an install-time RAT payload, and shows which one actually prevents code execution during npm install. You get the commands, the observed output, and a defense matrix you can carry into a security review.
Short version: one of them works, and it's the one you have to remember to type.
What the Overlord RAT npm campaign actually did
Lifecycle scripts are the delivery mechanism
Install-time malware works because npm treats package.json as executable configuration. A postinstall entry fires as part of installing a dependency — not when you import it, not when you call it. Your npm install becomes remote code execution on a developer laptop or a CI runner before any of your own code runs.
The phases, in order:
preinstall— before the package is linkedinstall— during linkingpostinstall— after linking, the classic carrierprepare— for git dependencies andnpm install <folder>
Any one is enough. A postinstall that forks a detached process and exits cleanly leaves no trace in a normal install log unless you pass --foreground-scripts.
Confirmed facts vs what the source does not say
Confirmed by the reporting: eight packages, 40,767 aggregate downloads, a payload described as the Overlord RAT with a stealer component alongside it.
Not stated anywhere in that material, and I won't guess: the package names, the version ranges, the maintainer identities, whether these were typosquats or hijacked existing releases, and any C2 addresses. Those gaps matter — they decide whether this was account takeover or someone publishing their own malware, and the two cases call for different defenses. Unknown.
Building a safe stand-in instead of pulling live samples
A benign install-time payload package
I'm not fetching the live samples. It isn't needed for the test, and no version of "download a RAT onto your workstation" ends well. Instead, a minimal package that reproduces the shape of the attack — code runs at install time, touches the filesystem, makes a network call — without doing anything harmful.
{
"name": "benign-install-probe",
"version": "1.0.0",
"description": "Test double for an install-time payload",
"scripts": {
"postinstall": "node ./probe.js"
},
"license": "MIT"
}
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
// 1. filesystem side effect — proof the script ran
const marker = path.join(os.tmpdir(), "install-probe.marker");
fs.writeFileSync(marker, `${new Date().toISOString()} ${process.env.npm_package_name}
`, { flag: "a" });
console.log("[probe] marker written:", marker);
// 2. bounded loopback beacon — proof of outbound capability
// fixed 127.0.0.1 target, 500ms timeout, no response body handling
const req = http.request({
host: "127.0.0.1", port: 8123, path: "/beacon",
method: "POST", timeout: 500,
}, (res) => {
res.resume();
res.on("end", () => console.log("[probe] beacon status", res.statusCode));
});
req.on("error", (e) => console.log("[probe] beacon failed:", e.code));
req.on("timeout", () => req.destroy());
req.end("probe");The probe never evaluates, downloads, or runs anything it fetches. The beacon fires once, to loopback, and gives up after half a second. That's enough to answer the only question that matters here: did the lifecycle script run?
## terminal 1 — loopback listener
node -e "require('node:http').createServer((req,res)=>{console.log('beacon',req.method,req.url);res.end('ok')}).listen(8123,'127.0.0.1',()=>console.log('listening on 8123'))"
## terminal 2
npm pack # -> benign-install-probe-1.0.0.tgz
Everything below runs on Node 22 / npm 10, macOS arm64.
Baseline install results
rm -f /tmp/install-probe.marker
npm install ./benign-install-probe-1.0.0.tgz
Observed:
added 1 package, and audited 2 packages in 1s
[probe] marker written: /tmp/install-probe.marker
[probe] beacon status 200
cat /tmp/install-probe.marker
2026-10-08T09:12:04.118Z benign-install-probe
Terminal 1 printed beacon POST /beacon. The control works: one npm install, and third-party code has already run, written to disk, and phoned out. No prompt, no warning.
Testing --ignore-scripts against the install-time payload
What --ignore-scripts blocks during npm install
Same tarball, scripts disabled two ways:
rm -f /tmp/install-probe.marker
npm install ./benign-install-probe-1.0.0.tgz --ignore-scripts
ls /tmp/install-probe.marker
added 1 package, and audited 2 packages in 640ms
ls: /tmp/install-probe.marker: No such file or directory
rm -f /tmp/install-probe.marker
echo "ignore-scripts=true" >> .npmrc
npm ci
ls /tmp/install-probe.marker
added 1 package, and audited 2 packages in 780ms
ls: /tmp/install-probe.marker: No such file or directory
The listener saw nothing in either run. The install still succeeded — packages were fetched, linked, and resolvable. Only the lifecycle scripts were skipped, which covers preinstall, install, postinstall, and prepare.
Where --ignore-scripts leaks in practice
From here I'm reasoning rather than measuring, and I'll flag which is which.
Tested: the two runs above. --ignore-scripts on the command line and ignore-scripts=true in a committed .npmrc both stopped the probe.
Not tested, but a straightforward inference from npm's config resolution: CLI flags beat project-level .npmrc, so a teammate typing npm install --no-ignore-scripts (or --ignore-scripts=false) re-enables everything. This isn't a sandbox. It's a default that any invocation can override.
Also reasoning, not measurement: ignore-scripts does nothing about a malicious bin entry. The install completes, the binstub lands in node_modules/.bin, and whoever runs that command later executes the payload. Same story for a package shipping a prebuilt native artifact or a .node file that gets required at runtime — no script has to run at all. And some tooling re-runs install scripts during rebuilds or post-install repair, re-triggering a postinstall you thought was disabled.
ignore-scripts protects the install step. It does not protect the first require(), the first CLI invocation, or the first native binding load. If the payload isn't in a lifecycle script, this flag is irrelevant.
Testing npm provenance attestations against the same payload
What npm provenance attestations actually cover
A provenance attestation, per npm's docs, is a signed statement about build origin: this tarball came out of this CI workflow, in this repository, at this commit, from this publisher identity. It's a chain-of-custody claim.
It says nothing about what the code does — not the intent, not the runtime behavior, not whether the postinstall is a build step or a beacon.
Verifying signatures locally and in CI
npm audit signatures
Representative passing output:
audited 412 packages in 4s
412 packages have verified registry signatures
38 packages have verified attestations
Two things to notice. Only 38 of 412 packages had attestations — provenance is still uncommon. And a package with no attestation doesn't fail this command; it just isn't counted in that second line. Attestations are checked where present, not required. Wording and exit codes shift between npm majors, so read this as a report, not a gate.
To check whether one specific version has an attestation, the registry serves them at https://registry.npmjs.org/-/npm/v1/attestations/<name>@<version>. I read output through the CLI rather than scripting that endpoint, so the exact response shape is untested on my side.
Why a valid attestation is not a safety signal
This is the argument the whole post hangs on.
A maintainer with a working GitHub Actions pipeline publishes a new version with --provenance. Between the last release and this one, they add a postinstall that fetches and runs a payload — the classic compromised-maintainer or malicious-insider move. CI builds the package. The build succeeds. The provenance statement is generated, and it is true: this tarball really was built by that workflow from that commit. Nothing was forged, so nothing needs forging.
Run the probe under that scenario and the marker file shows up. Provenance verifies identity and origin. It does not verify behavior. What it does narrow is impersonation and account takeover — typosquats, publishes from a stolen token, a package built on someone's laptop and uploaded by hand. Real value, wrong threat for this incident.
Results compared
The defense matrix
| Scenario | Scripts allowed | Probe ran? | Evidence |
|---|---|---|---|
Default npm install | yes | yes | measured |
--ignore-scripts | no | no | measured |
ignore-scripts=true + npm ci | no | no | measured |
| Valid attestation, scripts allowed | yes | yes | reasoned |
Valid attestation + --ignore-scripts | no | no | reasoned |
| No attestation + scripts allowed | yes | yes | reasoned |
Malicious bin or shipped binary | n/a | yes | reasoned, not run |
That last row is the gap, and it's the one that matters: the two controls people reach for most don't overlap with each other, and neither overlaps with payloads that never touch a lifecycle script. --ignore-scripts doesn't stop a bin entry. Provenance doesn't stop anything behavioral.
What actually reduces install-time risk in CI
Disable scripts by default
Commit the setting so it travels with the repository instead of living in someone's shell history:
echo "ignore-scripts=true" >> .npmrc
git add .npmrc
Then use npm ci in CI, which reads that file and installs from the lockfile. This is the single highest-value change, and it's why the --ignore-scripts result above matters more than the provenance result.
Explicit rebuild allowlist
Scripts off by default means native modules that need a compile step won't build. Handle those one at a time:
npm ci --ignore-scripts
npm rebuild better-sqlite3 sharp
Every name on that list is a deliberate decision about which publishers get to execute code in your build. Review them the way you'd review a new dependency, because that's what they are.
One caveat I'd rather flag than assume: whether npm rebuild <pkg> still runs lifecycle scripts when ignore-scripts=true is set in project config has varied across npm versions, and I didn't test it. Check it in your own environment before you build a release pipeline on top of it.
Provenance as a review filter, not a gate
Run npm audit signatures and diff the lockfile on every dependency PR. You're looking for change, not pass/fail: a new publisher, an attestation appearing or disappearing, a transitive dependency that shows up out of nowhere, a patch bump that adds a postinstall to a package that never had one. Send those to a human.
Keep the position honest on the way: this catches identity problems. It does not catch behavior problems. If your only node-side control is a green npm audit signatures, a maintainer with a valid CI pipeline can ship you a RAT through a perfectly attested release.
Diff the lockfile, not just the version bump. A patch release that adds a postinstall where none existed is the highest-signal anomaly in a dependency PR, and it's invisible if you only look at semver.
Further Reading
- The Hacker News report on the campaign (Google News link) — source for the eight-package, 40,767-download figure
- npm docs: Generating provenance statements — official docs on what attestations contain
- npm docs:
npm audit— official docs coveringnpm audit signatures - npm docs:
ignore-scriptsconfig — official docs on lifecycle script suppression - npm docs:
npm rebuild— official docs for the rebuild allowlist workflow
Conclusion
Only one of the two defenses would have stopped the install-time payload: --ignore-scripts. Provenance wouldn't, because a maintainer publishing from their own working CI pipeline produces a valid, honest attestation while shipping a malicious postinstall. Provenance is a real control against impersonation and stolen tokens, and it's worth running. It isn't a behavioral check, and it shouldn't be described as one in a security review.
The uncomfortable part is that the control that works is opt-in per invocation and lives in a file most teams never commit. The fix is boring and it's the entire recommendation: ignore-scripts=true in a committed .npmrc, npm ci in CI, a short reviewed npm rebuild allowlist for the packages that genuinely need to compile, and provenance plus lockfile diffs pointed at humans who will notice when a publisher identity changes. That combination doesn't make install-time execution safe. It makes it deliberate — which is about all you can ask from a package manager that treats package.json as executable configuration.


