all posts

Running npm install in a MicroVM: Dependency Installation Is Arbitrary Code Execution

Ajay Kumar··9 min read

There is a category of code execution that most engineering organisations have quietly decided is not code execution. You would never pipe a stranger's shell script into bash on your build server; you would raise it in review if someone did. But `npm install` runs arbitrary code from several hundred strangers, with the full privileges of the build agent, before a single line of your application has been evaluated — and that is a normal Tuesday. A postinstall script is an unsigned contract you agreed to by typing eight characters. It is `curl | sh` wearing a package.json.

I'm Ajay; I built PandaStack, a Firecracker microVM platform. This post is about treating dependency installation as what it actually is — untrusted code execution — and running it inside a disposable microVM with no long-lived credentials and no route to anything except the registry. Then, because nobody wants to pay that cost on every build, the pattern that makes it free: resolve once, snapshot the post-install state, and fork per build so warm builds skip the risk window entirely.

The framing worth internalising: your pipeline probably has an approval gate on a one-line config change, and no gate at all on a transitive dependency you have never heard of publishing a patch version that runs a shell script on the machine holding your deploy keys.

Installing a package is executing a package

The mental model most people carry is that installation is a download step, and execution begins when you run the program. That model is wrong for every major ecosystem, and has been for a decade.

npm lifecycle scripts

npm runs hooks automatically during install: `preinstall` before a package is unpacked, `install` during, `postinstall` after, plus `prepare` in some flows. They exist for good reasons — native addons need to compile against your Node ABI, and packages like sharp or better-sqlite3 genuinely cannot ship one artifact that works everywhere. The hook is a shell command, running as whatever user the install runs as, with that user's filesystem access and that user's network.

The important detail is depth. You audited your direct dependencies, probably. But the hook belongs to any package in the tree, and a modern application's tree runs to the high hundreds. You are not accepting fifteen contracts, you are accepting nine hundred, several of which changed last week.

#!/usr/bin/env bash
# What actually happens when you type `npm install`. Run this in a throwaway
# machine, not on the laptop with your SSH keys on it.
set -euo pipefail

cat > package.json <<'JSON'
{
  "name": "totally-normal-app",
  "version": "1.0.0",
  "dependencies": { "left-pad-ish": "file:./left-pad-ish" }
}
JSON

mkdir -p left-pad-ish
cat > left-pad-ish/package.json <<'JSON'
{
  "name": "left-pad-ish",
  "version": "1.0.0",
  "scripts": {
    "preinstall":  "node -e \"console.log('preinstall  uid=' + process.getuid())\"",
    "install":     "node -e \"console.log('install     cwd=' + process.cwd())\"",
    "postinstall": "node ./phone-home.js"
  }
}
JSON

# The postinstall hook. Nothing here is exotic -- it is the same file access
# and the same outbound socket any Node program is allowed to make.
cat > left-pad-ish/phone-home.js <<'JS'
const fs = require("fs");
const os = require("os");

// 1. The env block. On a build agent this is where the good stuff lives.
const interesting = Object.keys(process.env).filter((k) =>
  /TOKEN|SECRET|KEY|PASSWORD|AWS|GITHUB|NPM_/i.test(k)
);
console.log("postinstall  env keys worth stealing:", interesting.length);

// 2. The registry credential, sitting on disk in a documented location.
const npmrc = `${os.homedir()}/.npmrc`;
console.log("postinstall  npmrc readable:", fs.existsSync(npmrc));

// 3. The cloud metadata endpoint. One unauthenticated HTTP call from
//    inside the network to instance credentials, on most default setups.
fetch("http://169.254.169.254/latest/meta-data/", { signal: AbortSignal.timeout(800) })
  .then(() => console.log("postinstall  metadata endpoint: REACHABLE"))
  .catch(() => console.log("postinstall  metadata endpoint: blocked"));
JS

npm install --no-audit --no-fund
# Three scripts ran. You approved all three by typing eight characters.

Nothing in that script is a vulnerability. Every line is a documented, supported feature working exactly as designed. That is the uncomfortable part: there is no exploit to patch, because the exploit is the feature.

Python: setup.py and PEP 517 build backends

Python's version is older and, if anything, more direct. A source distribution's `setup.py` is a Python program, and pip executes it to learn the package's metadata. Not parsed — executed. PEP 517 and 518 improved things by isolating the build in its own environment with declared build requirements, which is a genuine win for reproducibility. It is not a security boundary: the build backend is still arbitrary Python running on your machine, and `pyproject.toml` simply names which arbitrary Python.

  • A `setup.py` runs at install time for any sdist. If your CI hits an architecture the maintainer didn't publish a wheel for, you silently fall back to executing their build.
  • PEP 517 backends — setuptools, hatchling, poetry-core, maturin, or one a package ships in-tree — run as part of the build. An in-tree backend means the package executes its own code to build itself.
  • Ruby's `bundle install` compiles native extensions via `extconf.rb`; Rust runs `build.rs` from any crate in the graph, with full std access. Go is the notable outlier — no install-time hook at all, a deliberate and underappreciated design decision.

The attack shapes, and why they are cheap to run

None of this would matter if getting malicious code into a dependency graph were hard. It is not — it is one of the highest-leverage, lowest-cost operations available to an attacker, because a single successful publish reaches every machine that installs, within hours.

  • Typosquatting — register a name one keystroke from something popular, or the singular of a plural. You aren't attacking a specific target; you're collecting whoever mistypes. The install script runs before anyone notices the package does nothing useful.
  • Compromised maintainer accounts — a real maintainer of a widely-depended-on package gets phished, and a patch version ships with an extra postinstall. Because the package has a legitimate history, every automated update policy pulls it in enthusiastically. A range like `^1.2.0` is a subscription to whatever that account publishes next.
  • Dependency confusion — your build resolves an internal package name against a public registry that also has that name, and the public one wins on version number or resolution order. Particularly nasty because it requires compromising nothing you own; the attacker publishes `@yourcompany/internal-utils` at version 99.0.0 and waits.
  • Build-time-only payloads — the sophisticated version never touches your shipped artifact. It reads the CI environment, exfiltrates a token, and leaves. Tests pass, bundle hash looks normal, SBOM is clean, and someone else has your registry credential.
You would never run a stranger's shell script on the machine holding your deploy keys. Then you typed a command that ran nine hundred of them, on the machine holding your deploy keys.

Why `--ignore-scripts` is not the complete answer

The obvious mitigation is to turn the hooks off. `npm ci --ignore-scripts` and `pip install --only-binary :all:` are real controls, worth having as defaults, and not a solution — for two reasons people tend to discover in the wrong order.

The first is that native builds legitimately need them. Turn scripts off globally and things break in irritating ways: a native module works until the day no prebuild matches your platform, a CLI's binary shim never gets linked. Teams respond with an allowlist of packages permitted to run scripts — a genuine improvement, and also a decaying maintenance burden whose membership is precisely the set of packages compiling C against your toolchain.

The second reason gets less airtime and matters more. Skipping install hooks does not make the code safe; it defers it. The package is still on disk, and the moment your test suite imports it — or your bundler resolves it, or a linter evaluates its config — that code runs. Top-level module code executes on import in both Node and Python, and a test run imports the whole dependency graph by construction. So `--ignore-scripts` moves execution from `npm install` to `npm test`, on the same agent, with the same environment variables, ten seconds later.

The useful reframe: the question is not "how do I stop dependency code from running?" You cannot, because running it is the point of having it. The question is "what is standing next to that code when it runs?" That is a question about the machine, not about a CLI flag.

What your build agent is holding while that code runs

Inventory a typical CI runner at the moment `npm install` executes. It has a registry token, often with publish scope, because someone needed it for a private package three years ago. It has cloud credentials — either an instance role reachable at the metadata endpoint over unauthenticated HTTP from inside the network, or long-lived keys in the environment. It has the full CI secret set injected as environment variables: deploy keys, database URLs, webhooks, signing material. It has your source checkout, probably a Docker socket, and network reach to your internal registry.

Now ask what the isolation boundary is. Usually a container: a namespaced, cgroup-limited process sharing a kernel with the host and with every other job on that runner. Containers are excellent at resource accounting and at separating cooperating workloads, and I use them constantly. But here is the part people miss — the boundary does not need to be broken for this attack to succeed. A postinstall script does not need a container escape. It needs `process.env`, an outbound socket, and a filesystem read, and all three are ordinary permitted operations inside the container. The isolation model was never what stood between the attacker and the credential. Proximity was, and there is none.

The microVM design: one machine per install, holding nothing

The design that addresses this is boring, which is a good sign. Run the install in its own microVM — a hardware-virtualised guest with its own kernel — constructed to hold nothing worth taking and reach nothing worth reaching.

  1. One VM per install, destroyed after. Not per repository — per install. The machine exists for the duration of a risky operation and then stops existing, along with anything a hook left behind that would have poisoned the next job on a shared runner.
  2. No long-lived credentials inside. The install does not need your deploy key or your cloud role. It needs a registry. Put a mirror in front that authenticates on the guest's behalf, so the guest never holds a token it could leak. A credential that was never mounted cannot be exfiltrated by any hook, however clever.
  3. Egress restricted to the registry, enforced outside the guest. On PandaStack every sandbox gets its own network namespace with its own veth pair and TAP device — each agent pre-allocates 16,384 /30 subnets — so the allowlist is host-side filtering, not a setting the guest can be politely asked to respect. The metadata endpoint is not on the list, and neither is the internet.
  4. Record what ran. Lockfiles already say which packages declare install scripts; capture that list with resolved versions and keep it. When a package is disclosed as compromised eight weeks from now, the urgent question is which builds installed it.
  5. Snapshot the result, then destroy the machine. This is the step that turns the whole thing from a security tax into a build speedup, and it deserves its own section.
import json
from pandastack import Sandbox

LOCKFILE = open("package-lock.json").read()
MANIFEST = open("package.json").read()

# ---------------------------------------------------------------------------
# 1. RESOLVE ONCE, in a machine that holds nothing worth stealing.
#    No registry token, no cloud credentials, no CI secrets -- the install
#    step does not need any of them, and a postinstall script cannot exfiltrate
#    what was never mounted.
# ---------------------------------------------------------------------------
resolver = Sandbox.create(template="base", ttl_seconds=900)

resolver.filesystem.write("/work/package.json", MANIFEST)
resolver.filesystem.write("/work/package-lock.json", LOCKFILE)

# Egress for this guest is restricted to the registry at the host's network
# namespace, so `fetch("http://169.254.169.254/...")` from a postinstall hook
# resolves to nothing at all. That is enforced outside the guest; the guest
# has no say in it.
resolver.filesystem.write("/work/.npmrc", "\n".join([
    "registry=https://registry.internal.example.com/",
    "audit=false",
    "fund=false",
    # NOTE: no _authToken. Private packages are proxied by the registry
    # mirror, which authenticates on our behalf and never hands the guest
    # a credential it could leak.
]))

install = resolver.exec(
    "cd /work && npm ci --no-audit --no-fund 2>&1 | tail -40",
    timeout_seconds=900,
)
if install.exit_code != 0:
    resolver.destroy()
    raise RuntimeError(f"install failed:\n{install.stdout}")

# Record what the install actually did, while the evidence still exists.
audit = resolver.exec(
    "cd /work && node -e \""
    "const l=require('./package-lock.json');"
    "const s=Object.entries(l.packages||{})"
    " .filter(([k,v])=>v.hasInstallScript)"
    " .map(([k])=>k);"
    "console.log(JSON.stringify({withInstallScripts:s},null,0))\"",
    timeout_seconds=60,
)
print("packages that ran install scripts:", json.loads(audit.stdout))

# ---------------------------------------------------------------------------
# 2. SNAPSHOT the post-install state. Everything risky already happened,
#    exactly once, in a machine we are about to throw away.
# ---------------------------------------------------------------------------
warm = resolver.snapshot()
resolver.destroy()

# ---------------------------------------------------------------------------
# 3. FORK PER BUILD. Each build resumes into a fully populated node_modules
#    without re-running a single lifecycle script. Same-host forks land in
#    400-750ms; cross-host is 1.2-3.5s because memory has to travel.
# ---------------------------------------------------------------------------
def build(commit: str) -> str:
    sbx = warm.fork(ttl_seconds=1800)
    try:
        sbx.exec(f"cd /work && git fetch --depth 1 origin {commit} && git checkout FETCH_HEAD")
        result = sbx.exec("cd /work && npm run build && npm test", timeout_seconds=1800)
        return result.stdout
    finally:
        sbx.destroy()   # node_modules, the hooks that ran, and the guest kernel

Resolve once, snapshot the node_modules state, fork per build

The objection to per-install isolation is always cost, and it is fair if you do the naive thing. Installing from scratch in a fresh machine for every build is slow, hammers the registry, and re-runs every lifecycle script — so you re-enter the risk window on every build, including the eleven you triggered this afternoon fixing a typo in a test.

The fix is to separate resolution from execution. Do the install exactly once, in a machine holding nothing, with egress restricted to the registry. Then snapshot that machine — memory and disk, with `node_modules` populated, native modules compiled, and every hook already run. That snapshot is now an artifact, and every subsequent build forks it. A fork is copy-on-write: guest memory is shared until something writes and the rootfs is a reflink clone, so the tenth build does not copy gigabytes. On PandaStack a same-host fork lands in 400–750ms, and cross-host is 1.2–3.5s because memory has to travel.

  • The risk window becomes a function of lockfile changes, not build volume. Ten thousand builds against an unchanged lockfile execute zero install hooks, because the hooks ran once, months ago, in a machine that no longer exists.
  • The install becomes reviewable. A new lockfile that introduces a package running an install script is a discrete event with a diff attached, not something that happens invisibly on every CI run.
  • Builds get faster as a side effect. That is the part that gets the pattern adopted; the security property comes along for free, which is the only reliable way to ship a security property.
One snapshot caveat that bites this workload specifically: a restored guest wakes up believing it is the moment the snapshot was taken. A frozen clock is stable, which is useful, but anything doing TLS to a real endpoint will find every certificate "expired." Sync the guest clock on resume before the build talks to your registry or artifact store.
// The record you want out of an install, and almost nobody keeps. It is the
// difference between "we think our dependencies are fine" and being able to
// answer, for a specific build, what code ran and what it could reach.
export type InstallAttestation = {
  id: string;                    // sha256(lockfileHash + templateGeneration)

  // WHAT was resolved. A lockfile hash, not a range -- "^4.17.1" is a
  // statement of intent, and the attacker publishes 4.17.2.
  resolution: {
    lockfileSha256: string;
    packageManager: "npm" | "pnpm" | "yarn" | "pip" | "uv" | "bundler" | "cargo";
    directDeps: number;
    transitiveDeps: number;      // this is the number that should scare you
  };

  // WHICH packages executed code at install time. Every entry here is a
  // shell you granted, and the list is usually longer than people guess.
  executed: Array<{
    name: string;
    version: string;
    integrity: string;           // the lockfile's own subresource hash
    hooks: Array<"preinstall" | "install" | "postinstall" | "prepare" | "build_backend">;
    firstSeenInLockfile: string; // ISO date -- brand-new transitive deps
                                 // that run scripts deserve a human look
  }>;

  // WHERE it ran. The whole argument of this post compressed into one object.
  environment: {
    isolation: "microvm";
    templateGeneration: string;  // restorable, unlike "ubuntu-latest"
    credentialsPresent: [];      // deliberately, provably empty
    egress: { policy: "deny"; allow: string[] };  // ["registry.internal..."]
    metadataEndpointReachable: false;
  };

  // The artifact the rest of the pipeline consumes. Downstream builds fork
  // THIS, so the risk window is closed before any secret is ever in scope.
  output: {
    snapshotId: string;
    nodeModulesSha256: string;   // tree hash of the installed state
    capturedAt: string;
  };
};

// The useful query six months later is not "was that package malicious?"
// It is: "which of our builds forked a snapshot whose install ran code from
// version 3.3.2 of that package?" With this record that is a lookup. Without
// it, it is an archaeology project with a deadline attached.

Bare runner vs container vs microVM for the install step

  • What a postinstall script can read — Bare runner: the full CI environment, the shared `~/.npmrc`, the checkout, and whatever a previous job left on disk. Container: the job's environment variables and mounted secrets, which is usually the whole set. MicroVM: only what you deliberately wrote into the guest — a manifest and a lockfile — because there is no shared filesystem and no ambient credential to inherit.
  • Reachable network by default — Bare runner: whatever the host can reach, typically the internal network and the metadata endpoint. Container: much the same, since a container shares the host's network path unless someone wrote a per-job policy, which is rarer than per-cluster policy. MicroVM: its own network namespace with host-enforced default-deny filtering, so the metadata endpoint simply does not resolve.
  • Residue after the job — Bare runner: persistent, and a poisoned package cache silently affects the next job. Container: the writable layer goes away, but shared cache volumes and the Docker socket frequently do not. MicroVM: the guest kernel and every byte of its state is destroyed, so residue is not a category that exists.
  • Blast radius if the boundary itself is attacked — Bare runner: none to speak of; the script is already on the machine you care about. Container: namespaces and seccomp are real controls enforced by the shared kernel, though a supply-chain payload usually doesn't need to escape, since the secrets are already in scope. MicroVM: a hardware-virtualised guest with its own kernel, so even a full compromise owns a machine that was about to be deleted.
  • Cost per build — Bare runner: fast, and the speed is why nobody changes it. Container: fast to start, but a cold install re-runs every lifecycle script every time. MicroVM: a create restores a baked snapshot in about 179ms p50 (203ms p99), a first-ever cold boot is around 3s, and forking a post-install snapshot is 400–750ms same-host — so the isolated path is the fast path once resolve-once is in place.
  • Auditability — Bare runner: the environment it ran in is whatever the machine has drifted to. Container: image tags help, though base-image and cache drift still bite. MicroVM: the install ran in a named snapshot generation you can restore, so "which environment executed that hook" is a lookup, not a reconstruction.

The honest caveat on that table: the container column describes general architectural properties and common defaults, not a benchmark, and a well-configured container platform closes several of these gaps with per-job network policy, scoped secrets, and ephemeral runners. Verify against your platform's own documentation before deciding on my say-so. The only measured numbers here are PandaStack's.

The summary

Dependency installation is arbitrary code execution by hundreds of strangers on the machine holding your credentials, and we have collectively agreed not to think of it that way because doing so is inconvenient. npm lifecycle hooks, Python build backends, `build.rs`, `extconf.rb` — every ecosystem except Go grants a shell at install time, by design.

`--ignore-scripts` is a reasonable default and an incomplete control: native builds need the hooks, and the code you skipped runs anyway the moment your tests import it. A container does not help much either, not because containers are bad but because the payload never needs to escape anything — the token is already in the environment variable next to it.

What holds is a disposable microVM per install, holding no credentials, with egress restricted to the registry and enforced outside the guest. Then resolve once, snapshot the post-install state, and fork per build at 400–750ms so ten thousand builds execute zero install hooks. A faster pipeline and a much shorter risk window, and the only thing you gave up was the assumption that downloading a package and running a package are different activities.

They were never different activities. The tooling just never asked you to sign anything.

Frequently asked questions

Does `npm install --ignore-scripts` make dependency installation safe?

It helps and it is worth setting as a default, but it is not a complete control. Two problems. First, native builds legitimately need those hooks — packages that compile C against your Node ABI or fetch a platform binary will break, so teams end up maintaining an allowlist of packages permitted to run scripts, and that allowlist is exactly the set of packages compiling code on your machine. Second, and more fundamentally, skipping install hooks defers execution rather than preventing it. The package is still on disk, and its top-level module code runs the moment your test suite imports it, your bundler resolves it, or your linter evaluates its config. That happens on the same agent with the same environment variables, seconds later.

Why isn't a container enough isolation for running `npm install`?

Because a supply-chain payload does not need to escape the container. Look at what it can reach without touching the boundary at all: the environment variables, which on a CI runner hold the registry token, deploy keys, and database URLs; the filesystem, including a shared `~/.npmrc`; and an outbound socket, including the cloud metadata endpoint reachable over unauthenticated HTTP from inside the network. All three are ordinary permitted operations inside a container. Namespaces and seccomp are genuine controls against a process trying to break out, but the isolation model was never what stood between the attacker and the credential — proximity was, and a shared runner has none. Verify your own platform's defaults, since a well-configured setup with per-job network policy and scoped secrets closes several of these gaps.

What is the "resolve once, snapshot, fork per build" pattern?

Separate dependency resolution from build execution. You run the install exactly once, in a fresh microVM that holds no credentials and whose egress is restricted to your package registry. When the install finishes — node_modules populated, native modules compiled, every lifecycle hook already run — you snapshot that machine's memory and disk. That snapshot becomes the artifact every subsequent build forks. A fork is copy-on-write, so guest memory is shared until written and the rootfs is a reflink clone; on PandaStack a same-host fork lands in 400–750ms and cross-host in 1.2–3.5s. The security property is that install hooks now execute once per lockfile change rather than once per build, so ten thousand builds against an unchanged lockfile run zero untrusted install scripts. Builds also get considerably faster, which is usually what gets the pattern adopted.

Does Python have the same install-time code execution problem as npm?

Yes, and arguably a more direct version of it. A source distribution's `setup.py` is a Python program that pip executes to learn the package's metadata — not parsed, executed. PEP 517 and 518 improved this by building in an isolated environment with declared build requirements, which is a real win for reproducibility, but it is not a security boundary: the build backend is still arbitrary Python running on your machine, and `pyproject.toml` only names which arbitrary Python. You can fall back into executing a build without noticing when no wheel exists for your platform or architecture. Ruby has the same shape through `extconf.rb` during `bundle install`, and Rust runs `build.rs` from any crate in the dependency graph. Go is the notable exception, with no install-time hook at all.

What egress policy should a dependency-install sandbox have?

Default-deny, with your package registry as the only allowlisted destination, and enforced outside the guest so the code running inside cannot renegotiate it. Explicitly not on the list: the cloud metadata endpoint, your internal network, your artifact store, and the general internet. Pair that with a registry mirror that authenticates upstream on the guest's behalf, so the sandbox never holds a token at all — a credential that was never mounted cannot be exfiltrated by any hook, however clever. On PandaStack this is structural rather than configured per job: every sandbox gets its own network namespace with a veth pair and TAP device, with 16,384 /30 subnets pre-allocated per agent, so filtering is a host-side property of the sandbox rather than a setting inside it.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.