
Fifteen years building engineering platforms, currently focused on advanced AI infrastructure at yeet. I love turning the deeply complex topics into something everyone can understand. I relate deeply with the core yeet philosophy that you can just build things.
TL;DR. A dependency's
preinstall,installandpostinstallscripts execute as your user duringnpm install, with your filesystem and network access. Readingpackage.jsontells you what an author wrote, not what ran. To see what actually executed, run the install under a process-launch tracer:./bin/exectop -- npm cishows every program the install started, folds the repetition, and ranks anything unusual on top. The limit to understand before you trust the output: a package that does its work inside Node without launching a program produces no rows at all.
Most of the risk in a dependency tree is not exotic. It is that installing a package runs code, that code runs as you, and the thing everyone reaches for to check it is the manifest, which is a record of intent rather than of execution. I write eBPF tools rather than audit packages for a living, so I am not going to tell you which registries to trust or how a specific incident unfolded. What I can tell you is what the kernel sees when an install runs, and where that view stops being useful, which turns out to be the more important half.
Yes, at every depth in the tree, and there are three of them by name. A package can define preinstall, install and postinstall in its package.json, and npm executes them in that order as part of ordinary installation. The npm documentation makes the mechanism concrete when describing native modules: "If there is a binding.gyp file in the root of your package and you haven't defined your own install or preinstall scripts, npm will default the install command to compile using node-gyp via node-gyp rebuild", and those "are run from the scripts of <pkg-name>".
That last clause is the part worth sitting with. The scripts that run belong to the package being installed, not to you. Per npm's own description, these lifecycle scripts "all run after the actual installation of modules into node_modules, in order, with no internal actions happening in between."
The depth is what defeats manual review. Install scripts run regardless of how far down the tree a package sits, so a single direct dependency can pull in dozens of transitive ones, each able to define its own. Your package.json lists what you chose; it says almost nothing about what will execute.
Because a script line is an entry point, not a description of behavior. "postinstall": "node scripts/setup.js" is entirely normal and tells you nothing about what setup.js does. The real behavior can sit one file away, inside a binary shipped in the package, or behind a download the script performs at install time, and none of those are visible in the manifest you audited.
The two questions are worth keeping separate, because they have different answers and different failure modes:
package.json, then read the files it invokes. This answers intent, catches obvious hostility, and takes about two minutes for a package you are evaluating.Neither replaces the other. A script that looks clean can behave differently based on the environment, the platform, or what a remote server returns when it is asked, and a manifest cannot show you that. The asymmetry runs one way: reading the files can prove that a package intends something hostile, and it can never prove the absence of it, because the code that runs is assembled at install time from inputs the manifest does not contain.
Launch the install under a process-launch tracer rather than attaching to it afterward. The distinction matters more here than in most tracing work, because an install is short and the interesting part is often at the very beginning.
curl -fsSL https://yeet.cx | sh # install yeet, once
./bin/exectop -- npm ci # run the install, watch every process it starts
The launcher starts npm stopped, hands its pid to the probe, waits for the tracepoints to attach, and then lets it go. Nothing runs before the probe is watching, which is why the status bar can say complete tree and mean it. If you attach to an install already in progress with --pid, you get what it starts from that point on, and a child that forked before you attached is outside the traced set until it forks again.
What comes back is grouped rather than streamed, which matters for an install specifically. A dependency tree's execs are overwhelmingly repetition, mostly the same handful of build and shell commands run once per package, so a flat stream buries the one unusual command in several hundred ordinary ones. Folding collapses the repetition into a row per kind of command and ranks the exceptions above it:
── doesn't fit ──────────────────────────────────────────────────────────────
▲ curl -fsS -o ⟨2 args⟩ ran once, fetches from the network
▲ base64 -d ran once, evaluates constructed input
▲ ls ⟨1 arg⟩ ran once, touches credential paths
▲ chmod ⟨2 args⟩ ran once, widens permissions
Each reason names something observed in the arguments rather than a judgment about intent. fetches from the network means the command line contained a URL, not that the fetch was malicious; plenty of legitimate installs download a prebuilt binary. The panel is a shortlist to read, not a verdict.
Because running once is ordinary in an install, where most commands are one-off setup rather than repeated work. That measurement, 11 of 34 distinct commands running exactly once in a real npm install, is what shaped the whole flag rule. A rule that surfaced anything rare would have surfaced a third of the commands in a completely ordinary install, which trains you to skim past the panel within a day.
So rarity is only half the rule, and the other half is what the command actually did. A row appears when it ran three times or fewer and matched one of a named set of behaviors observed in its arguments, which is what keeps an ordinary install quiet while leaving the unusual visible:
| Behavior in the arguments | Why it stands out in an install |
|---|---|
| Fetching from the network | The package is pulling something the registry did not provide |
| Piping a download into a shell | Executing code fetched at install time, unreviewed |
| Evaluating constructed input | Decoding or interpreting a blob rather than running a program |
| Widening permissions | Making something writable or executable after writing it |
| Changing privileges | Attempting to run as a different user |
| Touching credential paths | Reading from a directory that holds keys or tokens |
Unpacking into /tmp | Staging content outside the project tree |
A build that legitimately fetches six times stays silent, because six is that build's normal. That is the intended behavior and it is also a real limitation: a hostile script that fetches four times hides in the same rule that stops the panel from crying wolf.
It looks like a short sequence of ordinary commands in an order that makes no sense for building a package. The individual programs are not suspicious. curl is a normal tool, base64 is a normal tool, chmod is a normal tool. What stands out is the shape: fetch something, decode it, make it executable, run it, in a context where the package's stated job is to compile a native module.
The reason process-level observation catches this shape at all is that each step is a program launch, and a program launch is a kernel event that the code performing it does not control. A script can decline to log, it cannot decline to be observed launching curl. That is the entire value of watching from the kernel side rather than trusting the tooling inside the install. If you want this demonstrated on a deliberately obfuscated payload rather than described, capturing every command a build runs walks one through end to end.
But the shape is only one shape, and this is where I want to be direct rather than reassuring.
Because reading a file is a syscall inside a process that already exists, not a new program being launched. This is the boundary that matters most in this post, and it is not a rough edge; it is a straight consequence of what a process-launch probe observes.
A dependency doing its work inside the Node runtime produces no rows at all. Fetching a URL with fetch() looks like nothing. Reading ~/.ssh/id_rsa with fs.readFileSync looks like nothing. Writing a file, parsing a token out of an environment variable, sending it somewhere over an existing connection: all of it happens inside a process that already execed, so a tracer watching exec sees one Node process and no further detail.
A quiet screen is not evidence that nothing happened. It is evidence that nothing was launched. If you take one thing from this post, take that sentence, because the failure mode of observability tooling is not usually a wrong answer, it is a confident-looking absence of one.
Two more gaps are worth naming plainly. Pattern matching on command lines is defeated by anything that renames a binary or assembles its argument string at runtime, so the flag panel is not a control that something can be prevented from evading. And a fork storm past roughly 2,700 execs a second outruns the ring buffer: in a deliberate stress test, 96,000 execs fired across 24 workers produced 36,000 captured and 60,000 dropped. Drops are counted rather than hidden, and the report says the numbers are a floor, but a normal install runs tens of execs a second and never approaches this.
Seeing which files a package opened needs a different hook: file access is a syscall inside a process that already exists, not a new program. For that, agent-lock watches and enforces file access, and if the thing you are auditing is an MCP server your agent launched rather than a package you installed, auditing a local MCP server covers the file and socket view alongside the launches.
Often, yes, and it is the strongest single mitigation on this page. npm's documentation defines the flag directly: "If true, npm does not run scripts specified in package.json files." An install that runs no scripts cannot be attacked through a script, and the reason more people do not use it by default is habit rather than necessity.
Two caveats keep it from being the whole answer. npm documents one of them: commands explicitly intended to run a script, such as npm start, npm test and npm run-script, "will still run their intended script if ignore-scripts is set, but they will not run any pre- or post-scripts." The other is practical rather than documented. Some packages need their install script, because compiling a native module is what that script does, and --ignore-scripts leaves you with a package that does not work.
That is where observation earns its place: not as an alternative to --ignore-scripts, but as what you do for the specific packages you have decided to let run scripts. The workflow that makes sense is narrow. Install with scripts disabled by default, and when a package needs them, watch that one install.
npm ci --ignore-scripts # default posture
./bin/exectop -- npm rebuild sharp # watch the one package that needs a build
No, and pretending otherwise would waste your time. Those tools analyze packages before you run them, maintain databases of known-bad releases, and integrate with a pipeline so a merge can be blocked. A process-launch tracer does none of that. It has no rules to configure, no database, no way to block anything, keeps nothing after you quit, and sees one host.
What it adds is the one thing a database cannot have: what this install did on this machine just now, including behavior nobody has catalogued yet. A scanner tells you a release is known-bad. Observation tells you what happened when you ran it. The honest position is that these are complementary, that the scanner is the one that scales across a team, and that the tracer is the one you reach for when you want to see a specific install with your own eyes.
| Approach | Runs when | Blocks a merge | Sees behavior nobody has catalogued |
|---|---|---|---|
exectop | During the install | No | Yes, if it launches a process |
| Snyk, Socket.dev and similar scanners | Before you install | Yes | No, matches against known releases |
--ignore-scripts | Prevents the install running scripts | Not applicable | Not applicable, nothing runs |
Reading package.json | Before you install | No, human review | Only what is written in the files |
Yes, within limits worth knowing. The data layer prints plain text with no terminal required, which is what makes it work in a CI job at all, and it is a single binary run with no platform behind it:
yeet run src/probes/capture.js -- $(pgrep -n npm) 60
What it is not is a gate. It has no rules, no policy engine, and no way to fail a build on a finding, so anything that reads its output has to make its own decision from text. Treat it as a look at what happened that a human reads, and if you need a hard gate on dependencies, that is what the scanners in the table above are built for. Wiring this into a pipeline properly, including diffing what a job launched between two environments, is its own subject.
The other honest limit for CI: a finding here is not proof of malice and an empty panel is not proof of safety, so wiring it to fail a build on any flagged row will produce false alarms on packages that legitimately download a prebuilt binary. Read it, do not automate on it.
Run npm ci --ignore-scripts as your default and you have removed the attack surface this post is about, at the cost of breaking packages that compile a native module. For those, watch the single install with exectop and read the flagged rows yourself. Use Snyk, Socket.dev or a comparable scanner for the part that has to scale across a team and block a merge, because a terminal tool on one laptop will never do that job. Install in a container when you can, since it bounds what an install script can reach even when you let it run. Reach for agent-lock when you want a kernel-enforced boundary rather than a record of what happened. And do not read a quiet screen as an all-clear: a package that stays inside Node never launches anything, and the tracer will show you nothing at all while it works.
Yes. A package can define preinstall, install and postinstall scripts in its package.json and npm runs them as part of installation. The npm documentation describes the default behavior for native modules: if there is a binding.gyp file in the root of a package and the author has not defined their own install or preinstall scripts, npm defaults the install command to node-gyp rebuild, run from the scripts of that package. Those scripts execute as the user running npm install, with that user's full filesystem and network access.
Pass --ignore-scripts, which the npm documentation defines as: if true, npm does not run scripts specified in package.json files. It is the strongest single mitigation available and it is underused. The caveat in npm's own docs is that commands explicitly intended to run a particular script, such as npm start, npm test and npm run-script, still run their intended script, though they will not run any pre or post scripts.
Nothing structural prevents it. An install script runs as the user running npm install, in that user's session, so it can read anything that user can read, including ~/.ssh, ~/.aws, ~/.npmrc and the full environment of the process. This is not a flaw in npm so much as a consequence of running arbitrary code as yourself. Containerizing the install or using --ignore-scripts are the mitigations; file permissions alone are not.
It tells you what the author wrote, which is a different question from what ran. A script line that invokes another file, downloads a build helper, or calls a binary shipped inside the package moves the real behavior out of the manifest. Auditing the manifest is worth doing and it answers intent. Observing the install answers what executed, including the parts that live outside any file you read.
The common shape is a package whose install script does something beyond building the package: fetching a payload from a URL, decoding an embedded blob, reading credential files, or widening permissions on something it wrote. It reaches you through a dependency you added on purpose or through a transitive dependency several levels down that you never chose directly. The install is the moment the code runs, which is why it is the moment worth watching.
No, and the two answer different questions. Scanners analyze packages before you run them, maintain databases of known-bad releases, and integrate with a pipeline to block a merge. Watching an install observes one run on one host, keeps nothing after you quit, and blocks nothing. What observation adds is what this install did on this machine just now, including behavior nobody has catalogued yet. Using both is the sensible position.
Yes, and the way it does so is not exotic. Process tracing sees programs being launched, so a package that does its work inside the Node runtime, fetching a URL with fetch() rather than shelling out to curl, produces no process events at all. Renaming a binary or building an argument string at runtime also defeats pattern matching on command lines. Process-level observation is evidence of what launched, never proof that nothing happened.
It is a meaningful improvement, because it bounds what the install can reach: no host SSH keys, no host cloud credentials, no host environment. It is not a complete answer, since the container still has network access by default and the installed artifacts still end up on your machine. Combining a container with --ignore-scripts, and reserving script execution for packages you have a reason to trust, covers most of the practical risk.
Both run lifecycle scripts; the difference is in how dependencies are resolved rather than whether code executes. npm ci installs from the lockfile and expects it to be present and in sync, which makes what gets installed reproducible. Reproducible is not the same as safe: a lockfile pins a version, and if the pinned version of a transitive dependency carries an install script, npm ci runs it exactly as reliably as npm install does.
That needs a file-access probe rather than a process-launch probe, because opening a file is a syscall inside an existing process and not a new program. Tracing openat and its relatives shows the reads and writes; process tracing shows only what got launched. They are complementary views, and the reason they are usually separate tools is that each needs a different kernel hook.
A transitive dependency is one you never listed: a dependency of a dependency, often several levels deep. It matters because install scripts run regardless of depth. Adding one direct package can pull in dozens of others, each able to define its own install script, and the count is why manual review does not scale. Checking your own package.json tells you almost nothing about what will execute.
It runs as the user who ran npm install, with that user's privileges, which on a developer laptop usually means full access to that user's files, keys and tokens. Running the install under sudo escalates this considerably and is worth avoiding for that reason. Nothing in npm's design sandboxes an install script from the account performing the install.
preinstall, install, postinstall, prepublish, preprepare, prepare, postprepare), states they "all run after the actual installation of modules into node_modules, in order, with no internal actions happening in between", and documents that a binding.gyp in a package root with no author-defined install or preinstall script makes npm default to node-gyp rebuild, "run from the scripts of <pkg-name>".--ignore-scripts as "If true, npm does not run scripts specified in package.json files", and records the exception that npm start, npm stop, npm restart, npm test and npm run-script "will still run their intended script if ignore-scripts is set, but they will not run any pre- or post-scripts".npm install, 11 of 34 distinct commands ran exactly once, so rarity alone would flag a third of an ordinary build; a row surfaces only at three runs or fewer plus a match against a named behavior, and every reason names something observed in the arguments rather than inferred intent.fetch() producing no rows where curl produces one; the measured ceiling of roughly 2,700 execs per second, with 96,000 fired across 24 workers yielding 36,000 captured and 60,000 dropped; the 1 KiB argv window; and that findings are pattern matches defeated by a renamed binary or a runtime-assembled argument string.binding.gyp, which compiles native addons on the installing machine and is the legitimate reason a large share of packages need install scripts to run at all, and therefore the reason --ignore-scripts cannot simply be left on for every project.sched_process_exec and sched_process_fork, the hooks a process-launch probe attaches to; a tracepoint fires from inside the kernel, which is why a script cannot decline to be observed launching a program the way it can decline to log its own actions.-f as tracing children as they are created and the ptrace attach mechanism that stops the tracee on each traced event, the per-syscall cost that makes it impractical across the hundreds of short-lived processes a package install can create.binding.gyp.