Published
- 20 min read
A Clean npm Install Still Needs a Runtime Receipt
Books by the author
Compare all 5As an Amazon Associate I earn from qualifying purchases. Buying through these links costs you nothing extra and helps pay for the blog.
A developer adds a small indexing library. The package installs without running a lifecycle script. The lockfile settles, the build stays green, and the service starts normally. Every control watching preinstall, install, or postinstall records a quiet run.
Then the application calls a normal method.
On 17 September 2026, Checkmarx researchers described a malicious npm package named indexed-btree that put its trigger inside BTree.prototype.set(), according to two independent reports on the research. The package impersonated the legitimate sorted-btree library and appeared to offer ordinary B-tree storage. Its dangerous path waited for the program to use the library rather than asking npm to execute code during installation (Servnet: indexed-btree bypasses install-time checks; Security Arsenal: runtime npm campaign).
That placement matters more than the package name. npm 12 made several good supply-chain controls the default in July 2026. Dependency lifecycle scripts stopped running unless a project approved them, while Git and remote URL dependencies also became opt-in (GitHub Changelog: npm v12 install-time security). Those controls close real execution routes. They cannot stop malicious logic that ships as the library code you asked your application to run.
A successful install is therefore one receipt, for one boundary, at one point in time. It proves less than many pipelines ask it to prove. Teams using Node.js need a second receipt: what the dependency could reach when the application actually loaded it.
What happened, without inflating the claim
The clearest public account comes from Checkmarx’s technical findings as reported on 20 and 21 September. The researchers tied indexed-btree to nine additional packages: ordered-kv-index, btree-leaderboard, priority-slot-queue, btree-range-store, btree-core, btree-time-index, btree-lru-cache, neighbor-key-map, and sliding-score-window. Servnet reported that the nine related packages were removed from npm and that indexed-btree had shown roughly two million weekly downloads (Servnet: package list and reported download counts).
Treat that download number carefully. Registry downloads count retrievals, not unique developers, affected companies, or successful executions. Automated builds, mirrors, repeated installs, and attacker-driven inflation can all increase a package counter. As of 21 September 2026, the public reports do not establish two million compromised machines. They establish substantial distribution and a credible need to check dependency records.
The trigger location is better supported than any victim count. Both reports say the loader sat behind an ordinary library method rather than an npm lifecycle hook. Servnet adds an important qualification: the malicious path required a specific key value when the program called BTree.prototype.set(). Installing the package alone did not necessarily produce the same behavior as reaching that runtime condition. Security Arsenal describes the broader defensive gap plainly: --ignore-scripts has nothing to suppress when no install script exists (Security Arsenal: why install-script suppression misses runtime code).
Once activated, the reported code collected host details and used Slack and Telegram channels to send information out. Servnet says it also polled a smart contract on Ethereum’s Sepolia test network for instructions, used X25519 key exchange and AES decryption around a later payload, and included cleanup behavior that could remove files and the trigger. These are claims about the analysed package family, not a template for what all runtime package malware does. They tell responders where evidence may exist: process records, DNS and network logs, messaging-service connections, testnet traffic, filesystem changes, and secrets present in the process environment.
The attackers also spent effort on appearance. Servnet reports a plausible repository, fabricated commit history, and credible-looking developer account. That defeats the five-minute review in which someone checks for a README, recent commits, tests, and a respectable download number. Each signal can be manufactured. Popularity can be manufactured too, or at least misunderstood.
There is no useful CVE to wait for here. A malicious package is not a defect in a legitimate product that receives a patched version and a vulnerability score. The response unit is the package identity, every resolved version and integrity value in your lockfiles, the environments where that code ran, and the authority those environments carried.
This distinction keeps the incident proportionate. Finding a package name in a lockfile proves dependency presence. Finding the files under node_modules proves installation. Finding that an application loaded and exercised the affected path changes the exposure assessment. Finding a matching outbound connection, child process, or secret access turns suspicion into stronger incident evidence. Do not collapse those four states into one alarming claim.
Why npm 12 did not fail
npm 12’s new defaults answer a specific question: should a dependency be allowed to run special code while npm is installing it? Since July 2026, the default answer for dependency lifecycle scripts and implicit node-gyp builds has been no. A project can approve trusted scripts and commit that decision in package.json (GitHub Changelog: npm v12 is generally available).
That is a meaningful improvement. An installation step often runs before anyone has reviewed the package’s behavior, and historically it could execute merely because the package entered the dependency graph. Turning automatic execution into an explicit project decision removes a cheap route for malware. Restricting Git and remote URL dependencies also reduces ways for a package to fetch mutable or project-controlled code outside the registry path.
The indexed-btree case sits after that gate. Your application imports a dependency because it intends to execute the dependency’s exported code. A data structure must allocate memory, compare keys, update nodes, and return results. npm cannot disable those operations without making the package useless. Once a package is accepted as application code, the runtime has to distinguish useful behavior from behavior that reads secrets, starts a shell, or contacts an unrelated service.
That makes “bypass” slightly misleading. The package did not need to defeat a script approval prompt if it never approached that control. A badge reader on the loading dock can reject unapproved deliveries. It cannot govern what an approved box does after someone carries it into the server room and plugs it in.
The practical response is to keep the loading-dock control and add boundaries inside the building. Removing script restrictions because they missed this campaign would reopen an older path while doing nothing about the new one. npm’s own version 12 documentation still makes the scope clear: allow-scripts governs install-time lifecycle scripts, and Git dependency settings govern remote repository resolution (npm Docs: npm 12 configuration).
Lockfiles have a similarly precise job. A lockfile records the dependency graph and resolved artifacts so repeated installs can use the same versions. It prevents a floating range from silently choosing a later release during the next build. It also gives an investigation a searchable record. A lockfile cannot decide that the artifact it pinned was honest on the day it entered the graph.
The same rule applies to hashes. An integrity value proves that the bytes installed now match the bytes recorded earlier. That is valuable evidence. If the recorded bytes were already malicious, perfect integrity delivers the malicious bytes perfectly.
Good engineering controls become dangerous only when we assign them a larger claim than they can support. Script approval controls installation-time execution. Locking controls dependency drift. Integrity checks control byte identity. Runtime isolation and monitoring control what executed code can reach and show what it did. The layers cooperate because none of them covers the whole path.
The real boundary is the Node.js process
A Node.js package does not receive a small permission envelope of its own. Once loaded into an application, it normally runs with the process’s operating-system identity and can use the capabilities available to that process. The useful question is therefore not “what can this package access?” in isolation. Ask what the service, test runner, build job, local development shell, or serverless function could access while the package was present.
Consider a CI test job. The package may see environment variables holding repository credentials, registry tokens, cloud identity hints, signing material, feature-service keys, or deployment endpoints. It may share a filesystem with checked-out source, generated artifacts, and configuration. If outbound connections are open to the whole internet, the process still has a way out.
A production service presents a different surface. It may hold a database connection string and permission to reach internal APIs, while lacking a package-publishing token. A developer laptop may have SSH configuration, cloud command-line sessions, and several repositories under the same user account. The package name stays constant, but the consequence changes with the host.
This is why a flat instruction to “rotate every secret” is both expensive and incomplete. Teams either rotate so broadly that the work stalls, or rotate the obvious token and miss a less visible credential. Start by drawing the authority map for each affected execution context. Record the identity, readable files, inherited environment variables, reachable network destinations, writable repositories, and control sockets such as Docker.
The map does not need a new platform. For a CI job, read the workflow file and secret bindings. For a container, inspect its environment injection, mounts, service account, network policy, and runtime socket exposure. For a workstation, combine shell history, credential stores, active cloud sessions, SSH agents, and local configuration. Write down facts rather than trusting the intended architecture diagram.
One quiet failure deserves special attention: tests often run with more reach than production. A test runner may receive package-registry credentials so it can install dependencies, cloud credentials for integration tests, a broad network route, and a checked-out monorepo. Teams call it non-production because it does not serve customers. Attack code cares about authority, not the environment label.
The reverse can also happen. A well-built production container may have a read-only filesystem, one narrow service identity, no shell, and outbound access limited to two internal destinations. The compromised dependency still warrants removal and investigation, but its available routes are fewer. Boundaries turn a vague package incident into a bounded one.
Runtime monitoring supplies the other half of the receipt. Security Arsenal recommends watching Node.js processes for unusual child processes, credential-file reads, temporary-file writes, and outbound destinations that the service does not normally need. Those indicators require context. A build tool may legitimately start a compiler, and a web service may legitimately call several public APIs. Baselines and destination allowlists produce better evidence than an alert for every node network connection.
Start with services that should be quiet. A unit-test job has no reason to contact Telegram, Slack, a public blockchain test network, or an arbitrary new hostname unless the tests explicitly cover those systems. A package-indexing utility has no reason to read cloud credentials. A server-side rendering worker may need the web, but it should still have an explicit destination set. Unexpected behavior becomes easier to see when intended behavior is narrow.
Work out whether the package reached you
The first pass is an inventory exercise, not a malware hunt across every JavaScript file. Search source repositories, lockfiles, software bills of materials, dependency caches, container manifests, and deployed images for the ten reported package names. Search every lockfile format your teams use. A company that standardised on npm last year may still have a Yarn lockfile in an older service or a pnpm workspace in a new one.
Preserve the matched record before changing it. Keep the repository commit, lockfile, resolved version, integrity value, package tarball or installed directory when policy allows, image digest, build identifier, and deployment times. If a self-removal path existed, absence after the fact cannot be your only test. External build and network records may outlive the files.
Then classify each finding by stage. A dependency mentioned only in an abandoned branch differs from one installed in a CI cache. An installed package differs from one imported by tests. Code that ran on a credential-free local container differs from code that ran in a release job. This classification determines what you contain first.
The package family may also appear transitively. A developer may never have typed npm install indexed-btree. Use the package manager’s dependency explanation tools and lockfile ancestry to identify which direct dependency brought it in. Capture that path. Removing only a node_modules directory leaves the parent dependency ready to restore it on the next clean install.
Download counts do not answer any of these local questions. A popular-looking number can help prioritise ecosystem review, but your own lockfile and build records decide whether your organisation was exposed. This is one reason dependency inventory has more operational value than a dashboard of global package reputation.
If the package appears, establish when the relevant version entered the graph and where builds carrying it ran. Check pull requests, dependency-update automation, lockfile changes, artifact attestations, registry proxy logs, and image build histories. The goal is a time window with named environments, not a perfect reconstruction before containment starts.
Next, look for execution evidence. Application traces may show the import path or affected method in use. Test coverage and call sites may establish that the library’s core operation ran. Process telemetry can show Node.js starting an unexpected shell. DNS, proxy, firewall, and cloud flow logs can reveal destinations outside the service’s normal set. Endpoint records may show access to .npmrc, cloud credential files, environment files, or temporary payload locations.
Do not turn the public technical details into a single brittle signature. Slack and Telegram are legitimate services. Ethereum Sepolia is used by developers. An alert should combine context: a service that never used those destinations, a first-seen connection inside the exposure window, a Node.js parent process, and nearby secret or child-process activity. The strongest finding usually comes from several ordinary records lining up.
There is also a hard limit. Missing endpoint telemetry cannot prove the package stayed harmless, especially if the reported code could clean up local traces. Say “we found no matching activity in the records retained from 18 to 21 September” rather than “nothing happened.” The first sentence is a defensible result. The second outruns the evidence.
Contain the authority, not just the folder
Deleting the dependency is necessary. It does not revoke a copied token, close an active cloud session, remove a modified artifact, or tell you which release carried the package. The containment plan should follow the authority map built for each affected environment.
If an affected CI job held an npm publishing token, revoke or rotate it and inspect package publication history. If it had cloud deployment credentials, invalidate the relevant session or key and review actions during the exposure window. If the process could read repository credentials, inspect clones, pushes, deploy keys, workflow changes, and new automation identities. Do not rotate a database password while leaving the package-publishing route untouched merely because databases feel more sensitive.
Order matters. Preserve volatile evidence and isolate the environment before rebuilding it. Revoke credentials from a trusted administrative path, not from the machine under investigation. Record the old credential identifier, revocation time, replacement owner, and systems updated. A rotation without a receipt can leave an old key active or break a production service without closing the suspected route.
Rebuild affected developer and CI environments from known-clean definitions where practical. Package removal inside a long-lived workspace may leave altered files, cached artifacts, shell configuration, or an unknown second stage. A clean image and restored source are often cheaper than proving every file on an old runner. That advice is strongest for disposable CI and container hosts, where replacement should already be routine.
Production services require controlled replacement. Build a new artifact from a reviewed lockfile and trusted source commit. Give the build job only the credentials it needs, record the resulting digest, deploy through the normal release path, and verify which instances now run the clean artifact. “We merged the removal” proves source intent. The runtime receipt is the deployed digest and instance inventory.
Review outputs created during the exposure window. A compromised build environment may have changed generated JavaScript, container layers, release archives, or deployment manifests even after the package disappeared from source. Compare artifact digests and provenance where available. Rebuild releases whose production history depends on an affected runner if the environment had meaningful write authority and your evidence cannot exclude tampering.
Avoid blind emergency upgrades of every dependency. Replace the malicious package path, update npm to the current supported major, preserve script restrictions, and test the changed graph. A rushed refresh of the entire lockfile can introduce unrelated versions while responders are trying to reduce uncertainty. Narrow changes produce better evidence.
The public package list may grow as researchers connect more artifacts. Keep the incident query reusable. Put the names, versions, integrity values, and indicators in a small tracked file or case record so a new name can be checked across the same repositories and images. One repeatable search beats ten improvised terminal histories.
Build a runtime receipt
A runtime receipt is a small set of evidence proving what a service ran and what authority surrounded it. It need not capture every instruction. It should answer four questions after an incident: which dependency bytes reached the artifact, which workload identity ran them, which destinations and sensitive resources were available, and what unusual behavior was observed.
The first part comes from the build. Use a frozen lockfile command in CI, retain the lockfile with the source commit, and record the artifact or image digest. Generate a software bill of materials if your tooling supports one, but keep the resolved lockfile too. The bill of materials helps across ecosystems; the lockfile often gives the fastest package ancestry during a Node.js incident.
The second part comes from identity. Give each production service and sensitive CI job a named, narrow workload identity. Avoid one cloud key copied into every pipeline. Separate read-only dependency access from publish authority. Separate a test deployment identity from a production release identity. When a package runs where it should not, narrow identities reduce both the cleanup scope and the number of logs responders must inspect.
The third part comes from network policy. Default-open outbound access turns every imported package into a potential client for every public destination. Start with release jobs, signing jobs, and services whose destination set is already small. Permit the registry during installation, the exact internal APIs during execution, and nothing else by default. Provide an explicit reviewed path when a job genuinely needs broader internet access.
This change pays off before any detector fires. If malicious code cannot contact its channel, theft becomes harder. The denied connection also creates a high-signal event. “Node tried to reach a new external host from the release job” is more useful than “a scanner assigned this package a suspicious score.”
The fourth part is runtime telemetry. Retain process starts, network connections, identity events, and material changes long enough to cover the gap between package introduction and disclosure. Join them by workload, build, image digest, and time. Logging every file read forever is rarely practical, but sensitive paths such as package credentials, cloud configuration, signing keys, and deployment manifests deserve focused coverage on high-authority hosts.
Teams often buy endpoint monitoring and omit ephemeral runners. That leaves the environment with the richest tokens and shortest lifespan outside the record. Export runner telemetry before teardown. Centralise DNS, proxy, and cloud identity logs. Tag jobs with source commit and run ID so the records can be joined after the runner is gone.
Finally, test the receipt. Pick one service and ask an engineer to answer the four questions using retained evidence, without relying on the person who built the pipeline. Time the exercise. Missing joins, unlabeled identities, and expired logs appear quickly when the question is concrete.
The AI Native Engineer argues that production systems need measurements where demos rely on feelings. Dependency safety follows the same rule. A clean terminal line feels reassuring. A build digest, workload identity, denied destination, and searchable event make a claim you can inspect.
What to do on Monday morning
A useful response fits into an ordered sequence. Do the first three steps even if you cannot redesign runtime policy this week; they reduce immediate uncertainty without pretending to solve the whole supply chain.
-
Search the dependency record. Check current and historical lockfiles, software bills of materials, registry proxy records, images, caches, and installed trees for
indexed-btreeand the nine related names reported on 21 September. Record the repository, version, integrity value, dependency parent, build, and image for every match. -
Preserve evidence and classify execution. Save the matched artifacts and relevant build metadata. Mark whether each finding was referenced, installed, imported, or supported by behavioral evidence. State the retention window and gaps. Avoid reinstalling over the environment before deciding what evidence matters.
-
Contain the reachable authority. Isolate high-authority affected runners or workstations. Revoke secrets from a trusted system based on what each environment could read or use. Inspect publication, repository, cloud, and deployment history tied to those identities. Give each rotation a timestamp and owner.
-
Remove the package path and rebuild cleanly. Change the direct dependency that introduced the package, keep the lockfile change narrow, and build on a fresh runner with the current supported npm release. Preserve npm 12’s script, Git, and remote dependency restrictions. Record the clean artifact digest and verify the deployed instances.
-
Add one enforceable runtime boundary. Start where authority is highest. Restrict outbound destinations on release jobs, remove a shared cloud key, make the workspace read-only, or disconnect a container control socket. Choose a control that blocks an action rather than another dashboard that only describes risk.
-
Create the receipt and rehearse it. For one Node.js service, join source commit, lockfile, artifact digest, workload identity, network policy, and runtime events. Ask someone outside the pipeline team to reconstruct the answer. Fix the first missing link, then repeat with the next service.
Do not wait for perfect fleet coverage before tightening the release path. A package used only in local development can still matter, but a release runner with write access to registries and production deserves the first hour. Risk is package presence multiplied by reachable authority.
Do not disable useful npm controls because one campaign sat outside them. Keep dependency scripts off by default, approve the exceptions, restrict mutable dependency sources, and review lockfile changes. The lesson is an added layer, not a replacement layer.
Most of all, stop treating “installed successfully” as the end of the dependency story. Installation is the moment code enters the building. Runtime is where it receives keys, meets data, and finds doors.
The receipt must match the claim
The indexed-btree campaign is useful because its mechanism is ordinary. The package reportedly hid behind a method call that a real user would make. No dramatic exploit was needed to cross from dependency to application. The application invited the code in and supplied the process authority.
npm 12 still made the ecosystem safer. Its defaults remove automatic installation behavior that packages once received merely by appearing in a graph. Keep them. Then state their claim accurately: they restrict install-time scripts and mutable dependency sources.
Your next claim begins when Node.js starts. Prove which bytes ran. Bound the identity and destinations around them. Keep enough external evidence to investigate behavior that local files can no longer explain.
That is the runtime receipt. It will not certify that every dependency is benevolent. It gives you something better on a bad morning: a bounded answer, a short list of credentials, and a clean path back to service.
For one practical security and AI engineering note each month, the newsletter sends one email per month. The signup is on this site.
Sources
- Servnet: Malicious npm Package indexed-btree Bypasses Install-Time Security Checks, accessed 2026-09-21
- Security Arsenal: Malicious npm Packages Evade Install-Script Defenses at Runtime, accessed 2026-09-21
- GitHub Changelog: npm install-time security and GAT bypass2fa deprecation, accessed 2026-09-21
- npm Docs: npm 12 configuration, accessed 2026-09-21