How to Detect npm Packages That Hide Malware in Runtime Code, Not Install Scripts
A fresh npm campaign hides malware in ordinary runtime code instead of install scripts; here's how to build a Python static scanner that catches it, no Node.js required.
On September 17, 2026, researchers at Checkmarx Zero disclosed an npm package called indexed-btree that had quietly collected close to two million weekly downloads while impersonating a legitimate library. What makes it worth a whole tutorial, rather than just another breach writeup, is how it stayed hidden: it carried no preinstall, install, or postinstall script at all, which is exactly the attack surface npm’s newest security defaults were built to close. Instead, the malicious code was grafted directly into a function the library’s own users would call constantly during normal operation. Installing the package looked completely clean. Using it, eventually, was not.
Table Of Content
- What You Will Accomplish
- What Is an npm Lifecycle Script?
- Why Blocking Lifecycle Scripts Was Not Enough
- Prerequisites
- Step 1: Build Three Small Packages to Scan
- A Normal, Well-Behaved Package
- A Package Modeled on the Real Evasion Technique
- A Legitimate Package That Looks Suspicious
- Step 2: Reproduce the Blind Spot
- Step 3: A Naive Fix, and Its Real False Positive
- Step 4: Build a Scanner That Distinguishes the Two
- Signal A: A Dynamic eval
- Signal B: An Encoded Blob Sitting in Source
- Signal C: A Magic-Value Gate (Weak on Its Own)
- Recognizing a Legitimate Pattern
- Step 5: Run the Refined Scanner
- A Real Gotcha: A Byte Order Mark Will Silently Break This Too
- Step 6: Wire It Into CI With a Real Exit Code
- Verify Everything Works End-to-End
- Common Mistakes and Gotchas
- Next Steps
This tutorial teaches you to build a small Python tool that catches that gap: code hidden in a package’s ordinary, exported behavior rather than in an install-time hook. You do not need Node.js or npm installed to follow along. Every package you will scan is just a folder of text files (a package.json and some .js source), and the scanner you build reads those files as plain text, the same way a linter or a security scanner does. By the end, you will have a working static analyzer, a real false positive you catch and fix, and a CI-ready gate with real exit codes.
What You Will Accomplish
- Understand why npm v12’s default block on lifecycle scripts, while a genuine improvement, does not stop malware that avoids lifecycle scripts entirely
- Build three small fixture packages: one clean, one modeled on the real evasion technique, and one that is legitimate but structurally resembles the attack (your false-positive test)
- Reproduce the exact blind spot: a lifecycle-script scanner that reports the malicious package as clean
- Write a first, naive content scanner, watch it produce a real false positive, and understand why
- Refine it into a scanner that distinguishes a dangerous pattern from a legitimate one that merely looks similar
- Wire the result into a CI gate with real, testable exit codes
What Is an npm Lifecycle Script?
When you run npm install, npm can automatically execute code declared under the scripts key of a dependency’s package.json, specifically the preinstall, install, and postinstall hooks. That code runs the instant the package lands on disk, before your own application ever starts, with whatever file system and network access the installing user or CI runner has. It is a real and long-abused mechanism: a prior sxz.io tutorial, How to Detect Malicious npm preinstall Scripts and Verify Package Integrity, walks through the August 2026 keyv compromise, where a hidden preinstall script fetched a second-stage credential stealer the moment anyone ran npm install.
Why Blocking Lifecycle Scripts Was Not Enough
In response to that pattern, GitHub announced a set of breaking security defaults for npm v12. Their own changelog is explicit about the target: the June 9, 2026 GitHub changelog entry states that “allowScripts defaults to off: npm install will no longer execute preinstall, install, or postinstall scripts from dependencies unless they are explicitly allowed in your project,” a change that shipped behind warnings in npm 11.16.0 and became the default with npm v12 in July 2026. Developers can see what would be blocked with npm approve-scripts --allow-scripts-pending, then explicitly allow trusted packages with npm approve-scripts or block the rest with npm deny-scripts.
That is a real improvement. It is also, by design, a check that only looks at one specific field of one specific file at install time. The indexed-btree campaign was built to walk straight past it. As BleepingComputer reported, “the malicious indexed-btree package sidesteps these protections by avoiding installation scripts and instead hiding its loader in the package’s BTree.prototype.set() method, which executes at runtime when the application calls it with a specific key value. As a result, installation appears clean and triggers none of npm v12’s approval mechanisms.” Checkmarx’s own assessment of the technique, quoted directly in that same report, is blunter still: “The malware loader hides inside the library’s own BTree.prototype.set method, which is the main function that every user would call constantly. This triggers the sharedLoad.min.js, which contains the obfuscated first stage of the malware. This is a well-built way to sneak past standard taint-analysis tools and most static scanners.”
An independent technical writeup at The CyberSec Guru, cross-checked directly against the raw page text rather than an AI-summarized version, adds the specific trigger detail: “The malware loader is grafted into BTree.prototype.set(), the library’s hottest code path, and runs only when a live application inserts the key value 100 while an obfuscated loader file is present on disk.” The obfuscated loader itself, named sharedLoad.min.js, decrypted a second-stage payload from ciphertext stored in an Ethereum smart contract on the Sepolia testnet, a command-and-control channel with no domain to sinkhole and no server to seize. Nine more packages tied to the same operation, including one called btree-core with nearly two million downloads on its own, were found using the same trick.
The package even had a fabricated GitHub history: a maintainer account with an AI-generated profile photo, realistic pull-request-style commits stretching back to December 2025, and a public repository that, notably, never contained the malicious code at all. The divergence lived only in the tarball actually published to the npm registry, a detail worth sitting with: reviewing a package’s GitHub repository is not the same as reviewing what npm install actually downloads.
The rest of this tutorial builds a defense against the specific gap this campaign exploited: code that never runs at install time, hidden inside a function your own code calls normally.
Prerequisites
- Python 3.10 or later (this tutorial was built and tested with Python 3.13.14). No Node.js or npm installation is required anywhere in this tutorial: every package you scan is treated as plain text, never executed.
pytestfor the verification suite in the last step (pip install pytest).- Basic familiarity with reading JavaScript, and with running Python scripts from a terminal. No prior security experience is required.
Step 1: Build Three Small Packages to Scan
Create a working folder with a packages subfolder, and inside it, three package directories: safe-utils, indexed-store, and legit-templating.
mkdir npm-runtime-scanner
cd npm-runtime-scanner
mkdir -p packages/safe-utils packages/indexed-store/extended packages/legit-templating/plugins
A Normal, Well-Behaved Package
This one has no lifecycle scripts and no dynamic code execution anywhere. It is your baseline for “nothing here should ever be flagged.” Save it as packages/safe-utils/package.json:
{
"name": "safe-utils",
"version": "1.0.0",
"description": "A small in-memory ordered key/value store used to represent a normal, well-behaved dependency in this tutorial's fixtures.",
"main": "index.js",
"license": "MIT"
}
And packages/safe-utils/index.js:
"use strict";
/**
* A tiny ordered key/value store, kept intentionally simple.
* Real dependency code, no lifecycle scripts, nothing hidden.
*/
class OrderedStore {
constructor() {
this._entries = new Map();
}
set(key, value) {
this._entries.set(key, value);
return this;
}
get(key) {
return this._entries.get(key);
}
has(key) {
return this._entries.has(key);
}
delete(key) {
return this._entries.delete(key);
}
size() {
return this._entries.size;
}
entries() {
return Array.from(this._entries.entries()).sort((a, b) => (a[0] > b[0] ? 1 : -1));
}
}
module.exports = { OrderedStore };
A Package Modeled on the Real Evasion Technique
This fixture is not a copy of the real indexed-btree source, which was never published to the researchers’ own report in full; it is an original, simplified package built from the publicly reported mechanism, for the sole purpose of teaching detection. Note what it does not have: look at its package.json and you will find no scripts field whatsoever.
packages/indexed-store/package.json:
{
"name": "indexed-store",
"version": "2.1.0",
"description": "A drop-in ordered key/value store. (Fixture package for this tutorial: modeled on the real indexed-btree campaign's mechanism, not a copy of its code.)",
"main": "index.js",
"license": "MIT"
}
packages/indexed-store/index.js. Everything through line 12 is a completely ordinary key/value store. The five lines after that are the injected pattern: a strict-equality check against a specific, arbitrary-looking number, reachable only from inside the public set() method that every caller uses normally.
"use strict";
const fs = require("fs");
const path = require("path");
class IndexedStore {
constructor() {
this._entries = new Map();
}
set(key, value) {
this._entries.set(key, value);
if (key === 1337) {
const loaderPath = path.join(__dirname, "extended", "loader.blob.js");
if (fs.existsSync(loaderPath)) {
const blob = fs.readFileSync(loaderPath, "utf8");
eval(blob);
}
}
return this;
}
get(key) {
return this._entries.get(key);
}
has(key) {
return this._entries.has(key);
}
delete(key) {
return this._entries.delete(key);
}
size() {
return this._entries.size;
}
entries() {
return Array.from(this._entries.entries()).sort((a, b) => (a[0] > b[0] ? 1 : -1));
}
}
module.exports = { IndexedStore };
packages/indexed-store/extended/loader.blob.js. This file is never mentioned anywhere in package.json, and nothing in the package statically require()s it by name; it is only reachable by constructing its path at runtime, exactly as index.js does above.
var _0x4a1f = "Y29uc3QgZnMgPSByZXF1aXJlKCJmcyIpOwpmcy53cml0ZUZpbGVTeW5jKCJSVU5USU1FX1RSSUdHRVJfRklSRUQudHh0IiwgYFRyaWdnZXJlZCBhdCAke25ldyBEYXRlKCkudG9JU09TdHJpbmcoKX0KYCk7Cg==";
eval(Buffer.from(_0x4a1f, "base64").toString("utf8"));
That base64 string is not a mystery. Decode it and it is entirely inert: it just writes a marker file with a timestamp, the same safe way the keyv preinstall-script tutorial demonstrated exploitation without doing anything harmful. Nothing in this tutorial ever executes this JavaScript with a JavaScript runtime; the scanner you are about to build reads it purely as text.
A Legitimate Package That Looks Suspicious
This is the fixture that makes the tutorial honest. Plenty of real, legitimate npm packages, templating engines and plugin systems especially, load code dynamically for entirely defensible reasons. If your scanner cannot tell this apart from indexed-store, it is not useful; it is just noisy.
packages/legit-templating/package.json:
{
"name": "legit-templating",
"version": "3.2.0",
"description": "A small templating library with a user-plugin system. Legitimate dynamic code loading, included as a false-positive fixture for this tutorial.",
"main": "index.js",
"license": "MIT"
}
packages/legit-templating/index.js. This loads a plugin file that ships inside the package itself, by a name the caller supplies, and wraps the loaded code in an explicit function with declared module, exports, and require parameters, the same wrapper shape Node’s own module system effectively provides:
"use strict";
const fs = require("fs");
const path = require("path");
/**
* Loads a bundled plugin module by name. This is a normal plugin-loading
* mechanism: the plugin file ships inside this package, and the caller
* only ever supplies a name that maps to a known, bundled file.
*/
function loadPlugin(pluginName) {
const pluginPath = path.join(__dirname, "plugins", `${pluginName}.js`);
const code = fs.readFileSync(pluginPath, "utf8");
const moduleObj = { exports: {} };
const fn = new Function("module", "exports", "require", code);
fn(moduleObj, moduleObj.exports, require);
return moduleObj.exports;
}
module.exports = { loadPlugin };
And a bundled plugin at packages/legit-templating/plugins/uppercase.js, just so loadPlugin("uppercase") has something real to load:
module.exports.render = function render(input) {
return String(input).toUpperCase();
};
Step 2: Reproduce the Blind Spot
Before building anything new, run the check that most CI pipelines and the prior keyv tutorial already teach: scan every package.json for a preinstall, install, or postinstall hook. Save this as step1_lifecycle_scan.py:
"""
The scan most CI pipelines already run: does package.json declare a
preinstall, install, or postinstall script? This is the exact check
npm v12's allowScripts default and the Node-based scanner from a prior
tutorial both perform. It is necessary. This script shows it is not
sufficient.
"""
import json
from pathlib import Path
RISKY_HOOKS = ("preinstall", "install", "postinstall")
PACKAGES_ROOT = Path(__file__).parent / "packages"
def scan_lifecycle_scripts(packages_root: Path) -> list[dict]:
findings = []
for pkg_json in sorted(packages_root.glob("*/package.json")):
raw = pkg_json.read_text(encoding="utf-8-sig") # tolerate a leading BOM
data = json.loads(raw)
scripts = data.get("scripts", {})
hits = {hook: scripts[hook] for hook in RISKY_HOOKS if hook in scripts}
if hits:
findings.append({"package": data.get("name", pkg_json.parent.name), "hooks": hits})
return findings
if __name__ == "__main__":
findings = scan_lifecycle_scripts(PACKAGES_ROOT)
packages = sorted(p.name for p in PACKAGES_ROOT.iterdir() if p.is_dir())
print(f"Scanned {len(packages)} package(s): {', '.join(packages)}\n")
if not findings:
print("Lifecycle-script scan result: 0 packages carry a preinstall, install, or postinstall hook.")
print("Every package in this tree reports CLEAN.")
else:
for f in findings:
print(f"FLAGGED: {f['package']} -> {f['hooks']}")
Run it:
python step1_lifecycle_scan.py
Real output from this exact script:
Scanned 3 package(s): indexed-store, legit-templating, safe-utils
Lifecycle-script scan result: 0 packages carry a preinstall, install, or postinstall hook.
Every package in this tree reports CLEAN.
Sit with that for a second: indexed-store, the package carrying an actual malicious-pattern loader, reports exactly as clean as safe-utils, which has nothing hidden at all. This is not a bug in the script above; it is doing precisely what it was built to do, and what npm v12’s own allowScripts default does. It just was never designed to look at anything except one field of one file. That is the real gap the indexed-btree campaign found and used.
Step 3: A Naive Fix, and Its Real False Positive
The obvious next move is to stop trusting package.json alone and actually read the source. A first, blunt version: flag any file that contains eval( or new Function( anywhere, the two constructs Node code uses to execute a string as code. Save this as step2_naive_content_scan.py:
"""
A first attempt at closing the gap: instead of only checking package.json,
read every .js file's actual source and flag any use of eval() or
new Function(), the two constructs Node code uses to execute a string as
code. This catches indexed-store. It also flags a package that never
does anything malicious.
"""
import re
from pathlib import Path
PACKAGES_ROOT = Path(__file__).parent / "packages"
DYNAMIC_EXEC_PATTERN = re.compile(r"\beval\s*\(|\bnew\s+Function\s*\(")
def scan_content_naive(packages_root: Path) -> list[dict]:
findings = []
for pkg_dir in sorted(p for p in packages_root.iterdir() if p.is_dir()):
hits = []
for js_file in sorted(pkg_dir.rglob("*.js")):
text = js_file.read_text(encoding="utf-8")
for match in DYNAMIC_EXEC_PATTERN.finditer(text):
line_no = text.count("\n", 0, match.start()) + 1
hits.append({"file": str(js_file.relative_to(pkg_dir)), "line": line_no, "construct": match.group().strip()})
if hits:
findings.append({"package": pkg_dir.name, "hits": hits})
return findings
if __name__ == "__main__":
findings = scan_content_naive(PACKAGES_ROOT)
if not findings:
print("No dynamic-execution constructs found.")
for f in findings:
print(f"FLAGGED: {f['package']}")
for h in f["hits"]:
print(f" {h['file']}:{h['line']} -> {h['construct']}")
Run it:
python step2_naive_content_scan.py
Real output:
FLAGGED: indexed-store
extended\loader.blob.js:2 -> eval(
index.js:18 -> eval(
FLAGGED: legit-templating
index.js:15 -> new Function(
The good news: indexed-store is correctly caught this time, on both the constructor-side eval call and the loader file itself. The bad news is right below it: legit-templating got flagged too, and it has never done anything malicious. Its plugin loader is a normal, if slightly unusual, pattern. A scanner that cries wolf on legitimate code trains its own users to ignore it, which is arguably worse than not scanning at all. The fix is not to drop the check; it is to make it precise enough to tell the two apart.
Step 4: Build a Scanner That Distinguishes the Two
Looking at what actually differs between the two packages’ use of dynamic execution:
indexed-storecalls bareeval(blob)on a variable, first read from a sibling file, then (in the loader itself) decoded from a base64 string. In both cases, the argument toevalis something computed at runtime, not a literal you can read directly in the source.legit-templatingcallsnew Function("module", "exports", "require", code), the exact parameter shape of Node’s own CommonJS module wrapper. It is still dynamic code execution, and still worth a human glancing at, but it is a recognized, purposeful pattern rather than a bare, unexplainedevalof something that was just read off disk.
That distinction, plus two supporting signals, is enough to separate the two correctly. Save this as scanner.py:
"""
A refined static scanner for npm packages that hide code outside
install-time lifecycle scripts. Three independent signals, each checked
on the actual bytes of every .js file in a package:
A. dynamic_eval - an eval(...) call whose argument is NOT a plain
quoted string literal. eval("a literal") is inert
and pointless to obfuscate; eval(someVariable) or
eval(decode(x)) executes whatever that expression
produces at runtime, which the scanner cannot see
by reading the source. This is the strongest
signal on its own.
B. encoded_blob - a string literal at least MIN_BLOB_LEN characters
long, at least BLOB_DENSITY of it drawn from the
base64 alphabet. A heuristic for an encoded
payload sitting in source. Common in real
obfuscated loaders; also occasionally present in
legitimate packages (embedded fonts, compressed
data tables, wasm blobs), so treated as medium
severity alone.
C. magic_gate - inside a method body, a strict-equality check of a
bare parameter against an integer literal (for
example `if (key === 1337)`). Extremely common in
ordinary code for unrelated reasons (status codes,
enum values, array indices), so this signal is
never enough by itself. It only raises the
reported severity when signal A or B is also
present in the same file.
new Function(...) is treated separately: when its first arguments are
literal strings matching the CommonJS module-wrapper convention
(module, exports, require), it is a recognized, if uncommon, pattern
for a sandboxed plugin loader and is reported as an informational note
rather than a high-severity finding. Any other new Function(...) usage
is treated the same as signal A.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
EVAL_CALL = re.compile(r"\beval\s*\((.*?)\)\s*;?", re.DOTALL)
NEW_FUNCTION_CALL = re.compile(r"\bnew\s+Function\s*\((.*?)\)\s*;?", re.DOTALL)
STRING_LITERAL_ARG = re.compile(r'^\s*(["\']).*\1\s*$', re.DOTALL)
MODULE_WRAPPER_ARGS = re.compile(r'^\s*"module"\s*,\s*"exports"\s*,\s*"require"', re.DOTALL)
LONG_STRING_LITERAL = re.compile(r'"([A-Za-z0-9+/=]{%d,})"' % 40)
MAGIC_GATE = re.compile(r"if\s*\(\s*\w+\s*===\s*\d+\s*\)")
MIN_BLOB_LEN = 40
BLOB_DENSITY = 0.90
BASE64_ALPHABET = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
def _is_literal_string(arg_text: str) -> bool:
return bool(STRING_LITERAL_ARG.match(arg_text.strip()))
def _looks_like_encoded_blob(candidate: str) -> bool:
if len(candidate) < MIN_BLOB_LEN:
return False
density = sum(1 for ch in candidate if ch in BASE64_ALPHABET) / len(candidate)
return density >= BLOB_DENSITY
@dataclass
class FileFinding:
file: str
line: int
signal: str
detail: str
severity: str # "high", "medium", "info"
@dataclass
class PackageReport:
package: str
findings: list[FileFinding] = field(default_factory=list)
@property
def verdict(self) -> str:
severities = {f.severity for f in self.findings}
has_high = "high" in severities
has_medium = "medium" in severities
if has_high and has_medium:
return "CRITICAL"
if has_high:
return "HIGH"
if has_medium:
return "MEDIUM"
if self.findings:
return "INFO"
return "CLEAN"
def _line_of(text: str, offset: int) -> int:
return text.count("\n", 0, offset) + 1
def scan_file(path: Path, relative_to: Path) -> list[FileFinding]:
text = path.read_text(encoding="utf-8")
rel = str(path.relative_to(relative_to))
findings: list[FileFinding] = []
for match in EVAL_CALL.finditer(text):
arg = match.group(1)
line = _line_of(text, match.start())
if _is_literal_string(arg):
continue # eval("literal") is inert; not worth flagging
findings.append(FileFinding(
file=rel, line=line, signal="dynamic_eval",
detail=f"eval({arg.strip()[:60]}...)" if len(arg.strip()) > 60 else f"eval({arg.strip()})",
severity="high",
))
for match in NEW_FUNCTION_CALL.finditer(text):
arg = match.group(1)
line = _line_of(text, match.start())
if MODULE_WRAPPER_ARGS.match(arg):
findings.append(FileFinding(
file=rel, line=line, signal="module_wrapper_pattern",
detail="new Function(\"module\", \"exports\", \"require\", ...) - recognized sandboxed-module pattern",
severity="info",
))
else:
findings.append(FileFinding(
file=rel, line=line, signal="dynamic_eval",
detail=f"new Function({arg.strip()[:60]}...)",
severity="high",
))
for match in LONG_STRING_LITERAL.finditer(text):
candidate = match.group(1)
if _looks_like_encoded_blob(candidate):
line = _line_of(text, match.start())
findings.append(FileFinding(
file=rel, line=line, signal="encoded_blob",
detail=f"{len(candidate)}-char base64-alphabet string literal",
severity="medium",
))
for match in MAGIC_GATE.finditer(text):
line = _line_of(text, match.start())
findings.append(FileFinding(
file=rel, line=line, signal="magic_gate",
detail=match.group().strip(),
severity="info", # never escalates on its own; see aggregate logic
))
return findings
def scan_package(pkg_dir: Path) -> PackageReport:
report = PackageReport(package=pkg_dir.name)
for js_file in sorted(pkg_dir.rglob("*.js")):
report.findings.extend(scan_file(js_file, pkg_dir))
return report
def scan_all(packages_root: Path) -> list[PackageReport]:
return [scan_package(p) for p in sorted(packages_root.iterdir()) if p.is_dir()]
Signal A: A Dynamic eval
eval("a literal string") is inert and pointless to obfuscate: anyone reading the source already knows exactly what it does. eval(someVariable) or eval(decode(x)) is different in kind, because the code that actually runs depends on something computed at runtime, which the scanner (or a human reviewer) cannot fully see just by reading the source. The scanner checks whether an eval(...) call’s argument matches a plain quoted string; if it does not, it flags it as high severity on its own.
Signal B: An Encoded Blob Sitting in Source
A string literal at least 40 characters long, at least 90 percent of which is drawn from the base64 alphabet, is a reasonable heuristic for an encoded payload hiding in plain sight. It is deliberately treated as medium severity alone, not high, because legitimate packages occasionally embed genuinely encoded data too (fonts, compressed lookup tables, WebAssembly blobs), so a lone blob is worth a second look, not an automatic verdict.
Signal C: A Magic-Value Gate (Weak on Its Own)
A strict-equality check against a bare integer, like if (key === 1337), inside an exported method, is exactly the shape of the real campaign’s trigger. It is also extremely common in ordinary code for entirely unrelated reasons: status codes, enum values, array bounds. The scanner records it, but never lets it escalate a verdict by itself; it is context, not proof.
Recognizing a Legitimate Pattern
When the first arguments to new Function(...) are literal strings matching "module", "exports", "require", the scanner reports it as an info-level note, a recognized sandboxed-module pattern, rather than treating it identically to a bare eval. This one distinction is what fixes the false positive from Step 3.
Step 5: Run the Refined Scanner
Save this small runner as step3_refined_scan.py:
from pathlib import Path
from scanner import scan_all
PACKAGES_ROOT = Path(__file__).parent / "packages"
if __name__ == "__main__":
reports = scan_all(PACKAGES_ROOT)
for report in reports:
print(f"{report.package}: {report.verdict}")
for f in report.findings:
print(f" [{f.severity.upper():6}] {f.file}:{f.line} {f.signal} - {f.detail}")
print()
Run it:
python step3_refined_scan.py
Real output, from this exact code, against the exact same three packages:
indexed-store: CRITICAL
[HIGH ] extended\loader.blob.js:2 dynamic_eval - eval(Buffer.from(_0x4a1f, "base64")
[MEDIUM] extended\loader.blob.js:1 encoded_blob - 160-char base64-alphabet string literal
[HIGH ] index.js:18 dynamic_eval - eval(blob)
[INFO ] index.js:14 magic_gate - if (key === 1337)
legit-templating: INFO
[INFO ] index.js:15 module_wrapper_pattern - new Function("module", "exports", "require", ...) - recognized sandboxed-module pattern
safe-utils: CLEAN
indexed-store now reports CRITICAL, with both the dynamic eval calls and the encoded blob correctly identified, plus the magic-value gate flagged as supporting context. legit-templating drops all the way down to an informational note: still visible if you want to audit it, but no longer treated as a threat. safe-utils is untouched. This is the whole point of the refinement: catching the real pattern without punishing the legitimate one that merely resembles it on the surface.
Look closely at the first finding line and something looks off: eval(Buffer.from(_0x4a1f, "base64") is missing its closing parentheses. That is not a copy-paste error in this article; it is exactly what the code prints, and it is worth understanding why. EVAL_CALL uses a non-greedy (.*?) to capture an eval(...) call’s argument, which means it stops at the first closing parenthesis it finds, not the one that actually balances the opening parenthesis of eval itself. Since the real argument here is Buffer.from(_0x4a1f, "base64").toString("utf8"), itself containing nested calls with their own parentheses, the regex’s capture gets cut short at the end of Buffer.from(...). Verifying this directly:
>>> import re
>>> EVAL_CALL = re.compile(r"\beval\s*\((.*?)\)\s*;?", re.DOTALL)
>>> text = open("packages/indexed-store/extended/loader.blob.js", encoding="utf-8").read()
>>> [m.group(1) for m in EVAL_CALL.finditer(text)]
['Buffer.from(_0x4a1f, "base64"']
The good news is that this does not create a false negative here: _is_literal_string() only needs to know whether the captured text starts and ends with a matching quote character, and a truncated fragment starting with Buffer.from( obviously does not, so it is still correctly flagged as high severity. But the cosmetic truncation is a real crack in the regex approach, not a display bug, and it is the same underlying limitation spelled out in the Common Mistakes section below: a regex has no concept of balanced parentheses, and a real JavaScript parser would not have this problem.
A Real Gotcha: A Byte Order Mark Will Silently Break This Too
The Step 1 lifecycle scanner reads each package.json with encoding="utf-8-sig" rather than plain "utf-8", and that is not decoration. Some Windows-native editors and tools prepend a three-byte UTF-8 byte order mark (BOM) to text files. Reproducing it directly:
>>> import json
>>> raw = open("bom_test.json", "r", encoding="utf-8").read()
>>> json.loads(raw)
Traceback (most recent call last):
...
json.decoder.JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)
Python’s own error message is a rare kindness here: it tells you exactly what went wrong and exactly what to do about it. If your file-reading code catches that exception too broadly (a bare except: around the whole parse, logging nothing), you get a scanner that silently skips a package instead of erroring loudly, which looks identical to “nothing suspicious here” from the outside. The same care applies to the file-reading code inside scanner.py. Switching to encoding="utf-8-sig" resolves it cleanly, since it tolerates a BOM if present and behaves exactly like plain UTF-8 if not:
>>> raw2 = open("bom_test.json", "r", encoding="utf-8-sig").read()
>>> json.loads(raw2)
{'name': 'bom-test', 'scripts': {'postinstall': 'node evil.js'}}
Step 6: Wire It Into CI With a Real Exit Code
A scanner nobody runs is not a defense. The last piece is a thin entry point that prints the same report and exits non-zero when anything scores HIGH or CRITICAL, so it can be wired into a pipeline as a required check next to your test suite. Save this as step4_ci_gate.py:
"""
A CI-friendly entry point. Run it as part of a pipeline right after
`npm install` (or against a vendored dependency cache). It prints a
report and exits non-zero if anything scores HIGH or CRITICAL, so it can
be wired in as a required check the same way a linter or test suite is.
Exit codes:
0 - nothing above INFO severity found
1 - at least one package scored HIGH or CRITICAL
"""
import sys
from pathlib import Path
from scanner import scan_all
FAILING_VERDICTS = {"HIGH", "CRITICAL"}
def main(packages_root: Path) -> int:
reports = scan_all(packages_root)
failing = [r for r in reports if r.verdict in FAILING_VERDICTS]
for report in reports:
marker = "FAIL" if report.verdict in FAILING_VERDICTS else "ok"
print(f"[{marker}] {report.package}: {report.verdict}")
for f in report.findings:
print(f" [{f.severity.upper():6}] {f.file}:{f.line} {f.signal} - {f.detail}")
if failing:
names = ", ".join(r.package for r in failing)
print(f"\nnpm-runtime-scan: FAILED - {len(failing)} package(s) scored HIGH or CRITICAL: {names}")
return 1
print("\nnpm-runtime-scan: PASSED - no package scored above INFO severity.")
return 0
if __name__ == "__main__":
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent / "packages"
sys.exit(main(root))
Run it against the full three-package tree:
python step4_ci_gate.py
Real captured output, including the actual exit code:
[FAIL] indexed-store: CRITICAL
[HIGH ] extended\loader.blob.js:2 dynamic_eval - eval(Buffer.from(_0x4a1f, "base64")
[MEDIUM] extended\loader.blob.js:1 encoded_blob - 160-char base64-alphabet string literal
[HIGH ] index.js:18 dynamic_eval - eval(blob)
[INFO ] index.js:14 magic_gate - if (key === 1337)
[ok] legit-templating: INFO
[INFO ] index.js:15 module_wrapper_pattern - new Function("module", "exports", "require", ...) - recognized sandboxed-module pattern
[ok] safe-utils: CLEAN
npm-runtime-scan: FAILED - 1 package(s) scored HIGH or CRITICAL: indexed-store
exit code: 1
Now point it at a directory containing only safe-utils and legit-templating, the same command, with only the argument changed:
python step4_ci_gate.py path/to/clean-packages-only
[ok] legit-templating: INFO
[INFO ] index.js:15 module_wrapper_pattern - new Function("module", "exports", "require", ...) - recognized sandboxed-module pattern
[ok] safe-utils: CLEAN
npm-runtime-scan: PASSED - no package scored above INFO severity.
exit code: 0
That is a real, testable exit code (1 when a HIGH or CRITICAL package is present, 0 otherwise), the same contract a linter or test runner uses, which means it drops into a GitHub Actions job or any other CI system as a required step with no special handling.
Verify Everything Works End-to-End
Lock in this behavior with a real test suite so a future change cannot quietly break it. Save this as test_scanner.py:
from pathlib import Path
from scanner import _is_literal_string, _looks_like_encoded_blob, scan_all, scan_package
from step1_lifecycle_scan import scan_lifecycle_scripts
PACKAGES_ROOT = Path(__file__).parent / "packages"
def test_lifecycle_scan_misses_indexed_store():
findings = scan_lifecycle_scripts(PACKAGES_ROOT)
assert findings == [], "indexed-store has zero lifecycle scripts by design; the old scan must report clean"
def test_dynamic_eval_distinguishes_literal_from_variable():
assert _is_literal_string('"just a string"') is True
assert _is_literal_string("blob") is False
assert _is_literal_string('Buffer.from(x, "base64").toString()') is False
def test_encoded_blob_heuristic():
assert _looks_like_encoded_blob("short") is False
assert _looks_like_encoded_blob("A" * 39) is False # one under the length floor
assert _looks_like_encoded_blob("A" * 40) is True
assert _looks_like_encoded_blob("not-base64-!!!!-content-with-symbols-that-fail-density-check!!") is False
def test_indexed_store_is_flagged_critical():
report = scan_package(PACKAGES_ROOT / "indexed-store")
assert report.verdict == "CRITICAL"
signals = {f.signal for f in report.findings}
assert "dynamic_eval" in signals
assert "encoded_blob" in signals
def test_safe_utils_is_clean():
report = scan_package(PACKAGES_ROOT / "safe-utils")
assert report.verdict == "CLEAN"
assert report.findings == []
def test_legit_templating_is_not_flagged_high_or_critical():
report = scan_package(PACKAGES_ROOT / "legit-templating")
assert report.verdict == "INFO"
assert all(f.severity != "high" for f in report.findings)
def test_full_sweep_matches_expected_verdicts():
reports = {r.package: r.verdict for r in scan_all(PACKAGES_ROOT)}
assert reports == {
"indexed-store": "CRITICAL",
"legit-templating": "INFO",
"safe-utils": "CLEAN",
}
Run it:
pytest test_scanner.py -v
Real output:
collected 7 items
test_scanner.py::test_lifecycle_scan_misses_indexed_store PASSED [ 14%]
test_scanner.py::test_dynamic_eval_distinguishes_literal_from_variable PASSED [ 28%]
test_scanner.py::test_encoded_blob_heuristic PASSED [ 42%]
test_scanner.py::test_indexed_store_is_flagged_critical PASSED [ 57%]
test_scanner.py::test_safe_utils_is_clean PASSED [ 71%]
test_scanner.py::test_legit_templating_is_not_flagged_high_or_critical PASSED [ 85%]
test_scanner.py::test_full_sweep_matches_expected_verdicts PASSED [100%]
============================== 7 passed in 0.03s ==============================
Notice the first test in that list. It is not testing that the scanner catches something; it is testing that the old lifecycle-only scan still fails to catch indexed-store, on purpose. Keeping a regression test for a known blind spot is a useful habit: it documents, permanently, exactly what an earlier layer of defense cannot see, which is precisely why a second layer exists.
Common Mistakes and Gotchas
- Regex is not a JavaScript parser. Every pattern in
scanner.pyis a regular expression, which means it can be defeated by tricks a real parser would see straight through: string concatenation ("ev" + "al"), computed property access (window["ev" + "al"]), unicode escapes, or code split across template literals. A production-grade version of this tool should parse the JavaScript into an actual abstract syntax tree (via a real JS parser) rather than pattern-matching text. This tutorial uses regex because it is the fastest way to teach the underlying signals without a parser dependency, not because it is the most robust way to ship one. - A heuristic scanner is a layer, not a guarantee. Checkmarx’s own characterization of the real campaign, that it is “a well-built way to sneak past standard taint-analysis tools and most static scanners,” is a direct warning that a sufficiently determined, well-resourced attacker designs specifically to defeat exactly this kind of check. Treat this scanner as one more required check in CI, alongside dependency pinning, lockfile integrity verification, and (where you have the infrastructure for it) sandboxed dynamic analysis that actually executes untrusted code and watches what it does, not as a single source of truth.
- A clean scan today does not mean you were never compromised. Part of what made the real campaign notable is that it could delete its own trigger and loader file on command, restoring the package to something that would hash clean against the registry on a later scan. That is exactly why this scanner belongs in CI, running on every install, rather than as an occasional manual audit; a single clean result well after the fact tells you very little.
- A magic-value gate alone is noise, not a finding. If you are tempted to make Signal C flag on its own, resist it; ordinary code compares things to specific numbers constantly, for reasons that have nothing to do with malware. It earns its keep only as supporting context next to a stronger signal.
- Broad exception handling hides the BOM bug, and bugs like it. Whatever language you build a scanner in, a broad
try/except(ortry/catch) around a file-parsing step will silently convert “this file is malformed in a way I did not anticipate” into “nothing found here,” which is the most dangerous possible failure mode for a security tool.
Next Steps
If this is useful in your own CI pipeline, a few natural directions to take it further: swap the regex layer for a real JavaScript AST parser to close the evasion gaps described above; add a signal for packages that read a sibling file whose name is never referenced anywhere else in the codebase, a rough proxy for orphaned, dead-looking code that only a dynamic fs read can reach; and pair static scanning like this with the lockfile-integrity techniques from How to Detect Malicious npm preinstall Scripts and Verify Package Integrity, since the two catch different halves of the same problem. If you maintain GitHub Actions workflows too, How to Pin and Verify GitHub Actions to Stop Supply Chain Poisoning covers the same “do not trust a name or a version tag alone” lesson from a different angle. And for a sense of scale, ChainDrop Worm Infected Over 400 npm Packages While Leaving Their Source Code Clean covers a separate 2026 campaign built on the exact same insight this tutorial’s fixtures rely on: it rewrote the tarball actually published to the npm registry while leaving the GitHub repository untouched, and it too used an Ethereum smart contract to hide its command server. Two unrelated campaigns converging on the same trick, months apart, is a strong signal that “the published package matches the public repository” is worth verifying on its own, not assuming.








No Comment! Be the first one.