On 5 October 2026 at 11:56 UTC, a malicious @subql/common 5.8.3 was published to npm. A postinstall hook started the payload, and the same file was wired into the package's exports so that simply importing it started the payload a second time. StepSecurity's write-up put it bluntly: "Disabling install scripts alone is insufficient because importing the package also starts the payload."
What's affected
Any setup that relies only on install-time controls. That includes npm ci --ignore-scripts, npm 12's new default of blocking dependency install scripts, and pip install --only-binary :all:. Each one stops code that runs while packages are installed. None of them stops code that runs when your tests, build or app load the package.
The import path in 5.8.3 was short. Per the analysis attached to OSV record MAL-2026-17571, dist/index.js re-exports ./project, which re-exports the malicious module. That module ends with a call to ManifestCacheReader.warm() whenever it is loaded as a dependency, and warm() spawns a detached node child that decodes and runs the payload.
Python has the same split. The site module docs say lines starting with import in a .pth file are executed, and that such lines "are always run at Python startup", whether or not the package is used. A wheel unpacks straight into site-packages, so skipping setup.py does nothing about a .pth file inside a wheel.
How to check if you're exposed
List npm packages that declare install scripts. npm query reads the installed tree, so install with scripts off first:
npm ci --ignore-scripts
npm query ':attr(scripts, [preinstall]), :attr(scripts, [install]), :attr(scripts, [postinstall])' \
| jq -r '.[] | "\(.name)@\(.version)"' | sort -u
On npm 12, npm install-scripts ls lists every package whose scripts your allowScripts policy doesn't cover yet.
Probe for import-time side effects. The Node.js permission model has an audit mode, added in v25.8.0, that reports would-be violations on diagnostics channels instead of blocking them. Save this as import-probe.cjs:
const dc = require('node:diagnostics_channel');
const hits = [];
let current = '(startup)';
for (const scope of ['child', 'net', 'worker', 'fs', 'addon']) {
dc.channel(`node:permission-model:${scope}`).subscribe((msg) => {
hits.push(`${current}: ${msg.permission} ${msg.resource}`);
});
}
const deps = Object.keys(require('./package.json').dependencies || {});
(async () => {
for (const name of deps) {
current = name;
try { await import(name); } catch (err) { console.error(`${name}: ${err.code || err.message}`); }
}
setTimeout(() => {
console.log(hits.length ? hits.join('\n') : 'no side effects observed');
process.exit(hits.length ? 1 : 0);
}, 3000);
})();
Run it with reads allowed and everything else audited, inside a container with no network:
docker run --rm --network none -v "$PWD":/app -w /app node:26-slim \
node --permission-audit '--allow-fs-read=*' import-probe.cjs
A dependency that spawns a process, opens a socket or writes files just by being imported shows up by name. Audit mode doesn't block anything, which is why the container has no network and should hold no secrets.
Check Python environments for startup code. Use -S so listing the directories doesn't itself run .pth code:
for d in $(python -S -c 'import site; print("\n".join(site.getsitepackages()))'); do
grep -lE '^import[[:space:]]' "$d"/*.pth 2>/dev/null
ls "$d"/*.start 2>/dev/null
done
Python 3.15 adds .start files, which name entry points to call at startup, so review those too.
What to do now
-
Block install scripts and approve them one by one. npm 12 does this by default. Add
strict-allow-scripts=trueto the project.npmrcso an unreviewed script fails the install instead of producing a warning, and approve withnpm install-scripts approve <pkg>, which pins the approval to the installed version. On npm 11 or older, runnpm ci --ignore-scriptsand thennpm rebuild <pkg>for each package on your allowlist. -
Add a release cooldown.
min-release-age=7in.npmrcmakes npm install only versions published more than seven days ago. The registry no longer lists 5.8.3, and its metadata was last modified at 12:46 UTC on 5 October, under an hour after the bad publish. -
Split install from test. Install with network access and scripts blocked, then run the import probe and the test suite in a job or container with no network. An import-time payload that can't reach its server can't send anything out.
-
Run tests under the permission model. Start in audit mode to learn what the suite needs, then enforce it:
node --permission '--allow-fs-read=*' --allow-fs-write=./tmp --test --test-isolation=noneThe default test isolation spawns a child process per file, which would need
--allow-child-process. Network restriction arrived in Node.js v25.0.0, so older runtimes won't block sockets. -
Harden Python installs and then look past them. Use
pip install --require-hashes --only-binary :all: -r requirements.txtto reject source builds and tampered files, then run the.pthcheck above as a CI step.
Keep the limits in view. The Node.js docs call the permission model a "seat belt" that "does not provide security guarantees in the presence of malicious code." It is a tripwire. The container boundary and the missing network do the real containment.
Why it matters
Install hooks became the obvious place to look, so attackers moved code to where it runs anyway. Every package you import runs its top-level code with your process's permissions, in CI and in production. A control that only watches npm install sees half of that.
The useful question isn't whether a package has a postinstall script. It's what a package does the first time something loads it, and the probe above answers that in a few seconds per repository.
Try it yourself
Pick one service, run the import probe against it in a no-network container, and read every line it prints. Then drop the same package.json into the free browser scan at https://gold.safeguard.sh/scan, which runs locally and checks up to 100 dependencies against known malicious packages.
Click through it at your own pace, or open it full screen.
Quick check
Would npm 12's default script blocking have stopped @subql/common 5.8.3?
It would have stopped the postinstall path. The payload still ran as soon as anything imported the package, because the module called its loader on load.
Does refusing source builds in pip stop .pth startup code?
No. It stops source builds at install time. A wheel can still place a .pth file in site-packages, and its import lines run at every interpreter startup.
Is the Node.js permission model a sandbox for untrusted packages?
No. The docs say malicious code can bypass it. Use it as a tripwire, and rely on containers and blocked network access for containment.
Sources
- StepSecurity: SubQuery ecosystem compromised
- OSV: MAL-2026-17571
- npm registry metadata for @subql/common
- npm docs: npm install-scripts
- npm docs: npm rebuild
- npm docs: npm query
- npm CLI changelog
- Node.js docs: Permissions
- Node.js docs: Command-line options
- Python docs: site
- pip docs: Secure installs
- Python Packaging: Binary distribution format
- Docker docs: None network driver