# Native Code (Native Addons and Prebuilt Binaries)

> Native code is compiled machine code in a package, such as a Node.js addon built by node-gyp or a prebuilt binary, that source review cannot read.

Also known as: Node.js addon, C++ addon, native module, prebuilt binary, prebuilds, node-gyp, binding.gyp, .node file, Node-API, N-API, Python extension module, compiled extension

Source: https://socket.dev/glossary/native-code

Last updated: 2026-11-02

Native code is compiled machine code shipped inside an open source package: a Node.js native addon that node-gyp builds from a `binding.gyp` file, a prebuilt binary, or a Python extension module. It runs with the full privileges of the process that loads it, often during install, and nobody can review it the way they review JavaScript or Python source.

Native code gives a [malicious package](https://socket.dev/glossary/malicious-package) two execution points that source review does not cover. The first is the build. npm treats any package with a `binding.gyp` as having an [install script](https://socket.dev/glossary/postinstall-scripts), and node-gyp evaluates that file as a Python expression, so a package whose `package.json` declares no scripts can still run a shell command during `npm install`. The second is the load. A `.node` file is a shared library, and the operating system runs its constructors the moment `require()` loads it, before any exported function is called.

Our position: a `binding.gyp` is an install script, and a binary is unreviewed code. [pnpm 10](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default) in January 2025 and [npm v12](https://socket.dev/blog/npm-12) on July 8, 2026 closed the first gap by default. Nothing in npm, pnpm, or pip gates the second. The useful signal is not that a package contains native code, because thousands legitimately do. It is that a package which shipped none in its previous version ships some now.

## What is node-gyp?

node-gyp is the build tool npm bundles to compile Node.js native addons from C and C++ source. Its [README](https://github.com/nodejs/node-gyp) calls it "a cross-platform command-line tool written in Node.js for compiling native addon modules for Node.js." It reads `binding.gyp` from the package root, runs gyp-next, the Node.js project's Python fork of Google's GYP, to generate a Makefile or a Visual Studio project, and compiles against the Node.js headers into `build/Release/<target>.node`. The installing machine needs Python, `make`, and a C/C++ toolchain, which is why native builds fail so often in slim CI images.

A native addon is what node-gyp produces. The [Node.js addon documentation](https://nodejs.org/api/addons.html) defines addons as "dynamically-linked shared objects that can be loaded via the require() function." On Linux a `.node` file is an ELF shared object, on macOS a Mach-O bundle, and on Windows a DLL. Addons built on Node-API, a stable ABI, let one binary run across Node.js versions.

npm runs node-gyp for you. The [npm scripts documentation](https://docs.npmjs.com/cli/v11/using-npm/scripts) says that when a package root contains `binding.gyp` and defines no `install` or `preinstall` script, npm defaults `install` to `node-gyp rebuild`. The rule lives in npm's manifest normalizer, trimmed here from the copy npm 10.9.9 bundles:

```javascript
// add "install" attribute if any "*.gyp" files exist
if (!scripts.install && !scripts.preinstall && data.gypfile !== false) {
  const files = await lazyLoadGlob()('*.gyp', { cwd: pkg.path })
  if (files.length) {
    scripts.install = 'node-gyp rebuild'
    data.scripts = scripts
    data.gypfile = true
  }
}
```

The script exists only in npm's normalized view of the package, not in the `package.json` a reviewer opens. That gap between the two copies of a package's metadata is the same one behind [manifest confusion](https://socket.dev/glossary/manifest-confusion).

A `binding.gyp` looks like JSON but is not. Here is the top of the one bcrypt 6.0.0 ships:

```python
{
  "variables": {
    "NODE_VERSION%":"<!(node -p \"process.versions.node.split(\\\".\\\")[0]\")"
  },
  'targets': [
    {
      'target_name': 'bcrypt_lib',
      'sources': [
        'src/blowfish.cc',
        'src/bcrypt.cc',
        'src/bcrypt_node.cc'
      ],
      'dependencies': [
          "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
      ],
```

Mixed quote styles, a trailing comma, and two `<!(...)` command expansions. The [GYP input format reference](https://github.com/nodejs/gyp-next/blob/main/docs/InputFormatReference.md) says command expansions make GYP "execute a command at generation time," and that "the entire string contained within the parentheses is passed to the system's shell." bcrypt uses them for ordinary build plumbing. That is the core detection problem: the syntax a malicious `binding.gyp` needs is the syntax popular legitimate packages use every day.

## Why is native code in a package a security risk?

Native code in a package is a security risk because it executes at two points that source review, JavaScript analysis, and most install controls do not see: when gyp evaluates `binding.gyp` during the build, and when the operating system loads the compiled binary. Neither point requires `package.json` to declare anything.

> Lifecycle of a Node.js native addon from npm install to first call. Step 1: npm extracts the tarball; no package code runs. Step 2: node-gyp configure evaluates binding.gyp as Python; command expansions and conditions run package-controlled code. Step 3: the compiler builds the .node file, or an install script downloads a prebuilt one. Steps 2 and 3 are gated by npm 12 allowScripts and --ignore-scripts. Step 4: require() calls process.dlopen and the dynamic loader runs the library's constructors. Step 5: Node-API calls the addon's Init function. Steps 4 and 5 are gated only by the Node.js permission model without --allow-addons. Step 6: your code calls an exported function.

The figure traces a native addon from `npm install` to its first call in six steps. Step 1, extracting the tarball, runs no package code. Step 2, node-gyp's configure phase, evaluates `binding.gyp`: command expansions go to a shell and `conditions` entries run as Python expressions. Step 3 compiles the `.node` file, or an install script downloads a prebuilt one. Steps 2 and 3 run package-controlled code, and npm 12's `allowScripts` default and `--ignore-scripts` both stop them. Step 4 is `require()`, which calls `process.dlopen`; the operating system's loader maps the library and runs its constructors. Step 5 is Node-API calling the addon's `Init` function. Steps 4 and 5 also run package-controlled code, and the only built-in control that stops them is the Node.js permission model with native addons left disabled. Step 6, your code calling an exported function, is where most reviews start looking.

### binding.gyp is a program, not a config file

gyp does not parse `binding.gyp`; it evaluates it. In the node-gyp 11.5.0 that npm 10.9.9 bundles, gyp-next's `input.py` loads the file with `eval(build_file_contents, {"__builtins__": {}}, None)` and runs every `conditions` entry through Python's `compile()` and `eval()`. Removing `__builtins__` is not a sandbox. The [openapi-react-query-codegen compromise](https://socket.dev/blog/openapi-react-query-codegen-npm-compromise) on August 28, 2026 shipped a condition that walks Python's class hierarchy to recover the builtins, imports `os`, and calls `os.system` on `node 3FWCvzduYZg.js`, a 5,616,000-byte payload. Our install script entry walks through that escape. Command expansion is simpler and needs no escape at all.

We packed a package whose `package.json` has no `scripts` field and whose `binding.gyp` is this:

```json
{
  "variables": {
    "probe": "<!(node -e \"require('fs').writeFileSync('/tmp/gyp-ran', 'ran in ' + process.cwd())\")"
  },
  "targets": [{ "target_name": "noop", "type": "none" }]
}
```

Installing the tarball with npm 10.9.9 printed the usual two lines, and the probe had run:

```text
$ cat gyp-demo/package.json
{ "name": "gyp-demo", "version": "1.0.0", "main": "index.js" }

$ npm install ../gyp-demo-1.0.0.tgz
added 1 package, and audited 2 packages in 596ms
found 0 vulnerabilities

$ cat /tmp/gyp-ran
ran in /home/claude/demo/app/node_modules/gyp-demo

$ rm -rf node_modules /tmp/gyp-ran
$ npm install ../gyp-demo-1.0.0.tgz --ignore-scripts
added 1 package, and audited 2 packages in 379ms
found 0 vulnerabilities

$ cat /tmp/gyp-ran
cat: /tmp/gyp-ran: No such file or directory
```

Two details from the same experiment matter more than the marker. Our first version had an empty `targets` list, which crashed gyp's Makefile generator with an `IndexError`, and npm reported the install as failed. The marker file had already been written. Command expansion runs while gyp loads the file, before it generates or compiles anything, so a payload does not need a working build. And npm recorded `"hasInstallScript": true` for the package in [package-lock.json](https://socket.dev/glossary/package-lock-json), although its `package.json` contains no scripts at all.

Attackers adopted this trigger in 2026. The [Miasma](https://socket.dev/supply-chain-attacks/miasma-mini-shai-hulud-supply-chain-attack) campaign's "Phantom Gyp" waves added a `binding.gyp` to packages that had no native code, as in the [LeoPlatform wave](https://socket.dev/blog/miasma-mini-shai-hulud-hits-leoplatform-npm-packages-go-ecosystem) on June 24, 2026, where command expansion launched a replaced `index.js` during `npm install` with no `preinstall` or `postinstall` entry for a scanner to match. In the [ImmobiliareLabs compromise](https://socket.dev/blog/miasma-mini-shai-hulud-hits-immobiliarelabs-npm-packages) two days later, the `binding.gyp` was byte-identical across 22 malicious versions of four `@immobiliarelabs` Backstage plugins. All ten malicious openapi-react-query-codegen versions carried valid npm provenance, because the attacker triggered the project's own [trusted publishing](https://socket.dev/glossary/npm-trusted-publishing) workflow from a pull request comment. Provenance proved which pipeline built the tarball. It said nothing about what the build file inside it would do.

### Loading a .node file runs code before you call it

A native addon executes when it is loaded, not when it is called. `require()` hands a `.node` file to `process.dlopen`, which asks the operating system's dynamic loader to map it. On Linux the loader runs every function in the library's `.init_array` before `dlopen` returns, and only then does Node-API call the addon's `Init`. We compiled this addon with node-gyp:

```c
#include <node_api.h>
#include <stdio.h>
#include <stdlib.h>

/* Runs when the dynamic loader maps the .node file,
   before Node.js calls the addon's init function. */
__attribute__((constructor))
static void on_load(void) {
  FILE *f = fopen("/tmp/addon-ctor-ran", "w");
  if (f) { fprintf(f, "HOME=%s\n", getenv("HOME")); fclose(f); }
}

static napi_value Init(napi_env env, napi_value exports) { return exports; }
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
```

```text
$ node -e "require('./build/Release/addon.node')"
$ cat /tmp/addon-ctor-ran
HOME=/root

$ nm -D build/Release/addon.node | grep ' U '
                 U __fprintf_chk@GLIBC_2.3.4
                 U fclose@GLIBC_2.2.5
                 U fopen64@GLIBC_2.2.5
                 U getenv@GLIBC_2.2.5
```

The JavaScript side of such a package is one `require()` call; everything it does lives in machine code. The dynamic symbol table is the closest thing a binary has to an import list. Even a stripped library must name the libc functions it links against, so calls like `getenv`, `connect`, or `execve` appear there unless the author resolves them at runtime, which is itself a reason to look closer.

The same trick works in Python. In June 2026 the [bioinformatics wave of Mini Shai-Hulud, Miasma, and Hades](https://socket.dev/blog/mini-shai-hulud-miasma-and-hades-worms-target-bioinformatics-and-mcp-developers-via-malicious) trojanized compiled extension modules such as `ensmallen_haswell.abi3.so` in ensmallen 0.8.101, which ran a JavaScript stealer when Python imported the package. Its `.py` files looked normal. Install-time controls never saw it, because installing a [wheel](https://socket.dev/glossary/wheel-python-packaging-format) runs no package code; the payload waited for the first import.

### A binary breaks the link between source and artifact

A prebuilt binary is an unverified claim that some source, compiled somewhere, produced these bytes. Reviewers read the repository, and users run the artifact. The [backdoored xz package](https://socket.dev/blog/how-to-use-socket-to-find-out-if-you-were-affected-by-the-backdoored-xz-package) lived in that gap: according to the [GitHub advisory for CVE-2024-3094](https://github.com/advisories/GHSA-rxwq-x6h5-x525), build instructions present only in the 5.6.0 and 5.6.1 release tarballs would "extract a prebuilt object file from one of the test archives" and use it to modify liblzma.

The binaries in a typical dependency tree are not suspect, but most are unverifiable. bcrypt 6.0.0 ships ten prebuilt `.node` files for Linux, macOS, and Windows, 991,908 bytes of machine code, and all six Linux builds are stripped of symbols. As of November 2, 2026, its registry entry carries no provenance attestation tying those bytes to a build. Checking them means rebuilding from source and comparing, which only works if the build is reproducible.

## How do native binaries get into node\_modules?

Native binaries get into `node_modules` in one of four ways, and the choice decides whether anything runs at install and whether the lockfile pins the binary. We read each example's registry metadata on November 2, 2026, and installed bcrypt and sharp with npm 12.2.1's defaults.

| Model | Examples | Runs package code at install | Binary pinned by lockfile integrity | Works under npm 12 defaults |
| --- | --- | --- | --- | --- |
| Build from source | binding.gyp with node-gyp rebuild; the fallback in canvas 3.2.3, sqlite3 6.0.1, node-pty 1.1.0 | Yes: gyp evaluation, then the compiler | No: built on the installing machine | No: needs approval, then fails at runtime if skipped |
| Download at install | prebuild-install, used first by canvas 3.2.3, sqlite3 6.0.1, kerberos 7.0.0 | Yes: an install script fetches the binary | No: fetched from outside the tarball | No: needs approval |
| Bundled prebuilds | prebuildify plus node-gyp-build: bcrypt 6.0.0 ships 10 binaries | Only to compile when no bundled binary matches | Yes: the binaries are inside the tarball | Yes: loads its prebuild without approval |
| Platform packages | optionalDependencies filtered by os, cpu, and libc: sharp 0.35.5 lists 25 | No for sharp; esbuild 0.28.2 still runs node install.js as a postinstall | Yes: each platform package has its own integrity entry | Yes |

Verdict: ship platform packages, or bundled prebuilds when the binary set is small. Both put the binary inside a registry tarball, where lockfile integrity pins it and npm provenance can attest it, and neither needs an install script. Download-at-install is the weakest model. prebuild-install 7.1.3 builds its URL from the package's `repository` field, defaults to the GitHub release for that version, streams the response to disk, and checks no hash, so nothing npm records pins the binary it fetches. Its own README recommends prebuildify with node-gyp-build instead. Building from source belongs only on platforms nobody prebuilt for.

Under npm 12, the models behave the way that table predicts:

```text
$ npm install bcrypt@6.0.0 sharp@0.35.5
...
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   bcrypt@6.0.0 (install: node-gyp-build)

$ node -e "require('bcrypt'); require('sharp'); console.log('both loaded')"
both loaded
```

npm blocked bcrypt's install script, and bcrypt loaded its bundled Linux binary anyway. sharp produced no warning. That is the right outcome for users, and it shows exactly what `allowScripts` governs: builds, not binaries. Native code that arrives prebuilt loads with no prompt at all.

Platform packages are safer because they move the binary into the registry, not because `optionalDependencies` deserves trust. In the [TanStack compromise](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) in May 2026, malicious versions added an `optionalDependencies` entry pointing at a git commit whose `package.json` ran a `prepare` hook.

## Which controls stop native code?

The controls that stop native code split cleanly by execution point: install-time controls stop `binding.gyp` evaluation and downloads, and only a runtime control stops a binary from loading.

| Control | Stops binding.gyp evaluation | Stops a malicious binary from loading | Cost and gap |
| --- | --- | --- | --- |
| npm 12 allowScripts default, pnpm 10 and later | Yes, until someone approves the package | No | A skipped build fails at runtime, which pushes teams to approve broadly; approvals are keyed to name and version |
| --ignore-scripts | Yes | No | Breaks every package that must build from source |
| pip install --only-binary :all: | Prevents source builds, so setup.py never runs | No: wheels carry compiled extensions | Fails for any package without a wheel for your platform |
| Lockfile integrity | No | Only for binaries inside a tarball | No coverage for install-time downloads, or for npx and global installs, which never touch a project lockfile |
| npm provenance | No | No | Attests the pipeline, not the behavior; the openapi-react-query-codegen versions had it |
| node --permission without --allow-addons | No | Yes: dlopen is refused | All or nothing: sharp and bcrypt cannot load either |

npm 12's script review shows the command, not the code. After installing our `gyp-demo` tarball with npm 12.2.1, the review listed only the synthesized command:

```text
$ npm install-scripts ls
1 package has install scripts blocked because they are not covered by allowScripts:
  gyp-demo@1.0.0 (install: node-gyp rebuild)
```

Nothing in that line reveals the `<!(...)` expansion. We approved it, reinstalled, and the probe ran. Every legitimate native package prints the same `node-gyp rebuild`, so the line a reviewer approves is identical for bcrypt and for a Phantom Gyp payload. Review the `binding.gyp`, not the command npm prints.

The Node.js permission model is the only built-in control on the load path. It has been stable since Node.js 22.13.0 and 23.5.0, according to the [permissions documentation](https://nodejs.org/api/permissions.html), and with `--permission` set it refuses native addons unless `--allow-addons` is passed:

```text
$ node --permission --allow-fs-read=* -e "require('./build/Release/addon.node')"
Error: Cannot load native addon because loading addons is disabled.
```

The cost is that the flag is all or nothing. An application that needs one image library has to allow every addon in its tree.

## Native code outside npm

Every major registry carries native code, and each has its own build hook.

- **PyPI**: wheels ship compiled extension modules (`.so` on Linux and macOS, `.pyd` on Windows) and run no code at install. Source distributions run [setup.py](https://socket.dev/glossary/setup-py-python) or a PEP 517 build backend, which pip's `--only-binary :all:` refuses to do. The ensmallen case shows that wheels are not safe either; they move execution to import time.
- **crates.io**: Cargo compiles and executes a package's `build.rs` just before building the package, per the [Cargo book](https://doc.rust-lang.org/cargo/reference/build-scripts.html). Build scripts often compile bundled C code, and nothing gates them.
- **Go modules**: cgo compiles C sources in the module, but `go build` runs no package-defined hooks, and the [cgo documentation](https://pkg.go.dev/cmd/cgo) states that "for security reasons, only a limited set of flags are allowed" in `#cgo` directives.

## Detecting native code that should not be there

Most native code is legitimate, so its presence is a weak signal and its arrival is a strong one. Malware does not get a CVE, so none of the packages above would have tripped a vulnerability scanner when they ran. Miasma's indicator list in the LeoPlatform report leads with "binding.gyp added to packages that previously did not require native build behavior," followed by a large one-line [obfuscated](https://socket.dev/glossary/code-obfuscation) `index.js`. A pure-JavaScript package that gains a `binding.gyp`, a `.node` file, or a new `.so` in a patch release deserves the same scrutiny as one that gains a `postinstall`.

Socket's [Native code alert](https://socket.dev/alerts/hasNativeCode) flags packages that contain compiled binaries, shared libraries, or native build files. It began as a `binding.gyp` check and, since an [October 2024 update](https://socket.dev/changelog/native-code-alert-enhanced-to-support-multiple-ecosystems), also detects prebuilt binaries and ecosystem-specific native files; our [alert list](https://docs.socket.dev/docs/issues-list) shows it for npm, PyPI, Go, Maven Central, RubyGems, NuGet, and crates.io, among others. It is Medium severity, which fits the evidence: a package with a legitimate addon trips it too. The alert keys on files inside the tarball rather than on the `scripts` field, so a `binding.gyp` with no install script of its own still registers.

npm 12 and pnpm 10 turned the build path off by default, and attackers have already shown the next path. A blocked build no longer runs, but a bundled prebuild still loads without a prompt, and the trojanized ensmallen wheels proved that a binary can wait for `import` as easily as for `install`. Until registries can tie each binary to the source and the build that produced it, every native package in a tree is machine code that its users trust and cannot read.
