all posts

Scanning Package Postinstall Scripts in a microVM

Ajay Kumar··10 min read

If you operate a package registry — a private npm mirror, an internal PyPI, an artifact repository, a plugin marketplace, or a "verified publisher" badge that means anything at all — you have a question you cannot answer from the tarball alone: what does this thing do when somebody installs it? You can read the source. You can grep for the obvious. But the code that runs first is the install script, and an install script is a shell command whose author has every incentive to make it look boring.

The uncomfortable resolution is that the only reliable way to learn what an install script does is to run it, and running it is precisely the thing you built a registry gate to avoid. That is not actually a contradiction. It is a location problem. Run it somewhere you are happy to lose.

I'm Ajay; I build PandaStack, a Firecracker microVM platform, so read this as opinionated. This post is about the detonation half of a registry gate: how to execute a submitted package's install lifecycle on purpose, in a machine with its own kernel and no route anywhere, and record enough about the execution to make a publish/quarantine decision. It is also, at the end, honest about what this design does not catch, because a scanning gate that oversells itself is worse than no gate.

The framing that makes this tractable: you are not trying to prove a package is safe. You are trying to make the cheap attacks expensive and the loud attacks visible, so that the residual is a small set of quiet, targeted attacks you can spend human review on. Filter first, review what survives.

The install script runs before anybody reads the code

Worth restating the mechanics precisely, because the mental model most engineers carry — download, then execute when I run it — has been wrong in every major ecosystem for over a decade.

npm: preinstall, install, postinstall

npm runs lifecycle scripts automatically during installation. preinstall fires before the package is unpacked, install during, postinstall after, and prepare in some flows. Each is a shell command from the package's own manifest, executed as whatever user ran the install, with that user's filesystem access, that user's environment variables, and that user's network. On a developer laptop that user has an SSH agent, a cloud credential file and a session token. On a build agent it has a registry publish token and a deploy key.

These hooks exist for good reasons. Native addons genuinely need to compile against the local Node ABI, and no single prebuilt artifact works everywhere. That legitimacy is the whole problem: the feature cannot be removed, only contained.

npm install --ignore-scripts exists. Almost nobody sets it, and the people who do tend to unset it within a month, because something in the tree needs a native build and the error message does not say which. It is a real control that mostly fails in practice for social reasons rather than technical ones.

Python: setup.py executes at build and install time

A source distribution's setup.py is a Python program that the installer executes to learn what the package is. Under PEP 517 the build backend runs in a subprocess your installer starts, and its job is literally to run arbitrary project-defined build code. Wheels are better — a wheel install is closer to an unpack — but a wheel can still ship compiled artifacts whose build step ran on the publisher's machine, and if no wheel matches the platform, the installer falls back to building the sdist right there.

In both ecosystems the timing is the point: this code runs before any human, and before most tooling, has evaluated a single line of the package's actual functionality. Your reviewer reads the library. The attacker ships the payload in the step that happens before reading starts.

The recurring real-world pattern is unglamorous and worth describing qualitatively rather than with invented specifics: a typosquatted name a keystroke away from something popular, or a legitimate maintainer account compromised through a phished token or a stale recovery email, publishing a patch version whose install script collects environment variables and local credential files and posts them somewhere. Sometimes the library itself is a working, useful library. That is the version that survives review the longest.

Why the static scanner loses this one

Static analysis is necessary and I am not arguing against it. It is fast, it costs nothing per package, and it catches the entire category of attacker who did not think anyone was looking. Run it first, always. Just be clear about what it is doing: it is a filter on effort, not on intent.

  • Minification and obfuscation — a postinstall of one minified line with hex-escaped strings is not searchable in any useful sense, and it is indistinguishable from a bundled build artifact, which is a thing legitimate packages ship constantly.
  • Runtime assembly — the payload is base64 in a constant, or split across three string concatenations, or fetched from a URL that is itself assembled at runtime. Your grep for a curl-piped-to-shell string finds nothing because the string does not exist until the process is running.
  • Environment gating — the script checks the hostname, the presence of CI environment variables, the number of CPUs, whether a debugger is attached, or the country the IP geolocates to, and behaves impeccably when any of them looks like analysis. Static reading sees both branches and cannot tell you which one your developers will get.
  • Native build steps — the manifest says node-gyp rebuild or a setup.py with a custom build_ext, and the interesting code is in a binding.gyp rule, a Makefile, or a C file's constructor attribute. You are now statically analysing a build system, which is a research project, not a CI step.
  • Dependency indirection — the submitted package is clean and its install script simply installs another package that is not. Whatever your scanner reads, it is not the thing that will run.

Dynamic analysis inverts every one of those. It does not care whether the code was minified, because it observes the process, not the text. It does not care that the URL was assembled at runtime, because the connect syscall carries the finished address. Most importantly, it is the only method that can observe the network call at all — and the network call is the part of a credential stealer that cannot be optional. The exfiltration is the payload's one required, irreducible, observable act.

Static analysis asks what the code says. Dynamic analysis asks what the machine did. Only one of those is hard to lie to, and it is not the one that reads files.

The observation harness: be invasive, the machine is disposable

Here is the design freedom that makes this pleasant. Normally instrumentation is a negotiation — you cannot strace production, you cannot run every process under a syscall tracer, you cannot diff the whole filesystem before and after. In a machine created 180ms ago that you will destroy in three minutes, none of those constraints exist. Be as invasive as you like. Nobody is going to complain about the overhead, and the only workload on the box is the one you are trying to catch.

So run the install with scripts explicitly enabled, and record four things: processes spawned, files written outside the package's own directory, outbound connection attempts, and reads of anything credential-shaped.

#!/usr/bin/env bash
# scan-package.sh -- runs INSIDE the disposable guest. We are root here, which
# is not a privilege so much as a convenience: this machine has a three-minute
# life expectancy and nothing on it belongs to anyone.
set -uo pipefail

TARBALL="${1:?usage: scan-package.sh /work/pkg.tgz}"
OUT=/work/observations
mkdir -p "$OUT" /work/install
chown -R scanner:scanner /work/install

# 1. Baseline the filesystem, so "wrote outside its own directory" is a diff
#    rather than an opinion. Path+size+mtime only -- hashing a whole rootfs
#    costs more than the VM does.
find / -xdev -type f -printf '%p %s %T@\n' 2>/dev/null | sort > "$OUT/fs.before"

# 2. Detonate. Note what is NOT here: --ignore-scripts. Running the lifecycle
#    scripts is the entire purpose of this machine. strace -f follows children,
#    which matters because the payload is never in the parent.
timeout --signal=KILL 180 \
  strace -f -qq -s 512 -o "$OUT/strace.raw" \
    -e trace=execve,openat,connect,sendto,unlink,chmod,rename \
    runuser -u scanner -- \
      npm install --foreground-scripts --no-audit --no-fund "$TARBALL" \
    > "$OUT/npm.stdout" 2> "$OUT/npm.stderr"
echo $? > "$OUT/exit_code"

# 3. What did it run? Every exec, including the ones spelled sh -c 'eval ...'.
grep -oP 'execve\("\K[^"]+' "$OUT/strace.raw" | sort | uniq -c > "$OUT/processes"

# 4. Where did it try to go? Egress is default-deny at the host, so these are
#    ATTEMPTS -- the syscall is recorded even though the packet never left.
#    A blocked connect is not a failed detection, it IS the detection.
grep -E 'connect\(.*(sin_addr|sin6_addr)' "$OUT/strace.raw" > "$OUT/connects"
grep -oP 'openat\(.*"\K[^"]*' "$OUT/strace.raw" \
  | grep -E '/etc/resolv.conf|/etc/hosts' | sort -u >> "$OUT/connects"

# 5. What did it read that a package installer has no business reading?
grep -oP 'openat\(.*"\K[^"]*' "$OUT/strace.raw" \
  | grep -E '\.npmrc|\.pypirc|\.aws/|\.ssh/|\.docker/config|\.git-credentials|/proc/self/environ|/var/run/secrets' \
  | sort -u > "$OUT/credential_reads"

# 6. What did it leave behind outside the tree it was allowed to touch?
find / -xdev -type f -printf '%p %s %T@\n' 2>/dev/null | sort > "$OUT/fs.after"
comm -13 "$OUT/fs.before" "$OUT/fs.after" \
  | grep -vE '^/work/install/node_modules|^/proc|^/sys|^/run|^/tmp/npm-' \
  > "$OUT/fs.written"

# 7. Bait. A plausible-looking credential file that no honest installer opens.
#    A read of this path is a zero-false-positive signal.
#    (Placed before the install by the caller; we just report whether it moved.)
stat -c '%x' /home/scanner/.aws/credentials > "$OUT/bait_atime" 2>/dev/null || true

tar czf /work/observations.tgz -C /work observations

A few notes on why it is shaped this way. The install runs as an unprivileged user even though the harness is root, because npm behaves differently under root and you want the guest to resemble a developer machine, not an unusual one. strace follows forks because the payload is always in a child — a postinstall that itself spawns node -e is the common shape. And the filesystem diff excludes node_modules deliberately: writing into its own package directory is what an installer does. Writing to /home, /etc, a shell profile or a systemd unit is what something else does.

Default-deny egress turns exfiltration into your best signal

This is the design point I would keep if I had to throw the rest away. Configure the guest's network as default-deny outbound, enforced outside the guest, with an allowlist containing exactly one thing: your own registry mirror, which the install needs to resolve dependencies. Nothing else. Not the open internet, not DNS to a public resolver, not your internal network, and absolutely not the cloud metadata endpoint.

Now consider what happens when a package tries to POST a stolen environment variable to an attacker's collector. The connect fails. The install script, written by someone who assumed a normal machine, either retries, errors out, or silently swallows it. And you have a log line containing the destination address the attacker chose. The exfiltration attempt is simultaneously prevented and recorded. The package told on itself, and the mechanism that stopped the attack is the same mechanism that detected it.

Contrast the alternative. Give the scanner unrestricted egress so packages behave naturally, and now a successful detection means the credentials already left. You have converted your detection system into a delivery mechanism with logging. Deny by default and you get the signal without the loss.

On PandaStack each sandbox gets its own network namespace with a veth pair and TAP device — 16,384 /30 subnets are pre-allocated per agent, which is what makes per-sandbox network policy cheap enough to do on every create. The filtering lives in the host namespace, so it is not a setting the guest can be persuaded to reconsider. The guest can see that packets are not going anywhere; it cannot do anything about it.

Do not put your registry's own publish token, mirror upstream credential, or scanner API key inside the scan guest. The whole population of code running in there is code you already suspect. If the guest needs the mirror, front it with a proxy that authenticates upstream on the guest's behalf, so no credential is ever mounted in the machine you are detonating in.

Why a microVM and not a container, concretely

I use containers all day and they are excellent at what they are for. This is not what they are for, and the reason is specific rather than ideological.

You are deliberately executing code you believe may be hostile, on a workload that legitimately includes a native compile step. That compile step must spawn processes, write files, allocate memory, exec a compiler, and — because it is fetching sources or checking a version — open sockets. Now try to write a seccomp profile or an AppArmor policy for it. What would you deny? The malicious behaviour and the legitimate behaviour are the same syscalls, issued in the same order, by the same binaries. Hardening works by removing capabilities a program does not need; this program needs all of them. You end up with a policy file full of allows and a comforting feeling.

And underneath the policy file, every container on that node is calling into one shared host kernel, which is the same kernel serving the other scans, the scheduler, and whatever else lives on the box. A container is a polite suggestion to the kernel, and you are running a workload selected for its willingness to be impolite.

A Firecracker microVM changes the shape of the question. The guest gets its own kernel — 5.10 in our templates — running under KVM, and the interface the guest code can reach is the hypervisor's small device model rather than the full host syscall surface. Firecracker itself runs jailed and seccomp-filtered on the host side, so even a VMM compromise lands somewhere narrow. Complete compromise of the guest means the attacker owns a machine with 170 seconds left to live, containing one package tarball and nothing else, unable to reach the network.

The honest cost is that a VM boot is more expensive than a container start. That was the real objection for years, and snapshot-restore is what dissolved it: a PandaStack create restores a baked snapshot at about 179ms p50 and 203ms p99, with the restore step itself near 49ms. That is fast enough that per-package isolation stops being a budget conversation.

Fleet mechanics: one VM per package version, never reused

The operational rules are short, and one of them is non-negotiable in a way the others are not.

  1. One fresh machine per package version. Not per package, per version — a clean 1.4.2 tells you nothing about 1.4.3, and version-scoped compromise is exactly the observed pattern when a maintainer account is taken over.
  2. Never reuse a scan VM between packages. This is the rule that has teeth. Reuse is how package B inherits package A's dropped payload, and it is also how you get an unreproducible verdict you cannot defend three weeks later when someone asks why you quarantined a customer's release.
  3. A hard, platform-enforced TTL under every scan. A package that spins forever is then the platform's non-problem: the guest is reaped whether or not your scanner process is still alive to notice. Cleanup that depends on your own code running is cleanup that fails during an incident, which is the only time it mattered.
  4. Treat timeout as a verdict, not an error. Exit 124 is information: this package's install does not terminate in three minutes on a two-core machine. Route it to human review, do not silently pass it.
  5. Record the environment with the verdict. Template name, snapshot generation, harness version. A scan report you cannot reproduce is an opinion.
import json
import pathlib
from pandastack import Sandbox

SCAN_SECONDS = 180
HARNESS = pathlib.Path("harness/scan-package.sh").read_text()

# Paths that no honest package installer opens. We plant plausible contents so
# a payload that greps for keys finds something -- and reading them is then a
# signal with, in practice, no false positives.
BAIT = {
    "/home/scanner/.aws/credentials": "[default]\naws_access_key_id = AKIAEXAMPLECANARY000\n",
    "/home/scanner/.npmrc": "//registry.example.com/:_authToken=npm_canary_not_a_real_token\n",
}


def scan_version(name: str, version: str, tarball: pathlib.Path) -> dict:
    """Detonate exactly one package version in exactly one machine."""
    # ttl_seconds is the backstop, not the timeout. If this process is killed
    # mid-scan by a deploy, the guest still dies on schedule.
    sbx = Sandbox.create(template="npm-scanner", ttl_seconds=SCAN_SECONDS + 120)
    try:
        for path, contents in BAIT.items():
            sbx.filesystem.write(path, contents)
        sbx.filesystem.write("/work/pkg.tgz", tarball.read_bytes())
        sbx.filesystem.write("/work/scan-package.sh", HARNESS)

        r = sbx.exec(
            "chmod +x /work/scan-package.sh && /work/scan-package.sh /work/pkg.tgz",
            timeout_seconds=SCAN_SECONDS + 30,
        )

        # Read the observations back BEFORE destroying the machine. Obvious,
        # and still the bug everyone writes once.
        obs = {
            key: sbx.filesystem.read(f"/work/observations/{key}")
            for key in ("processes", "connects", "credential_reads",
                        "fs.written", "npm.stderr", "exit_code")
        }
        return verdict(name, version, obs, harness_exit=r.exit_code)
    finally:
        # Not "if it succeeded". Always. The machine is the containment.
        sbx.destroy()


def verdict(name: str, version: str, obs: dict, harness_exit: int) -> dict:
    findings = []

    # Any outbound attempt to something that is not the mirror. Egress is
    # default-deny, so these all FAILED -- which is why we still have the log.
    for line in obs["connects"].splitlines():
        if "10.200." in line or "mirror.internal" in line:
            continue
        findings.append(("egress_attempt", line.strip()))

    if obs["credential_reads"].strip():
        findings.append(("credential_read", obs["credential_reads"].strip()))

    for line in obs["fs.written"].splitlines():
        if line.startswith(("/home/", "/etc/", "/usr/local/bin", "/root/")):
            findings.append(("write_outside_package", line.split()[0]))

    if harness_exit == 124 or harness_exit == 137:
        # Did not terminate, or ate its own RAM. Not a pass.
        findings.append(("no_terminate", f"harness exit {harness_exit}"))

    return {
        "package": f"{name}@{version}",
        "status": "quarantine" if findings else "clean",
        "findings": findings,
        # Reproducibility metadata. A verdict you cannot re-run is a rumour.
        "harness": "scan-package.sh@v7",
        "template": "npm-scanner",
    }

Fork: every scan starts from a byte-identical clean state

Creating a machine per package is affordable at 179ms, but there is a better version once you have volume. The expensive, repetitive part of a scan is not the VM — it is getting the machine ready: registry mirror configured, npm and Python toolchains warm, node-gyp's build prerequisites present, the baseline filesystem listing already computed. Do that once, snapshot it, and fork per package.

A same-host fork is 400–750ms and cross-host 1.2–3.5s, and the mechanism is why it is cheap: guest memory is copy-on-write on the restored snapshot and the rootfs is a reflink clone, so a fork costs metadata rather than a copy of the machine. Cross-host is slower because the memory image has to travel; keep a scan fleet host-local and you stay in the fast band.

The security property is at least as valuable as the speed. Every fork starts from the same frozen state, byte for byte, so two packages cannot influence each other's results through anything left on disk or in memory — and when a scan produces a verdict a publisher disputes, you re-fork the same snapshot and run it again. Reproducibility is not a nice-to-have for a gate that can block someone's release. It is the difference between a policy and an argument.

One caveat that matters here specifically: a snapshot freezes the guest clock along with everything else, so a restored guest wakes believing it is the moment of the bake. Sync the clock at the start of each scan or TLS handshakes to your mirror start failing in ways that look like a package problem and are not.

The consumer-side half nobody configures

Detonation is the registry's job. There is a much cheaper control on the developer side, and since we are here, it deserves the four lines it takes. It is not a substitute — it is defence in depth, and it protects your organisation even from packages your own gate has not seen yet.

# ~/.npmrc or the repo's .npmrc -- opt OUT of install-time code execution.
ignore-scripts=true
registry=https://mirror.internal.example.com/npm/
audit=false                      # your mirror already gated this; do not
                                 # leak the dependency graph to a third party

# The honest catch: some packages genuinely need their build step. Do not
# fight it globally -- allow it per-package, deliberately, and write down why:
#   npm rebuild better-sqlite3       # reviewed 2026-09, native build required

# Python: prefer wheels, never silently fall back to executing a setup.py.
#   pip install --only-binary=:all: -r requirements.txt
# ...and pin by hash so "the version I reviewed" and "the version I installed"
# are the same artifact:
#   pip install --require-hashes -r requirements.lock

# Verify what you actually enabled -- the failure mode here is believing you
# set this and finding out in an incident that a CI image overrode it:
npm config get ignore-scripts     # must print: true
npm config list -l | grep -E 'ignore-scripts|registry'

If you run the registry, the strongest version of this is to make the safe path the default path: publish a mirror config your developers get automatically, and let the gate's verdict decide whether a version is even resolvable. A quarantined version that cannot be installed beats a warning nobody reads.

What each control actually catches

Weakest to strongest, for this specific job. Container behaviour here describes general architectural properties and common defaults rather than a benchmark — a well-configured container platform with per-job network policy and ephemeral workers closes several of these gaps, so verify against your own platform's current docs. The only measured numbers are PandaStack's.

  • npm install --ignore-scripts — Catches: the entire install-time execution class, for the packages that tolerate it. Misses: everything that runs at import or runtime rather than install, and every package with a real native build step, which is where you will turn it back off. Costs nothing, protects only the person who set it, and tells you nothing about whether the package was malicious.
  • Static scanner on the tarball — Catches: plaintext curl-to-shell, known-bad domains, obvious credential-file reads, typosquat name distance, unexpected new install scripts between versions. Misses: minified or base64'd payloads, runtime-assembled URLs, sandbox-aware branches, anything hidden in a node-gyp or setup.py build. Fast and free per package, so run it first — just never call its silence a pass.
  • Container sandbox running the install — Catches: everything dynamic analysis catches, which is a lot — the exec tree, the file writes, the connect attempts. Misses: nothing about observation, but the boundary is thin for deliberately hostile code, because you are sharing one kernel and a build step legitimately needs the syscalls you would otherwise deny. Fine for packages you mostly trust, uncomfortable as a public submission gate.
  • microVM detonation, default-deny egress — Catches: process tree, files written outside the package, credential-file reads, and every outbound connection attempt — logged and blocked, so a detection does not cost you the secret. Misses: sandbox-aware and time-delayed payloads, and anything that only triggers at runtime rather than install. Costs about 179ms p50 to create from a snapshot, or 400–750ms to fork a warmed one, per package version.
  • Human review of what survives — Catches: intent, novelty, and the quiet targeted attack that behaves perfectly in your sandbox. Misses: volume — this does not scale and must never be the first filter. The point of the four rows above is to make this row's queue small enough to actually staff.

Be honest: this is a filter, not a proof

A gate that oversells itself creates worse outcomes than no gate, because people stop looking. So, plainly, the things this design does not do.

A script that detects the sandbox passes. Minimal microVMs are extremely fingerprintable — the device model is distinctive, the CPU count is small, the MAC prefix is recognisable, there is no browser history, no logged-in user, no third-party processes, and the uptime after a snapshot restore is implausible. An attacker who checks any of that and behaves impeccably will get a clean verdict from you. You can raise the cost with plausible noise and randomised guest identity, but this is an arms race and you will not win it permanently.

Time- and trigger-delayed payloads pass. A script that does nothing for a week, or nothing until it sees a hostname matching a target's naming convention, or nothing until a specific package is present alongside it, produces an empty observation log. Your three-minute detonation window cannot observe a payload that has not decided to exist yet.

Runtime behaviour is out of scope. This gate scans install time. A package whose install is spotless and whose exported function exfiltrates on first call is entirely clean by this measure — which is a good argument for scanning both, and for not letting the install-time badge imply more than it means.

And a clean scan is a statement about one execution of one version on one machine configuration, not a property of the package. Write the verdict that way in your UI. "No malicious install-time behaviour observed" is true and useful. "Verified safe" is a claim you cannot support and will eventually have to retract in public.

The failure mode to design against is not a missed detection. It is a green badge that causes a human reviewer to skim. Rank the queue by observation richness rather than by verdict, and make "the sandbox saw nothing at all" its own suspicious category — because for a package with a native build step, seeing nothing is itself unusual.

The summary

Install scripts run automatically, as your user, with your environment, before anybody has reviewed a line of the package's real code — npm's preinstall/install/postinstall lifecycle and Python's setup.py both. Static scanning catches the lazy attacker and loses to minification, runtime-assembled payloads, environment gating and native build steps. Dynamic analysis is the only method that observes the network call, and the network call is the one part of a credential stealer that cannot be optional.

So detonate. One fresh microVM per package version, install with scripts deliberately enabled, strace the process tree, diff the filesystem, plant bait credentials, and make egress default-deny so the exfiltration attempt is logged and blocked rather than logged and successful. A container is the wrong shape here because a native build legitimately needs every syscall you would want to forbid; a separate guest kernel per scan is the boundary that survives. Snapshot-restore makes it 179ms per machine, fork makes it 400–750ms from an identical warm state, and a hard TTL makes a package that spins forever somebody else's problem.

Then say what you found in the words that are true. You did not prove the package is safe. You made the cheap attacks expensive, the loud attacks visible, and the remaining set small enough that a human can look at it — and you did it on a machine that was always going to be deleted.

Frequently asked questions

Why not just require --ignore-scripts instead of building a detonation gate?

Because it protects the person who set it, not the ecosystem, and because it does not survive contact with reality. Native addons genuinely need to compile against the local ABI, so the first time a developer hits an inscrutable build failure in a transitive dependency they turn scripts back on globally and never turn them off again. It is also a blunt instrument: it tells you nothing about whether a package was malicious, it just declines to find out. The two controls do different jobs and belong together — set ignore-scripts organisation-wide with a small, documented, per-package allowlist for the handful of packages that genuinely need a build step, and run detonation at the registry so you learn what the packages you are blocking actually wanted to do. The registry-side gate is also the only one of the two that can protect a developer who has not configured anything.

Doesn't blocking outbound network make the scan less accurate?

It changes what you observe, and the trade is strongly in your favour. Yes, a payload that cannot reach its collector may take a different branch, and some install scripts will fail in ways that generate noise. But the syscall is recorded whether or not the packet leaves, so you still get the destination the attacker chose — which is the single most valuable artifact of the whole scan. The alternative is unrestricted egress so packages behave naturally, which means every successful detection is also a completed exfiltration: you have built a delivery mechanism with logging. Allow exactly one destination, your registry mirror, so dependency resolution works, and deny everything else. In practice a blocked connect attempt is a higher-confidence finding than almost anything else in the observation log, because legitimate installers talk to the registry and essentially nothing else.

Can a package detect it is being scanned in a microVM and behave differently?

Yes, and you should assume competent attackers do. A minimal microVM is highly fingerprintable: a small distinctive device model, a low CPU count, a recognisable MAC prefix, no browser history, no logged-in user session, no unrelated processes, and after a snapshot restore an uptime and clock that do not look like a developer laptop. A script that checks any of those and behaves impeccably will get a clean verdict. You can raise the cost — randomise guest identity and hostname, populate a plausible home directory, vary the CPU and memory shape, sync the clock on restore — but this is an arms race with no permanent win. The correct response is architectural rather than technical: treat a clean scan as a filter result, not a safety proof, rank sandbox-aware indicators as their own suspicious signal, and keep human review in the loop for what survives.

How much does one machine per package version actually cost?

Less than the alternative, which is why the design became practical. A PandaStack create restores a baked snapshot at roughly 179ms p50 and 203ms p99, with the restore step itself near 49ms; the only cold boot is the very first one at around 3 seconds, and you pay it once per template. If you warm a scanner template with your mirror configured and the toolchains present, forking it is 400–750ms same-host, so every scan starts from a byte-identical clean state without repeating setup. The actual cost driver is the install itself — resolving and building a dependency tree takes seconds to minutes regardless of where it runs, and that cost is identical on a container or a bare runner. The isolation is close to free on top of work you were already doing.

What do you do with a package that times out or produces no observations at all?

Neither is a pass, and the second one surprises people. A timeout means the install did not terminate within your budget on your machine shape, which is a legitimate finding — route it to human review with the partial observation log attached, and consider whether the package is simply enormous or is deliberately burning your scanner's time. An empty observation log deserves its own category. For a pure-JavaScript package with no install script, seeing nothing is expected and fine. For a package that declares a native build step, an install that spawned almost nothing and touched almost nothing is anomalous — it usually means the build silently no-op'd, which means you did not actually scan the interesting part. Rank your review queue by observation richness rather than by verdict, so 'the sandbox saw nothing' cannot quietly become 'the package is clean'.

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.