Safeguard
Supply Chain Security•

Import-Time Package Malware: Why Blocking Install Scripts Isn't Enough

@subql/common 5.8.3 ran on install and again on import. Here is how to block install scripts, catch import-time side effects in CI, and cover Python too.

Safeguard Security Team
6 min read

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

  1. Block install scripts and approve them one by one. npm 12 does this by default. Add strict-allow-scripts=true to the project .npmrc so an unreviewed script fails the install instead of producing a warning, and approve with npm install-scripts approve <pkg>, which pins the approval to the installed version. On npm 11 or older, run npm ci --ignore-scripts and then npm rebuild <pkg> for each package on your allowlist.

  2. Add a release cooldown. min-release-age=7 in .npmrc makes 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.

  3. 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.

  4. 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=none
    

    The 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.

  5. Harden Python installs and then look past them. Use pip install --require-hashes --only-binary :all: -r requirements.txt to reject source builds and tampered files, then run the .pth check 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

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.