TRENDING
Five alphabetical thumb-index tabs cut into the edge of a dictionary, each labeled with a letter range
September 27, 2026
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
Five sample state-issued EBT benefit cards fanned out on a white background
September 27, 2026
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
A real wooden outdoor sandbox filled with sand and toys, empty of people
September 27, 2026
OpenAI Pauses Training of Its Most Capable Models for the Second Time in Three Months
Subway turnstiles showing a green ENTER sign and a red DO NOT ENTER sign side by side
September 27, 2026
How to Verify Cloudflare Turnstile Tokens Server-Side in a Python App
Macro photo of a brass keyhole with a key partially inserted in a wooden door
September 27, 2026
TU Graz’s File Notification Attacks Turn a Decades-Old OS Feature Into a Side Channel
27 Sep 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A green highway sign splitting into an EXPRESS lane and a LOCAL lane, the same express-lane idea a skip list uses to skip ahead through sorted data
How to Build a Skip List in Python to Get Balanced-Tree Speed Without the Rotations
September 27, 2026
Two well-worn paper archery targets riddled with arrow holes, mounted on cardboard backing at an outdoor range
Red Hat’s RHEL 10 STIG Update Turns Compliance Into a Moving Target
September 27, 2026
A manila file folder with a paperclip clipped to its tab, against a white background
CISA Orders Federal Agencies to Patch a SharePoint RCE Flaw Microsoft First Called Spoofing
September 27, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 210 Posts
News 211 Posts
Learning Hub 181 Posts
Home/Learning Hub/How to Detect npm Packages That Hide Malware in Runtime Code, Not Install Scripts
Learning Hub

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.

September 20, 2026 21 Min Read
23

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.
  • pytest for 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-store calls bare eval(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 to eval is something computed at runtime, not a literal you can read directly in the source.
  • legit-templating calls new 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, unexplained eval of 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.py is 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 (or try/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.

Tags:

JavaScriptnpmPythonStatic AnalysisSupply Chain Security

Share

A European mole emerges from a dirt mound with its front claws extended, photographed close up against a blurred green background
Previous Post

Google’s TeamPCP Mole Turns Threat Intelligence Into a Spy Operation

A real Amazon fulfillment center building with the Amazon logo, a chain-link fence, and a blue sign reading 'No personal or unauthorized vehicles beyond this point' in the foreground
Next Post

Amazon Blocks Meta’s Muse AI Agent From Shopping, 12 Days After Its Launch

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
27 Sep
How to Build a Skip List in Python to Get Balanced-Tree Speed Without the Rotations
27 Sep
Red Hat’s RHEL 10 STIG Update Turns Compliance Into a Moving Target
Trending
September 27, 2026
How to Build a Skip List in Python to Get Balanced-Tree Speed Without the Rotations
September 27, 2026
Red Hat’s RHEL 10 STIG Update Turns Compliance Into a Moving Target
September 27, 2026
CISA Orders Federal Agencies to Patch a SharePoint RCE Flaw Microsoft First Called Spoofing
September 26, 2026
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
September 26, 2026
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
September 26, 2026
OpenAI Pauses Training of Its Most Capable Models for the Second Time in Three Months

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026